01
Why integrate?
Your game already works without us. That is the point. Each SDK call you add turns anonymous plays into retention, revenue, and reach, and every feature is optional.
Leaderboards that bring players back
Three calls give your game real high score boards. Weekly top player boards run automatically, and guests who set a score get nudged into an account to claim it. Returning players, not one time visitors.
Rewarded ad revenue share
Eligible Arcade studios keep 90% of finalized ad revenue attributed to their game, settled monthly via Stripe once the $50 minimum is reached. Terms apply. Rewarded ads need a Bounty hosted build and admin approval. No AdSense account or ads ops on your side.
Multiplayer without the infrastructure
Approved games can start with signed rooms, public matchmaking, reconnect handling, and a built in relay lobby for 2 to 64 players with no server module. When wins or rewards depend on the rules, the rules have to run on a server: connect your own authoritative server, or ask on Discord about a referee module in Bounty Board rooms (today only first party games have one).
Cloud saves across devices
Uploaded builds run in a sandbox without localStorage. One save() blob (about 1 MB) follows logged in players across devices. Progress survives, players stay.
Experiments without a backend
getVariant() gives each player a stable A/B assignment per experiment key. Tune onboarding or difficulty with data instead of guesses.
Zero risk to your build
Zero dependencies, ESM + CommonJS + script tag, and every feature degrades safely off host. The same artifact ships to your own site and Bounty Board unchanged.
02
Quickstart
Use the npm package when your game has a build step. The package includes strict TypeScript declarations and is safe to import during SSR or prerendering.
npm install @bountyboard/arcade-sdkimport { BBArcade } from '@bountyboard/arcade-sdk';
// Optional anti-rehosting check. Call before your game boots. Bounty Board and
// localhost are always allowed, so allow lists only the domains YOU host on.
// 'play.yourgame.com' is a placeholder. If we host your build, drop the allow
// list or the whole call.
BBArcade.lockToHost({ allow: ['play.yourgame.com'] });
// Starts the host handshake. Awaiting is optional and must not block boot.
void BBArcade.init();
// Wire these calls to your actual game state.
export function onGameReady() {
BBArcade.gameLoadingFinished();
}
export function onRunStart() {
BBArcade.gameplayStart();
}
export function onScoreChange(score: number) {
BBArcade.submitScore(Math.trunc(score));
}
export function onPause() {
BBArcade.gameplayStop();
}
export function onRunEnd(finalScore: number) {
BBArcade.gameplayStop();
BBArcade.gameOver(Math.trunc(finalScore)); // exactly once per run
}No build step? Use the script tag
<script src="https://www.bountyboard.gg/arcade-sdk/v1.js"></script>
<script>
// The script installs window.BBArcade. Do not also bundle the npm package.
// Optional site lock: 'play.yourgame.com' is a placeholder for YOUR domains.
// Bounty Board and localhost are always allowed, so a build we host needs no
// allow list: omit the option, or the whole call.
BBArcade.lockToHost({ allow: ['play.yourgame.com'] });
void BBArcade.init(); // never gate boot on it
// Wire the rest to your actual game state. Loading alone is not an integration:
// gameplayStart/gameplayStop bracket ACTIVE play (not menus) and drive your
// playtime numbers, and gameOver closes the run.
function onGameReady() {
BBArcade.gameLoadingFinished(); // assets and first scene are playable
}
function onRunStart() {
BBArcade.gameplayStart();
}
function onScoreChange(score) {
BBArcade.submitScore(Math.trunc(score)); // repeat freely; the host throttles
}
function onRunEnd(finalScore) {
BBArcade.gameplayStop();
BBArcade.gameOver(Math.trunc(finalScore)); // exactly once per run
}
</script>Let your agent integrate it
claude mcp add --transport http bounty-board https://www.bountyboard.gg/api/mcpcodex mcp add bounty-board --url https://www.bountyboard.gg/api/mcpChatGPT, Vercel Connect and any MCP client take the same URL: every client and your connections.
03
Choose an integration
Distribution format and runtime environment are separate decisions. npm versus script tag changes how code loads; the Bounty Board host determines which capabilities are available.
npm package (recommended)
Best for Vite, Phaser, React, Unity WebGL wrappers, and other bundled projects. Typed imports; multiplayer uses a dedicated subpath.
Script tag
Best for plain HTML/JavaScript builds. It installs window.BBArcade, including BBArcade.multiplayer, and the localStorage shim. A bundler that needs the global can import @bountyboard/arcade-sdk/global instead.
| Capability | Bounty hosted upload | Approved URL embed | Standalone / own site |
|---|---|---|---|
| Lifecycle, scores, and host analytics | Supported | Supported in the Bounty Board player | Does nothing |
| Player display identity | Logged in player or null | Logged in player or null | null |
| A/B variants | Stable host assignment | Stable host assignment | Alphabetical control |
| Cloud save / load | Logged in players | Unsupported | Unsupported |
| Native localStorage | Throws (opaque origin), use the storage shim | Works on your own origin | Works |
| Rewarded ads | Approved games | Unavailable (no payable attribution yet) | Unavailable |
| Multiplayer rooms | Per game approval (shared service relay available) | Per game approval (shared service relay available) | Unsupported |
04
Core lifecycle
Wire these transitions to real game states. Missing stop events inflate active play signals; duplicate game over events create bad score data.
05
Feature recipes
Add only the capabilities your game needs. Each recipe includes the failure path that should be part of the first implementation, not a later cleanup.
Scores and game over
Cloud saves
import type { BBArcadeError } from '@bountyboard/arcade-sdk';
function writeLocalProgress(blob: string) {
try {
localStorage.setItem('progress', blob);
} catch {
// Hosted uploads use an opaque origin, so localStorage may be unavailable.
}
}
function readLocalProgress(): Progress | null {
try {
const blob = localStorage.getItem('progress');
return blob ? JSON.parse(blob) : null;
} catch {
return null;
}
}
// Ask who is playing first. getPlayer() resolves null for guests and never
// rejects, so the guest case is a branch instead of a thrown error.
export async function loadProgress(): Promise<Progress | null> {
const player = await BBArcade.getPlayer();
if (!player) return readLocalProgress(); // and offer "Sign in to save"
try {
const blob = await BBArcade.load(); // null means no save yet
return blob ? JSON.parse(blob) : null;
} catch {
return readLocalProgress();
}
}
export async function saveProgress(progress: Progress) {
const blob = JSON.stringify(progress); // one blob, about 1 MB max
try {
await BBArcade.save(blob);
} catch (error) {
const code = (error as BBArcadeError).code;
// Normal outside a Bounty hosted upload. Use your own same-origin store.
if (code === 'unsupported') writeLocalProgress(blob);
// A guest reached save() without the gate above. Never report success.
else if (code === 'unauthenticated') offerSignIn();
else reportSaveFailure(code);
}
}Engine exports: the localStorage shim
<!-- Script tag: load order is the whole integration. The SDK installs the
shim at load, so the engine below finds a working localStorage. -->
<script src="https://www.bountyboard.gg/arcade-sdk/v1.js"></script>
<script src="html5game/YourGame.js"></script>// Module builds: install it yourself, before any engine code runs.
import { BBArcade } from '@bountyboard/arcade-sdk';
BBArcade.storage.install(); // no-op where a real localStorage works
// getItem() is synchronous, but the cloud read that fills it is not.
const mode = await BBArcade.storage.ready(); // 'cloud' | 'memory' | 'native'
if (mode === 'memory') showGuestProgressNotice();
startGame(); // now localStorage.getItem() sees the savePlayer identity and A/B variants
const player = await BBArcade.getPlayer();
if (player) {
greet(player.name);
if (player.avatarUrl) drawAvatar(player.avatarUrl);
}
// Fires only when the identity changes (login/logout mid session).
const unsubscribe = BBArcade.onPlayerChange(next => updateGreeting(next));
// Alphabetical first is the standalone/error control: "control" here.
const cta = await BBArcade.getVariant('start-cta', ['control', 'short-label']);
renderStartButton(cta ?? 'control');Prepared rewarded ads
const button = document.querySelector<HTMLButtonElement>('#watch-ad')!;
button.disabled = true;
// Prepare when the defeat/reward panel opens.
const prepared = await BBArcade.prepareRewardedAd({
placement: 'death_revive',
reward: 'extra_life',
onStart: () => pauseGameAndAudio(),
});
if (prepared.status === 'ready') {
button.disabled = false;
button.addEventListener('click', () => {
// Keep this first. No await, timeout, or network call before show().
const resultPromise = prepared.show();
button.disabled = true;
void resultPromise.then(result => {
resumeGameAndAudio();
if (result.status === 'viewed') revivePlayer();
else keepNormalFallbackAvailable();
});
}, { once: true });
} else {
keepNormalFallbackAvailable();
}Multiplayer rooms and lobbies
import type { BBArcadeError } from '@bountyboard/arcade-sdk';
import { joinRoom } from '@bountyboard/arcade-sdk/multiplayer';
try {
const room = await joinRoom({
create: true, // or code: 'ABCD', or match: true for public quick match
// A relay founder may set roomSize to an integer from 2-64 (default 8).
joinData: { roomSize: 4, avatar: 'golem' }, // always untrusted
});
// welcome already arrived: render these fields before waiting for an event.
renderLobby(room.code, room.players, room.state);
room.on('playerJoin', () => renderPlayers(room.players));
room.on('playerLeave', () => renderPlayers(room.players));
room.on('snapshot', ({ state }) => renderRoomState(state));
room.on('event', event => handleRoomEvent(event));
room.on('connection', ({ connected, reconnecting }) =>
setConnectionState({ connected, reconnecting })
);
let ended = false;
room.on('close', ({ reconnecting }) => {
if (!reconnecting && !ended) showConnectionClosed();
});
room.on('end', ({ results }) => {
ended = true;
showResults(results); // finite refereed rooms only
});
onPlayerInput(input => {
if (!room.trySend(input)) {
if (!room.connected) setReconnecting(true);
else showSendFailure(); // serialization/WebSocket.send failed locally
}
});
onExit(() => room.leave());
} catch (error) {
const { code } = error as BBArcadeError;
showSoloFallback(code); // every BBArcadeErrorCode is an expected state
}Site lock and WebXR
// Site lock is a deterrent, not DRM. Run it before boot.
BBArcade.lockToHost({
allow: ['play.yourgame.com', 'preview.yourgame.com'],
signed: true,
});
// WebXR only: keep immersive playtime visible to the host.
session.addEventListener('end', () => BBArcade.xrSessionEnd());
BBArcade.xrSessionStart();06
Test before submitting
A correct integration is proven in both hosted and unhosted conditions. Treat unsupported, guest, unavailable, and reconnecting as expected states.
07
Troubleshooting
Start from the result or error code. Most integration failures are environment boundaries or lost browser user activation, not transport bugs.
| Symptom | Likely cause | Fix |
|---|---|---|
| 'BBArcade is not defined' and the script tag was blocked by COEP | The page is cross origin isolated (Cross-Origin-Embedder-Policy: require-corp), which many engine test servers turn on for SharedArrayBuffer. Chrome reports ERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoep and drops the download. | The hosted file opts in with Cross-Origin-Resource-Policy: cross-origin, so the tag above works as written. If a proxy strips that header, add crossorigin="anonymous" to the tag or ship a copy of the file inside your build and load it from the same origin. |
| The game waits forever during boot | Game startup is gated on an SDK promise or host only feature. | Start init() without awaiting it and give every promise feature a standalone outcome. |
| Scores never reach the leaderboard | The game has not declared Leaderboards in its Arcade submission, so the host rejects every posted score. Scores above the score ceiling set on the game are rejected too. submitScore() does not wait for an answer, so the game sees no error. | Tick Leaderboards under Arcade SDK features on the game (we tick it when we find the calls in your build or page, and you can edit the game later), check its score ceiling, then confirm gameOver() fires once per run with an integer score. |
| save() or load() rejects with 'unsupported' | The game is a URL embed, self hosted, or otherwise outside a Bounty hosted upload. | Use your own same origin storage there. SDK cloud save is primary only in hosted uploads. |
| save() or load() rejects with 'unauthenticated' | The player is browsing as a guest. | Gate on getPlayer() so the guest case is a branch instead of a catch. Continue with defaults or a local fallback, and offer sign in where progress is at stake; do not force login from the game loop. |
| save() rejects with 'rejected' | The player saved more than 60 times in a minute, or the host refused the request as invalid. | Save at checkpoints and game over, never per frame. The storage shim already batches its writes. |
| localStorage throws SecurityError in a hosted build | Uploaded builds run on an opaque origin, where localStorage, sessionStorage, IndexedDB, and document.cookie all throw rather than returning empty. | Call save()/load() when you control the source, or BBArcade.storage.install() for an engine export whose storage layer you cannot rewire. |
| Shimmed storage reads empty on the first frame | getItem() is synchronous but the cloud read that fills it is not. | await BBArcade.storage.ready() before restoring progress. Writes made before then are kept and merged, so nothing is lost. |
| Ad result says 'direct_user_action_required' | prepared.show() ran after an await, timer, microtask, animation, or network call. | Call show() directly as the first line of the click/tap handler. |
| Ad result says 'host_disabled' or 'unavailable' | The host has not enabled ads for the game, no placement is available, or local test inventory is empty. Ads run only on Bounty hosted builds that declare Rewarded ads and are approved for them. | Keep the ad free fallback visible. Confirm the game declares Rewarded ads and the live host config before debugging Google. |
| joinRoom() rejects with 'unsupported' | The game is standalone/SSR, or its multiplayer room service is not configured. | Hide or disable multiplayer, preserve solo play, and confirm per game enablement and the room deployment. |
| Inputs disappear during a reconnect | The socket is temporarily closed and trySend() correctly returned false. | Show reconnecting UI, stop sending, and resume from server snapshots after connection=true. |
| joinRoom() rejects with 'rejected' | Invalid options/joinData/code, the game does not declare Multiplayer or is not approved for it, rate limiting, a seat that already has two open connections, or authority admission failure. External authorities also reject match: true. | Do not guess from the generic code. Use a specific error.detail when present; keep solo play available and avoid blind retries. |
| joinRoom() rejects with 'unauthenticated' | The room server refused the room ticket before welcome. | Keep solo play available and offer a manual retry, which fetches a fresh ticket. Never build or cache tickets in the game. |
| joinRoom() rejects with 'error' | No welcome arrived within the timeout (10 seconds by default), the socket closed before welcome, the host ticket request failed, or leave() ran during the join. | Offer a manual retry and keep solo play. Raise timeoutMs only when your authority is slow to admit. |
08
Defaults at a glance
What you get without configuring anything. Every value here can be relied on in code review and QA.
| Setting | Default |
|---|---|
| lockToHost() allowlist | bountyboard.gg and its subdomains, the Bounty Board staging host, localhost, and 127.0.0.1 are always allowed. Your allow entries extend that list; each matches the host and its subdomains, and plain hosts, host:port, or full URLs all work. |
| lockToHost() host check | When browser signals cannot name the embedding page (hosted uploads), the SDK asks the host and blocks if nothing answers within about 5 seconds. |
| init() and getPlayer() wait | Resolve when the host answers, and at once when the game runs top level or on another site the browser can identify, like an itch.io or Newgrounds page. Only an embed whose parent page the browser cannot identify waits about 1.5 seconds. |
| Host request timeout | save(), load(), and multiplayer ticket requests reject with 'error' after 15 seconds with no answer; getVariant() resolves the control instead. Top level or on another site the browser can identify, they reject 'unsupported' at once. |
| getVariant() fallback | The alphabetically first variant. It is the control standalone and on any error. A host assignment needs at least two variants: one variant resolves itself, an empty list resolves null. |
| save() blob limit | One string blob of about 1 MB (1 MiB) per player per game. Oversized blobs reject 'too_large' before any request is sent. |
| save() rate | Up to 60 saves per player per minute; beyond that save() rejects 'rejected'. |
| submitScore() transport | Throttled by the host, safe to call on every score change. |
| Rewarded ad wait | A placement waits 6 seconds for an ad before resolving 'unavailable'. Set rewardedAds.loadTimeoutMs in init() or loadTimeoutMs per call, up to 15 seconds. |
| Rewarded ad test mode | On for localhost and file: pages; the Bounty Board host sets it per environment and its setting wins. |
| Room codes | Four characters, generated for you on create: true (ambiguous characters excluded). |
| Quick match | Bounty shared service relay and referee rooms: match: true uses the current public room while a seat is safely available, otherwise rolls a new one. Conservative reservations can roll early; lost seat races retry up to twice. |
| Room size | A relay founder chooses 2 to 64 with joinData.roomSize (default 8); referee modules declare 1 to 64; registered external authorities set their own reviewed limit. |
| Relay traffic | Public to every seat: 1 KiB per payload, 15 messages/player/second, and 120 messages/room/second. Limit drops are silent and increment state.dropped. |
| joinData limit | Untrusted JSON capped at 1 KiB; the room authority validates it. |
| Room input guard | Every Bounty hosted room accepts client frames up to 4096 characters and up to 90 inputs per player per second; the rest are dropped. Relay payloads also have the tighter limits above. |
| Reconnect policy | Join welcome times out after 10 seconds. The SDK makes up to 3 reconnect attempts; Bounty hosted seats have a 15 second grace. room.leave() never reconnects. |
| Connections per seat | At most 2 open sockets per player seat on Bounty hosted rooms. A third is rejected with 'rejected' and does not retry. |
| Rewarded ad revenue share | Eligible Arcade studios keep 90% of finalized ad revenue attributed to their game, settled monthly via Stripe once the $50 minimum is reached. Terms apply. |
09
API reference
Canonical calls and aliases from the package declarations. For exact option and result unions, use the bundled TypeScript definitions or the standalone declaration file.
Lifecycle and scoring
| Signature | Returns | Behavior |
|---|---|---|
init(options?) | Promise<void> | Host config handshake. Resolves when the host answers, at once top level or on another site, or after about 1.5 seconds in an embed whose parent page the browser cannot identify. Options: { rewardedAds: { enabled, testMode, preload, loadTimeoutMs } }. Safe to call without awaiting. |
configure(options?) | void | Apply init options without repeating the handshake. |
ready() / gameLoadingFinished() | void | The game is loaded and the player can start. |
gameplayStart() / gameplayStop() | void | Bracket active play, including resume and pause transitions. |
submitScore(score, { mode?: 'daily' }) | void | Current integer score. The host throttles transport. |
gameOver(score, { mode?: 'daily' }) | void | Final integer score, exactly once per run. |
xrSessionStart() / xrSessionEnd() | void | Bracket immersive WebXR sessions. |
Player data and experiments
| Signature | Returns | Behavior |
|---|---|---|
save(blob) | Promise<void> | One string blob, about 1 MB. Hosted uploads and logged in players only. |
load() | Promise<string | null> | null means no save. Rejections use the same error codes as save(). Gate on getPlayer() so a guest is a branch, not a catch. |
storage.install() | 'native' | 'cloud' | 'memory' | Replace a throwing localStorage with a cloud backed shim, for engine exports. Idempotent; does nothing where a real Storage works. The script tag calls it for you. |
storage.ready() | Promise<'native' | 'cloud' | 'memory'> | Resolves once the cloud read has landed. Await before restoring progress at boot. |
storage.flush() / storage.mode | Promise<void> / mode | Force the debounced write out now; mode reports what is backing storage. |
getPlayer() | Promise<Player | null> | Display name and optional avatar only; never ids, emails, or roles. Never rejects or hangs, so it doubles as the signed in check for save()/load(). |
onPlayerChange(handler) | () => void | Runs only when the display identity changes, e.g. the player logs in or out mid session. Subscribing does not replay the current value (call getPlayer() for that); returns an unsubscribe function. |
getVariant(key, variants) | Promise<string | null> | Stable even split per player, game, and key; alphabetical first is the fallback/control. Needs at least two variants for a host assignment. Results show per variant in your game analytics. |
Rewarded ads
| Signature | Returns | Behavior |
|---|---|---|
prepareRewardedAd(options?) | Promise<Ready | Failure> | Recommended two stage flow. Alias: prepareRewardedBreak(). |
rewardedAd(options?) | Promise<AdResult> | Low level structured result. Alias: showRewardedAd(). |
rewardedBreak(options | onStart) | Promise<boolean> | Deprecated compatibility helper. Use the prepared flow for new work. |
preloadRewardedAds(options?) | Promise<boolean> | Warm the ads library. Alias: preloadRewardedAd(). |
Security and multiplayer
| Signature | Returns | Behavior |
|---|---|---|
lockToHost({ allow?, signed?, onBlocked?, redirect? }) | void | Early anti rehosting check; Bounty Board hosts and localhost are always allowed. signed: true also verifies a host signed origin attestation. A deterrent, not DRM. |
joinRoom({ code? | create? | match?, joinData?, timeoutMs?, roomUrl?, ticket? }) | Promise<Room> | Import from '@bountyboard/arcade-sdk/multiplayer'. Empty options/create generate a four character code; shared service match: true uses the open public room; joinData is untrusted and capped at 1 KiB. timeoutMs overrides the 10 second welcome wait (clamped 1000 to 60000 ms, reconnects included). roomUrl + ticket are paired local dev overrides. |
room.code / playerId / players / state / connected / latencyMs | live fields | Initial lobby data is available when joinRoom resolves; player and snapshot events keep it current. latencyMs is a join handshake estimate for render smoothing, refreshed on every reconnect and null before the first welcome. |
room.send(input) | void | Send a JSON value: an input to referee rooms, or a public game message to relay rooms. Prefer trySend() when local write status matters. |
room.trySend(input) | boolean | false means disconnected, raced closed, or the input failed JSON serialization. |
room.on(event, handler) | () => void | snapshot, playerJoin, playerLeave, event, end, error, connection, or close. |
room.leave() | void | Intentional close with no automatic reconnect. |
version | number | Current postMessage wire protocol version, not the npm version. Also exported as PROTOCOL_VERSION. |
10