@bountyboard/arcade-sdk 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -19,18 +19,28 @@ https://www.bountyboard.gg/arcade/sdk/llms.txt — this file is about using it C
19
19
  the pattern for new integrations.
20
20
  3. **Scores are integers and `gameOver` fires exactly once per run.** Don't submit
21
21
  post-game-over corrections; the server enforces per-game plausibility caps and rejects
22
- implausible values. Call `submitScore` freely during play — the host throttles.
22
+ implausible values. Call `submitScore` freely during play — the host throttles. Wiring the
23
+ calls is only half of a leaderboard: the studio must also declare Leaderboards on the game
24
+ in its Arcade submission, or the host rejects every score and the game never learns why.
23
25
  4. **Always handle `getPlayer() === null`** (guests, standalone, off-host). The SDK never
24
26
  provides ids, emails, or roles — do not design features that need them.
25
27
  5. **One save blob.** Serialize the whole progress object into a single `save(string)` (~1MB
26
28
  cap). Save on checkpoints/game-over, never per frame. Handle all five rejection codes; keep
27
29
  localStorage as the standalone fallback (hosted builds have no localStorage — the sandbox is
28
- opaque-origin — so the SDK save IS primary there).
30
+ opaque-origin, where reading it THROWS rather than returning empty — so the SDK save IS
31
+ primary there). **Exception: engine exports.** When the game is a GameMaker/Godot/Unity/
32
+ Construct build whose storage layer can't be rewired without patching engine internals, use
33
+ `storage.install()` instead: it swaps the throwing `localStorage` for a cloud-backed shim. The
34
+ script tag installs it for you; module consumers call it before boot. `getItem` is synchronous
35
+ and the cloud read is not, so anything reading progress at boot must await `storage.ready()`.
29
36
  6. **`lockToHost()` before boot** when the studio wants anti-theft, with their own domains in
30
- `allow`. It's a deterrent, not DRM — never present it as more.
31
- 7. **Multiplayer: clients send inputs, never state.** The room server simulates and validates;
32
- design the game so all authority lives server-side. Don't trust or display any value another
33
- client sent directly.
37
+ `allow`. It's a deterrent, not DRM — never present it as more. Bounty Board's own hosts and
38
+ localhost are always allowed, so a build Bounty Board hosts passes no `allow` entries; never
39
+ ship the `play.yourgame.com` placeholder from the docs as if it were a real value.
40
+ 7. **Multiplayer: clients send inputs, never state.** The approved room authority (a Bounty Board
41
+ module or a registered external server) simulates and validates; design the game so all
42
+ authority lives in that one server. Don't trust or display any value another client sent
43
+ directly, and never run a second copy of a mature external simulation in a relay/Worker.
34
44
 
35
45
  ## Correct lifecycle order
36
46
 
@@ -71,10 +81,12 @@ the safe/default experience the alphabetically-first name. Don't re-randomize cl
71
81
  `'unavailable'` locally is normal. Mock both a ready placement and an unavailable placement;
72
82
  assert that `show()` is called directly by your UI handler and only final status `'viewed'`
73
83
  grants the reward.
74
- - Multiplayer: run the room server locally (`wrangler dev` with `DEV_ALLOW_UNSIGNED=1`) and pass
75
- `{ roomUrl, ticket }` overrides to `joinRoom` for development. Exercise a disconnect/reconnect,
76
- assert `trySend()` returns false while offline, and verify the authoritative module validates the
77
- optional `joinData` rather than treating avatar/loadout/mode selections as trusted state.
84
+ - Multiplayer: for Bounty-hosted modules, run the room server locally (`wrangler dev` with
85
+ `DEV_ALLOW_UNSIGNED=1`) and pass `{ roomUrl, ticket }` overrides to `joinRoom`. For a registered
86
+ external authority, exercise its isolated signed-ticket adapter endpoint. In both cases test a
87
+ disconnect/reconnect, assert `trySend()` returns false while offline, and verify the authority
88
+ validates optional `joinData` rather than treating avatar/loadout/mode selections as trusted
89
+ state. See `docs/external-authoritative-servers.md` before adapting an existing server.
78
90
 
79
91
  ## Design intent (read docs/game-design-playbook.md before building a NEW game)
80
92
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,78 @@
1
1
  # @bountyboard/arcade-sdk
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0f53ae3: Add `BBArcade.storage`, a localStorage compatibility shim for engine exports.
8
+
9
+ Bounty-Board-hosted builds run on an opaque origin where `localStorage` throws
10
+ rather than returning empty. Games that own their source can just call
11
+ `save()`/`load()`, but engine runtimes bake synchronous storage in: GameMaker
12
+ HTML5 routes `ini_open`/`ini_write_*`/`game_save` through it, as do Godot,
13
+ Unity, and Construct.
14
+
15
+ `storage.install()` swaps the throwing `localStorage` for a Storage-shaped
16
+ object backed by the SDK's cloud save, so those builds persist per player and
17
+ across devices with no engine changes. It is idempotent and a no-op wherever a
18
+ real Storage works, so standalone play and URL embeds are untouched. The
19
+ script-tag build installs it at load; module consumers keep a side-effect-free
20
+ import and call it themselves before boot.
21
+
22
+ `ready()` resolves once the cloud read lands (`getItem` is synchronous, the read
23
+ is not), `flush()` forces the debounced write out, and `mode` reports
24
+ `'native' | 'cloud' | 'memory'`. `sessionStorage` is shimmed memory-only. With
25
+ the shim active `save()`/`load()` share the player's one save slot through a
26
+ tagged envelope that `load()` unwraps; a raw blob written before the shim
27
+ existed reads back untouched.
28
+
29
+ The shim never writes over a save it has not successfully read. A read that
30
+ FAILS settles just like one that succeeds, so persisting on that basis would
31
+ serialize only the current session's keys and wipe the rest; instead a failed
32
+ read is retried on the next write, and a write that still cannot confirm what
33
+ the player had is refused rather than allowed to overwrite it blind. `save()`
34
+ is size-checked against the whole envelope before it commits, so a rejected
35
+ oversized blob cannot leave the session unable to write at all. Keys named
36
+ `__proto__` are stored and restored like any other, matching a real `Storage`.
37
+
38
+ ## 1.2.0
39
+
40
+ ### Minor Changes
41
+
42
+ - c2854a7: Support curated external authoritative multiplayer servers with per-game ticket keys while preserving the existing `joinRoom()` API and Bounty-hosted room modules.
43
+ - 81fdada: Multiplayer: public quick match. `joinRoom({ match: true })` joins the game's
44
+ open public room (or starts a fresh one when no seat is free) instead of
45
+ requiring a share code. Rooms fill to the game's player cap (at most 64), a
46
+ matched room still exposes `room.code` for inviting friends, and the SDK
47
+ automatically re-matchmakes up to twice when it loses a last-seat race
48
+ (`error.detail` now carries the raw server reason, e.g. `room_full`).
49
+ - 387a347: Player-identity change notifications, multiplayer tuning hooks, and tighter failure semantics.
50
+
51
+ - Add `BBArcade.onPlayerChange(handler)`: fires when the host delivers a changed display identity (mid-session login/logout); returns an unsubscribe function.
52
+ - Add `joinRoom({ timeoutMs })` to override the 10-second welcome timeout (clamped to 1000–60000 ms, applies to reconnects too), and `room.latencyMs`, a join-handshake latency estimate refreshed on every (re)connect.
53
+ - `save()` now rejects oversized blobs with `too_large` immediately via a client-side precheck against the same 1 MiB cap the server enforces.
54
+ - A rewarded placement whose break ends before a prepared ad is ever shown now resolves `dismissed` instead of the non-terminal `ready` (the shown-but-unreported legacy edge keeps `ready`).
55
+ - `lockToHost()`'s opaque-origin host handshake times out after ~5 seconds instead of 15, so scraped copies are blocked sooner.
56
+ - `leave()` during an in-flight join/reconnect settles immediately, and a welcome arriving after `leave()` is ignored instead of resurrecting the room.
57
+ - Fractional scores are truncated toward zero (`Math.trunc`), matching the documented contract (previously floored, which differed for negative scores).
58
+
59
+ ### Patch Changes
60
+
61
+ - fb26238: Use the declared multiplayer SDK integration when authorizing room tickets and
62
+ server-authoritative result reports.
63
+ - 8289ae5: Replace private-repository documentation links with public Bounty Board URLs and rewrite the external authoritative server guide as a vendor-neutral integration contract.
64
+ - 145d951: Document the built-in relay room tier: every multiplayer-approved game gets
65
+ hosted casual lobbies (public quick match, invite codes, host succession,
66
+ rate-guarded public message fan-out) with zero server code, via the existing
67
+ `joinRoom()` API. New `docs/relay-rooms.md` covers the wire contract — the
68
+ `joinData.roomSize` founding rule (2–64, default 8), `relay`/`relay_host`
69
+ events, the 1 KiB / 15 msg/s / 120 msg/room/s guardrails, and the
70
+ client-trusted outcome model (relay rooms never emit `end` or report results).
71
+ No runtime code changes.
72
+ - f55f6e6: Multiplayer: treat the server's 4005 seat-full close as terminal. A seat
73
+ already holding its maximum concurrent sockets now rejects newcomers, and the
74
+ client stops retrying instead of looping ticket requests against a full seat.
75
+
3
76
  ## 1.1.0
4
77
 
5
78
  ### Minor Changes
package/README.md CHANGED
@@ -1,12 +1,15 @@
1
1
  # @bountyboard/arcade-sdk
2
2
 
3
- Connect your HTML5 game to the [Bounty Board arcade](https://www.bountyboard.gg/arcade):
4
- global leaderboards, cross-device cloud saves, player identity, A/B variants, rewarded-ad
5
- revenue (90% studio share), site-lock anti-theft, and authoritative multiplayer rooms.
6
- Zero dependencies. Every call is optional and safe standalone — off Bounty Board the SDK
7
- quietly does nothing, so the same build runs anywhere.
3
+ Add Bounty Board leaderboards, cloud saves, player display identity, A/B variants,
4
+ rewarded ads, site lock, WebXR playtime, and authoritative multiplayer to an
5
+ HTML5 game.
8
6
 
9
- ## Install
7
+ - Zero runtime dependencies
8
+ - ESM, CommonJS, global, and strict TypeScript declarations
9
+ - Safe to import during SSR or prerendering
10
+ - Safe standalone behavior: the SDK never becomes a requirement for booting or playing
11
+
12
+ ## Quickstart
10
13
 
11
14
  ```bash
12
15
  npm install @bountyboard/arcade-sdk
@@ -15,55 +18,223 @@ npm install @bountyboard/arcade-sdk
15
18
  ```ts
16
19
  import { BBArcade } from '@bountyboard/arcade-sdk';
17
20
 
18
- BBArcade.lockToHost(); // optional: refuse to run on scrape-and-reupload sites
19
- BBArcade.init(); // handshake with the host (fire-and-forget is fine)
20
- BBArcade.gameLoadingFinished(); // the player can press start
21
- BBArcade.gameplayStart(); // a run began
22
- BBArcade.submitScore(score); // as the score changes (host throttles)
23
- BBArcade.gameOver(finalScore); // once per run — feeds the leaderboards
21
+ // Optional; call before boot. 'play.yourgame.com' is a placeholder: allow lists
22
+ // the domains YOU host on, and Bounty Board plus localhost are always allowed,
23
+ // so a Bounty-hosted build needs no allow list (or no call at all).
24
+ BBArcade.lockToHost({ allow: ['play.yourgame.com'] });
25
+ void BBArcade.init(); // host handshake; awaiting is optional
26
+
27
+ export function onGameReady() {
28
+ BBArcade.gameLoadingFinished();
29
+ }
30
+
31
+ export function onRunStart() {
32
+ BBArcade.gameplayStart();
33
+ }
34
+
35
+ export function onScoreChange(score: number) {
36
+ BBArcade.submitScore(Math.trunc(score));
37
+ }
38
+
39
+ export function onPause() {
40
+ BBArcade.gameplayStop();
41
+ }
42
+
43
+ export function onRunEnd(finalScore: number) {
44
+ BBArcade.gameplayStop();
45
+ BBArcade.gameOver(Math.trunc(finalScore)); // exactly once per run
46
+ }
24
47
  ```
25
48
 
26
- Full TypeScript definitions are included. Importing the module never installs a
27
- `window.BBArcade` global and is SSR-safe; if your setup wants the global, use
28
- `import '@bountyboard/arcade-sdk/global'`.
49
+ No build step? Load the same SDK as a global:
29
50
 
30
- No build step? Use the script tag instead — same code, same protocol:
51
+ ```html
52
+ <script src="https://www.bountyboard.gg/arcade-sdk/v1.js"></script>
53
+ <script>
54
+ void BBArcade.init();
55
+
56
+ // Call this from your game when assets and the first scene are playable.
57
+ function onGameReady() {
58
+ BBArcade.gameLoadingFinished();
59
+ }
60
+ </script>
61
+ ```
62
+
63
+ The script tag installs `window.BBArcade`. Do not mix it with the npm package
64
+ in one page. Standalone declarations are available from
65
+ [`/arcade-sdk.d.ts`](https://www.bountyboard.gg/arcade-sdk.d.ts). If a bundler
66
+ specifically needs the global build, import `@bountyboard/arcade-sdk/global`.
67
+
68
+ Bundle note: importing the package attaches the host `postMessage` listeners
69
+ immediately (the host pushes its config at load time, so listener timing is
70
+ part of the protocol), so the package is honestly marked `sideEffects: true`
71
+ and will not tree-shake. The full script-tag build is ~55 KB unminified;
72
+ module consumers who only use multiplayer still get the whole core.
73
+
74
+ ## Runtime support
75
+
76
+ npm versus script tag only changes how the SDK loads. The environment where
77
+ the game runs determines which host-backed features are available.
78
+
79
+ | Capability | Bounty-hosted upload | Approved URL embed inside Bounty Board | Standalone / own site |
80
+ | --------------------------------- | -------------------- | -------------------------------------- | --------------------- |
81
+ | Lifecycle, scores, host analytics | Supported | Supported | Safe no-op |
82
+ | Player identity | Player or `null` | Player or `null` | `null` |
83
+ | A/B variants | Stable assignment | Stable assignment | Alphabetical control |
84
+ | Cloud save / load | Logged-in players | Unsupported | Unsupported |
85
+ | Native `localStorage` | Throws (opaque) | Works on your origin | Works |
86
+ | Rewarded ads | Approved games | Unavailable (no payable attribution) | Unavailable |
87
+ | Multiplayer | Enabled authority | Enabled authority | Unsupported |
88
+
89
+ An externally hosted URL opened directly is standalone play. The same URL
90
+ inside the approved Bounty Board player is a URL embed. Guests are normal:
91
+ identity resolves `null`, and account-backed promises may reject with
92
+ `code: 'unauthenticated'`.
93
+
94
+ ## Correct lifecycle
95
+
96
+ ```text
97
+ lockToHost() -> init() -> [getPlayer()] -> gameLoadingFinished()
98
+ -> per run: gameplayStart() -> submitScore()* -> gameplayStop() -> gameOver()
99
+ -> save() at checkpoints or game over
100
+ ```
101
+
102
+ `gameplayStart()` and `gameplayStop()` bracket active play, not menus. Call
103
+ `gameOver()` exactly once per run and submit integer scores. Pass
104
+ `{ mode: 'daily' }` to both score calls for a shared-seed Daily run.
105
+
106
+ The SDK must never be load-bearing. With no host present:
107
+
108
+ - fire-and-forget signals no-op;
109
+ - `init()` resolves after a short grace;
110
+ - `getPlayer()` resolves `null`;
111
+ - `getVariant()` returns the alphabetically first control;
112
+ - save, load, ads, and multiplayer settle quickly with their documented
113
+ unsupported/unavailable outcomes.
114
+
115
+ Your game must still boot and remain fully playable when all of those outcomes
116
+ happen at once.
117
+
118
+ ## Cloud saves
119
+
120
+ Store the whole progress model as one string blob (about 1 MB maximum). Save at
121
+ checkpoints or game over, never in a hot loop.
122
+
123
+ ```ts
124
+ import type { BBArcadeError } from '@bountyboard/arcade-sdk';
125
+
126
+ const blob = JSON.stringify(progress);
127
+
128
+ try {
129
+ await BBArcade.save(blob);
130
+ } catch (error) {
131
+ const code = (error as BBArcadeError).code;
132
+ if (code === 'unsupported') localStorage.setItem('progress', blob);
133
+ }
134
+
135
+ try {
136
+ const saved = await BBArcade.load(); // string or null when no save exists
137
+ if (saved) restore(JSON.parse(saved));
138
+ } catch {
139
+ restoreStandaloneProgress();
140
+ }
141
+ ```
142
+
143
+ Hosted uploads run in an opaque-origin sandbox without `localStorage`; SDK
144
+ cloud save is primary there. URL embeds and standalone builds need their own
145
+ same-origin storage. Oversized blobs reject `too_large` immediately (a
146
+ client-side precheck against the same 1 MiB cap the server enforces), and a
147
+ host that never answers rejects `error` after a 15-second request timeout —
148
+ the same timeout covers load, variant, and multiplayer-ticket requests. Every
149
+ save/load rejection is a real `Error` with a typed `code`:
150
+
151
+ ```text
152
+ unsupported | unauthenticated | too_large | rejected | error
153
+ ```
154
+
155
+ ## Engine exports: the localStorage shim
156
+
157
+ Prefer `save()`/`load()` when you control the source. The shim is for engine
158
+ runtimes whose storage layer can't be rewired without patching engine
159
+ internals — GameMaker HTML5 (`ini_open`, `ini_write_*`, and `game_save` all sit
160
+ on `localStorage`), Godot, Unity, Construct. It replaces the throwing
161
+ `localStorage` with a Storage-shaped object backed by your cloud save, so those
162
+ builds persist per player and across devices with no engine changes.
163
+
164
+ The script-tag build installs it at load, so the only requirement is load order:
31
165
 
32
166
  ```html
33
167
  <script src="https://www.bountyboard.gg/arcade-sdk/v1.js"></script>
168
+ <script src="html5game/YourGame.js"></script>
169
+ ```
170
+
171
+ Module consumers keep a side-effect-free import and install it themselves,
172
+ before any engine code runs:
173
+
174
+ ```ts
175
+ BBArcade.storage.install(); // idempotent; no-op where a real Storage works
176
+
177
+ // getItem() is synchronous but the cloud read that fills it is not.
178
+ const mode = await BBArcade.storage.ready(); // 'cloud' | 'memory' | 'native'
179
+ startGame();
34
180
  ```
35
181
 
36
- with standalone typings at [`/arcade-sdk.d.ts`](https://www.bountyboard.gg/arcade-sdk.d.ts).
182
+ Writes made before hydration are kept and merged with the cloud read (your
183
+ write wins; a `removeItem` isn't resurrected), and nothing is pushed to the
184
+ server until that read has SUCCEEDED — a failed read is retried, and a write
185
+ that still can't confirm what the player had is refused rather than allowed to
186
+ overwrite it blind, so neither a fresh-start write at boot nor a transport blip
187
+ can wipe an existing save. `sessionStorage` is shimmed too, but memory-only. Guests and
188
+ standalone play settle in `memory` mode: storage works for the session and is
189
+ never persisted. `setItem` throws `QuotaExceededError` past the save cap, like
190
+ a real Storage.
191
+
192
+ `save()`/`load()` keep working alongside it: the player has one save slot, so
193
+ both halves share it inside a tagged envelope that `load()` unwraps. A raw blob
194
+ written before the shim existed reads back untouched.
195
+
196
+ ## Player identity and A/B variants
37
197
 
38
- ## The surface in one look
198
+ The SDK only provides public display identity. It never exposes account ids,
199
+ emails, or roles.
39
200
 
40
- | Call | What it does |
41
- | ---------------------------------------- | --------------------------------------------------------------------------- |
42
- | `lockToHost({ allow? , signed? })` | Block scraped re-hosts of your build (deterrent, not DRM) |
43
- | `init(options?)` / `configure(options?)` | Configure modules (rewarded ads), announce to the host |
44
- | `ready()` / `gameLoadingFinished()` | Loading finished, player can start |
45
- | `gameplayStart()` / `gameplayStop()` | Bracket active play (playtime + feed ranking) |
46
- | `submitScore(n, { mode?: 'daily' })` | Live integer score; server enforces plausibility caps |
47
- | `gameOver(n, { mode?: 'daily' })` | Final score, once per run |
48
- | `save(blob)` / `load()` | 1MB cloud save per player (hosted builds; always catch rejections) |
49
- | `getPlayer()` | `{ name, avatarUrl }` or `null` — display identity only, always handle null |
50
- | `getVariant(key, variants)` | Deterministic A/B split; alphabetical first variant = control |
51
- | `prepareRewardedAd(opts)` | Prepare an ad; call ready `show()` directly from the player's click |
52
- | `rewardedBreak(opts)` | Legacy one-call ad flow (kept for compatibility; avoid in new integrations) |
53
- | `xrSessionStart()` / `xrSessionEnd()` | Keep WebXR headset time counting as playtime |
54
- | `multiplayer.joinRoom(...)` | Authoritative multiplayer rooms — see below |
201
+ ```ts
202
+ const player = await BBArcade.getPlayer();
203
+ if (player) {
204
+ greet(player.name);
205
+ if (player.avatarUrl) drawAvatar(player.avatarUrl);
206
+ }
55
207
 
56
- Rejections from `save()`/`load()` are real `Error`s carrying a typed machine-readable
57
- `code`: `'unsupported' | 'unauthenticated' | 'too_large' | 'rejected' | 'error'`.
208
+ // Alphabetical first is the standalone/error control: "control" here.
209
+ const variant = await BBArcade.getVariant('start-cta', ['control', 'short-label']);
210
+ renderStartButton(variant ?? 'control');
211
+ ```
58
212
 
59
- ## Rewarded ads without losing the player's click
213
+ Always handle `getPlayer() === null` and `avatarUrl === null`. Variant
214
+ assignments are stable per player, game, and key. Do not re-randomize them in
215
+ the client.
60
216
 
61
- Google can report that an ad is ready asynchronously. Prepare at the natural break (for
62
- example, when the defeat panel opens), then call the retained `show()` as the first SDK action
63
- inside the player's explicit Watch Ad click. Do not `await`, queue a microtask, or schedule a
64
- timer before `show()`.
217
+ `getPlayer()` is a one-shot read of the identity at handshake time. To react
218
+ when the player logs in or out mid-session, subscribe to changes:
65
219
 
66
220
  ```ts
221
+ const unsubscribe = BBArcade.onPlayerChange(player => {
222
+ // Fires only when the identity actually changes; null means logged out.
223
+ updateGreeting(player);
224
+ });
225
+ // Later, when the UI is gone: unsubscribe();
226
+ ```
227
+
228
+ ## Rewarded ads: prepare, then show from the player gesture
229
+
230
+ New integrations should use `prepareRewardedAd()`. Prepare when a natural
231
+ reward panel opens, keep the Watch Ad control disabled until the result is
232
+ `ready`, and call its one-shot `show()` directly inside the click/tap handler.
233
+
234
+ ```ts
235
+ const button = document.querySelector<HTMLButtonElement>('#watch-ad')!;
236
+ button.disabled = true;
237
+
67
238
  const prepared = await BBArcade.prepareRewardedAd({
68
239
  placement: 'death_revive',
69
240
  reward: 'extra_life',
@@ -71,64 +242,186 @@ const prepared = await BBArcade.prepareRewardedAd({
71
242
  });
72
243
 
73
244
  if (prepared.status === 'ready') {
74
- watchAdButton.disabled = false;
75
- watchAdButton.onclick = () => {
76
- const result = prepared.show(); // synchronous: keep this first in the click handler
77
- void result.then(final => {
78
- if (final.status === 'viewed') revivePlayer();
79
- else showAdUnavailable(final.error, final.breakStatus);
80
- });
81
- };
245
+ button.disabled = false;
246
+ button.addEventListener(
247
+ 'click',
248
+ () => {
249
+ // Keep first: no await, timer, microtask, or network call before show().
250
+ const resultPromise = prepared.show();
251
+ button.disabled = true;
252
+
253
+ void resultPromise.then(result => {
254
+ resumeGameAndAudio();
255
+ if (result.status === 'viewed') revivePlayer();
256
+ else keepNormalFallbackAvailable();
257
+ });
258
+ },
259
+ { once: true }
260
+ );
82
261
  } else {
83
- showAdUnavailable(prepared.error, prepared.breakStatus);
262
+ keepNormalFallbackAvailable();
84
263
  }
85
264
  ```
86
265
 
87
- `show()` is one-shot and always returns the same final-result promise. Grant the reward only
88
- when `status === 'viewed'`; a raw `breakStatus` string is diagnostic context, not proof that
89
- Google fired its completion callback. `rewardedBreak()` remains available so existing games do
90
- not break, but new integrations should use the prepared flow because an asynchronous
91
- `beforeReward` callback cannot reliably preserve browser user activation.
266
+ Only the final `status === 'viewed'` grants a reward. `dismissed`,
267
+ `unavailable`, and `error` are non-rewarding; a prepared ad whose break ends
268
+ before `show()` is ever called also resolves `dismissed`, never `ready`.
269
+ Never auto-trigger, loop, or auto-retry an ad. `rewardedBreak()` remains for
270
+ compatibility with shipped games but cannot preserve direct browser user
271
+ activation in every case.
272
+
273
+ ## Multiplayer rooms
92
274
 
93
- ## Multiplayer
275
+ SDK 1.1 adds production room support with bounded `joinData`, observable
276
+ reconnect state, and explicit `trySend()` backpressure. SDK 1.2 adds public
277
+ quick match.
278
+
279
+ Multiplayer is enabled per game and runs on one of three rails, all behind the
280
+ same `joinRoom()` transport:
281
+
282
+ 1. **Built-in relay rooms** (the default): every multiplayer-approved game
283
+ gets hosted casual lobbies with zero server code. The relay owns signed
284
+ admission, roster, seat capacity, host succession, and rate-guarded public
285
+ message fan-out; it never interprets payloads, never settles, and never
286
+ reports results, so relay outcomes are client-trusted. See the
287
+ [relay room guide](https://www.bountyboard.gg/arcade/sdk/relay-rooms.txt).
288
+ 2. **Refereed Bounty-hosted modules**: a curated server module validates
289
+ inputs, runs the only simulation, and emits authoritative results.
290
+ 3. **Registered external authorities**: a mature game keeps its own server
291
+ with a dedicated ticket key.
292
+
293
+ The platform assigns the rail per game slug; clients cannot select it.
94
294
 
95
295
  ```ts
96
296
  import { joinRoom } from '@bountyboard/arcade-sdk/multiplayer';
97
297
 
98
298
  const room = await joinRoom({
99
- create: true, // or code: 'ABCD'
100
- joinData: { avatar: 'golem', mode: 'ffa' }, // untrusted, validated by your module
299
+ create: true, // or code: 'ABCD', or match: true for public quick match
300
+ joinData: { avatar: 'golem', mode: 'ffa' }, // untrusted; server validates
101
301
  });
102
- room.on('snapshot', ({ state }) => render(state)); // server-authoritative state
103
- room.on('end', ({ results }) => showResults(results));
104
- room.on('connection', status => showReconnecting(!status.connected));
105
- if (!room.trySend({ move: dir })) showReconnecting(true); // inputs only
302
+
303
+ showShareCode(room.code);
304
+ room.on('snapshot', ({ state }) => renderAuthoritativeState(state));
305
+ room.on('connection', ({ connected }) => setReconnecting(!connected));
306
+ room.on('end', ({ results }) => showResults(results)); // finite games only
307
+
308
+ onPlayerInput(input => {
309
+ if (!room.trySend(input)) setReconnecting(true);
310
+ });
311
+
312
+ onExit(() => room.leave());
313
+ ```
314
+
315
+ On the refereed and external rails, clients send inputs, never authoritative
316
+ state. On the relay rail, `room.send(data)` fans the payload publicly to every
317
+ player (including the sender) in per-tick batches of
318
+ `{ type: 'relay', messages: [{ from, data }] }`; the room's first joiner fixes
319
+ the seat count with `joinData.roomSize` (an integer 2–64, default 8), the
320
+ oldest retained seat is host (`state.hostId`, changes broadcast as
321
+ `{ type: 'relay_host', hostId }`), and traffic is capped at 1 KiB per message,
322
+ 15 messages per player per second, and 120 per room per second.
323
+
324
+ Two tuning hooks: `joinRoom({ timeoutMs })` overrides how long a join (and
325
+ each reconnect attempt) waits for the server's welcome before rejecting with
326
+ `code: 'error'` (default 10000, clamped to 1000–60000), and `room.latencyMs`
327
+ reports the join-handshake latency (socket open to server welcome, refreshed
328
+ on every reconnect; `null` until the first welcome) — an estimate to tune
329
+ render-side smoothing against, not a measured RTT.
330
+
331
+ Rooms are lobby-based: each game module declares its player range (up to 64
332
+ per room). `match: true` is public quick match — Bounty Board fills the game's
333
+ open public room and starts a fresh one when no seat is free; the matched
334
+ room's `room.code` still works as an invite code. When the last free seat is
335
+ lost to a race the SDK re-matchmakes automatically (up to two retries) before
336
+ rejecting with `code: 'rejected'` and `detail: 'room_full'`. Quick match is
337
+ mutually exclusive with `code`/`create` and is unavailable on external
338
+ authorities, which run their own rooms.
339
+
340
+ ### Latency model
341
+
342
+ Refereed and external rooms are state-sync only: the server simulates, clients receive per-viewer
343
+ snapshots at the room tick rate, and every input takes a network round trip
344
+ before its effect appears in a snapshot. There is no built-in client-side
345
+ prediction, interpolation, or rollback. Design for it: turn-based, timing-duel,
346
+ score-race, and party games feel native; twitch physics (fighting games,
347
+ precision platform duels) need your own render-side smoothing — interpolate
348
+ between snapshots and animate optimistic feedback for the local player's input,
349
+ but treat the next snapshot as truth. Assume 50-150 ms of input-to-snapshot
350
+ latency on real connections when tuning game feel. (Relay rooms differ: game
351
+ messages arrive as public per-tick event batches, and all game rules live in
352
+ your clients.) Every referee or external authority must:
353
+
354
+ - validate admission, `joinData`, and every input;
355
+ - clear held controls on disconnect;
356
+ - run the only simulation;
357
+ - serialize only viewer-safe state;
358
+ - return server-authored results for finite games;
359
+ - avoid fabricating `match_end` for endless rooms.
360
+
361
+ `joinData` is untrusted JSON capped at 1 KiB and must not contain credentials or
362
+ other secrets. External games keep their existing simulation and add only the
363
+ signed-ticket + SDK envelope at an isolated authenticated endpoint. Read the
364
+ public [external authoritative server contract](https://www.bountyboard.gg/arcade/sdk/external-authority)
365
+ before adapting an existing server.
366
+
367
+ ## Site lock and WebXR
368
+
369
+ ```ts
370
+ // Anti-rehosting deterrent, not DRM. Call before boot. When the embedder
371
+ // can't be read from browser signals (opaque-origin sandbox), the SDK asks
372
+ // the embedding page and blocks if nothing answers within ~5 seconds.
373
+ // The allowed domains below are placeholders for your own; Bounty Board and
374
+ // localhost are allowed by default, so a Bounty-hosted build passes none.
375
+ BBArcade.lockToHost({
376
+ allow: ['play.yourgame.com', 'preview.yourgame.com'],
377
+ signed: true,
378
+ });
379
+
380
+ // WebXR only: keep immersive playtime visible to the host.
381
+ session.addEventListener('end', () => BBArcade.xrSessionEnd());
382
+ BBArcade.xrSessionStart();
106
383
  ```
107
384
 
108
- Rooms run on Bounty Board's authoritative room servers: clients send inputs, the server
109
- validates and simulates, and each player receives only the state they're allowed to see
110
- (your hiders stay hidden). Auth is a short-lived signed ticket the SDK obtains from the
111
- host automatically; off Bounty Board `joinRoom` rejects with `code: 'unsupported'`.
112
- `joinData` is limited to a 1 KiB JSON object, is never trusted or broadcast by the
113
- platform, and must not contain secrets. The room runtime supports separate simulation
114
- and snapshot rates (for example 60 Hz simulation / 30 Hz snapshots), a 15-second seat
115
- grace for automatic reconnects, and bounded server frames.
116
-
117
- Multiplayer is currently a curated integration: Bounty Board must add and deploy a
118
- server-side game module for your slug before `joinRoom()` can accept players. The
119
- initial reference module is `hide-and-seek`; arbitrary games do not become
120
- authoritative multiplayer games merely by importing the client SDK. A production
121
- module must validate join data and every input, clear held controls on disconnect,
122
- serialize only per-viewer-safe state, and return final authoritative standings.
123
-
124
- ## Docs
125
-
126
- - Human docs: https://www.bountyboard.gg/arcade/sdk
127
- - Agent-readable reference: https://www.bountyboard.gg/arcade/sdk/llms.txt
128
- - Integration best practices for coding agents: [`AGENTS.md`](./AGENTS.md)
129
- - What makes a game perform on Bounty Board: [`docs/game-design-playbook.md`](./docs/game-design-playbook.md)
130
- - Submit your game: https://www.bountyboard.gg/arcade/submit
385
+ ## Test before submitting
386
+
387
+ - Run the production artifact without a Bounty Board parent and confirm the
388
+ whole game remains playable.
389
+ - Verify ready, active-play, pause/resume, and exactly-one game-over transitions.
390
+ - Test guest, no-save, unsupported, too-large, and transport-failure save paths.
391
+ - Test rewarded `ready`, `unavailable`, `dismissed`, `error`, and `viewed`; only
392
+ `viewed` may grant.
393
+ - Test multiplayer disconnect/reconnect, `trySend() === false`, viewer-safe
394
+ snapshots, and intentional `leave()`.
395
+ - Keep ads and multiplayer controls unavailable until their capabilities are
396
+ actually ready; do not make the rest of the game wait.
397
+
398
+ Give coding agents the public
399
+ [agent-readable verification contract](https://www.bountyboard.gg/arcade/sdk/llms.txt).
400
+
401
+ ## API at a glance
402
+
403
+ | Area | Calls |
404
+ | ------------- | ------------------------------------------------------------------------------------- |
405
+ | Lifecycle | `init`, `configure`, `ready` / `gameLoadingFinished`, `gameplayStart`, `gameplayStop` |
406
+ | Scores | `submitScore`, `gameOver` |
407
+ | Player data | `save`, `load`, `storage.install` / `ready` / `flush`, `getPlayer`, `onPlayerChange`, `getVariant` |
408
+ | Rewarded ads | `prepareRewardedAd`, `rewardedAd`, `rewardedBreak`, `preloadRewardedAds` |
409
+ | Security / XR | `lockToHost`, `xrSessionStart`, `xrSessionEnd` |
410
+ | Multiplayer | `joinRoom`, `room.trySend`, `room.on`, `room.leave`, `room.latencyMs` |
411
+
412
+ The package declarations are the exact type reference. The script-tag build
413
+ exposes multiplayer at `BBArcade.multiplayer.joinRoom(...)`; module consumers
414
+ import `joinRoom` from `@bountyboard/arcade-sdk/multiplayer`.
415
+
416
+ ## More documentation
417
+
418
+ - [Human integration guide](https://www.bountyboard.gg/arcade/sdk)
419
+ - [Agent-readable API and integration contract](https://www.bountyboard.gg/arcade/sdk/llms.txt)
420
+ - [Release changelog](https://www.bountyboard.gg/arcade/sdk/changelog.txt)
421
+ - [Game design playbook](https://www.bountyboard.gg/arcade/sdk/game-design-playbook.txt)
422
+ - [External authoritative server contract](https://www.bountyboard.gg/arcade/sdk/external-authority)
423
+ - [Submit a game](https://www.bountyboard.gg/arcade/submit)
131
424
 
132
425
  ## License
133
426
 
134
- MIT. See [`LICENSE`](./LICENSE).
427
+ MIT licensed.