Challenge
Docs>Reference>Challenge.js

Challenge.js reference

Every option, method, error code and custom element in the browser half of the integration.

Runs in the browser. The guide is Install Challenge.js.

Challenge.js mounts the island, runs the account, age-check, deposit and arming flows, starts paid rounds, and renders every result a player sees. It does not report scores. A finished round goes to your own server, and that server reports the measurement to Challenge with your secret key. See Report the score from your server.

The two script tags

<script src="https://api.withchallenge.com/widget/dist/island-stub.js"></script> <script async src="https://api.withchallenge.com/widget/dist/island.js"></script>
FileLoadingWhat it does
island-stub.jsSynchronous, about 2 KBDefines the <challenge-island> custom element and paints the dormant pill into its shadow DOM immediately. Installs a shim Challenge.init that queues configs, and a shim Challenge.destroy that empties the queue. Captures one early tap and dims the pill until the bundle can answer it.
island.jsAsynchronousThe island proper: state machine, bootstrap, arming, the panel, the catalog elements, the round hooks. It replaces window.Challenge and replays whatever the stub queued.
island-full.jsFetched on demandThe deep flows: sign-up, email verification, identity and age verification, deposits, withdrawals, support. Loaded from the same directory as island.js, so keep the files together if you mirror them.

Both script tags define window.Challenge, so Challenge.init() is safe to call in the same tick as the tags. The stub only ever queues one tap, never a list: replaying a burst of impatient taps would run the arming flow several times over.

To paint the island before your own script runs at all, name the corner on the stub tag itself:

<script src="https://api.withchallenge.com/widget/dist/island-stub.js" data-anchor="top-center"></script>

The anchor has to be spelled out. An empty data-anchor mounts nothing.

Configuration

Challenge.init({ program: '<<YOUR_PROGRAM_ID>>' })

init() returns the Challenge object, so it chains. It is safe to call on every route change of a single-page app: the element is reused when the placement has not changed, the player's armed session survives, and an attempt already in flight is carried across — a route change mid-round keeps the live pill live and the score still lands on it, so you never need to guard init() against a round in progress. It never calls destroy() for you.

A call with no program, or a non-string one, writes one console.error and does nothing else. The island does not mount.

Options

OptionTypeDefaultMeaning
programstringrequiredYour program ID from the Developer Portal. Public, and meant to be in your client.
anchorstring'bottom-right'bottom-right, bottom-left, bottom-center, top-right, top-left and top-center float the island over the page. Any other string is treated as a CSS selector and the island renders inline inside the first element that matches.
sizestring'regular''compact' or 'regular'. Any other value reads as 'regular'.
themestring'light''light' or 'dark'. Reaches every surface: the dormant pill, all four armed costumes, the arming card and the account panel. An unrecognised value falls back to light rather than throwing.
dormantThemestringnoneDeprecated alias of theme. Still honoured. theme wins when both are given.
draggablebooleanfalseLets the player drag the island. Overlay anchors only; an inline mount ignores it.
challengestringnoneA Challenge id. Pins the island to that one Challenge instead of letting the player pick from the catalog.
dormantCopystring'play-for-money'The dormant line, chosen from a built-in list. Not free text.
apiBasestringdetectedLocal development only. Otherwise the API base is read from the script origin.

Anything not in this table is ignored.

anchor

Which edge is pinned is which way the card grows. A top anchor grows down into the room below it, a bottom anchor grows up. Combined with the card's viewport ceiling, an expanded island at any of the five anchors is fully on screen by construction.

An anchor that looks like an overlay name but is not one of the five (anything starting top- or bottom-) falls back to bottom-right with a console warning. A selector that matches no element in the document also falls back to the default overlay, loudly: an armed island is the only thing telling a player money is at stake, so it is never allowed to simply not appear.

An inline anchor may point at a <challenge-island> element you wrote yourself, at a container holding one, or at any element at all, in which case the island appends its own.

theme

One choice, every surface and every state.

The armed costumes used to be exempt, on the reading that they are what money at stake looks like rather than a preference. They are themed now. The old behaviour made the armed pill a black lozenge in the middle of a light page, and it meant one island changed theme underneath a player the moment they armed something. A theme is a placement decision about the page, and arming does not move the island to a different page.

dormantTheme was honest while the theme only reached the dormant pill. It now governs every expanded surface too, so the name became a lie rather than a synonym. The old name still works, because a partner with a live game should not get a white card because Challenge renamed something. Passing it writes one console warning for the life of the page, not one per init(), since init() runs on every route change of a single-page app.

challenge

The key is literally challenge, and it takes a Challenge id. The name predates the naming law that made the product unit a Challenge, and the option is part of a shipped public surface, so it was not renamed. Everywhere else, including the value you pass here, the unit is a Challenge.

Challenge.init({ program: '<<YOUR_PROGRAM_ID>>', challenge: '<<YOUR_CHALLENGE_ID>>' })

A non-string value is read as no pin at all.

dormantCopy

The dormant line is a money claim, so it is picked from an approved list rather than typed.

KeyRenders
play-for-moneyPlay for money
challenge-modeChallenge mode
mark-onlyNothing. The squiggle alone.

An unrecognised key falls back to the default rather than rendering an empty pill.

Options that are accepted and ignored

These are from the retired versus-era widget. They are ignored rather than rejected, because a partner upgrading a live game should not have their integration throw for still passing an entryFee. Passing any of them writes one console warning naming exactly which ones were dropped.

apiKey, gameId, entryFee, mode, matchmaking, settlement, playerRating, position, variant, dashboardUrl, challengeDomain, ssoEnabled, ssoTimeout.

apiKey is the one worth calling out. There is no configuration in which a secret key belongs in a browser. See Programs and keys.

What the island shows

The island reflects the state of your program, with no code change on your side as that state moves.

Program stateWhat renders
Nothing heard from the game yetNothing at all
Rounds recorded, no Challenge liveThe coming-soon island, where a player can ask to be told
A Challenge liveThe dormant pill, the arming card, and the panel behind it
Something armedThe armed island, and the live costume during a paid round

A bootstrap that fails is a dormant island, not a broken one: the partner's page keeps working and nothing claims money is at stake.

Methods

Challenge.init(config)

Mounts the island. Documented above. Returns Challenge.

Challenge.record(metric, score)

Report one ordinary round. Call it at the end of every round your game already has: paid or free, armed or not, signed in or not, practice included.

Challenge.record('moves', 34)

Returns a Promise that resolves { recorded: true } or { recorded: false }. Every rule below follows from the fact that this call lives inside a game loop.

  • It never rejects. A listener that throws is a listener that ends someone's round. A bad metric or a non-finite score writes one console.warn and resolves { recorded: false }. A dead network writes one console.debug and does the same.
  • One request per second per metric. Rapid calls are not dropped and not batched into one body: they queue in that metric's own lane and go out in order, a second apart, so a game that finishes ten rounds in a burst still reports ten. The clock starts when a request goes out, not when it comes back, so a slow server cannot make the next round wait twice.
  • At most 50 unsent rounds per metric. Past that, the newest calls resolve { recorded: false } with a console warning. Reaching it means the game is reporting far faster than it plays, which is an integration bug rather than a very quick player.
  • Calls made before the bootstrap are held, not lost, and flushed the moment there is a program to report them against. That holds even if the bootstrap failed: a program id is all a recorded round needs.
  • Metric names are trimmed and lowercased, so Combo Length, combo length and combo length are one metric. They may be 1 to 40 characters. The string you pass here and the metric_name on the Challenge must be the same string.
  • After destroy(), queued rounds resolve { recorded: false } rather than hanging, and further calls are not sent.

These rounds are what a Challenge's bar is measured from, which is why this line ships before anything chargeable. See Record rounds and Calibration.

Challenge.startAttempt(options?)

Start a paid round and charge for it.

const attempt = await Challenge.startAttempt()

The island is told nothing until the server has charged the fee. During the round trip it dims and stays armed and idle, because "attempt live" is a claim that money is at stake and showing it for a request that then fails would be the island lying about a balance. The dim only appears if the request takes longer than 600 ms.

Resolves with four facts plus the id, frozen onto this attempt at its creation:

FieldTypeMeaning
attemptIdstringThe id your server reports the score against
feeChargednumberWhat the player actually paid
prizeOfferednumberThe prize frozen onto this attempt. 0 on a calibrating Challenge and on a tournament round.
thresholdnumber or nullThe bar frozen onto this attempt. Null on a calibrating Challenge and on a tournament round.
thresholdDirectionstring or nullhigher_is_better or lower_is_better, in the Challenge's own units

Something must be armed first. There is no arm() for a game to call on a player's behalf: agreeing to a price is the player's act, and the island is the one place it happens.

One call covers tournaments too. A player arms a Challenge by sliding its card in the panel and arms a tournament round by sliding its card the same way, so a game that already offers Challenges offers tournaments without changing a line. A player has at most one armed thing at a time, of one kind; arming either replaces the other.

If your own server decides which round a player is in, name it instead:

const attempt = await Challenge.startAttempt({ tournamentRound: '<<YOUR_ROUND_ID>>' })
OptionTypeMeaning
tournamentRoundstringAn open tournament round id. Nothing needs to be armed on this path, because naming the round is the decision.

With nothing named, the request carries no parent id at all and the armed session is read on the backend. That is deliberate: sending the armed id from the browser would let the two disagree about what was armed at the moment the fee moved.

Rejections

Rejections are an Error with name: 'ChallengeAttemptError' and a .code. Branch on the code, never on the wording.

CodeCause
not_initialisedstartAttempt() ran before init(), or the island was destroyed while the request was in flight
not_armedNothing is armed for this player, neither a Challenge nor a tournament round, and no tournamentRound was named
attempt_in_progressA round is already live. It ends when your server reports its score.
client_reporting_removedOnly from reportScore(), which is removed. Never from startAttempt().
insufficient_fundsThe balance does not cover the fee. Nothing was charged, and the island offers a deposit.
challenge_not_availableSuspended, closed, or a prize over your integrity tier ceiling. Also what the server answers when it and the island disagree about what is armed.
blockedA responsible gaming decision the player made about themselves. Shown to them verbatim.
networkThe request never reached the server, after its retries
unauthorizedThe player's session is not valid
unknownAnything the server refused with a code this list does not name. Read .message.

Server-side conflicts pass through with the server's own code, so challenge_already_claimed, rung_locked, attempt_cap_reached and entries_closed can all arrive here too. See Errors for the full catalog.

The island shows the player whatever they need to see, refusals included, so most integrations only log the error and let the round not start.

Challenge.reportScore()

Removed. It writes a console.error and throws an Error with code client_reporting_removed.

No money rides on a score the player's own device reported. Your server reports the measurement to POST /v1/attempts/{id}/outcome with your secret key, and the island renders the result as soon as Challenge settles it. A stale bundle that still calls the old browser endpoint gets 410 with the same code and no side effects, so an old cache and a current one fail the same way.

It throws rather than resolving quietly because a game that keeps calling this is a game whose rounds are not being settled at all, and the fastest way to that discovery is a loud one.

Challenge.disarm()

Stop playing for money. Returns a Promise resolving to Challenge.

The server is asked first and the island only changes once it has agreed. An island that went dormant while the session was still armed would be the one lie this surface must never tell. If the server call fails, one console.warn is written and nothing changes.

Between rounds it takes effect immediately and closes the settlement socket and its poll. During a paid round the machine queues it to the next round boundary and leaves the socket alone, because that round is paid for and its result is still owed.

Challenge.destroy()

Tear everything down. Returns Challenge. Used by single-page apps on unmount.

What goes:

  • The full layer, if a deposit or sign-up screen is open over the game.
  • The catalog controller. Your <challenge-list> and <challenge-card> elements stay exactly where you wrote them and go empty.
  • The listen queue. Rounds still waiting resolve { recorded: false } rather than hanging.
  • Every timer: the press timer, the attempt-wait timer, the tally rotation.
  • The document-level escape and outside-click listeners, the slide controllers and the drag-to-scroll controllers.
  • Every animation on the pill, every pin, and the spring group's requestAnimationFrame loop. A frame loop still running on a page with no island is a permanent wakeup in someone else's tab.
  • The settlement WebSocket and its poll timer, unconditionally. A live round has no claim on a page with no island to show it on.
  • The state machine, the keydown listener and the mount. An overlay island Challenge created is removed from the DOM; an element you wrote yourself stays and its shadow root is emptied.
  • The remembered session and the dragged position.

What survives on purpose: the anonymous device ref, so a re-mount does not look like a new device, and the per-program quiet flag, so a game with nothing to offer does not flash a loading pill on the way back.

init() never calls destroy(). That is a house rule and it stays: an implicit destroy inside init is how a re-render silently drops a player's state.

Challenge.version

The string '0.9.0'.

Custom elements

Three elements. All of them are registered by the code, not by you, and none of them takes a render call.

<challenge-island>

Defined by the stub, so it exists before the bundle arrives. Two attributes, both set by Challenge rather than by you:

AttributeSet byMeaning
overlayThe stub or createMountThis element is Challenge furniture, floating over the page, and destroy() may remove it
anchorapplyAnchorWhich of the five corners is currently pinned

Write it into your own markup when you want the island inside your layout, and point anchor at it or at its container:

<div class="hud"> <challenge-island></challenge-island> </div>
Challenge.init({ program: '<<YOUR_PROGRAM_ID>>', anchor: '.hud' })

The island adopts an element it finds rather than creating a second one, including one that connects later in a single-page app. There is only ever one island on a page: two would be two answers to "what am I being charged".

<challenge-list> and <challenge-card>

The catalog, in your own layout. The elements are the API, which is why there is no renderCatalog() and no options object: you already have a language for saying where things go, and it is HTML.

<challenge-list></challenge-list> <challenge-card challenge-id="<<YOUR_CHALLENGE_ID>>"></challenge-card>
AttributeElementMeaning
challenge-id<challenge-card>Which Challenge this card shows. The attribute name predates the naming law, exactly as the challenge init option does; the value is a Challenge id.
themeboththeme="dark", light by default.

theme here is per element and deliberately not inherited from Challenge.init({ theme }). That theme is about the island, which floats over your game canvas. These elements sit in your own layout, which may be a light Challenges page belonging to a dark game, so the two are separate knobs and a partner who wants them to match says so twice.

Both elements observe their attributes, so changing one repaints that element.

Lifecycle:

  • They are registered by init(), not by the stub. A card has nothing honest to draw until the bootstrap has landed, so it costs the critical path nothing to wait.
  • Registration is idempotent, because init() runs on every route change.
  • With no island behind them they render empty: no placeholder, no "unavailable" card. What an island-less page has to say about Challenges is nothing at all.
  • After destroy() they go empty again and stay in your markup. A card still quoting a fee with nothing behind it to honour would be an offer nobody can take.
  • Arming anywhere, whether on the island, a panel row or another card, repaints every card on the page in the same tick.
  • A challenge-id that this program does not have renders nothing and warns once. Falling back to some other Challenge would put a price in front of a player for a contest you never meant to offer.

Tapping a card runs the same arming flow through the island, with the same fee confirmation, so there is still exactly one place a player agrees to a price.

What is not API

Anything on window.Challenge beginning with an underscore is internal. It is not versioned, not documented beyond this paragraph, and may change or disappear in any release:

_island, _adopt, _queue, _hydrate, _pendingTap, _debugSend, _debugFull, _debugState.

The three _debug* members are the state driver behind ?debug=1 on Challenge's own test page. They are inert unless the page asks for them, and they only forward events the state machine already validates, so they cannot fake a costume that money is not behind.

What Challenge.js handles for you

Account creation and sign-in, email verification, age and identity verification, deposits, balances and withdrawals, responsible gaming limits, the arming flow and its fee confirmation, the account panel, the leaderboard, and every result screen. None of it needs a callback from your game, and none of it is something to rebuild. See Real-time settlement for how a result reaches the island without your game relaying it.

Related


Source: https://www.withchallenge.com/docs/challenge-js.md Docs index for AI agents: https://www.withchallenge.com/llms.txt

Ready to integrate?

Put a Bounty in your game in minutes.

Challenge for Players

Create an account, deposit funds, and compete for real money in games you already play.

Challenge for Developers

Add Challenge to your game and start earning from every match. Integration takes minutes.