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 drive-by plays.
Rewarded ad revenue, 90% yours
Approved Bounty hosted games join rewarded ad revenue share at a 90% default studio split, attributed per game and settled monthly via Stripe. 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 2-64 player relay lobby with no server module. Add a bespoke referee module for trusted rules and results, or connect a mature external authority.
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.
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
It exposes the same API as window.BBArcade. Put it in your <head> ahead of your game bundle so the site lock check and host handshake start before your game boots. Do not use the npm package and script tag in the same page. Standalone declarations are available at /arcade-sdk.d.ts.
<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>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.
| Capability | Bounty hosted upload | Approved URL embed | Standalone / own site |
|---|---|---|---|
| Lifecycle, scores, and host analytics | Supported | Supported in the Bounty Board player | Safe no-op |
| 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 |
“Approved URL embed” means your externally hosted game is running inside the Bounty Board player. Opening the same URL directly is standalone play. Guests are also normal: identity resolves null and account backed features may reject as unauthenticated.
Core lifecycle
Wire these transitions to real game states. Missing stop events inflate active play signals; duplicate game over events create bad score data.
- 1Before bootlockToHost() if you use site lock, then init().
- 2PlayablegameLoadingFinished() after assets and the first scene are ready.
- 3Run startsgameplayStart(); submitScore() as the integer score changes.
- 4PausedgameplayStop() on pause, death prompts, menus, and ad breaks.
- 5Run endsgameplayStop(), then gameOver(finalScore) exactly once.
- 6Checkpointsave() progress outside the hot loop; always catch rejections.
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
Submit integer score snapshots freely; the host throttles transport. Call gameOver() exactly once with the final score. For a shared seed daily mode, pass { mode: 'daily' } to both calls. The server still enforces the plausibility caps configured for your game.
Cloud saves
Serialize the complete progress model into one string and save at checkpoints or game over, never every frame. Hosted uploads run in an opaque-origin sandbox without localStorage, so SDK save is primary there. URL embeds and standalone builds need their own same origin fallback.
Gate the account calls on getPlayer(), which resolves null for guests and never rejects or hangs. That keeps the try/catch for real transport failures instead of turning a signed out player into an error you have to remember to swallow.
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);
}
}Rejection codes: unsupported, unauthenticated, too_large, rejected, and error. save() rejects for a guest on purpose: a write that did not happen must never resolve as if it had, and it is the one moment where you can offer to sign in and keep the progress.
Engine exports: the localStorage shim
Prefer save()/load() when you control the source. The shim is for engine runtimes whose storage layer cannot be rewired without patching engine internals: GameMaker HTML5 (where ini_open, ini_write_*, and game_save all sit on localStorage), Godot, Unity, and Construct. It replaces the throwing localStorage with a Storage shaped object backed by your cloud save, so those builds persist per player and across devices with no engine changes.
<!-- 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 savesessionStorage is shimmed too, but memory only. Guests and standalone play settle in memory mode: storage still works for the session, it is just never persisted. setItem throws QuotaExceededError past the ~1 MB save cap, like a real Storage.
Player identity and A/B variants
Player data is display only: name and optional avatar, never an account id, email, or role. getPlayer() reads the identity once at handshake time; subscribe with onPlayerChange() to react when the player logs in or out mid session. Variants are stable per player, game, and key; name the alphabetically first option as the safe control because that is what standalone and error paths receive.
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
Prepare at a natural break and keep your Watch Ad control disabled until status is ready. The retained show() call must stay in the browser's direct click/tap stack. Grant only on the final viewed result, pause gameplay and audio in onStart, and never loop or auto-retry.
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
Multiplayer is enabled per game and has three rails. An approved game on Bounty's shared service without a bespoke module gets the built-in casual relay. The relay owns signed admission, roster, reconnect grace, capacity, host succession, and public message fan out, but it does not referee game state or results. Competitive or reward bearing play needs a reviewed Bounty referee module or a registered external authority that validates inputs, runs the simulation, and sends viewer safe state.
The first seat in a relay room fixes its capacity with joinData.roomSize, an integer from 2-64; missing or invalid means 8. The oldest retained seat is host, including during the 15-second reconnect grace. Relay state is { mode: 'relay', hostId, size, dropped }. Every game payload is public to every player, including the sender: there are no private relay messages or hidden state.
Relay payloads arrive in 20 Hz batches as { type: 'relay', messages: [{ from, data }] }; host changes arrive as { type: 'relay_host', hostId }. A payload may be at most 1 KiB of UTF-8 JSON, with 15 messages per player and 120 per room each second. Byte/rate excess is silently dropped and increments state.dropped. Relay metadata snapshots are 1 Hz. Relay rooms are endless, never emit end, and never report client trusted outcomes to Bounty Board.
Referee modules declare a 1-64 player range; relay rooms use 2-64. Players can use match: true to enter the game's open public room, or create: true/code for a shareable four-character invite (a matched room's code also works as an invite). Registered external authorities set their own reviewed capacity and run their own matchmaking, so match: true is unavailable there.
joinRoom() is room transport, not one universal ready/start lobby state machine. Relay has the host contract above; referee modules and external authorities define their own ready, team, spectator, kick, start, and rematch schemas. The platform selects the tier for the game; joinData cannot choose it. When the promise resolves, welcome state is already in room.state and the initial roster is in room.players; render both before waiting for later events. Quick match is capacity based and may late join a running room, so spectator or late entry behavior is tier/game defined.
Simulation backed state sync is server round trip based. The SDK does not provide client side prediction, interpolation, or rollback. Plan around roughly 50-150 ms from input to viewer safe snapshot; smooth rendering locally, but correct to the next referee snapshot. Relay game messages instead arrive in the public 20 Hz event batches described above.
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
}See the relay room wire guide for a focused implementation contract. Already have a mature server? Keep it as the single authority and add an isolated signed ticket adapter; read the public external authority contract before implementing the endpoint.
Site lock and WebXR
Site lock is an optional early anti rehosting check, not DRM. Bounty Board and localhost are always allowed, so allow is where you list the domains you host the build on yourself. A build we host needs no entries, and the domains below are placeholders. WebXR games should bracket immersive sessions so playtime does not disappear when the flat page becomes hidden.
// 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();Test before submitting
A correct integration is proven in both hosted and unhosted conditions. Treat unsupported, guest, unavailable, and reconnecting as expected states.
- The build boots, starts, pauses, resumes, and finishes with no Bounty Board host present.
- gameLoadingFinished() fires only when the first playable scene is ready.
- gameplayStart() and gameplayStop() bracket active play, not menus or tab open time.
- Scores are integers, plausibility caps match the game, and gameOver() fires once per run.
- Guest and unsupported save/load paths keep progress usable without hanging the game.
- getPlayer() null and avatarUrl null both render cleanly.
- Rewarded UI covers ready, unavailable, dismissed, error, and viewed; only viewed grants.
- The rewarded show() call is the first action in the player click/tap handler.
- Multiplayer handles trySend() false, reconnect, relay events or viewer safe referee snapshots, and leave().
- The same production artifact works in its standalone location and inside Bounty Board.
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 same-origin. |
| The game waits forever during boot | Game startup is gated on an SDK promise or host only feature. | Start init() fire-and-forget 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. submitScore() is fire-and-forget, so the game sees no error. | Tick Leaderboards on the game (in the submit dialog or by editing it later), 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. |
| 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, no placement is available, or local test inventory is empty. | Keep the non-ad fallback visible. Confirm 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, disabled approval, rate limiting, 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. |
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 | Bounty Board hosts and localhost are always allowed; your allow entries extend that list. |
| getVariant() fallback | The alphabetically first variant. It is the control standalone and on any error. |
| save() blob limit | One string blob of about 1 MB per player per game. |
| submitScore() transport | Throttled by the host, safe to call on every score change. |
| 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-64 with joinData.roomSize (default 8); referee modules declare 1-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. |
| 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. |
| Rewarded ad revenue share | 90% of net revenue to the studio by default; Bounty Board admins can adjust per game. |
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 after a short grace off host. Safe to fire-and-forget. |
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; no-op 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; alphabetical first is the fallback/control. |
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. 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 wire protocol version. |