@bountyboard/arcade-sdk 1.2.0 → 1.4.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/docs/llms.txt CHANGED
@@ -1,26 +1,23 @@
1
- # Bounty Board Arcade SDK — agent-readable integration contract
1
+ # Bounty Board Arcade SDK: agent readable integration contract
2
2
 
3
3
  Human guide: https://www.bountyboard.gg/arcade/sdk
4
- Package: @bountyboard/arcade-sdk
5
- Stable npm release: 1.1.0
6
- Current browser v1/source addition: public quick match (queued for npm 1.2.0)
4
+ Package: @bountyboard/arcade-sdk (https://www.npmjs.com/package/@bountyboard/arcade-sdk)
5
+ Stable npm release: 1.4.0
7
6
  Wire protocol: 1
8
7
 
9
8
  This file is the complete integration contract for coding agents integrating an
10
9
  HTML5 game. The package TypeScript declarations remain the exact public type
11
- reference. Version availability matters: npm 1.1.0 supports code/create rooms
12
- on all three approved rails: Bounty shared-service referee modules, Bounty
13
- shared-service relay rooms, and host-routed registered external authorities.
14
- match: true works on both Bounty shared-service tiers in the current
15
- /arcade-sdk/v1.js browser artifact and repository source, and will enter the
16
- npm package in 1.2.0. BBArcadeError.detail follows that same version gate. The
17
- external-authority adapter guide is public now and will also join the packaged
18
- docs in 1.2.0.
10
+ reference. npm 1.4.0, the /arcade-sdk/v1.js browser artifact, and repository
11
+ source all expose the same surface. Rooms open on all three approved rails:
12
+ Bounty shared service referee modules, Bounty shared service relay rooms, and
13
+ host routed registered external authorities. code/create works on every rail;
14
+ match: true works on both Bounty shared service tiers. The external authority
15
+ adapter guide ships in the package and is public at the URL above.
19
16
 
20
17
  ## Non-negotiable integration rules
21
18
 
22
19
  1. The SDK is never load-bearing. A game must boot and remain fully playable
23
- with no Bounty Board parent, no logged-in player, no ad inventory, no cloud
20
+ with no Bounty Board parent, no logged in player, no ad inventory, no cloud
24
21
  save, and no multiplayer authority.
25
22
  2. Call gameOver() exactly once per run and use integer scores. The host/server
26
23
  enforces per-game plausibility caps.
@@ -28,17 +25,24 @@ docs in 1.2.0.
28
25
  death prompts, and ad breaks are not active play.
29
26
  4. getPlayer() can be null and avatarUrl can be null. The SDK never exposes
30
27
  account ids, emails, or roles.
31
- 5. Store all progress in one save(string) blob (about 1 MB). Save at
32
- checkpoints/game over, not in a frame loop, and catch every rejection.
33
- 6. For new rewarded-ad work, prepare first, enable the game's button only when
28
+ 5. Store all progress in one save(string) blob (about 1 MB). Gate the account
29
+ backed calls on getPlayer() rather than discovering the guest case through a
30
+ thrown unauthenticated. Save at checkpoints/game over, not in a frame loop,
31
+ and catch every rejection. When the game is an engine export whose storage
32
+ layer you cannot rewire, use storage.install() instead and let the shim
33
+ carry localStorage to the cloud.
34
+ 6. For new rewarded ad work, prepare first, enable the game's button only when
34
35
  status is ready, call prepared.show() directly from the click/tap handler,
35
36
  and grant only when the final status is viewed.
36
37
  7. Multiplayer has three rails. Referee modules and external authorities own
37
38
  simulation/results and receive client inputs. The casual relay owns signed
38
39
  admission, roster, capacity, host succession, reconnect grace, and public
39
- message fan-out, but it does not referee game state or outcomes. Catch every
40
+ message fan out, but it does not referee game state or outcomes. Catch every
40
41
  joinRoom() rejection and keep solo/standalone play available.
41
- 8. lockToHost() is an optional anti-rehosting deterrent, not DRM. Call it before boot.
42
+ 8. lockToHost() is an optional anti-rehosting deterrent, not DRM. Call it before
43
+ boot. allow lists the domains the STUDIO hosts the build on, so a build that
44
+ only runs on Bounty Board passes no allow entries (or skips the call);
45
+ play.yourgame.com in every example is a placeholder, never a real value.
42
46
 
43
47
  ## Install and distribution
44
48
 
@@ -47,16 +51,21 @@ Preferred for games with a build step:
47
51
  npm install @bountyboard/arcade-sdk
48
52
  import { BBArcade } from '@bountyboard/arcade-sdk';
49
53
 
50
- The package is zero-dependency, typed, ESM + CommonJS, and SSR-safe. Importing
54
+ The package is zero dependency, typed, ESM + CommonJS, and SSR safe. Importing
51
55
  the module does not install a window.BBArcade global.
52
56
 
53
- No-build script tag:
57
+ Script tag, no build step:
54
58
 
55
59
  <script src="https://www.bountyboard.gg/arcade-sdk/v1.js"></script>
56
60
 
57
61
  This installs window.BBArcade. Standalone declarations:
58
62
  https://www.bountyboard.gg/arcade-sdk.d.ts
59
63
 
64
+ The hosted file is served with Cross-Origin-Resource-Policy: cross-origin and
65
+ Access-Control-Allow-Origin: *, so the tag also loads on a cross-origin-isolated
66
+ page (Cross-Origin-Embedder-Policy: require-corp). Vendoring a copy of the file
67
+ into the build and loading it same-origin is equally supported.
68
+
60
69
  Do not mix npm and the script tag in one page. A bundler that specifically
61
70
  wants the global may import @bountyboard/arcade-sdk/global.
62
71
 
@@ -64,7 +73,7 @@ Multiplayer module import:
64
73
 
65
74
  import { joinRoom } from '@bountyboard/arcade-sdk/multiplayer';
66
75
 
67
- Script-tag multiplayer is BBArcade.multiplayer.joinRoom(...).
76
+ Script tag multiplayer is BBArcade.multiplayer.joinRoom(...).
68
77
 
69
78
  Public package exports:
70
79
 
@@ -79,29 +88,39 @@ Public package exports:
79
88
 
80
89
  Distribution format does not determine capabilities; the embedding host does.
81
90
 
82
- - Bounty-hosted upload:
91
+ - Bounty hosted upload:
83
92
  lifecycle/scores supported; player identity/variants supported; cloud
84
- save/load available to logged-in players; ads available to approved games;
85
- multiplayer available after per-game approval, with relay as the default
86
- Bounty shared-service tier when no bespoke referee module is registered.
93
+ save/load available to logged in players; native localStorage/sessionStorage/
94
+ IndexedDB/cookies all THROW (opaque origin), so engine exports need
95
+ storage.install(); ads available to approved games; multiplayer available
96
+ after per-game approval, with relay as the default Bounty shared service tier
97
+ when no bespoke referee module is registered.
87
98
  - Approved external URL embed inside the Bounty Board player:
88
99
  lifecycle/scores, identity, variants, and approved multiplayer (including the
89
100
  Bounty relay tier) are supported; rewarded ads are unavailable (no payable
90
101
  per-game attribution yet); cloud save/load is unsupported.
91
102
  - Standalone or opened directly on the game's own site:
92
103
  fire-and-forget calls no-op; init resolves immediately; getPlayer returns
93
- null; getVariant returns the alphabetical control; host-only promise APIs
104
+ null; getVariant returns the alphabetical control; host only promise APIs
94
105
  reject unsupported or return an unavailable outcome. An unanswered non-Bounty
95
106
  embed uses an approximately 1.5-second init/getPlayer grace instead.
96
107
 
97
- Guests are normal. getPlayer resolves null and account-backed calls may reject
108
+ Guests are normal. getPlayer resolves null and account backed calls may reject
98
109
  with code unauthenticated.
99
110
 
111
+ Leaderboards need one thing outside the code: the studio must declare
112
+ Leaderboards for the game in its Arcade submission (or later by editing it).
113
+ Until then the host rejects every posted score and the game sees nothing,
114
+ because submitScore()/gameOver() are fire-and-forget. Optional per-game score
115
+ ceilings live beside that declaration and reject implausible values.
116
+
100
117
  ## Minimal correct lifecycle
101
118
 
102
119
  import { BBArcade } from '@bountyboard/arcade-sdk';
103
120
 
104
- BBArcade.lockToHost({ allow: ['play.yourgame.com'] }); // optional, before boot
121
+ // Optional, before boot. Bounty Board and localhost are always allowed, so
122
+ // allow lists only domains YOU host on; omit it when Bounty Board hosts the build.
123
+ BBArcade.lockToHost({ allow: ['play.yourgame.com'] }); // placeholder domain
105
124
  void BBArcade.init(); // never gate boot on it
106
125
  BBArcade.gameLoadingFinished(); // first playable scene ready
107
126
 
@@ -110,11 +129,11 @@ with code unauthenticated.
110
129
  BBArcade.gameplayStop(); // pause/death/menu/ad
111
130
  BBArcade.gameOver(Math.trunc(finalScore)); // exactly once per run
112
131
 
113
- For a shared-seed Daily, pass { mode: 'daily' } to submitScore and gameOver.
132
+ For a shared seed Daily, pass { mode: 'daily' } to submitScore and gameOver.
114
133
 
115
134
  ## Exact shared types and configuration
116
135
 
117
- All rejected SDK promises use a real Error. Stable npm 1.1.0 has this shape:
136
+ All rejected SDK promises use a real Error:
118
137
 
119
138
  type BBArcadeErrorCode =
120
139
  | 'unsupported' // no compatible host/capability
@@ -123,12 +142,6 @@ All rejected SDK promises use a real Error. Stable npm 1.1.0 has this shape:
123
142
  | 'rejected' // host/authority refused the payload or room
124
143
  | 'error'; // timeout, transport, or server failure
125
144
 
126
- interface BBArcadeError extends Error {
127
- code: BBArcadeErrorCode;
128
- }
129
-
130
- The current browser v1/source build, and npm 1.2.0 when released, add:
131
-
132
145
  interface BBArcadeError extends Error {
133
146
  code: BBArcadeErrorCode;
134
147
  detail?: string; // raw authority reason when available
@@ -158,8 +171,8 @@ Core configuration shapes:
158
171
  lockToHost defaults allow bountyboard.gg and its subdomains, the fixed Bounty
159
172
  staging host, localhost, and 127.0.0.1. allow extends rather than replaces that
160
173
  list. signed mode requests an ECDSA origin attestation; it falls back to the
161
- best-effort browser-origin check only when the host reports no signing key or
162
- Web Crypto is unavailable. A missing host or present-but-invalid attestation
174
+ best effort browser origin check only when the host reports no signing key or
175
+ Web Crypto is unavailable. A missing host or present but invalid attestation
163
176
  blocks.
164
177
 
165
178
  Player and score shapes:
@@ -167,7 +180,21 @@ Player and score shapes:
167
180
  interface BBArcadePlayer { name: string; avatarUrl: string | null }
168
181
  // submitScore/gameOver use the inline option shape { mode?: 'daily' }.
169
182
 
170
- Rewarded-ad options and results:
183
+ Storage compatibility shapes:
184
+
185
+ type BBArcadeStorageMode =
186
+ | 'native' // a real Storage works here; the SDK changed nothing
187
+ | 'cloud' // shim installed, syncing to the player's Bounty Board save
188
+ | 'memory'; // shim installed but nothing can persist (guest/standalone)
189
+
190
+ interface BBArcadeStorage {
191
+ install(): BBArcadeStorageMode; // idempotent; no-op when native works
192
+ ready(): Promise<BBArcadeStorageMode>;
193
+ flush(): Promise<void>; // rejects with the save() error codes
194
+ readonly mode: BBArcadeStorageMode;
195
+ }
196
+
197
+ Rewarded ad options and results:
171
198
 
172
199
  interface BBArcadeRewardedAdOptions {
173
200
  placement?: string; name?: string; reward?: string; adBreakId?: string;
@@ -259,15 +286,26 @@ Lifecycle and scoring:
259
286
  Player data and experiments:
260
287
 
261
288
  - save(blob: string): Promise<void>
262
- One blob, about 1 MB. Requires a Bounty-hosted upload and logged-in player.
289
+ One blob, about 1 MB. Requires a Bounty hosted upload and logged in player.
263
290
  Oversized blobs reject too_large immediately via a client-side precheck
264
291
  against the same 1 MiB cap the server enforces.
265
292
  - load(): Promise<string | null>
266
- Returns null when no save exists. Rejects like save().
293
+ Returns null when no save exists. Rejects like save(). Gate on getPlayer() so
294
+ the guest case is a branch rather than a catch.
295
+ - storage: BBArcadeStorage
296
+ localStorage compatibility for hosted builds. install(): BBArcadeStorageMode
297
+ replaces a throwing window.localStorage with a Storage shaped object backed
298
+ by the cloud save, and is a no-op where a real Storage works. ready():
299
+ Promise<BBArcadeStorageMode> resolves once the cloud read has landed.
300
+ flush(): Promise<void> forces the debounced write out now. mode is the
301
+ current BBArcadeStorageMode ('native' | 'cloud' | 'memory'). The script tag
302
+ build calls install() for you at load; module consumers call it themselves
303
+ before boot.
267
304
  - getPlayer(): Promise<{ name: string, avatarUrl: string | null } | null>
268
- Display identity only; always handle null.
269
- - onPlayerChange(handler): () => void // current browser/source; npm 1.2.0 when released
270
- Subscribes to CHANGES in the display identity (mid-session login/logout).
305
+ Display identity only; always handle null. Never rejects and never hangs, so
306
+ it is also the cheapest signed in check to gate save()/load() on.
307
+ - onPlayerChange(handler): () => void
308
+ Subscribes to CHANGES in the display identity (mid session login/logout).
271
309
  The handler receives the same { name, avatarUrl } | null shape as
272
310
  getPlayer() and runs only when the identity actually changes; subscribing
273
311
  does not replay the current value. Returns an unsubscribe function.
@@ -278,9 +316,11 @@ Player data and experiments:
278
316
  item. Do not re-randomize client-side.
279
317
 
280
318
  save/load can reject with every BBArcadeErrorCode. load resolves null only when
281
- the logged-in hosted player has no save; a guest can reject unauthenticated.
282
- Every host request (save, load, variant, multiplayer ticket) rejects with code
283
- error after a 15-second timeout when no answer arrives.
319
+ the logged in hosted player has no save; a guest can reject unauthenticated.
320
+ Gate on getPlayer() so account state is a branch you took rather than an error
321
+ you caught, and keep the catch for transport failures. Every host request
322
+ (save, load, variant, multiplayer ticket) rejects with code error after a
323
+ 15-second timeout when no answer arrives.
284
324
 
285
325
  unsupported | unauthenticated | too_large | rejected | error
286
326
 
@@ -289,7 +329,7 @@ Rewarded ads:
289
329
  - prepareRewardedAd(options?): Promise<BBArcadeRewardedAdPreparation>
290
330
  Recommended. Alias: prepareRewardedBreak(). Prepared show() is one-shot.
291
331
  - rewardedAd(options?): Promise<BBArcadeRewardedAdResult>
292
- Low-level structured-result API. Alias: showRewardedAd().
332
+ Low-level structured result API. Alias: showRewardedAd().
293
333
  - rewardedBreak(options | onStart): Promise<boolean>
294
334
  Deprecated compatibility helper. New games must use the prepared flow.
295
335
  - preloadRewardedAds(options?): Promise<boolean>
@@ -307,16 +347,16 @@ ad_break_unavailable, ad_break_timeout, no_rewarded_ad, ad_in_progress,
307
347
  show_ad_error, direct_user_action_required, before_reward_error, and
308
348
  ad_break_error.
309
349
 
310
- Multiplayer (current browser v1/source shape; stable npm 1.1.0 omits match):
350
+ Multiplayer:
311
351
 
312
352
  type BBArcadeMpJoinOptions = {
313
353
  code?: string;
314
354
  create?: boolean;
315
- match?: boolean; // current browser/source; npm 1.2.0 when released
355
+ match?: boolean; // public quick match; Bounty shared service tiers only
316
356
  joinData?: Readonly<Record<string, unknown>>;
317
357
  roomUrl?: string; // local development override; provide with ticket
318
358
  ticket?: string; // local development override; provide with roomUrl
319
- timeoutMs?: number; // current browser/source; npm 1.2.0 when released
359
+ timeoutMs?: number; // welcome timeout override, clamped 1000-60000 ms
320
360
  };
321
361
 
322
362
  interface BBArcadeMpPlayer {
@@ -350,7 +390,7 @@ Multiplayer (current browser v1/source shape; stable npm 1.1.0 omits match):
350
390
  players: BBArcadeMpPlayer[];
351
391
  state: unknown;
352
392
  connected: boolean;
353
- latencyMs: number | null; // current browser/source; npm 1.2.0 when released
393
+ latencyMs: number | null; // join handshake estimate, refreshed on reconnect
354
394
  send(input: unknown): void;
355
395
  trySend(input: unknown): boolean;
356
396
  on<K extends keyof BBArcadeMpRoomEvents>(
@@ -364,7 +404,7 @@ Multiplayer (current browser v1/source shape; stable npm 1.1.0 omits match):
364
404
  joinRoom(options?: BBArcadeMpJoinOptions): Promise<BBArcadeMpRoom>;
365
405
  }
366
406
 
367
- Relay runtime payloads use these exact shapes through the otherwise-unknown
407
+ Relay runtime payloads use these exact shapes through the otherwise unknown
368
408
  room.state and room.on('event') values:
369
409
 
370
410
  interface BBArcadeRelayState {
@@ -382,16 +422,16 @@ room.state and room.on('event') values:
382
422
  | { type: 'relay_host'; hostId: string | null };
383
423
 
384
424
  BBArcadeRelayState and BBArcadeRelayEvent are documentation names, not package
385
- exports; the public SDK intentionally types game/tier-defined state and events
425
+ exports; the public SDK intentionally types game/tier defined state and events
386
426
  as unknown.
387
427
 
388
428
  - joinRoom(options?): Promise<BBArcadeMpRoom>
389
429
  No options and { create: true } both generate a 4-character invite code from
390
430
  ABCDEFGHJKLMNPQRSTUVWXYZ23456789. A supplied code is uppercased. match: true
391
- is Bounty-hosted public quick match and excludes code, create, roomUrl, and
431
+ is Bounty hosted public quick match and excludes code, create, roomUrl, and
392
432
  ticket. roomUrl+ticket bypass the host handshake for local development only;
393
433
  provide both (a lone value is not an override).
394
- - joinData must be a non-null, non-array JSON-serializable object whose UTF-8
434
+ - joinData must be a non-null, non-array JSON serializable object whose UTF-8
395
435
  JSON encoding is at most 1 KiB. Cycles, arrays, primitives, and oversized data
396
436
  reject with code rejected. Never include credentials or secrets.
397
437
  - joinRoom resolves only after the authority sends welcome (10-second default
@@ -400,26 +440,25 @@ as unknown.
400
440
  At resolution, code/playerId/players/state/connected already hold the initial
401
441
  lobby state. There is no replayed initial snapshot: render those fields first,
402
442
  then subscribe to future events.
403
- - room.latencyMs reports the join-handshake latency in ms (socket open to
443
+ - room.latencyMs reports the join handshake latency in ms (socket open to
404
444
  server welcome: ticket verification plus one round trip), refreshed on every
405
445
  successful (re)connect and null until the first welcome. Use it to tune
406
- render-side smoothing; it is an estimate, not a measured RTT.
407
- - Values passed to send/trySend must be JSON-serializable. A referee room
446
+ render side smoothing; it is an estimate, not a measured RTT.
447
+ - Values passed to send/trySend must be JSON serializable. A referee room
408
448
  treats the value as an input; a relay room treats it as a public game
409
449
  message. send discards local write status. trySend
410
450
  returns true only when JSON was written to an open socket; it does NOT mean
411
451
  the authority accepted the input. false means disconnected, raced closed, or
412
452
  serialization/WebSocket.send failed. The authority still validates,
413
- sequences, and may rate-drop inputs.
453
+ sequences, and may drop inputs above its rate limit.
414
454
  - on returns an unsubscribe function. The SDK updates room.players before
415
455
  playerJoin/playerLeave handlers and room.state before snapshot handlers.
416
456
  - leave intentionally closes the Room and permanently disables reconnect for it.
417
457
  A welcome that arrives after leave() (e.g. racing a reconnect) is ignored and
418
458
  never flips the room back to connected.
419
- - A pre-welcome room error rejects joinRoom with BBArcadeError. In the current
420
- browser/source build and npm 1.2.0+, raw reasons such as room_full/room_over
421
- are carried in error.detail when available. Stable npm 1.1.0 exposes only
422
- error.code. An error after welcome emits room.on('error', { code }).
459
+ - A pre-welcome room error rejects joinRoom with BBArcadeError. Raw reasons such
460
+ as room_full/room_over are carried in error.detail when available.
461
+ An error after welcome emits room.on('error', { code }).
423
462
  Connection loss emits connection/close events and may start automatic
424
463
  reconnect.
425
464
 
@@ -427,31 +466,112 @@ BBArcade.version and the exported PROTOCOL_VERSION are the core bb-arcade
427
466
  postMessage wire-protocol version. They are not npm semver and are not a
428
467
  version field on multiplayer WebSocket frames.
429
468
 
430
- ## Cloud-save recipe
469
+ ## Cloud save recipe
470
+
471
+ Hosted uploads have an opaque origin where a real localStorage is not merely
472
+ empty. Reading window.localStorage THROWS a SecurityError, as do
473
+ sessionStorage, IndexedDB, and document.cookie. SDK save is the primary store
474
+ there. URL embeds and standalone builds need their own same-origin fallback.
475
+
476
+ Ask who is playing before reaching for the account backed calls. getPlayer()
477
+ resolves null for guests, standalone play, and embeds off Bounty Board, and it
478
+ never rejects and never hangs, so it turns the guest case into a branch instead
479
+ of a thrown error:
480
+
481
+ const player = await BBArcade.getPlayer();
431
482
 
432
- Hosted uploads have an opaque origin and no localStorage, so SDK save is the
433
- primary store there. URL embeds and standalone builds need their own same-origin
434
- fallback.
483
+ if (player) {
484
+ const blob = await BBArcade.load(); // null = signed in, no save yet
485
+ restore(blob ? JSON.parse(blob) : defaults);
486
+ } else {
487
+ restoreStandaloneProgress(); // and offer "Sign in to save"
488
+ }
489
+
490
+ Still catch save()/load(), but for transport failures, not for account state
491
+ you already branched on:
435
492
 
436
493
  const blob = JSON.stringify(progress);
437
494
  try {
438
495
  await BBArcade.save(blob);
439
496
  } catch (error) {
440
497
  if (error.code === 'unsupported') localStorage.setItem('progress', blob);
498
+ else if (error.code !== 'unauthenticated') reportSaveFailure(error.code);
441
499
  }
442
500
 
443
- try {
444
- const blob = await BBArcade.load();
445
- restore(blob ? JSON.parse(blob) : defaults);
446
- } catch {
447
- restoreStandaloneProgress();
448
- }
501
+ save() rejecting for a guest is deliberate, not an oversight. A write that did
502
+ not happen must never resolve as if it had, or the game reports "Saved" over
503
+ progress that is already gone. It is also the only place "Sign in to keep your
504
+ progress" can be offered in context, which is worth more than a silent no-op.
505
+
506
+ Do not assume load() resolves null for a guest either. null means the logged in
507
+ player has no save yet; a guest rejects unauthenticated.
449
508
 
450
- Do not assume load() resolves null for a guest; it can reject unauthenticated.
509
+ A game that does not want to model accounts at all should use the storage shim
510
+ below instead. It folds guest and standalone play into 'memory' mode and never
511
+ rejects on either.
451
512
 
452
- ## Safe rewarded-ad recipe
513
+ ## localStorage shim for engine exports
453
514
 
454
- Prepare at the natural break. Keep the game-owned button disabled until ready.
515
+ Prefer save()/load() when you control the source. The shim exists for engine
516
+ runtimes whose storage layer cannot be rewired without patching engine
517
+ internals: GameMaker HTML5 (ini_open/ini_write_*/game_save all sit on
518
+ localStorage), Godot, Unity, and Construct.
519
+
520
+ It doubles as the tier for any game that does not want to model accounts at
521
+ all. Guests, standalone play, and embeds off Bounty Board settle into 'memory'
522
+ mode, so nothing rejects and no auth state has to be handled. The cost is that
523
+ the game never learns a save was not persisted, so it cannot offer the player a
524
+ chance to sign in and keep it. Check storage.mode for that.
525
+
526
+ With the script tag, nothing is required beyond load order. The SDK installs
527
+ the shim at load and window.localStorage starts working:
528
+
529
+ <script src="https://www.bountyboard.gg/arcade-sdk/v1.js"></script>
530
+ <script src="html5game/YourGame.js"></script>
531
+
532
+ Module consumers keep a side effect free import and install it themselves,
533
+ before any engine code runs:
534
+
535
+ import { BBArcade } from '@bountyboard/arcade-sdk';
536
+ BBArcade.storage.install();
537
+
538
+ install() is idempotent and a no-op wherever a real Storage works, so standalone
539
+ play and URL embeds are untouched. Behavior once installed:
540
+
541
+ - Reads and writes are synchronous and in-memory; the map is persisted to the
542
+ player's cloud save on a short debounce, on pagehide, and on flush().
543
+ - Progress becomes per-player and cross-device, not per-browser.
544
+ - setItem throws QuotaExceededError when the write would exceed the ~1 MB save
545
+ cap, matching a real Storage.
546
+ - sessionStorage is shimmed too, but memory only. It is per session by
547
+ definition and is never synced.
548
+ - Guests and standalone play settle in 'memory' mode: storage still works for
549
+ the session, it is just never persisted. Nothing rejects; nothing hangs.
550
+
551
+ The one real limitation is that getItem is synchronous while the cloud read is
552
+ not. Gate boot time reads on ready():
553
+
554
+ await BBArcade.storage.ready(); // 'cloud' | 'memory' | 'native'
555
+ startGame();
556
+
557
+ Writes made before hydration are kept and merged with the cloud read (the
558
+ game's own write wins, and a removeItem is not resurrected), and nothing is
559
+ pushed to the server until that read has SUCCEEDED. A failed read is retried on
560
+ the next write, and a write that still cannot confirm what the player had is
561
+ refused (rejecting with code error) rather than allowed to overwrite it blind,
562
+ so neither a fresh start write at boot nor a transport blip can wipe an existing
563
+ save. A game that reads
564
+ at boot WITHOUT awaiting ready() may still see an empty map on the first frame.
565
+
566
+ save()/load() keep working alongside the shim: the player has one save slot, so
567
+ with the shim active both halves share it inside a tagged envelope. load()
568
+ unwraps it and returns only what save() wrote. A raw blob written before the
569
+ shim existed is read back untouched, so turning the shim on never orphans a
570
+ save.
571
+
572
+ ## Safe rewarded ad recipe
573
+
574
+ Prepare at the natural break. Keep the game owned button disabled until ready.
455
575
  Call show() as the first operation in the direct click/tap handler: no await,
456
576
  timer, microtask, animation, state transition, or network call before it.
457
577
 
@@ -484,7 +604,7 @@ is normal even though test mode is enabled automatically.
484
604
 
485
605
  try {
486
606
  const room = await joinRoom({
487
- create: true, // or code: 'ABCD'; browser v1/npm 1.2 adds match: true
607
+ create: true, // or code: 'ABCD', or match: true for public quick match
488
608
  // Only a relay founder uses roomSize; every joinData field is untrusted.
489
609
  joinData: { roomSize: 4, avatar: 'golem' },
490
610
  });
@@ -522,79 +642,79 @@ Bounty referee module, or registered external authority. The signed ticket
522
642
  binds that tier. joinData cannot select or downgrade it; a ticket/registry
523
643
  mismatch fails closed, and a registered external slug never falls back to
524
644
  relay. All tiers use the same joinRoom transport, room codes, and client
525
- connection lifecycle; tier/game-specific state and event schemas still differ.
645
+ connection lifecycle; tier/game specific state and event schemas still differ.
526
646
 
527
647
  create/code/match select a room. Relay supplies the host contract below, but
528
648
  games build any ready/team/start/kick/rematch protocol on top and those rules
529
- remain client-trusted. Referee modules and external authorities define their
649
+ remain client trusted. Referee modules and external authorities define their
530
650
  own state, input, event, spectator, lobby, and rematch schemas. A module's
531
651
  minPlayers does not automatically gate simulation or start a match. Quick match
532
- may place a player into a joinable room already in progress; late-join behavior
533
- is tier/game-defined.
652
+ may place a player into a joinable room already in progress; late join behavior
653
+ is tier/game defined.
534
654
 
535
655
  joinData is always untrusted JSON. The SDK only validates its shape and 1 KiB
536
656
  cap. A relay reads roomSize only from the founding seat. Referee modules and
537
- external authorities validate any game-specific admission fields themselves.
657
+ external authorities validate any game specific admission fields themselves.
538
658
 
539
659
  ### Built-in Bounty relay tier
540
660
 
541
- The relay is the default Bounty shared-service tier for a multiplayer-approved
661
+ The relay is the default Bounty shared service tier for a multiplayer approved
542
662
  slug that has no bespoke referee module and is not routed to an external
543
- authority. It is a casual, zero-server-code lobby—not an authoritative game
663
+ authority. It is a casual lobby with no server code, not an authoritative game
544
664
  simulation or anti-cheat boundary.
545
665
 
546
666
  - The first admitted seat fixes capacity from joinData.roomSize. It must be an
547
- integer from 2 through 64; missing, invalid, or out-of-range means 8. The room
667
+ integer from 2 through 64; missing, invalid, or out of range means 8. The room
548
668
  may operate with one current occupant. Later joiners cannot change size. Once
549
669
  the room is completely empty, the next founder may choose it again. Ship the
550
- same roomSize from every client so quick-matched rooms found consistently.
670
+ same roomSize from every client so quick matched rooms found consistently.
551
671
  - Welcome and later 1 Hz metadata snapshots are identical for every viewer and
552
672
  expose exactly
553
673
  { mode: 'relay', hostId: string | null, size: number, dropped: number }.
554
674
  - The oldest retained seat is host. A transient disconnect preserves its seat
555
675
  and hostId through the 15-second grace. After the host actually leaves or its
556
- seat expires, the next-oldest retained seat becomes host and live clients get
676
+ seat expires, the next oldest retained seat becomes host and live clients get
557
677
  { type: 'relay_host', hostId } through room.on('event'). The founding welcome
558
678
  already carries hostId, so no initial relay_host event is required.
559
679
  - room.send(data) and room.trySend(data) publicly fan the JSON payload to EVERY
560
680
  player, including the sender. There are no private messages, hidden state, or
561
681
  viewer filtering. Batches arrive at 20 Hz through room.on('event') as
562
- { type: 'relay', messages: [{ from, data }] }, where from is a room-scoped
682
+ { type: 'relay', messages: [{ from, data }] }, where from is a room scoped
563
683
  player id. Tick batching adds at most about 50 ms before network latency.
564
684
  - Each relay payload's UTF-8 JSON encoding is capped at 1024 bytes. The relay
565
685
  accepts at most 15 messages per player per second and 120 per room per second.
566
686
  Byte/rate excess is silently dropped and increments cumulative state.dropped,
567
687
  visible on a later metadata snapshot. state.dropped does not count malformed,
568
- stale-sequence, or outer-envelope drops.
688
+ stale sequence, or outer envelope drops.
569
689
  - The common outer room guard still caps a client frame at 4096 characters and
570
690
  90 accepted envelopes per player per second; server frames are capped at
571
691
  256 KiB.
572
692
  - Relay rooms are endless. They never emit match_end/room.on('end'), never close
573
693
  because a game outcome was claimed, and never report results to Bounty Board.
574
- All game rules and outcomes are client-trusted; do not use relay claims for
694
+ All game rules and outcomes are client trusted; do not use relay claims for
575
695
  trusted rewards, standings, or anti-cheat decisions. Implement round boundaries
576
696
  in game messages and call room.leave() when the player exits.
577
697
 
578
698
  ### Refereed Bounty modules and external authorities
579
699
 
580
700
  A referee validates every input, runs the only game simulation, clears held
581
- controls on disconnect when its game requires it, and sends viewer-safe state.
701
+ controls on disconnect when its game requires it, and sends viewer safe state.
582
702
  Clients never report authoritative results. A finite referee may emit one
583
- server-authored match_end; the SDK treats it as terminal and does not reconnect.
703
+ server authored match_end; the SDK treats it as terminal and does not reconnect.
584
704
  There is no SDK reset/rematch method. Endless authorities do not fabricate
585
705
  match_end. Registered external authorities define their own socket shutdown and
586
- subsequent-join behavior.
706
+ subsequent join behavior.
587
707
 
588
708
  Bounty referee modules declare integer min/max players with
589
709
  1 <= min <= max <= 64. tickHz is 1-60. snapshotHz may be lower, from 1 through
590
- tickHz; not every simulation tick emits a snapshot. Simulation-backed sync has
710
+ tickHz; not every simulation tick emits a snapshot. Simulation backed sync has
591
711
  a full network round trip, and the SDK provides no client-side prediction,
592
- interpolation, or rollback. Expect roughly 50-150 ms input-to-snapshot latency
593
- on real connections; smooth locally, then correct to the next viewer-safe
712
+ interpolation, or rollback. Expect roughly 50-150 ms from input to snapshot
713
+ on real connections; smooth locally, then correct to the next viewer safe
594
714
  snapshot. Relay game payloads instead arrive in the public 20 Hz event batches
595
715
  described above; its 1 Hz snapshot contains metadata only.
596
716
 
597
- ### Common Bounty shared-service room behavior
717
+ ### Common Bounty shared service room behavior
598
718
 
599
719
  - Generated rooms use four-character invite codes. match: true works for relay
600
720
  and referee rooms: it uses the current public room while live occupancy plus
@@ -602,13 +722,12 @@ described above; its 1 Hz snapshot contains metadata only.
602
722
  failed occupancy probes, or reservations roll a new room, so live occupancy
603
723
  can roll before reaching the room's capacity. A matched room.code is also an
604
724
  invite code.
605
- - A lost last-seat/ended-room race triggers at most 2 re-matchmaking retries
606
- (3 total attempts). In the current browser/source build and npm 1.2.0+, final
607
- rejection has code rejected and detail room_full or room_over, whichever the
608
- last attempt returned.
725
+ - A lost last seat or ended room race triggers at most 2 re-matchmaking retries
726
+ (3 total attempts). Final rejection has code rejected and detail room_full or
727
+ room_over, whichever the last attempt returned.
609
728
  - When a finite Bounty referee module ends, it broadcasts match_end once, closes
610
729
  its sockets, and subsequent joins to that room reject with room_over. This
611
- finite-close rule does not apply to the endless relay tier.
730
+ closing rule does not apply to the endless relay tier.
612
731
  - Disconnected seats are reserved for 15 seconds. The SDK obtains fresh tickets
613
732
  and makes up to 3 reconnect attempts. During grace, the player remains in
614
733
  room.players and playerLeave is delayed until the seat expires. room.leave()
@@ -618,11 +737,10 @@ described above; its 1 Hz snapshot contains metadata only.
618
737
  terminal generic admission rejection; the public SDK does not expose the
619
738
  WebSocket close code/reason, so do not label every rejected error as this case.
620
739
 
621
- Public quick match is available in the current browser v1 artifact/source and
622
- is queued for npm 1.2.0. npm 1.1.0 consumers must use create/code. Quick match
623
- is unavailable on registered external authorities, which own their capacity,
624
- lobby lifecycle, and any matchmaking. Their reviewed limits can differ from
625
- the Bounty shared-service defaults, and an external authority may be endless.
740
+ Public quick match is unavailable on registered external authorities, which
741
+ own their capacity, lobby lifecycle, and any matchmaking. Their reviewed
742
+ limits can differ from the Bounty shared service defaults, and an external
743
+ authority may be endless.
626
744
 
627
745
  joinRoom rejection map:
628
746
 
@@ -634,20 +752,19 @@ joinRoom rejection map:
634
752
  - error: malformed host grant, postMessage/socket transport failure, server
635
753
  failure, or the 10-second welcome timeout.
636
754
 
637
- In the current browser/source build and npm 1.2.0+, use error.detail only when
638
- it is a specific documented value such as room_full or room_over. Stable npm
639
- 1.1.0 has no detail field. Generic rejected has multiple causes; do not blind
640
- retry or show “already playing elsewhere” without a distinct detail.
755
+ Use error.detail only when it is a specific documented value such as room_full
756
+ or room_over. Generic rejected has multiple causes; do not blind retry or show
757
+ “already playing elsewhere” without a distinct detail.
641
758
 
642
759
  Multiplayer is curated per game slug. After approval, Bounty Board assigns one
643
760
  of three rails: (a) the built-in casual relay, (b) a bespoke Bounty referee
644
761
  module, or (c) a registered external authority with a dedicated ticket key.
645
- Relay is the shared-service default when no bespoke module or external route is
762
+ Relay is the shared service default when no bespoke module or external route is
646
763
  registered; clients cannot select the tier. External servers keep their existing
647
- simulation and add only an isolated signed-ticket adapter plus the SDK room
648
- envelope—never create a second simulation beside one.
764
+ simulation and add only an isolated signed ticket adapter plus the SDK room
765
+ envelope. Never create a second simulation beside one.
649
766
 
650
- Local Bounty shared-service development bypasses the host ticket handshake only
767
+ Local Bounty shared service development bypasses the host ticket handshake only
651
768
  with paired roomUrl and ticket values (for example wrangler dev with
652
769
  DEV_ALLOW_UNSIGNED=1). A registered slug resolves to its referee module; another
653
770
  well-formed slug resolves to relay:
@@ -675,21 +792,34 @@ https://www.bountyboard.gg/arcade/sdk/external-authoritative-servers.txt
675
792
  - Mock rewarded ready, unavailable, dismissed, error, and viewed; only viewed grants.
676
793
  - Assert prepared.show() is called directly from the player gesture.
677
794
  - Test initial room.state/players rendering before the first later event.
678
- - Test multiplayer reconnect/grace, trySend false, tier-appropriate events/state,
795
+ - Test multiplayer reconnect/grace, trySend false, tier appropriate events/state,
679
796
  and leave.
680
- - Test create/code, and match only when using the current browser build/npm 1.2+.
681
- - For relay, test roomSize default/range, public fan-out (including sender), host
797
+ - Test create/code, and match on the Bounty shared service tiers.
798
+ - For relay, test roomSize default/range, public fan out (including sender), host
682
799
  succession after grace, byte/rate drops, state.dropped, and absence of end.
683
- - For referee/external rooms, test viewer-safe snapshots, authoritative results,
684
- and game-defined start/ready/late-join rules against that authority.
800
+ - For referee/external rooms, test viewer safe snapshots, authoritative results,
801
+ and game defined start/ready/late join rules against that authority.
685
802
  - Test the identical artifact in its standalone location and inside Bounty Board.
686
803
 
687
804
  ## Troubleshooting map
688
805
 
806
+ - BBArcade is not defined, script blocked with
807
+ ERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoep -> the
808
+ embedding page is cross-origin isolated -> the hosted file already sends CORP
809
+ cross-origin; if a proxy strips it, add crossorigin="anonymous" to the tag or
810
+ vendor the file into the build.
689
811
  - Boot waits forever -> game startup depends on an SDK promise -> start init
690
812
  fire-and-forget and give every promise feature a standalone outcome.
691
- - save/load unsupported -> not a Bounty-hosted upload -> use own same-origin store.
692
- - save/load unauthenticated -> guest player -> continue with defaults/fallback.
813
+ - save/load unsupported -> not a Bounty hosted upload -> use own same-origin store.
814
+ - save/load unauthenticated -> guest player -> continue with defaults/fallback,
815
+ and gate on getPlayer() so the guest case is a branch instead of a catch.
816
+ - SecurityError touching localStorage/sessionStorage/document.cookie -> hosted
817
+ builds run on an opaque origin -> use save()/load(), or storage.install() for
818
+ an engine export that cannot be rewired.
819
+ - Shimmed storage reads empty at boot -> the cloud read had not landed yet ->
820
+ await BBArcade.storage.ready() before restoring progress.
821
+ - storage.mode is 'memory' -> guest, standalone, or off host -> storage works
822
+ for the session but is never persisted; keep first run defaults sane.
693
823
  - direct_user_action_required -> show() lost browser activation -> make show()
694
824
  the first line of the click/tap handler.
695
825
  - host_disabled/unavailable -> host or inventory is not ready -> keep the normal
@@ -701,15 +831,15 @@ https://www.bountyboard.gg/arcade/sdk/external-authoritative-servers.txt
701
831
  - trySend false -> disconnected/raced socket or local serialization/send failure
702
832
  -> stop sending, show reconnecting when disconnected, and resume only after
703
833
  connection.connected is true. A true result still does not prove acceptance.
704
- - match rejected on an external authority -> external matchmaking is authority-
834
+ - match rejected on an external authority -> external matchmaking is authority
705
835
  owned -> use code/create or that authority's separately documented flow.
706
836
 
707
837
  ## More
708
838
 
709
839
  - Human guide: https://www.bountyboard.gg/arcade/sdk
840
+ - npm package: https://www.npmjs.com/package/@bountyboard/arcade-sdk
710
841
  - Relay room guide: https://www.bountyboard.gg/arcade/sdk/relay-rooms.txt
711
842
  - Standalone types: https://www.bountyboard.gg/arcade-sdk.d.ts
712
843
  - Submit a game: https://www.bountyboard.gg/arcade/submit
713
- - Package README, changelog, AGENTS.md, and design playbook ship in npm 1.1.0.
714
- The external-authority guide is public at the URL above and joins the package
715
- in 1.2.0.
844
+ - Package README, changelog, AGENTS.md, design playbook, relay room guide, and
845
+ external authority guide all ship in the npm package.