Install Challenge.js
Two script tags, one call, and the island is on your page.
Runs in the browser
Challenge.js is distributed as script tags only. There is no npm package, for the browser or for the server, and there is nothing to bundle. The whole browser half of the integration is the two tags below plus one init() call.
The two script tags
Put both in the page that runs your game, in this order.
<script src="https://api.withchallenge.com/widget/dist/island-stub.js"></script>
<script async src="https://api.withchallenge.com/widget/dist/island.js"></script>The first is synchronous and tiny. The second is the rest of the SDK and loads out of the way.
What the stub does
The stub is on the critical path of someone else's game, so it does exactly three things and nothing else.
- It defines
<challenge-island>as a custom element and paints the dormant pill into its shadow DOM immediately. Your page never shows a hole where the money surface is about to be. - It queues
init(). A call that lands beforeisland.jshas arrived is pushed ontoChallenge._queueand replayed the moment the full bundle takes over, soChallenge.init(...)is safe to run from the first line of your boot code. - It holds one early tap. If a player taps the pill before the bundle is live, the stub dims it and remembers a single tap, which is replayed once there is something to open. One tap, never a list: replaying a burst would run the arm flow several times over.
The stub makes no network request beyond its font, and it cannot know whether the player has anything armed, so it always paints the dormant costume and lets the bundle correct it.
If you want the pill to land in a specific corner before init() has run, name it on the stub's own tag:
<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.
Initialise
Once, wherever your game boots:
Challenge.init({ program: '<<YOUR_PROGRAM_ID>>' })program is the only required option. It is your program ID from the Developer Portal, it is public, and it belongs in the browser. Your sk_test_ and sk_live_ keys do not: see Programs and keys.
init() returns the Challenge object, so it chains. A call with no program, or with a non-string one, logs an error and mounts nothing.
Until your program has a live Challenge, the island draws a small dormant pill reading Coming soon. It offers nothing and charges nothing, but your players do see it, so decide deliberately whether to ship this line before a live Challenge exists.
The options you will actually set
The full option table is on the Challenge.js reference. These are the ones that come up in practice.
| Option | Type | Default | What it does |
|---|---|---|---|
program | string | required | Your program ID |
anchor | string | 'bottom-right' | An overlay corner, or a CSS selector for an inline mount |
size | 'regular' or 'compact' | 'regular' | How large the pill draws |
theme | 'light' or 'dark' | 'light' | The palette for every island surface |
draggable | boolean | false | Lets the player move the island, and remembers where they put it |
challenge | string | null | Pin the island to one Challenge id instead of letting it pick from your catalog |
apiBase | string | script origin | Local development only |
Placement
Six names mount the island as a fixed overlay: bottom-right, bottom-left, bottom-center, top-right, top-left, top-center. Safe-area insets are handled, so the island clears a notch and a home indicator without you doing anything.
Anything else is read as a CSS selector, and the island renders inline inside your own layout:
Challenge.init({
program: '<<YOUR_PROGRAM_ID>>',
anchor: '#challenge-slot',
})A selector that matches nothing falls back to the default overlay corner and says so loudly in the console. The island cannot simply not appear: while a player is armed it is the only thing on the page telling them money is at stake.
Which edge is pinned is which way the island grows. A top anchor expands downward, a bottom anchor expands upward, so an expanded island at any of the five corners is fully on screen by construction.
Theme
One choice reaches every surface: the dormant pill, the armed costumes, the arming card and the account panel. An unrecognised value renders light rather than erroring, because a theme is not worth refusing to render over.
dormantTheme is a deprecated alias of theme. It still works and logs one console warning per page; theme wins when both are given.
Pinning one Challenge
The option key is literally challenge, not challenge:
Challenge.init({
program: '<<YOUR_PROGRAM_ID>>',
challenge: '<<YOUR_CHALLENGE_ID>>',
})Pinning is filtered against live Challenges only. If the id you named is not live, the island warns, falls back to the first Challenge that is, and keeps working. A partner with one Challenge in their game wants this; a partner with a catalog should leave it off and let the island offer the whole list.
Options that are gone
Passing any of apiKey, gameId, entryFee, mode, matchmaking, settlement, playerRating, position, variant, dashboardUrl, challengeDomain, ssoEnabled or ssoTimeout logs a warning and is ignored. They belong to the retired versus model. apiKey in particular must never appear in browser code at all.
Single-page apps
init() is safe to call on every route change, and that is the intended use.
init()never callsdestroy(). That is a house rule, not an implementation detail. An implicit teardown insideinitis how a re-render silently drops a player's state.- The
<challenge-island>element is reused whenanchoranddraggablehave not changed, so navigating inside your app does not blink the island out and back. - The player's armed session survives a re-init, and so does a paid attempt that is already running, because a route change is not the end of a round.
destroy()is explicit and total. It tears down the element, every listener and timer, the state machine, the settlement socket and its poll, any queuedrecord()calls, and the remembered session. Call it when the game leaves the page for good, not on every navigation.
A complete React mount and unmount. Note that destroy() lives on the component that owns the game for the whole session, not on each route:
import { useEffect } from 'react'
export function ChallengeProvider({ programId, children }) {
useEffect(() => {
// Safe on every route change. The element and the armed session are reused.
window.Challenge.init({
program: programId,
anchor: 'bottom-right',
theme: 'dark',
})
return () => {
// Only on real unmount. destroy() also forgets the player's armed
// session, so calling it between routes would disarm them silently.
window.Challenge.destroy()
}
}, [programId])
return children
}With the two script tags in index.html:
<!doctype html>
<html>
<head>
<script src="https://api.withchallenge.com/widget/dist/island-stub.js"></script>
<script async src="https://api.withchallenge.com/widget/dist/island.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>If you write <challenge-island> into your own markup rather than letting the overlay create one, an element that connects after init() has run is adopted and painted rather than replaced.
Local and self-hosted sandboxes
The API base is detected from the origin the script was loaded from, so a production page needs no configuration. Point it somewhere else only when the sandbox you are integrating against is a Challenge backend running somewhere other than api.withchallenge.com, such as a local one on port 4837.
Both script tags come from that same origin, and apiBase names it as well:
<script src="http://localhost:4837/widget/dist/island-stub.js"></script>
<script async src="http://localhost:4837/widget/dist/island.js"></script>Challenge.init({
program: '<<YOUR_PROGRAM_ID>>',
apiBase: 'http://localhost:4837',
})Load the scripts and set apiBase together. A page that loads the bundle from one origin and talks to another is the one configuration the island cannot detect its way out of.
Everything else on that backend is at the same base, including these docs: a self-hosted or local backend serves every page at {your API base}/api/docs/<slug>.md, for example http://localhost:4837/api/docs/install.md. The agent index sits beside them under the same /api/docs path, named llms.txt.
Read Challenge.version in the console to confirm which bundle a page is running.
Next
- Record rounds is the next line to add, and the one that ships before anything chargeable exists.
- Start a paid attempt covers
startAttempt()and branching on its error codes. - Challenge.js reference has every option, method and error code, plus the catalog elements.
Source: https://www.withchallenge.com/docs/install.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.