@bountyboard/arcade-sdk
v1.4.12
Published
Bounty Board Arcade SDK: leaderboards, cloud saves, rewarded ads, A/B variants, and multiplayer rooms for games on bountyboard.gg
Readme
@bountyboard/arcade-sdk
Add Bounty Board leaderboards, cloud saves, player display identity, A/B variants, rewarded ads, site lock, WebXR playtime, and authoritative multiplayer to an HTML5 game.
- Zero runtime dependencies
- ESM, CommonJS, global, and strict TypeScript declarations
- Safe to import during SSR or prerendering
- Safe standalone behavior: the SDK never becomes a requirement for booting or playing
Let your agent do it
Bounty Board has an MCP server. Add it to Claude Code, Codex, Cursor or ChatGPT and the agent reads this SDK's rules by topic, checks your build against the Arcade's upload rules before anything leaves your machine, uploads it, and quotes creator campaigns with the site's own math:
claude mcp add --transport http bounty-board https://www.bountyboard.gg/api/mcp
codex mcp add bounty-board --url https://www.bountyboard.gg/api/mcpIt signs you in on bountyboard.gg and you pick what it may do. Nothing goes public without review. Every client: https://www.bountyboard.gg/studio/agents
Quickstart
npm install @bountyboard/arcade-sdkimport { BBArcade } from '@bountyboard/arcade-sdk';
// Optional; call before boot. 'play.yourgame.com' is a placeholder: allow lists
// the domains YOU host on, and Bounty Board plus localhost are always allowed,
// so a Bounty-hosted build needs no allow list (or no call at all).
BBArcade.lockToHost({ allow: ['play.yourgame.com'] });
void BBArcade.init(); // host handshake; awaiting is optional
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? Load the same SDK as a global:
<script src="https://www.bountyboard.gg/arcade-sdk/v1.js"></script>
<script>
void BBArcade.init();
// Call this from your game when assets and the first scene are playable.
function onGameReady() {
BBArcade.gameLoadingFinished();
}
</script>The script tag installs window.BBArcade. Do not mix it with the npm package
in one page. Standalone declarations are available from
/arcade-sdk.d.ts. If a bundler
specifically needs the global build, import @bountyboard/arcade-sdk/global.
Bundle note: importing the package attaches the host postMessage listeners
immediately (the host pushes its config at load time, so listener timing is
part of the protocol), so the package is honestly marked sideEffects: true
and will not tree-shake. The full script-tag build is ~55 KB unminified;
module consumers who only use multiplayer still get the whole core.
Runtime support
npm versus script tag only changes how the SDK loads. The environment where the game runs determines which host-backed features are available.
| Capability | Bounty-hosted upload | Approved URL embed inside Bounty Board | Standalone / own site |
| --------------------------------- | -------------------- | -------------------------------------- | --------------------- |
| Lifecycle, scores, host analytics | Supported | Supported | Safe no-op |
| Player identity | Player or null | Player or null | null |
| A/B variants | Stable assignment | Stable assignment | Alphabetical control |
| Cloud save / load | Logged-in players | Unsupported | Unsupported |
| Native localStorage | Throws (opaque) | Works on your origin | Works |
| Rewarded ads | Approved games | Unavailable (no payable attribution) | Unavailable |
| Multiplayer | Enabled authority | Enabled authority | Unsupported |
An externally hosted URL opened directly is standalone play. The same URL
inside the approved Bounty Board player is a URL embed. Guests are normal:
identity resolves null, and account-backed promises may reject with
code: 'unauthenticated'.
Correct lifecycle
lockToHost() -> init() -> [getPlayer()] -> gameLoadingFinished()
-> per run: gameplayStart() -> submitScore()* -> gameplayStop() -> gameOver()
-> save() at checkpoints or game overgameplayStart() and gameplayStop() bracket active play, not menus. Call
gameOver() exactly once per run and submit integer scores. Pass
{ mode: 'daily' } to both score calls for a shared-seed Daily run.
The SDK must never be load-bearing. With no host present:
- fire-and-forget signals no-op;
init()resolves after a short grace;getPlayer()resolvesnull;getVariant()returns the alphabetically first control;- save, load, ads, and multiplayer settle quickly with their documented unsupported/unavailable outcomes.
Your game must still boot and remain fully playable when all of those outcomes happen at once.
Cloud saves
Store the whole progress model as one string blob (about 1 MB maximum). Save at checkpoints or game over, never in a hot loop.
import type { BBArcadeError } from '@bountyboard/arcade-sdk';
const blob = JSON.stringify(progress);
try {
await BBArcade.save(blob);
} catch (error) {
const code = (error as BBArcadeError).code;
if (code === 'unsupported') localStorage.setItem('progress', blob);
}
try {
const saved = await BBArcade.load(); // string or null when no save exists
if (saved) restore(JSON.parse(saved));
} catch {
restoreStandaloneProgress();
}Hosted uploads run in an opaque-origin sandbox without localStorage; SDK
cloud save is primary there. URL embeds and standalone builds need their own
same-origin storage. Oversized blobs reject too_large immediately (a
client-side precheck against the same 1 MiB cap the server enforces), and a
host that never answers rejects error after a 15-second request timeout.
The same timeout covers load, variant, and multiplayer ticket requests.
Standalone, or embedded on another site the browser can identify (an itch.io or
Newgrounds page), those calls reject unsupported at once instead. Every
save/load rejection is a real Error with a typed code:
unsupported | unauthenticated | too_large | rejected | errorEngine exports: the localStorage shim
Prefer save()/load() when you control the source. The shim is for engine
runtimes whose storage layer can't be rewired without patching engine
internals: GameMaker HTML5 (ini_open, ini_write_*, and game_save all sit
on localStorage), Godot, Unity, 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.
The script-tag build installs it at load, so the only requirement is load order:
<script src="https://www.bountyboard.gg/arcade-sdk/v1.js"></script>
<script src="html5game/YourGame.js"></script>Module consumers keep a side-effect-free import and install it themselves, before any engine code runs:
BBArcade.storage.install(); // idempotent; no-op where a real Storage works
// getItem() is synchronous but the cloud read that fills it is not.
const mode = await BBArcade.storage.ready(); // 'cloud' | 'memory' | 'native'
startGame();Writes made before hydration are kept and merged with the cloud read (your
write wins; a removeItem isn't resurrected), and nothing is pushed to the
server until that read has SUCCEEDED. A failed read is retried, and a write
that still can't confirm what the player had is refused rather than allowed to
overwrite it blind, so neither a fresh-start write at boot nor a transport blip
can wipe an existing save. sessionStorage is shimmed too, but memory-only. Guests and
standalone play settle in memory mode: storage works for the session and is
never persisted. setItem throws QuotaExceededError past the save cap, like
a real Storage.
save()/load() keep working alongside it: the player has one save slot, so
both halves share it inside a tagged envelope that load() unwraps. A raw blob
written before the shim existed reads back untouched.
Player identity and A/B variants
The SDK only provides public display identity. It never exposes account ids, emails, or roles.
const player = await BBArcade.getPlayer();
if (player) {
greet(player.name);
if (player.avatarUrl) drawAvatar(player.avatarUrl);
}
// Alphabetical first is the standalone/error control: "control" here.
const variant = await BBArcade.getVariant('start-cta', ['control', 'short-label']);
renderStartButton(variant ?? 'control');Always handle getPlayer() === null and avatarUrl === null. Variant
assignments are stable per player, game, and key. Do not re-randomize them in
the client.
getPlayer() is a one-shot read of the identity at handshake time. To react
when the player logs in or out mid-session, subscribe to changes:
const unsubscribe = BBArcade.onPlayerChange(player => {
// Fires only when the identity actually changes; null means logged out.
updateGreeting(player);
});
// Later, when the UI is gone: unsubscribe();Rewarded ads: prepare, then show from the player gesture
New integrations should use prepareRewardedAd(). Prepare when a natural
reward panel opens, keep the Watch Ad control disabled until the result is
ready, and call its one-shot show() directly inside the click/tap handler.
const button = document.querySelector<HTMLButtonElement>('#watch-ad')!;
button.disabled = true;
const prepared = await BBArcade.prepareRewardedAd({
placement: 'death_revive',
reward: 'extra_life',
onStart: () => pauseGameAndAudio(),
});
if (prepared.status === 'ready') {
button.disabled = false;
button.addEventListener(
'click',
() => {
// Keep first: no await, timer, microtask, 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();
}Only the final status === 'viewed' grants a reward. dismissed,
unavailable, and error are non-rewarding; a prepared ad whose break ends
before show() is ever called also resolves dismissed, never ready.
Never auto-trigger, loop, or auto-retry an ad. rewardedBreak() remains for
compatibility with shipped games but cannot preserve direct browser user
activation in every case.
Multiplayer rooms
SDK 1.1 adds production room support with bounded joinData, observable
reconnect state, and explicit trySend() backpressure. SDK 1.2 adds public
quick match.
Multiplayer is enabled per game and runs on one of three rails, all behind the
same joinRoom() transport:
- Built-in relay rooms (the default): every multiplayer-approved game gets hosted casual lobbies with zero server code. The relay owns signed admission, roster, seat capacity, host succession, and rate-guarded public message fan-out; it never interprets payloads, never settles, and never reports results, so relay outcomes are client-trusted. See the relay room guide.
- Refereed Bounty-hosted modules: a curated server module validates inputs, runs the only simulation, and emits authoritative results.
- Registered external authorities: a mature game keeps its own server with a dedicated ticket key.
The platform assigns the rail per game slug; clients cannot select it.
import { joinRoom } from '@bountyboard/arcade-sdk/multiplayer';
const room = await joinRoom({
create: true, // or code: 'ABCD', or match: true for public quick match
joinData: { avatar: 'golem', mode: 'ffa' }, // untrusted; server validates
});
showShareCode(room.code);
room.on('snapshot', ({ state }) => renderAuthoritativeState(state));
room.on('connection', ({ connected }) => setReconnecting(!connected));
room.on('end', ({ results }) => showResults(results)); // finite games only
onPlayerInput(input => {
if (!room.trySend(input)) setReconnecting(true);
});
onExit(() => room.leave());On the refereed and external rails, clients send inputs, never authoritative
state. On the relay rail, room.send(data) fans the payload publicly to every
player (including the sender) in per-tick batches of
{ type: 'relay', messages: [{ from, data }] }; the room's first joiner fixes
the seat count with joinData.roomSize (an integer 2–64, default 8), the
oldest retained seat is host (state.hostId, changes broadcast as
{ type: 'relay_host', hostId }), and traffic is capped at 1 KiB per message,
15 messages per player per second, and 120 per room per second.
Two tuning hooks: joinRoom({ timeoutMs }) overrides how long a join (and
each reconnect attempt) waits for the server's welcome before rejecting with
code: 'error' (default 10000, clamped to 1000–60000), and room.latencyMs
reports the join-handshake latency (socket open to server welcome, refreshed
on every reconnect; null until the first welcome). Treat it as an estimate
for tuning render-side smoothing, not a measured RTT.
Rooms are lobby-based: each game module declares its player range (up to 64
per room). match: true is public quick match: Bounty Board fills the game's
open public room and starts a fresh one when no seat is free; the matched
room's room.code still works as an invite code. When the last free seat is
lost to a race the SDK re-matchmakes automatically (up to two retries) before
rejecting with code: 'rejected' and detail: 'room_full'. Quick match is
mutually exclusive with code/create and is unavailable on external
authorities, which run their own rooms.
Latency model
Refereed and external rooms are state-sync only: the server simulates, clients receive per-viewer snapshots at the room tick rate, and every input takes a network round trip before its effect appears in a snapshot. There is no built-in client-side prediction, interpolation, or rollback. Design for it: turn-based, timing-duel, score-race, and party games feel native; twitch physics (fighting games, precision platform duels) need your own render-side smoothing: interpolate between snapshots and animate optimistic feedback for the local player's input, but treat the next snapshot as truth. Assume 50-150 ms of input-to-snapshot latency on real connections when tuning game feel. (Relay rooms differ: game messages arrive as public per-tick event batches, and all game rules live in your clients.) Every referee or external authority must:
- validate admission,
joinData, and every input; - clear held controls on disconnect;
- run the only simulation;
- serialize only viewer-safe state;
- return server-authored results for finite games;
- avoid fabricating
match_endfor endless rooms.
joinData is untrusted JSON capped at 1 KiB and must not contain credentials or
other secrets. External games keep their existing simulation and add only the
signed-ticket + SDK envelope at an isolated authenticated endpoint. Read the
public external authoritative server contract
before adapting an existing server.
Site lock and WebXR
// Anti-rehosting deterrent, not DRM. Call before boot. When the embedder
// can't be read from browser signals (opaque-origin sandbox), the SDK asks
// the embedding page and blocks if nothing answers within ~5 seconds.
// The allowed domains below are placeholders for your own; Bounty Board and
// localhost are allowed by default, so a Bounty-hosted build passes none.
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
- Run the production artifact without a Bounty Board parent and confirm the whole game remains playable.
- Verify ready, active-play, pause/resume, and exactly-one game-over transitions.
- Test guest, no-save, unsupported, too-large, and transport-failure save paths.
- Test rewarded
ready,unavailable,dismissed,error, andviewed; onlyviewedmay grant. - Test multiplayer disconnect/reconnect,
trySend() === false, viewer-safe snapshots, and intentionalleave(). - Keep ads and multiplayer controls unavailable until their capabilities are actually ready; do not make the rest of the game wait.
Give coding agents the public agent-readable verification contract.
API at a glance
| Area | Calls |
| ------------- | ------------------------------------------------------------------------------------- |
| Lifecycle | init, configure, ready / gameLoadingFinished, gameplayStart, gameplayStop |
| Scores | submitScore, gameOver |
| Player data | save, load, storage.install / ready / flush, getPlayer, onPlayerChange, getVariant |
| Rewarded ads | prepareRewardedAd, rewardedAd, rewardedBreak, preloadRewardedAds |
| Security / XR | lockToHost, xrSessionStart, xrSessionEnd |
| Multiplayer | joinRoom, room.trySend, room.on, room.leave, room.latencyMs |
The package declarations are the exact type reference. The script-tag build
exposes multiplayer at BBArcade.multiplayer.joinRoom(...); module consumers
import joinRoom from @bountyboard/arcade-sdk/multiplayer.
More documentation
- Human integration guide
- Agent-readable API and integration contract
- Release changelog
- Game design playbook
- External authoritative server contract
- Submit a game
License
MIT licensed.
