Submit a game

Arcade SDK

Ship once. Reach players, keep their progress, get paid.

The Bounty Board Arcade SDK is one tiny script tag that plugs your existing HTML5 build into a live player audience, global leaderboards, cross-device cloud saves, a real analytics dashboard, and rewarded-ad revenue at a 90% studio share, without standing up a single server. Every call is optional and your game keeps running standalone, so you can wire in as little or as much as you want and never give up the right to host the build yourself.

Why integrate

  • Free distribution: drop into an arcade that already has players, instead of building an audience from zero.
  • Global leaderboards from two calls: submitScore() as the score moves, gameOver() at the end. We host the ranking and the throttling.
  • Cross-device cloud saves with zero backend: on builds we host, save() and load() a 1MB blob to the player's account, so progress follows them everywhere.
  • A per-game analytics dashboard you couldn't build alone: plays, unique players, playtime, retention, score distribution, and your feed ranking.
  • Real money: approved Partner games can earn a 90% studio share, whether uploaded here or embedded from your own host. External-host revenue stays held until its per-game provider reporting is verified.
  • Anti-theft baked in: lockToHost() refuses to run if a scraper re-uploads your bundle elsewhere, so the traffic stays yours.
  • Tiny, incremental, optional: one script tag, and every method is safe to no-op standalone. Wire one signal today, the rest whenever.
  • No lock-in: keep self-hosting and embedding the same build anywhere; the SDK quietly does nothing off-host.

Worried it isn't worth the effort?

It's tiny, incremental, and optional. Integration is one script tag plus a single BBArcade.init(), minutes not days. Every other method is independently safe to call or skip and no-ops when your game runs standalone, so you can wire one signal today and the rest never. The hard parts are already solved: the opaque-origin sandbox makes cloud saves and site-lock work without you touching CORS, auth, or storage, and we run the ad network and payouts so monetization is a button and an if-statement. No lock-in either, it's the same build you can keep hosting anywhere, and off-host the SDK simply does nothing.

One script tag to get in

Add one <script> and a single BBArcade.init() and your game is live in the arcade: no build pipeline changes, no SDK bundler, no account plumbing. /arcade-sdk/v1.js is the canonical URL (/arcade-sdk.js serves the same file as the latest-v1 alias). init() performs a quick config handshake with the host and resolves within about a second and a half even when nothing answers, so it never blocks your boot — and since a classic script can't top-level await, calling it fire-and-forget is the intended pattern. Writing TypeScript? Typings for the whole surface ship at /arcade-sdk.d.ts. That's the whole cost of entry; everything below is upside you opt into when you're ready. Rewarded ads stay off in production until we approve and allowlist the Partner game, whether it is uploaded here or hosted at your own embed URL, so nothing surprises your players.

Init
<script src="https://www.bountyboard.gg/arcade-sdk/v1.js"></script>
<script>
  // Classic scripts can't top-level await — fire-and-forget is fine. init()
  // returns a Promise that resolves once the host config handshake completes
  // (or after ~1.5s when nothing answers), so awaiting it is optional.
  BBArcade.init({ rewardedAds: { preload: true } });
</script>

Have a build step? Use the npm package (TypeScript included)

@bountyboard/arcade-sdk is the same SDK as a typed ES module: full TypeScript definitions, ESM + CommonJS builds, zero dependencies, and the exact same wire protocol as the script tag — the two never drift because the script tag is built from the package's source. Importing it never writes a window.BBArcade global (that's the script tag's job; bundlers that want the global can import @bountyboard/arcade-sdk/global), and it's safe to import in SSR/prerender contexts. Sticking with the plain script tag? Grab the standalone typings from /arcade-sdk.d.ts and reference them with /// <reference path="./arcade-sdk.d.ts" />.

npm + TypeScript
# npm install @bountyboard/arcade-sdk   (pnpm add / yarn add work too)

// Your game's entry module — TypeScript types come with the package.
import { BBArcade } from '@bountyboard/arcade-sdk';

BBArcade.lockToHost();
BBArcade.init();
// ...same surface as the script tag from here:
BBArcade.gameplayStart();
BBArcade.submitScore(score);
BBArcade.gameOver(finalScore);

// Typed helpers for everything else, e.g.:
import type { BBArcadeError } from '@bountyboard/arcade-sdk';
try {
  await BBArcade.save(JSON.stringify(progress));
} catch (err) {
  if ((err as BBArcadeError).code === 'unsupported') useLocalStorageInstead();
}

Keep the traffic you earn (Site Lock)

Your hard-won build is one right-click from being scraped and re-hosted on an ad farm that pays you nothing. lockToHost() shuts that down: the game refuses to run on any origin you didn't allow. The embedding origin is read straight from the browser and can't be forged by the page embedding you, and Bounty-Board-hosted builds pass automatically via an opaque-origin handshake, so theft sites go dark while your players keep playing. One line, before boot. It's a deterrent, not DRM, but it's the cheapest insurance you'll add.

Site lock
// Refuse to run if someone scrapes your build and re-hosts it elsewhere.
// Bounty-Board-hosted builds pass automatically. For a URL embed you host
// yourself, allow your own domain (and any staging or preview host).
BBArcade.lockToHost({ allow: ['yourgame.com'] });

Tell us when the fun starts (Game Loading)

One call, gameLoadingFinished(), the moment a player can actually press start, fired after your loader and first playable scene are ready. Today this signal is recorded as engagement telemetry — it's how we'll separate players who actually started from players who bounced on a spinner as feed ranking evolves, so wiring it now means your history is already there. (Playtime itself is currently measured by the arcade host while your game is open, not from this call.) gameLoadingFinished() and ready() are the same call — use whichever reads better.

Loading
// Call after your loader, asset preload, and first playable scene are ready.
BBArcade.gameLoadingFinished();

Measure the playtime that matters (Gameplay Start/Stop)

Bracket active play with gameplayStart() and gameplayStop(): stop on menus, pauses, death prompts, and ad breaks; start again on resume. Straight talk about what these do today: they're recorded as engagement telemetry, while the playtime and retention charts on your dashboard currently come from host-side heartbeats (time your game is actually open and visible). As feed ranking evolves, these brackets are the signal that will let real engaged minutes count for more than tab-open time — games that wire them now are the ones that benefit first.

Gameplay
BBArcade.gameplayStart(); // run begins or resumes
BBArcade.gameplayStop();  // death, pause menu, interstitial menu, etc.

Global leaderboards in two calls (Scores)

Competition is the cheapest retention mechanic in games, and you get it almost for free. Call submitScore() as the score changes (we throttle the network posts so you can fire it freely), then gameOver(finalScore) when the run ends. We host the leaderboard, the ranking, and the score-distribution analytics so you can see exactly how hard your game really plays. An honest word on anti-cheat: the protection today is plausibility, not cryptography. Scores are integers tied to a real play session, and the server rejects anything implausible — above your game's max-score cap, or for endless games above a per-second scoring allowance for the session's length. Set those caps as tight as your design allows (a loose cap is an open door), and know that a determined cheater can still submit a plausible fake: signed score verification is on the roadmap, so don't attach real-money prizes to leaderboard placement yet.

Scores
// Call freely as the score changes; the host throttles the network posts.
BBArcade.submitScore(score);

// On game over, report the final integer score for the leaderboard.
BBArcade.gameOver(finalScore);

Cross-device saves, zero backend (Cloud Saves)

Players who lose progress don't come back. save() and load() persist a ~1MB string blob to the player's account, so their run follows them from phone to laptop and back, and you wrote no database, no auth, no sync layer. Be clear about the boundary: cloud saves only work when you upload your build and Bounty Board hosts it — the hosted sandbox has no localStorage, and the save bridge lives in our player frame. Anywhere else (standalone play, your own site, a URL embed we frame), calls reject immediately with err.code === 'unsupported' instead of hanging, so keep your own storage as the fallback there. Failures are real Error objects carrying a code: unsupported, unauthenticated (logged-out player — load() rejects too, so catch it), too_large, rejected, or error; load() resolves null when there's simply no save yet.

Cloud saves
// Cloud saves require a build upload that Bounty Board hosts (the sandbox has
// no localStorage) and a logged-in player. Everywhere else — standalone play,
// your own site, URL embeds — calls reject fast with err.code 'unsupported',
// so keep your own storage as the fallback. Rejections are Error objects with
// a .code of 'unsupported' | 'unauthenticated' | 'too_large' | 'rejected' | 'error'.
async function saveProgress(progress) {
  try {
    await BBArcade.save(JSON.stringify(progress)); // string blob, ~1MB max
  } catch (err) {
    if (err.code === 'unsupported') {
      // Not a Bounty-Board-hosted build — use your own storage instead.
    }
  }
}

// load() resolves the saved string, or null when there's no save yet — and it
// REJECTS for logged-out players ('unauthenticated'), so always catch.
async function loadProgress() {
  try {
    const raw = await BBArcade.load();
    return raw ? JSON.parse(raw) : null;
  } catch (err) {
    return null; // 'unauthenticated' | 'unsupported' | 'error' — use defaults
  }
}

Greet the player by name (Player Identity)

A game that says “Good luck, Grant!” instead of “Player 1” feels alive from the first frame. getPlayer() resolves the logged-in player's display name and avatar URL after the same host handshake as init(), so you can put their name on the start screen or their avatar next to a personal best with one call. The boundary is deliberate: you get display identity only — never ids, emails, or roles — and it resolves null for guests, standalone play, and anywhere off Bounty Board, so your game must always handle the anonymous case. avatarUrl can also be null for players who haven't set one.

Player
// Resolves { name, avatarUrl } after the host handshake — and null for guests,
// standalone play, and anywhere off Bounty Board, so ALWAYS handle null.
async function greetPlayer() {
  const player = await BBArcade.getPlayer();
  if (!player) return;                 // guest: keep your default UI
  showBanner('Good luck, ' + player.name + '!');
  if (player.avatarUrl) drawAvatar(player.avatarUrl); // may be null
}

Get paid to make a great game (Rewarded Ads)

This is the part that turns a passion project into income. First call prepareRewardedAd() at a natural reward moment while your own “Watch ad for a reward” button stays disabled. Enable that button only when preparation returns status === 'ready'. Its click or tap handler must call the ready handle's show() method directly, before any await, timeout, animation, network call, or scheduled state change. Grant only when the final result has status === 'viewed'; dismissed, unavailable, timed-out, and error outcomes stay non-rewarding and should leave the player's normal fallback available.loadTimeoutMs caps how long preparation waits for an ad (default 6000ms, max 15000), and testMode forces Google's test ads — it's on automatically for localhost, and in production the host's setting wins, so you can't ship it by accident. We run the ad network, handle attribution per game, and credit your studio's share, 90% by default. That share settles monthly to your connected Stripe account (net of payout-provider fees) once it clears the $50 minimum — smaller balances simply roll forward. Available on approved Partner games, including externally hosted embed URLs; external-host revenue is held until its per-game provider reporting is verified.

Rewarded ad
const watchAdButton = document.querySelector('#watch-ad');
watchAdButton.disabled = true;

// Prepare first. Do not offer the ad action until the SDK says it is ready.
const prepared = await BBArcade.prepareRewardedAd({
  placement: 'revive',          // analytics label for this placement
  reward: 'extra_life',         // analytics label for the reward
  loadTimeoutMs: 6000,          // optional: default 6000, max 15000
  testMode: false,              // auto-on for localhost; the Bounty Board host wins
  onStart: () => pauseAudio(),  // runs synchronously immediately before the ad shows
});

if (prepared.status === 'ready') {
  watchAdButton.disabled = false;
  watchAdButton.addEventListener('click', async () => {
    // Keep this first: show() must run directly in the player's click/tap.
    const resultPromise = prepared.show();
    watchAdButton.disabled = true;
    const result = await resultPromise;
    resumeAudio();

    if (result.status === 'viewed') giveExtraLife();
    else showNoReward(result.status); // dismissed/unavailable/error: no reward
  }, { once: true });
} else {
  showAdUnavailable(); // leave the normal non-ad fallback available
}

Don't lose your VR minutes (WebXR Playtime)

If your game goes immersive, the flat 2D page goes hidden, and naive trackers would silently zero out a player's best, most-engaged minutes. Wire xrSessionStart() and xrSessionEnd() to your WebXR session and that headset time counts, credited as VR play, so your analytics and feed ranking reflect what people actually did. Not a WebXR game? Skip it entirely; like everything here, it's optional.

WebXR
// Wire these to your WebXR session so headset time still counts as playtime
// (credited as VR play) while the flat 2D page is hidden.
session.addEventListener('end', () => BBArcade.xrSessionEnd());
BBArcade.xrSessionStart();

Multiplayer rooms the server referees (Multiplayer)

Game-specific real-time rooms with none of the usual trust problems: the current hide and seek reference supports 3–10 players, while each added game defines its own safe capacity. The room server runs the simulation, validates every input (speed caps, phase rules), and sends each player only the state they're allowed to see — in hide and seek, a hacked seeker client can't reveal hiders it was never sent. Auth is a short-lived signed ticket the SDK fetches from the host automatically, and results are recorded server-to-server, so clients can't forge identities or outcomes. Rooms are joined by share code today; contact us to get your game's room module enabled.

Multiplayer
// npm: import { joinRoom } from '@bountyboard/arcade-sdk/multiplayer';
// script tag: const { joinRoom } = BBArcade.multiplayer;

// Create a room (a 4-letter share code is generated), or join a friend's.
const room = await joinRoom({ create: true });   // or { code: 'ABCD' }
showShareCode(room.code);

// The server runs the game. You send INPUTS, it sends back the state you're
// allowed to see (in hide-and-seek, the seeker is never sent unfound hiders).
room.on('snapshot', ({ state }) => render(state));
room.on('playerJoin', ({ player }) => toast(player.name + ' joined'));
room.on('event', e => handleGameEvent(e));       // tags, round timers, ...
room.on('end', ({ results }) => showResults(results));

onMove(dir => room.send({ move: dir }));

// Off Bounty Board, joinRoom rejects with err.code 'unsupported' — gate your
// multiplayer menu behind that instead of breaking the whole game.

Wire it in this order (Example Event Order)

You don't have to architect this. Here's the exact sequence, top to bottom. Follow the list once and you've wired the full SDK, or stop after step three: every call is independently safe, so a half-integration is a perfectly valid integration.

  1. 1BBArcade.lockToHost() right away, before boot
  2. 2BBArcade.init({ rewardedAds: { preload: true } })
  3. 3BBArcade.getPlayer() if you personalize the UI (resolves null for guests)
  4. 4BBArcade.gameLoadingFinished() when the player can start
  5. 5BBArcade.gameplayStart() as a run begins
  6. 6BBArcade.submitScore(score) whenever the score changes
  7. 7BBArcade.gameplayStop() on death or pause
  8. 8BBArcade.prepareRewardedAd({ placement, reward, onStart }) at a natural reward moment
  9. 9Enable your "Watch ad" button only when preparation.status is "ready"
  10. 10Player taps; call prepared.show() directly before any await or scheduled work
  11. 11Grant only when the final result.status is "viewed"; every other status is non-rewarding
  12. 12BBArcade.gameOver(finalScore)
  13. 13BBArcade.save(JSON.stringify(progress)) to persist the run (hosted builds)

The whole surface (API Reference)

Every method on window.BBArcade, cross-checked against the shipped script. Canonical names are listed; aliases noted alongside them keep working forever — we never remove one once a game has shipped against it. TypeScript definitions for this exact table ship at /arcade-sdk.d.ts.

MethodSignatureReturnsNotes
initinit(options?)Promise<void>Configures modules (e.g. { rewardedAds }) and announces the game to the host. Resolves when the host answers with its config, or after ~1.5s off-host. Safe to fire-and-forget.
configureconfigure(options?)voidSame options as init(), no handshake — call any time to change settings. This is what the internal reference builds use.
readyready()voidThe player can press start. Alias: gameLoadingFinished() (Poki-style).
gameplayStartgameplayStart()voidActive play began or resumed.
gameplayStopgameplayStop()voidActive play stopped: death, pause menu, ad break.
submitScoresubmitScore(score, { mode?: 'daily' })voidCurrent integer score. Call freely — the host throttles the network posts. Pass { mode: 'daily' } when the run is your shared-seed Daily so the Today board can mark it.
gameOvergameOver(finalScore, { mode?: 'daily' })voidFinal integer score for the leaderboards (all-time + the Today board, which resets at midnight UTC).
savesave(blob)Promise<void>String blob, ~1MB max. Hosted builds + logged-in players only; rejects with an Error carrying .code ('unsupported' | 'unauthenticated' | 'too_large' | 'rejected' | 'error').
loadload()Promise<string | null>null when there is no save yet. Rejects like save() — catch for logged-out players.
getPlayergetPlayer()Promise<{ name, avatarUrl } | null>The logged-in player's display name + avatar URL for in-game UI. Resolves null for guests and standalone play, so always handle it. Display identity only — never ids, emails, or roles.
getVariantgetVariant(key, ['a', 'b'])Promise<string | null>A/B testing: a deterministic even-split variant per player, game, and key (logged-in players keep it across devices). Falls back to the alphabetically-first variant — treat that as your control — on standalone play or any error. Assignments show up split-by-variant in your studio analytics automatically.
prepareRewardedAdprepareRewardedAd(options?)Promise<ReadyHandle | Failure>Recommended rewarded flow. Keep your button disabled while preparing; when status is 'ready', enable it and call the returned handle's show() directly inside the player's click/tap handler. Grant only on the final 'viewed' status. Alias: prepareRewardedBreak().
rewardedAdrewardedAd(options?)Promise<{ status, ... }>Low-level placement API with per-event callbacks; status is 'viewed' | 'dismissed' | 'ready' | 'unavailable' | 'error'. Only 'viewed' rewards; 'ready' without adViewed is non-rewarding. Alias: showRewardedAd().
rewardedBreakrewardedBreak(options | onStart)Promise<boolean>Deprecated compatibility helper for shipped one-call integrations. It resolves true only after a completed view. New games should use prepareRewardedAd() so show() runs directly in the player gesture.
preloadRewardedAdspreloadRewardedAds(options?)Promise<boolean>Warms up the ads library so the first placement is ready. Alias: preloadRewardedAd().
lockToHostlockToHost({ allow?, signed?, onBlocked?, redirect? })voidRefuse to run off allowed hosts (anti scrape-and-reupload). Call before boot. Pass signed: true to demand a cryptographically signed origin attestation from the host, verified with a public key baked into the SDK — a re-hosting site can neither forge nor replay it.
xrSessionStartxrSessionStart() / xrSessionEnd()voidBracket immersive WebXR sessions so headset time counts as playtime.
multiplayer.joinRoommultiplayer.joinRoom({ code? | create? })Promise<Room>Authoritative multiplayer rooms with game-defined capacity (the current hide-and-seek reference supports 3–10 players). Resolves a Room (send inputs, subscribe to per-viewer snapshots and events); rejects with .code 'unsupported' off Bounty Board. npm builds import { joinRoom } from '@bountyboard/arcade-sdk/multiplayer'.
versionversionnumberSDK protocol version (currently 1) — matches the v field on every message.

What's shipping next (Coming Soon)

Integrating now means you're first in line for what's coming. Listed honestly so you can plan; don't build against these until they ship, but know the platform you're joining is still accelerating.

  • SoonSigned score verification

    Cryptographically verified score submission. Today the boards run on per-game plausibility caps and session checks — solid against casual cheating, not against a determined attacker.

  • SoonMultiplayer quick-match

    Rooms ship today via share codes. Public matchmaking (join a stranger with one tap) is the next multiplayer milestone.