@bountyboard/arcade-sdk 1.0.1 → 1.2.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
@@ -28,9 +28,10 @@ https://www.bountyboard.gg/arcade/sdk/llms.txt — this file is about using it C
28
28
  opaque-origin — so the SDK save IS primary there).
29
29
  6. **`lockToHost()` before boot** when the studio wants anti-theft, with their own domains in
30
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.
31
+ 7. **Multiplayer: clients send inputs, never state.** The approved room authority (a Bounty Board
32
+ module or a registered external server) simulates and validates; design the game so all
33
+ authority lives in that one server. Don't trust or display any value another client sent
34
+ directly, and never run a second copy of a mature external simulation in a relay/Worker.
34
35
 
35
36
  ## Correct lifecycle order
36
37
 
@@ -71,8 +72,12 @@ the safe/default experience the alphabetically-first name. Don't re-randomize cl
71
72
  `'unavailable'` locally is normal. Mock both a ready placement and an unavailable placement;
72
73
  assert that `show()` is called directly by your UI handler and only final status `'viewed'`
73
74
  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.
75
+ - Multiplayer: for Bounty-hosted modules, run the room server locally (`wrangler dev` with
76
+ `DEV_ALLOW_UNSIGNED=1`) and pass `{ roomUrl, ticket }` overrides to `joinRoom`. For a registered
77
+ external authority, exercise its isolated signed-ticket adapter endpoint. In both cases test a
78
+ disconnect/reconnect, assert `trySend()` returns false while offline, and verify the authority
79
+ validates optional `joinData` rather than treating avatar/loadout/mode selections as trusted
80
+ state. See `docs/external-authoritative-servers.md` before adapting an existing server.
76
81
 
77
82
  ## Design intent (read docs/game-design-playbook.md before building a NEW game)
78
83
 
package/CHANGELOG.md ADDED
@@ -0,0 +1,68 @@
1
+ # @bountyboard/arcade-sdk
2
+
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c2854a7: Support curated external authoritative multiplayer servers with per-game ticket keys while preserving the existing `joinRoom()` API and Bounty-hosted room modules.
8
+ - 81fdada: Multiplayer: public quick match. `joinRoom({ match: true })` joins the game's
9
+ open public room (or starts a fresh one when no seat is free) instead of
10
+ requiring a share code. Rooms fill to the game's player cap (at most 64), a
11
+ matched room still exposes `room.code` for inviting friends, and the SDK
12
+ automatically re-matchmakes up to twice when it loses a last-seat race
13
+ (`error.detail` now carries the raw server reason, e.g. `room_full`).
14
+ - 387a347: Player-identity change notifications, multiplayer tuning hooks, and tighter failure semantics.
15
+
16
+ - Add `BBArcade.onPlayerChange(handler)`: fires when the host delivers a changed display identity (mid-session login/logout); returns an unsubscribe function.
17
+ - 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.
18
+ - `save()` now rejects oversized blobs with `too_large` immediately via a client-side precheck against the same 1 MiB cap the server enforces.
19
+ - 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`).
20
+ - `lockToHost()`'s opaque-origin host handshake times out after ~5 seconds instead of 15, so scraped copies are blocked sooner.
21
+ - `leave()` during an in-flight join/reconnect settles immediately, and a welcome arriving after `leave()` is ignored instead of resurrecting the room.
22
+ - Fractional scores are truncated toward zero (`Math.trunc`), matching the documented contract (previously floored, which differed for negative scores).
23
+
24
+ ### Patch Changes
25
+
26
+ - fb26238: Use the declared multiplayer SDK integration when authorizing room tickets and
27
+ server-authoritative result reports.
28
+ - 8289ae5: Replace private-repository documentation links with public Bounty Board URLs and rewrite the external authoritative server guide as a vendor-neutral integration contract.
29
+ - 145d951: Document the built-in relay room tier: every multiplayer-approved game gets
30
+ hosted casual lobbies (public quick match, invite codes, host succession,
31
+ rate-guarded public message fan-out) with zero server code, via the existing
32
+ `joinRoom()` API. New `docs/relay-rooms.md` covers the wire contract — the
33
+ `joinData.roomSize` founding rule (2–64, default 8), `relay`/`relay_host`
34
+ events, the 1 KiB / 15 msg/s / 120 msg/room/s guardrails, and the
35
+ client-trusted outcome model (relay rooms never emit `end` or report results).
36
+ No runtime code changes.
37
+ - f55f6e6: Multiplayer: treat the server's 4005 seat-full close as terminal. A seat
38
+ already holding its maximum concurrent sockets now rejects newcomers, and the
39
+ client stops retrying instead of looping ticket requests against a full seat.
40
+
41
+ ## 1.1.0
42
+
43
+ ### Minor Changes
44
+
45
+ - 10a4997: Support production real-time game modules with bounded join data, observable reconnect state, and explicit `trySend` backpressure.
46
+
47
+ ### Patch Changes
48
+
49
+ - f701c2c: Require Bounty Board-approved, host-session-bound tickets for multiplayer rooms and harden the authoritative room server's edge authorization.
50
+ - 732b929: Keep the Bounty Board host fullscreen overlay from covering provider iframe ad
51
+ controls on touch devices.
52
+ - 72daa42: Ship the package changelog and add reproducible release validation for npm,
53
+ browser-script, ESM, CommonJS, TypeScript, global, and multiplayer consumers.
54
+
55
+ ## 1.0.1
56
+
57
+ ### Patch Changes
58
+
59
+ - Added a two-stage rewarded-ad preparation flow that preserves the browser's
60
+ direct user activation requirement, with expanded integration guidance and
61
+ regression coverage.
62
+
63
+ ## 1.0.0
64
+
65
+ ### Major Changes
66
+
67
+ - Initial public release with leaderboard, cloud-save, rewarded-ad,
68
+ experimentation, player, sitelock, and multiplayer APIs.
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,178 @@ 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
24
- ```
21
+ BBArcade.lockToHost({ allow: ['play.yourgame.com'] }); // optional; call before boot
22
+ void BBArcade.init(); // host handshake; awaiting is optional
23
+
24
+ export function onGameReady() {
25
+ BBArcade.gameLoadingFinished();
26
+ }
27
+
28
+ export function onRunStart() {
29
+ BBArcade.gameplayStart();
30
+ }
25
31
 
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'`.
32
+ export function onScoreChange(score: number) {
33
+ BBArcade.submitScore(Math.trunc(score));
34
+ }
29
35
 
30
- No build step? Use the script tag instead — same code, same protocol:
36
+ export function onPause() {
37
+ BBArcade.gameplayStop();
38
+ }
39
+
40
+ export function onRunEnd(finalScore: number) {
41
+ BBArcade.gameplayStop();
42
+ BBArcade.gameOver(Math.trunc(finalScore)); // exactly once per run
43
+ }
44
+ ```
45
+
46
+ No build step? Load the same SDK as a global:
31
47
 
32
48
  ```html
33
49
  <script src="https://www.bountyboard.gg/arcade-sdk/v1.js"></script>
50
+ <script>
51
+ void BBArcade.init();
52
+
53
+ // Call this from your game when assets and the first scene are playable.
54
+ function onGameReady() {
55
+ BBArcade.gameLoadingFinished();
56
+ }
57
+ </script>
58
+ ```
59
+
60
+ The script tag installs `window.BBArcade`. Do not mix it with the npm package
61
+ in one page. Standalone declarations are available from
62
+ [`/arcade-sdk.d.ts`](https://www.bountyboard.gg/arcade-sdk.d.ts). If a bundler
63
+ specifically needs the global build, import `@bountyboard/arcade-sdk/global`.
64
+
65
+ Bundle note: importing the package attaches the host `postMessage` listeners
66
+ immediately (the host pushes its config at load time, so listener timing is
67
+ part of the protocol), so the package is honestly marked `sideEffects: true`
68
+ and will not tree-shake. The full script-tag build is ~47 KB unminified;
69
+ module consumers who only use multiplayer still get the whole core.
70
+
71
+ ## Runtime support
72
+
73
+ npm versus script tag only changes how the SDK loads. The environment where
74
+ the game runs determines which host-backed features are available.
75
+
76
+ | Capability | Bounty-hosted upload | Approved URL embed inside Bounty Board | Standalone / own site |
77
+ | --------------------------------- | -------------------- | -------------------------------------- | --------------------- |
78
+ | Lifecycle, scores, host analytics | Supported | Supported | Safe no-op |
79
+ | Player identity | Player or `null` | Player or `null` | `null` |
80
+ | A/B variants | Stable assignment | Stable assignment | Alphabetical control |
81
+ | Cloud save / load | Logged-in players | Unsupported | Unsupported |
82
+ | Rewarded ads | Approved games | Unavailable (no payable attribution) | Unavailable |
83
+ | Multiplayer | Enabled authority | Enabled authority | Unsupported |
84
+
85
+ An externally hosted URL opened directly is standalone play. The same URL
86
+ inside the approved Bounty Board player is a URL embed. Guests are normal:
87
+ identity resolves `null`, and account-backed promises may reject with
88
+ `code: 'unauthenticated'`.
89
+
90
+ ## Correct lifecycle
91
+
92
+ ```text
93
+ lockToHost() -> init() -> [getPlayer()] -> gameLoadingFinished()
94
+ -> per run: gameplayStart() -> submitScore()* -> gameplayStop() -> gameOver()
95
+ -> save() at checkpoints or game over
96
+ ```
97
+
98
+ `gameplayStart()` and `gameplayStop()` bracket active play, not menus. Call
99
+ `gameOver()` exactly once per run and submit integer scores. Pass
100
+ `{ mode: 'daily' }` to both score calls for a shared-seed Daily run.
101
+
102
+ The SDK must never be load-bearing. With no host present:
103
+
104
+ - fire-and-forget signals no-op;
105
+ - `init()` resolves after a short grace;
106
+ - `getPlayer()` resolves `null`;
107
+ - `getVariant()` returns the alphabetically first control;
108
+ - save, load, ads, and multiplayer settle quickly with their documented
109
+ unsupported/unavailable outcomes.
110
+
111
+ Your game must still boot and remain fully playable when all of those outcomes
112
+ happen at once.
113
+
114
+ ## Cloud saves
115
+
116
+ Store the whole progress model as one string blob (about 1 MB maximum). Save at
117
+ checkpoints or game over, never in a hot loop.
118
+
119
+ ```ts
120
+ import type { BBArcadeError } from '@bountyboard/arcade-sdk';
121
+
122
+ const blob = JSON.stringify(progress);
123
+
124
+ try {
125
+ await BBArcade.save(blob);
126
+ } catch (error) {
127
+ const code = (error as BBArcadeError).code;
128
+ if (code === 'unsupported') localStorage.setItem('progress', blob);
129
+ }
130
+
131
+ try {
132
+ const saved = await BBArcade.load(); // string or null when no save exists
133
+ if (saved) restore(JSON.parse(saved));
134
+ } catch {
135
+ restoreStandaloneProgress();
136
+ }
137
+ ```
138
+
139
+ Hosted uploads run in an opaque-origin sandbox without `localStorage`; SDK
140
+ cloud save is primary there. URL embeds and standalone builds need their own
141
+ same-origin storage. Oversized blobs reject `too_large` immediately (a
142
+ client-side precheck against the same 1 MiB cap the server enforces), and a
143
+ host that never answers rejects `error` after a 15-second request timeout —
144
+ the same timeout covers load, variant, and multiplayer-ticket requests. Every
145
+ save/load rejection is a real `Error` with a typed `code`:
146
+
147
+ ```text
148
+ unsupported | unauthenticated | too_large | rejected | error
34
149
  ```
35
150
 
36
- with standalone typings at [`/arcade-sdk.d.ts`](https://www.bountyboard.gg/arcade-sdk.d.ts).
151
+ ## Player identity and A/B variants
152
+
153
+ The SDK only provides public display identity. It never exposes account ids,
154
+ emails, or roles.
155
+
156
+ ```ts
157
+ const player = await BBArcade.getPlayer();
158
+ if (player) {
159
+ greet(player.name);
160
+ if (player.avatarUrl) drawAvatar(player.avatarUrl);
161
+ }
162
+
163
+ // Alphabetical first is the standalone/error control: "control" here.
164
+ const variant = await BBArcade.getVariant('start-cta', ['control', 'short-label']);
165
+ renderStartButton(variant ?? 'control');
166
+ ```
37
167
 
38
- ## The surface in one look
168
+ Always handle `getPlayer() === null` and `avatarUrl === null`. Variant
169
+ assignments are stable per player, game, and key. Do not re-randomize them in
170
+ the client.
39
171
 
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 |
172
+ `getPlayer()` is a one-shot read of the identity at handshake time. To react
173
+ when the player logs in or out mid-session, subscribe to changes:
55
174
 
56
- Rejections from `save()`/`load()` are real `Error`s carrying a typed machine-readable
57
- `code`: `'unsupported' | 'unauthenticated' | 'too_large' | 'rejected' | 'error'`.
175
+ ```ts
176
+ const unsubscribe = BBArcade.onPlayerChange(player => {
177
+ // Fires only when the identity actually changes; null means logged out.
178
+ updateGreeting(player);
179
+ });
180
+ // Later, when the UI is gone: unsubscribe();
181
+ ```
58
182
 
59
- ## Rewarded ads without losing the player's click
183
+ ## Rewarded ads: prepare, then show from the player gesture
60
184
 
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()`.
185
+ New integrations should use `prepareRewardedAd()`. Prepare when a natural
186
+ reward panel opens, keep the Watch Ad control disabled until the result is
187
+ `ready`, and call its one-shot `show()` directly inside the click/tap handler.
65
188
 
66
189
  ```ts
190
+ const button = document.querySelector<HTMLButtonElement>('#watch-ad')!;
191
+ button.disabled = true;
192
+
67
193
  const prepared = await BBArcade.prepareRewardedAd({
68
194
  placement: 'death_revive',
69
195
  reward: 'extra_life',
@@ -71,54 +197,184 @@ const prepared = await BBArcade.prepareRewardedAd({
71
197
  });
72
198
 
73
199
  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
- };
200
+ button.disabled = false;
201
+ button.addEventListener(
202
+ 'click',
203
+ () => {
204
+ // Keep first: no await, timer, microtask, or network call before show().
205
+ const resultPromise = prepared.show();
206
+ button.disabled = true;
207
+
208
+ void resultPromise.then(result => {
209
+ resumeGameAndAudio();
210
+ if (result.status === 'viewed') revivePlayer();
211
+ else keepNormalFallbackAvailable();
212
+ });
213
+ },
214
+ { once: true }
215
+ );
82
216
  } else {
83
- showAdUnavailable(prepared.error, prepared.breakStatus);
217
+ keepNormalFallbackAvailable();
84
218
  }
85
219
  ```
86
220
 
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.
221
+ Only the final `status === 'viewed'` grants a reward. `dismissed`,
222
+ `unavailable`, and `error` are non-rewarding; a prepared ad whose break ends
223
+ before `show()` is ever called also resolves `dismissed`, never `ready`.
224
+ Never auto-trigger, loop, or auto-retry an ad. `rewardedBreak()` remains for
225
+ compatibility with shipped games but cannot preserve direct browser user
226
+ activation in every case.
227
+
228
+ ## Multiplayer rooms
229
+
230
+ SDK 1.1 adds production room support with bounded `joinData`, observable
231
+ reconnect state, and explicit `trySend()` backpressure. SDK 1.2 adds public
232
+ quick match.
233
+
234
+ Multiplayer is enabled per game and runs on one of three rails, all behind the
235
+ same `joinRoom()` transport:
92
236
 
93
- ## Multiplayer
237
+ 1. **Built-in relay rooms** (the default): every multiplayer-approved game
238
+ gets hosted casual lobbies with zero server code. The relay owns signed
239
+ admission, roster, seat capacity, host succession, and rate-guarded public
240
+ message fan-out; it never interprets payloads, never settles, and never
241
+ reports results, so relay outcomes are client-trusted. See the
242
+ [relay room guide](https://www.bountyboard.gg/arcade/sdk/relay-rooms.txt).
243
+ 2. **Refereed Bounty-hosted modules**: a curated server module validates
244
+ inputs, runs the only simulation, and emits authoritative results.
245
+ 3. **Registered external authorities**: a mature game keeps its own server
246
+ with a dedicated ticket key.
247
+
248
+ The platform assigns the rail per game slug; clients cannot select it.
94
249
 
95
250
  ```ts
96
251
  import { joinRoom } from '@bountyboard/arcade-sdk/multiplayer';
97
252
 
98
- const room = await joinRoom({ create: true }); // or { code: 'ABCD' }
99
- room.on('snapshot', ({ state }) => render(state)); // server-authoritative state
100
- room.on('end', ({ results }) => showResults(results));
101
- room.send({ move: dir }); // inputs only — the server simulates
253
+ const room = await joinRoom({
254
+ create: true, // or code: 'ABCD', or match: true for public quick match
255
+ joinData: { avatar: 'golem', mode: 'ffa' }, // untrusted; server validates
256
+ });
257
+
258
+ showShareCode(room.code);
259
+ room.on('snapshot', ({ state }) => renderAuthoritativeState(state));
260
+ room.on('connection', ({ connected }) => setReconnecting(!connected));
261
+ room.on('end', ({ results }) => showResults(results)); // finite games only
262
+
263
+ onPlayerInput(input => {
264
+ if (!room.trySend(input)) setReconnecting(true);
265
+ });
266
+
267
+ onExit(() => room.leave());
102
268
  ```
103
269
 
104
- Rooms run on Bounty Board's authoritative room servers: clients send inputs, the server
105
- validates and simulates, and each player receives only the state they're allowed to see
106
- (your hiders stay hidden). Auth is a short-lived signed ticket the SDK obtains from the
107
- host automatically; off Bounty Board `joinRoom` rejects with `code: 'unsupported'`.
270
+ On the refereed and external rails, clients send inputs, never authoritative
271
+ state. On the relay rail, `room.send(data)` fans the payload publicly to every
272
+ player (including the sender) in per-tick batches of
273
+ `{ type: 'relay', messages: [{ from, data }] }`; the room's first joiner fixes
274
+ the seat count with `joinData.roomSize` (an integer 2–64, default 8), the
275
+ oldest retained seat is host (`state.hostId`, changes broadcast as
276
+ `{ type: 'relay_host', hostId }`), and traffic is capped at 1 KiB per message,
277
+ 15 messages per player per second, and 120 per room per second.
278
+
279
+ Two tuning hooks: `joinRoom({ timeoutMs })` overrides how long a join (and
280
+ each reconnect attempt) waits for the server's welcome before rejecting with
281
+ `code: 'error'` (default 10000, clamped to 1000–60000), and `room.latencyMs`
282
+ reports the join-handshake latency (socket open to server welcome, refreshed
283
+ on every reconnect; `null` until the first welcome) — an estimate to tune
284
+ render-side smoothing against, not a measured RTT.
285
+
286
+ Rooms are lobby-based: each game module declares its player range (up to 64
287
+ per room). `match: true` is public quick match — Bounty Board fills the game's
288
+ open public room and starts a fresh one when no seat is free; the matched
289
+ room's `room.code` still works as an invite code. When the last free seat is
290
+ lost to a race the SDK re-matchmakes automatically (up to two retries) before
291
+ rejecting with `code: 'rejected'` and `detail: 'room_full'`. Quick match is
292
+ mutually exclusive with `code`/`create` and is unavailable on external
293
+ authorities, which run their own rooms.
294
+
295
+ ### Latency model
296
+
297
+ Refereed and external rooms are state-sync only: the server simulates, clients receive per-viewer
298
+ snapshots at the room tick rate, and every input takes a network round trip
299
+ before its effect appears in a snapshot. There is no built-in client-side
300
+ prediction, interpolation, or rollback. Design for it: turn-based, timing-duel,
301
+ score-race, and party games feel native; twitch physics (fighting games,
302
+ precision platform duels) need your own render-side smoothing — interpolate
303
+ between snapshots and animate optimistic feedback for the local player's input,
304
+ but treat the next snapshot as truth. Assume 50-150 ms of input-to-snapshot
305
+ latency on real connections when tuning game feel. (Relay rooms differ: game
306
+ messages arrive as public per-tick event batches, and all game rules live in
307
+ your clients.) Every referee or external authority must:
308
+
309
+ - validate admission, `joinData`, and every input;
310
+ - clear held controls on disconnect;
311
+ - run the only simulation;
312
+ - serialize only viewer-safe state;
313
+ - return server-authored results for finite games;
314
+ - avoid fabricating `match_end` for endless rooms.
315
+
316
+ `joinData` is untrusted JSON capped at 1 KiB and must not contain credentials or
317
+ other secrets. External games keep their existing simulation and add only the
318
+ signed-ticket + SDK envelope at an isolated authenticated endpoint. Read the
319
+ public [external authoritative server contract](https://www.bountyboard.gg/arcade/sdk/external-authority)
320
+ before adapting an existing server.
321
+
322
+ ## Site lock and WebXR
323
+
324
+ ```ts
325
+ // Anti-rehosting deterrent, not DRM. Call before boot. When the embedder
326
+ // can't be read from browser signals (opaque-origin sandbox), the SDK asks
327
+ // the embedding page and blocks if nothing answers within ~5 seconds.
328
+ BBArcade.lockToHost({
329
+ allow: ['play.yourgame.com', 'preview.yourgame.com'],
330
+ signed: true,
331
+ });
332
+
333
+ // WebXR only: keep immersive playtime visible to the host.
334
+ session.addEventListener('end', () => BBArcade.xrSessionEnd());
335
+ BBArcade.xrSessionStart();
336
+ ```
337
+
338
+ ## Test before submitting
339
+
340
+ - Run the production artifact without a Bounty Board parent and confirm the
341
+ whole game remains playable.
342
+ - Verify ready, active-play, pause/resume, and exactly-one game-over transitions.
343
+ - Test guest, no-save, unsupported, too-large, and transport-failure save paths.
344
+ - Test rewarded `ready`, `unavailable`, `dismissed`, `error`, and `viewed`; only
345
+ `viewed` may grant.
346
+ - Test multiplayer disconnect/reconnect, `trySend() === false`, viewer-safe
347
+ snapshots, and intentional `leave()`.
348
+ - Keep ads and multiplayer controls unavailable until their capabilities are
349
+ actually ready; do not make the rest of the game wait.
350
+
351
+ Give coding agents the public
352
+ [agent-readable verification contract](https://www.bountyboard.gg/arcade/sdk/llms.txt).
353
+
354
+ ## API at a glance
355
+
356
+ | Area | Calls |
357
+ | ------------- | ------------------------------------------------------------------------------------- |
358
+ | Lifecycle | `init`, `configure`, `ready` / `gameLoadingFinished`, `gameplayStart`, `gameplayStop` |
359
+ | Scores | `submitScore`, `gameOver` |
360
+ | Player data | `save`, `load`, `getPlayer`, `onPlayerChange`, `getVariant` |
361
+ | Rewarded ads | `prepareRewardedAd`, `rewardedAd`, `rewardedBreak`, `preloadRewardedAds` |
362
+ | Security / XR | `lockToHost`, `xrSessionStart`, `xrSessionEnd` |
363
+ | Multiplayer | `joinRoom`, `room.trySend`, `room.on`, `room.leave`, `room.latencyMs` |
108
364
 
109
- Multiplayer is currently a curated integration: Bounty Board must add and deploy a
110
- server-side game module for your slug before `joinRoom()` can accept players. The
111
- initial reference module is `hide-and-seek`; arbitrary games do not become
112
- authoritative multiplayer games merely by importing the client SDK.
365
+ The package declarations are the exact type reference. The script-tag build
366
+ exposes multiplayer at `BBArcade.multiplayer.joinRoom(...)`; module consumers
367
+ import `joinRoom` from `@bountyboard/arcade-sdk/multiplayer`.
113
368
 
114
- ## Docs
369
+ ## More documentation
115
370
 
116
- - Human docs: https://www.bountyboard.gg/arcade/sdk
117
- - Agent-readable reference: https://www.bountyboard.gg/arcade/sdk/llms.txt
118
- - Integration best practices for coding agents: [`AGENTS.md`](./AGENTS.md)
119
- - What makes a game perform on Bounty Board: [`docs/game-design-playbook.md`](./docs/game-design-playbook.md)
120
- - Submit your game: https://www.bountyboard.gg/arcade/submit
371
+ - [Human integration guide](https://www.bountyboard.gg/arcade/sdk)
372
+ - [Agent-readable API and integration contract](https://www.bountyboard.gg/arcade/sdk/llms.txt)
373
+ - [Release changelog](https://www.bountyboard.gg/arcade/sdk/changelog.txt)
374
+ - [Game design playbook](https://www.bountyboard.gg/arcade/sdk/game-design-playbook.txt)
375
+ - [External authoritative server contract](https://www.bountyboard.gg/arcade/sdk/external-authority)
376
+ - [Submit a game](https://www.bountyboard.gg/arcade/submit)
121
377
 
122
378
  ## License
123
379
 
124
- MIT. See [`LICENSE`](./LICENSE).
380
+ MIT licensed.