npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/mcp

It 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-sdk
import { 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 over

gameplayStart() 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() resolves null;
  • 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 | error

Engine 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:

  1. 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.
  2. Refereed Bounty-hosted modules: a curated server module validates inputs, runs the only simulation, and emits authoritative results.
  3. 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_end for 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, and viewed; only viewed may grant.
  • Test multiplayer disconnect/reconnect, trySend() === false, viewer-safe snapshots, and intentional leave().
  • 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

License

MIT licensed.