@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/docs/llms.txt ADDED
@@ -0,0 +1,800 @@
1
+ # Bounty Board Arcade SDK — agent-readable integration contract
2
+
3
+ Human guide: https://www.bountyboard.gg/arcade/sdk
4
+ Package: @bountyboard/arcade-sdk
5
+ Stable npm release: 1.2.0
6
+ Wire protocol: 1
7
+
8
+ This file is the complete integration contract for coding agents integrating an
9
+ HTML5 game. The package TypeScript declarations remain the exact public type
10
+ reference. npm 1.2.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.
16
+
17
+ ## Non-negotiable integration rules
18
+
19
+ 1. The SDK is never load-bearing. A game must boot and remain fully playable
20
+ with no Bounty Board parent, no logged-in player, no ad inventory, no cloud
21
+ save, and no multiplayer authority.
22
+ 2. Call gameOver() exactly once per run and use integer scores. The host/server
23
+ enforces per-game plausibility caps.
24
+ 3. Bracket active play with gameplayStart()/gameplayStop(). Menus, pauses,
25
+ death prompts, and ad breaks are not active play.
26
+ 4. getPlayer() can be null and avatarUrl can be null. The SDK never exposes
27
+ account ids, emails, or roles.
28
+ 5. Store all progress in one save(string) blob (about 1 MB). Save at
29
+ checkpoints/game over, not in a frame loop, and catch every rejection. When
30
+ the game is an engine export whose storage layer you cannot rewire, use
31
+ storage.install() instead and let the shim carry localStorage to the cloud.
32
+ 6. For new rewarded-ad work, prepare first, enable the game's button only when
33
+ status is ready, call prepared.show() directly from the click/tap handler,
34
+ and grant only when the final status is viewed.
35
+ 7. Multiplayer has three rails. Referee modules and external authorities own
36
+ simulation/results and receive client inputs. The casual relay owns signed
37
+ admission, roster, capacity, host succession, reconnect grace, and public
38
+ message fan-out, but it does not referee game state or outcomes. Catch every
39
+ joinRoom() rejection and keep solo/standalone play available.
40
+ 8. lockToHost() is an optional anti-rehosting deterrent, not DRM. Call it before
41
+ boot. allow lists the domains the STUDIO hosts the build on, so a build that
42
+ only runs on Bounty Board passes no allow entries (or skips the call);
43
+ play.yourgame.com in every example is a placeholder, never a real value.
44
+
45
+ ## Install and distribution
46
+
47
+ Preferred for games with a build step:
48
+
49
+ npm install @bountyboard/arcade-sdk
50
+ import { BBArcade } from '@bountyboard/arcade-sdk';
51
+
52
+ The package is zero-dependency, typed, ESM + CommonJS, and SSR-safe. Importing
53
+ the module does not install a window.BBArcade global.
54
+
55
+ No-build script tag:
56
+
57
+ <script src="https://www.bountyboard.gg/arcade-sdk/v1.js"></script>
58
+
59
+ This installs window.BBArcade. Standalone declarations:
60
+ https://www.bountyboard.gg/arcade-sdk.d.ts
61
+
62
+ Do not mix npm and the script tag in one page. A bundler that specifically
63
+ wants the global may import @bountyboard/arcade-sdk/global.
64
+
65
+ Multiplayer module import:
66
+
67
+ import { joinRoom } from '@bountyboard/arcade-sdk/multiplayer';
68
+
69
+ Script-tag multiplayer is BBArcade.multiplayer.joinRoom(...).
70
+
71
+ Public package exports:
72
+
73
+ - @bountyboard/arcade-sdk: default BBArcade, named BBArcade,
74
+ PROTOCOL_VERSION, and the core public types.
75
+ - @bountyboard/arcade-sdk/multiplayer: joinRoom, multiplayer, and all BBArcadeMp*
76
+ types.
77
+ - @bountyboard/arcade-sdk/global: installs window.BBArcade for bundlers that
78
+ explicitly need the global. The normal module import does not install it.
79
+
80
+ ## Runtime capability matrix
81
+
82
+ Distribution format does not determine capabilities; the embedding host does.
83
+
84
+ - Bounty-hosted upload:
85
+ lifecycle/scores supported; player identity/variants supported; cloud
86
+ save/load available to logged-in players; native localStorage/sessionStorage/
87
+ IndexedDB/cookies all THROW (opaque origin), so engine exports need
88
+ storage.install(); ads available to approved games; multiplayer available
89
+ after per-game approval, with relay as the default Bounty shared-service tier
90
+ when no bespoke referee module is registered.
91
+ - Approved external URL embed inside the Bounty Board player:
92
+ lifecycle/scores, identity, variants, and approved multiplayer (including the
93
+ Bounty relay tier) are supported; rewarded ads are unavailable (no payable
94
+ per-game attribution yet); cloud save/load is unsupported.
95
+ - Standalone or opened directly on the game's own site:
96
+ fire-and-forget calls no-op; init resolves immediately; getPlayer returns
97
+ null; getVariant returns the alphabetical control; host-only promise APIs
98
+ reject unsupported or return an unavailable outcome. An unanswered non-Bounty
99
+ embed uses an approximately 1.5-second init/getPlayer grace instead.
100
+
101
+ Guests are normal. getPlayer resolves null and account-backed calls may reject
102
+ with code unauthenticated.
103
+
104
+ Leaderboards need one thing outside the code: the studio must declare
105
+ Leaderboards for the game in its Arcade submission (or later by editing it).
106
+ Until then the host rejects every posted score and the game sees nothing,
107
+ because submitScore()/gameOver() are fire-and-forget. Optional per-game score
108
+ ceilings live beside that declaration and reject implausible values.
109
+
110
+ ## Minimal correct lifecycle
111
+
112
+ import { BBArcade } from '@bountyboard/arcade-sdk';
113
+
114
+ // Optional, before boot. Bounty Board and localhost are always allowed, so
115
+ // allow lists only domains YOU host on; omit it when Bounty Board hosts the build.
116
+ BBArcade.lockToHost({ allow: ['play.yourgame.com'] }); // placeholder domain
117
+ void BBArcade.init(); // never gate boot on it
118
+ BBArcade.gameLoadingFinished(); // first playable scene ready
119
+
120
+ BBArcade.gameplayStart();
121
+ BBArcade.submitScore(Math.trunc(score)); // may repeat; host throttles
122
+ BBArcade.gameplayStop(); // pause/death/menu/ad
123
+ BBArcade.gameOver(Math.trunc(finalScore)); // exactly once per run
124
+
125
+ For a shared-seed Daily, pass { mode: 'daily' } to submitScore and gameOver.
126
+
127
+ ## Exact shared types and configuration
128
+
129
+ All rejected SDK promises use a real Error:
130
+
131
+ type BBArcadeErrorCode =
132
+ | 'unsupported' // no compatible host/capability
133
+ | 'unauthenticated' // login/ticket identity required or invalid
134
+ | 'too_large' // save blob exceeds the cap
135
+ | 'rejected' // host/authority refused the payload or room
136
+ | 'error'; // timeout, transport, or server failure
137
+
138
+ interface BBArcadeError extends Error {
139
+ code: BBArcadeErrorCode;
140
+ detail?: string; // raw authority reason when available
141
+ }
142
+
143
+ Core configuration shapes:
144
+
145
+ interface BBArcadeRewardedAdsConfig {
146
+ enabled?: boolean; // default true; host approval still wins
147
+ adChannel?: string; // legacy; ignored for Bounty attribution
148
+ testMode?: boolean; // defaults true on localhost/file:
149
+ preload?: boolean; // default false
150
+ loadTimeoutMs?: number; // default 6000, maximum 15000
151
+ }
152
+
153
+ interface BBArcadeConfig {
154
+ rewardedAds?: BBArcadeRewardedAdsConfig;
155
+ };
156
+
157
+ interface BBArcadeLockToHostOptions {
158
+ allow?: string[]; // hosts/host:port/full URLs; subdomains match
159
+ signed?: boolean;
160
+ onBlocked?: () => void;
161
+ redirect?: string;
162
+ }
163
+
164
+ lockToHost defaults allow bountyboard.gg and its subdomains, the fixed Bounty
165
+ staging host, localhost, and 127.0.0.1. allow extends rather than replaces that
166
+ list. signed mode requests an ECDSA origin attestation; it falls back to the
167
+ best-effort browser-origin check only when the host reports no signing key or
168
+ Web Crypto is unavailable. A missing host or present-but-invalid attestation
169
+ blocks.
170
+
171
+ Player and score shapes:
172
+
173
+ interface BBArcadePlayer { name: string; avatarUrl: string | null }
174
+ // submitScore/gameOver use the inline option shape { mode?: 'daily' }.
175
+
176
+ Storage compatibility shapes:
177
+
178
+ type BBArcadeStorageMode =
179
+ | 'native' // a real Storage works here; the SDK changed nothing
180
+ | 'cloud' // shim installed, syncing to the player's Bounty Board save
181
+ | 'memory'; // shim installed but nothing can persist (guest/standalone)
182
+
183
+ interface BBArcadeStorage {
184
+ install(): BBArcadeStorageMode; // idempotent; no-op when native works
185
+ ready(): Promise<BBArcadeStorageMode>;
186
+ flush(): Promise<void>; // rejects with the save() error codes
187
+ readonly mode: BBArcadeStorageMode;
188
+ }
189
+
190
+ Rewarded-ad options and results:
191
+
192
+ interface BBArcadeRewardedAdOptions {
193
+ placement?: string; name?: string; reward?: string; adBreakId?: string;
194
+ size?: 'small' | 'medium' | 'large'; // legacy SDK analytics only
195
+ adChannel?: string; testMode?: boolean; loadTimeoutMs?: number;
196
+ beforeReward?: (showAd: () => void) => void; // low-level API only
197
+ adViewed?: (result: BBArcadeRewardedAdResult) => void;
198
+ adDismissed?: (result: BBArcadeRewardedAdResult) => void;
199
+ adBreakDone?: (placementInfo: unknown) => void;
200
+ onReward?: (result: BBArcadeRewardedAdResult) => void; // adViewed alias
201
+ onDismissed?: (result: BBArcadeRewardedAdResult) => void; // dismissed alias
202
+ onDone?: (result: BBArcadeRewardedAdResult) => void;
203
+ onError?: (result: BBArcadeRewardedAdResult) => void;
204
+ onUnavailable?: (result: BBArcadeRewardedAdResult) => void;
205
+ }
206
+
207
+ type BBArcadePrepareRewardedAdOptions =
208
+ Omit<BBArcadeRewardedAdOptions, 'beforeReward'> & {
209
+ onStart?: () => void;
210
+ };
211
+
212
+ type BBArcadeRewardedAdStatus =
213
+ 'viewed' | 'dismissed' | 'ready' | 'unavailable' | 'error';
214
+
215
+ interface BBArcadeRewardedAdResult {
216
+ status: BBArcadeRewardedAdStatus;
217
+ placement: string; reward: string; adBreakId: string;
218
+ size?: 'small' | 'medium' | 'large';
219
+ error?: string; breakStatus?: string;
220
+ }
221
+
222
+ interface BBArcadePreparedRewardedAd {
223
+ status: 'ready'; placement: string; reward: string; adBreakId: string;
224
+ size?: 'small' | 'medium' | 'large';
225
+ show(): Promise<BBArcadeRewardedAdResult>; // one-shot
226
+ }
227
+
228
+ type BBArcadeRewardedAdPrepareFailure = BBArcadeRewardedAdResult & {
229
+ status: 'unavailable' | 'error';
230
+ };
231
+
232
+ type BBArcadeRewardedAdPreparation =
233
+ | BBArcadePreparedRewardedAd
234
+ | BBArcadeRewardedAdPrepareFailure;
235
+
236
+ interface BBArcadeRewardedBreakOptions extends BBArcadeRewardedAdOptions {
237
+ onStart?: () => void;
238
+ }
239
+
240
+ Defaults are placement 'rewarded' and reward 'reward'; ids are generated unless
241
+ supplied. BBArcadePreparedRewardedAd.show is one-shot; repeated calls share its
242
+ final promise.
243
+ The Bounty host's ad config overrides per-call adChannel/testMode when present.
244
+
245
+ ## API surface
246
+
247
+ BBArcadeSDK is the aggregate interface implemented by the named/default
248
+ BBArcade export and window.BBArcade. Its methods are all listed below. The
249
+ interface also has version: number and multiplayer?: BBArcadeMultiplayer;
250
+ multiplayer is installed by the script/global build, while normal module users
251
+ import the dedicated multiplayer subpath.
252
+
253
+ Lifecycle and scoring:
254
+
255
+ - lockToHost({ allow?: string[], signed?: boolean, onBlocked?, redirect? }): void
256
+ Early anti-rehosting check. Defaults allow Bounty Board hosts and localhost.
257
+ When the embedder cannot be read from browser signals (opaque-origin
258
+ sandbox), the SDK asks the embedding page and blocks if nothing answers
259
+ within about 5 seconds.
260
+ - init({ rewardedAds? }?): Promise<void>
261
+ Announces the game and receives host config. Resolves when answered,
262
+ immediately when top-level/standalone, or after about 1.5 seconds in an
263
+ unanswered embed; awaiting is optional and must not gate boot.
264
+ - configure(options?): void
265
+ Applies the same module configuration without another host handshake.
266
+ - ready() / gameLoadingFinished(): void
267
+ Signals that the game is loaded and the player can start.
268
+ - gameplayStart() / gameplayStop(): void
269
+ Brackets active play.
270
+ - submitScore(score: number, { mode?: 'daily' }?): void
271
+ Current integer score; transport is throttled by the host. Fractional
272
+ values are truncated toward zero (Math.trunc semantics).
273
+ - gameOver(score: number, { mode?: 'daily' }?): void
274
+ Final integer score; call exactly once per run. Same truncation as
275
+ submitScore.
276
+ - xrSessionStart() / xrSessionEnd(): void
277
+ Brackets immersive WebXR play.
278
+
279
+ Player data and experiments:
280
+
281
+ - save(blob: string): Promise<void>
282
+ One blob, about 1 MB. Requires a Bounty-hosted upload and logged-in player.
283
+ Oversized blobs reject too_large immediately via a client-side precheck
284
+ against the same 1 MiB cap the server enforces.
285
+ - load(): Promise<string | null>
286
+ Returns null when no save exists. Rejects like save().
287
+ - storage: BBArcadeStorage
288
+ localStorage compatibility for hosted builds. install(): BBArcadeStorageMode
289
+ replaces a throwing window.localStorage with a Storage-shaped object backed
290
+ by the cloud save, and is a no-op where a real Storage works. ready():
291
+ Promise<BBArcadeStorageMode> resolves once the cloud read has landed.
292
+ flush(): Promise<void> forces the debounced write out now. mode is the
293
+ current BBArcadeStorageMode ('native' | 'cloud' | 'memory'). The script-tag
294
+ build calls install() for you at load; module consumers call it themselves
295
+ before boot.
296
+ - getPlayer(): Promise<{ name: string, avatarUrl: string | null } | null>
297
+ Display identity only; always handle null.
298
+ - onPlayerChange(handler): () => void
299
+ Subscribes to CHANGES in the display identity (mid-session login/logout).
300
+ The handler receives the same { name, avatarUrl } | null shape as
301
+ getPlayer() and runs only when the identity actually changes; subscribing
302
+ does not replay the current value. Returns an unsubscribe function.
303
+ - getVariant(key: string, variants: string[]): Promise<string | null>
304
+ Deterministic even split per player+game+key. Alphabetical first is the
305
+ standalone/error control. At least 2 variants are needed for a host
306
+ assignment; an empty list resolves null and a one-item list resolves that
307
+ item. Do not re-randomize client-side.
308
+
309
+ save/load can reject with every BBArcadeErrorCode. load resolves null only when
310
+ the logged-in hosted player has no save; a guest can reject unauthenticated.
311
+ Every host request (save, load, variant, multiplayer ticket) rejects with code
312
+ error after a 15-second timeout when no answer arrives.
313
+
314
+ unsupported | unauthenticated | too_large | rejected | error
315
+
316
+ Rewarded ads:
317
+
318
+ - prepareRewardedAd(options?): Promise<BBArcadeRewardedAdPreparation>
319
+ Recommended. Alias: prepareRewardedBreak(). Prepared show() is one-shot.
320
+ - rewardedAd(options?): Promise<BBArcadeRewardedAdResult>
321
+ Low-level structured-result API. Alias: showRewardedAd().
322
+ - rewardedBreak(options | onStart): Promise<boolean>
323
+ Deprecated compatibility helper. New games must use the prepared flow.
324
+ - preloadRewardedAds(options?): Promise<boolean>
325
+ Warms the ads library. Alias: preloadRewardedAd().
326
+
327
+ Rewarded result status:
328
+
329
+ viewed | dismissed | ready | unavailable | error
330
+
331
+ Only final viewed grants. A prepared ad whose break ends before show() is ever
332
+ called resolves dismissed (never the non-terminal ready); ready survives as a
333
+ terminal state only in the legacy edge where the ad WAS shown but Google sent
334
+ no view/dismiss callback. Useful error detail includes host_disabled,
335
+ ad_break_unavailable, ad_break_timeout, no_rewarded_ad, ad_in_progress,
336
+ show_ad_error, direct_user_action_required, before_reward_error, and
337
+ ad_break_error.
338
+
339
+ Multiplayer:
340
+
341
+ type BBArcadeMpJoinOptions = {
342
+ code?: string;
343
+ create?: boolean;
344
+ match?: boolean; // public quick match; Bounty shared-service tiers only
345
+ joinData?: Readonly<Record<string, unknown>>;
346
+ roomUrl?: string; // local development override; provide with ticket
347
+ ticket?: string; // local development override; provide with roomUrl
348
+ timeoutMs?: number; // welcome timeout override, clamped 1000-60000 ms
349
+ };
350
+
351
+ interface BBArcadeMpPlayer {
352
+ id: string; // room-scoped, never a Bounty account id
353
+ name: string;
354
+ avatarUrl: string | null;
355
+ }
356
+
357
+ interface BBArcadeMpResult {
358
+ playerId: string;
359
+ name: string;
360
+ placement: number;
361
+ score: number;
362
+ outcome: 'win' | 'loss' | 'draw';
363
+ }
364
+
365
+ interface BBArcadeMpRoomEvents {
366
+ snapshot: { tick: number; state: unknown };
367
+ playerJoin: { player: BBArcadeMpPlayer };
368
+ playerLeave: { playerId: string };
369
+ event: unknown;
370
+ end: { results: BBArcadeMpResult[] };
371
+ error: { code: string };
372
+ connection: { connected: boolean; reconnecting: boolean };
373
+ close: { reconnecting: boolean };
374
+ }
375
+
376
+ interface BBArcadeMpRoom {
377
+ code: string;
378
+ playerId: string;
379
+ players: BBArcadeMpPlayer[];
380
+ state: unknown;
381
+ connected: boolean;
382
+ latencyMs: number | null; // join-handshake estimate, refreshed on reconnect
383
+ send(input: unknown): void;
384
+ trySend(input: unknown): boolean;
385
+ on<K extends keyof BBArcadeMpRoomEvents>(
386
+ event: K,
387
+ handler: (data: BBArcadeMpRoomEvents[K]) => void
388
+ ): () => void;
389
+ leave(): void;
390
+ }
391
+
392
+ interface BBArcadeMultiplayer {
393
+ joinRoom(options?: BBArcadeMpJoinOptions): Promise<BBArcadeMpRoom>;
394
+ }
395
+
396
+ Relay runtime payloads use these exact shapes through the otherwise-unknown
397
+ room.state and room.on('event') values:
398
+
399
+ interface BBArcadeRelayState {
400
+ mode: 'relay';
401
+ hostId: string | null;
402
+ size: number;
403
+ dropped: number;
404
+ }
405
+
406
+ type BBArcadeRelayEvent =
407
+ | {
408
+ type: 'relay';
409
+ messages: Array<{ from: string; data: unknown }>;
410
+ }
411
+ | { type: 'relay_host'; hostId: string | null };
412
+
413
+ BBArcadeRelayState and BBArcadeRelayEvent are documentation names, not package
414
+ exports; the public SDK intentionally types game/tier-defined state and events
415
+ as unknown.
416
+
417
+ - joinRoom(options?): Promise<BBArcadeMpRoom>
418
+ No options and { create: true } both generate a 4-character invite code from
419
+ ABCDEFGHJKLMNPQRSTUVWXYZ23456789. A supplied code is uppercased. match: true
420
+ is Bounty-hosted public quick match and excludes code, create, roomUrl, and
421
+ ticket. roomUrl+ticket bypass the host handshake for local development only;
422
+ provide both (a lone value is not an override).
423
+ - joinData must be a non-null, non-array JSON-serializable object whose UTF-8
424
+ JSON encoding is at most 1 KiB. Cycles, arrays, primitives, and oversized data
425
+ reject with code rejected. Never include credentials or secrets.
426
+ - joinRoom resolves only after the authority sends welcome (10-second default
427
+ welcome timeout; timeoutMs overrides it, clamped to 1000–60000 ms, and
428
+ applies to every automatic reconnect attempt too).
429
+ At resolution, code/playerId/players/state/connected already hold the initial
430
+ lobby state. There is no replayed initial snapshot: render those fields first,
431
+ then subscribe to future events.
432
+ - room.latencyMs reports the join-handshake latency in ms (socket open to
433
+ server welcome: ticket verification plus one round trip), refreshed on every
434
+ successful (re)connect and null until the first welcome. Use it to tune
435
+ render-side smoothing; it is an estimate, not a measured RTT.
436
+ - Values passed to send/trySend must be JSON-serializable. A referee room
437
+ treats the value as an input; a relay room treats it as a public game
438
+ message. send discards local write status. trySend
439
+ returns true only when JSON was written to an open socket; it does NOT mean
440
+ the authority accepted the input. false means disconnected, raced closed, or
441
+ serialization/WebSocket.send failed. The authority still validates,
442
+ sequences, and may rate-drop inputs.
443
+ - on returns an unsubscribe function. The SDK updates room.players before
444
+ playerJoin/playerLeave handlers and room.state before snapshot handlers.
445
+ - leave intentionally closes the Room and permanently disables reconnect for it.
446
+ A welcome that arrives after leave() (e.g. racing a reconnect) is ignored and
447
+ never flips the room back to connected.
448
+ - A pre-welcome room error rejects joinRoom with BBArcadeError. Raw reasons such
449
+ as room_full/room_over are carried in error.detail when available.
450
+ An error after welcome emits room.on('error', { code }).
451
+ Connection loss emits connection/close events and may start automatic
452
+ reconnect.
453
+
454
+ BBArcade.version and the exported PROTOCOL_VERSION are the core bb-arcade
455
+ postMessage wire-protocol version. They are not npm semver and are not a
456
+ version field on multiplayer WebSocket frames.
457
+
458
+ ## Cloud-save recipe
459
+
460
+ Hosted uploads have an opaque origin where a real localStorage is not merely
461
+ empty — reading window.localStorage THROWS a SecurityError, as do
462
+ sessionStorage, IndexedDB, and document.cookie. SDK save is the primary store
463
+ there. URL embeds and standalone builds need their own same-origin fallback.
464
+
465
+ const blob = JSON.stringify(progress);
466
+ try {
467
+ await BBArcade.save(blob);
468
+ } catch (error) {
469
+ if (error.code === 'unsupported') localStorage.setItem('progress', blob);
470
+ }
471
+
472
+ try {
473
+ const blob = await BBArcade.load();
474
+ restore(blob ? JSON.parse(blob) : defaults);
475
+ } catch {
476
+ restoreStandaloneProgress();
477
+ }
478
+
479
+ Do not assume load() resolves null for a guest; it can reject unauthenticated.
480
+
481
+ ## localStorage shim for engine exports
482
+
483
+ Prefer save()/load() when you control the source. The shim exists for engine
484
+ runtimes whose storage layer cannot be rewired without patching engine
485
+ internals: GameMaker HTML5 (ini_open/ini_write_*/game_save all sit on
486
+ localStorage), Godot, Unity, and Construct.
487
+
488
+ With the script tag, nothing is required beyond load order — the SDK installs
489
+ the shim at load and window.localStorage starts working:
490
+
491
+ <script src="https://www.bountyboard.gg/arcade-sdk/v1.js"></script>
492
+ <script src="html5game/YourGame.js"></script>
493
+
494
+ Module consumers keep a side-effect-free import and install it themselves,
495
+ before any engine code runs:
496
+
497
+ import { BBArcade } from '@bountyboard/arcade-sdk';
498
+ BBArcade.storage.install();
499
+
500
+ install() is idempotent and a no-op wherever a real Storage works, so standalone
501
+ play and URL embeds are untouched. Behavior once installed:
502
+
503
+ - Reads and writes are synchronous and in-memory; the map is persisted to the
504
+ player's cloud save on a short debounce, on pagehide, and on flush().
505
+ - Progress becomes per-player and cross-device, not per-browser.
506
+ - setItem throws QuotaExceededError when the write would exceed the ~1 MB save
507
+ cap, matching a real Storage.
508
+ - sessionStorage is shimmed too, but memory-only — it is per-session by
509
+ definition and is never synced.
510
+ - Guests and standalone play settle in 'memory' mode: storage still works for
511
+ the session, it is just never persisted. Nothing rejects; nothing hangs.
512
+
513
+ The one real limitation is that getItem is synchronous while the cloud read is
514
+ not. Gate boot-time reads on ready():
515
+
516
+ await BBArcade.storage.ready(); // 'cloud' | 'memory' | 'native'
517
+ startGame();
518
+
519
+ Writes made before hydration are kept and merged with the cloud read (the
520
+ game's own write wins, and a removeItem is not resurrected), and nothing is
521
+ pushed to the server until that read has SUCCEEDED. A failed read is retried on
522
+ the next write, and a write that still cannot confirm what the player had is
523
+ refused (rejecting with code error) rather than allowed to overwrite it blind —
524
+ so neither a fresh-start write at boot nor a transport blip can wipe an existing
525
+ save. A game that reads
526
+ at boot WITHOUT awaiting ready() may still see an empty map on the first frame.
527
+
528
+ save()/load() keep working alongside the shim: the player has one save slot, so
529
+ with the shim active both halves share it inside a tagged envelope. load()
530
+ unwraps it and returns only what save() wrote. A raw blob written before the
531
+ shim existed is read back untouched, so turning the shim on never orphans a
532
+ save.
533
+
534
+ ## Safe rewarded-ad recipe
535
+
536
+ Prepare at the natural break. Keep the game-owned button disabled until ready.
537
+ Call show() as the first operation in the direct click/tap handler: no await,
538
+ timer, microtask, animation, state transition, or network call before it.
539
+
540
+ const prepared = await BBArcade.prepareRewardedAd({
541
+ placement: 'death_revive',
542
+ reward: 'extra_life',
543
+ onStart: () => pauseGameAndAudio(),
544
+ });
545
+
546
+ if (prepared.status === 'ready') {
547
+ button.disabled = false;
548
+ button.addEventListener('click', () => {
549
+ const resultPromise = prepared.show(); // keep first
550
+ button.disabled = true;
551
+ void resultPromise.then(result => {
552
+ resumeGameAndAudio();
553
+ if (result.status === 'viewed') revivePlayer();
554
+ else keepNormalFallbackAvailable();
555
+ });
556
+ }, { once: true });
557
+ }
558
+
559
+ Never auto-trigger, loop, or auto-retry. Unavailable ad inventory on localhost
560
+ is normal even though test mode is enabled automatically.
561
+
562
+ ## Multiplayer room and lobby contract
563
+
564
+ import type { BBArcadeError } from '@bountyboard/arcade-sdk';
565
+ import { joinRoom } from '@bountyboard/arcade-sdk/multiplayer';
566
+
567
+ try {
568
+ const room = await joinRoom({
569
+ create: true, // or code: 'ABCD', or match: true for public quick match
570
+ // Only a relay founder uses roomSize; every joinData field is untrusted.
571
+ joinData: { roomSize: 4, avatar: 'golem' },
572
+ });
573
+
574
+ // welcome is already reflected here; no initial snapshot event is replayed.
575
+ renderLobby(room.code, room.players, room.state);
576
+ room.on('playerJoin', () => renderPlayers(room.players));
577
+ room.on('playerLeave', () => renderPlayers(room.players));
578
+ room.on('snapshot', ({ tick, state }) => renderRoomState(tick, state));
579
+ room.on('event', event => handleRoomEvent(event));
580
+ room.on('connection', ({ connected, reconnecting }) =>
581
+ setConnectionState({ connected, reconnecting })
582
+ );
583
+ let ended = false;
584
+ room.on('close', ({ reconnecting }) => {
585
+ if (!reconnecting && !ended) showConnectionClosed();
586
+ });
587
+ room.on('end', ({ results }) => {
588
+ ended = true;
589
+ showResults(results); // finite refereed rooms; relay never emits end
590
+ });
591
+ if (!room.trySend(input)) {
592
+ if (!room.connected) showReconnecting(true);
593
+ else showSendFailure(); // serialization/WebSocket.send failed locally
594
+ }
595
+ onExit(() => room.leave());
596
+ } catch (error) {
597
+ const { code } = error as BBArcadeError;
598
+ showSoloFallback(code);
599
+ }
600
+
601
+ The SDK is room transport, not a universal lobby rules engine. Bounty Board
602
+ selects exactly one tier for the approved game slug: built-in relay, bespoke
603
+ Bounty referee module, or registered external authority. The signed ticket
604
+ binds that tier. joinData cannot select or downgrade it; a ticket/registry
605
+ mismatch fails closed, and a registered external slug never falls back to
606
+ relay. All tiers use the same joinRoom transport, room codes, and client
607
+ connection lifecycle; tier/game-specific state and event schemas still differ.
608
+
609
+ create/code/match select a room. Relay supplies the host contract below, but
610
+ games build any ready/team/start/kick/rematch protocol on top and those rules
611
+ remain client-trusted. Referee modules and external authorities define their
612
+ own state, input, event, spectator, lobby, and rematch schemas. A module's
613
+ minPlayers does not automatically gate simulation or start a match. Quick match
614
+ may place a player into a joinable room already in progress; late-join behavior
615
+ is tier/game-defined.
616
+
617
+ joinData is always untrusted JSON. The SDK only validates its shape and 1 KiB
618
+ cap. A relay reads roomSize only from the founding seat. Referee modules and
619
+ external authorities validate any game-specific admission fields themselves.
620
+
621
+ ### Built-in Bounty relay tier
622
+
623
+ The relay is the default Bounty shared-service tier for a multiplayer-approved
624
+ slug that has no bespoke referee module and is not routed to an external
625
+ authority. It is a casual, zero-server-code lobby—not an authoritative game
626
+ simulation or anti-cheat boundary.
627
+
628
+ - The first admitted seat fixes capacity from joinData.roomSize. It must be an
629
+ integer from 2 through 64; missing, invalid, or out-of-range means 8. The room
630
+ may operate with one current occupant. Later joiners cannot change size. Once
631
+ the room is completely empty, the next founder may choose it again. Ship the
632
+ same roomSize from every client so quick-matched rooms found consistently.
633
+ - Welcome and later 1 Hz metadata snapshots are identical for every viewer and
634
+ expose exactly
635
+ { mode: 'relay', hostId: string | null, size: number, dropped: number }.
636
+ - The oldest retained seat is host. A transient disconnect preserves its seat
637
+ and hostId through the 15-second grace. After the host actually leaves or its
638
+ seat expires, the next-oldest retained seat becomes host and live clients get
639
+ { type: 'relay_host', hostId } through room.on('event'). The founding welcome
640
+ already carries hostId, so no initial relay_host event is required.
641
+ - room.send(data) and room.trySend(data) publicly fan the JSON payload to EVERY
642
+ player, including the sender. There are no private messages, hidden state, or
643
+ viewer filtering. Batches arrive at 20 Hz through room.on('event') as
644
+ { type: 'relay', messages: [{ from, data }] }, where from is a room-scoped
645
+ player id. Tick batching adds at most about 50 ms before network latency.
646
+ - Each relay payload's UTF-8 JSON encoding is capped at 1024 bytes. The relay
647
+ accepts at most 15 messages per player per second and 120 per room per second.
648
+ Byte/rate excess is silently dropped and increments cumulative state.dropped,
649
+ visible on a later metadata snapshot. state.dropped does not count malformed,
650
+ stale-sequence, or outer-envelope drops.
651
+ - The common outer room guard still caps a client frame at 4096 characters and
652
+ 90 accepted envelopes per player per second; server frames are capped at
653
+ 256 KiB.
654
+ - Relay rooms are endless. They never emit match_end/room.on('end'), never close
655
+ because a game outcome was claimed, and never report results to Bounty Board.
656
+ All game rules and outcomes are client-trusted; do not use relay claims for
657
+ trusted rewards, standings, or anti-cheat decisions. Implement round boundaries
658
+ in game messages and call room.leave() when the player exits.
659
+
660
+ ### Refereed Bounty modules and external authorities
661
+
662
+ A referee validates every input, runs the only game simulation, clears held
663
+ controls on disconnect when its game requires it, and sends viewer-safe state.
664
+ Clients never report authoritative results. A finite referee may emit one
665
+ server-authored match_end; the SDK treats it as terminal and does not reconnect.
666
+ There is no SDK reset/rematch method. Endless authorities do not fabricate
667
+ match_end. Registered external authorities define their own socket shutdown and
668
+ subsequent-join behavior.
669
+
670
+ Bounty referee modules declare integer min/max players with
671
+ 1 <= min <= max <= 64. tickHz is 1-60. snapshotHz may be lower, from 1 through
672
+ tickHz; not every simulation tick emits a snapshot. Simulation-backed sync has
673
+ a full network round trip, and the SDK provides no client-side prediction,
674
+ interpolation, or rollback. Expect roughly 50-150 ms input-to-snapshot latency
675
+ on real connections; smooth locally, then correct to the next viewer-safe
676
+ snapshot. Relay game payloads instead arrive in the public 20 Hz event batches
677
+ described above; its 1 Hz snapshot contains metadata only.
678
+
679
+ ### Common Bounty shared-service room behavior
680
+
681
+ - Generated rooms use four-character invite codes. match: true works for relay
682
+ and referee rooms: it uses the current public room while live occupancy plus
683
+ conservative 15-second reservations show a safe seat. Full/ended rooms,
684
+ failed occupancy probes, or reservations roll a new room, so live occupancy
685
+ can roll before reaching the room's capacity. A matched room.code is also an
686
+ invite code.
687
+ - A lost last-seat/ended-room race triggers at most 2 re-matchmaking retries
688
+ (3 total attempts). Final rejection has code rejected and detail room_full or
689
+ room_over, whichever the last attempt returned.
690
+ - When a finite Bounty referee module ends, it broadcasts match_end once, closes
691
+ its sockets, and subsequent joins to that room reject with room_over. This
692
+ finite-close rule does not apply to the endless relay tier.
693
+ - Disconnected seats are reserved for 15 seconds. The SDK obtains fresh tickets
694
+ and makes up to 3 reconnect attempts. During grace, the player remains in
695
+ room.players and playerLeave is delayed until the seat expires. room.leave()
696
+ disables client reconnect, but other players still observe leave after the
697
+ server processes the closed seat/grace.
698
+ - At most 2 concurrent sockets may occupy one seat. An excess connection is a
699
+ terminal generic admission rejection; the public SDK does not expose the
700
+ WebSocket close code/reason, so do not label every rejected error as this case.
701
+
702
+ Public quick match is unavailable on registered external authorities, which
703
+ own their capacity, lobby lifecycle, and any matchmaking. Their reviewed
704
+ limits can differ from the Bounty shared-service defaults, and an external
705
+ authority may be endless.
706
+
707
+ joinRoom rejection map:
708
+
709
+ - unsupported: SSR/no WebSocket, standalone play, or the room rail is not
710
+ configured (including a temporary 503 from the shared service).
711
+ - rejected: invalid option combinations, joinData, room code/session, disabled
712
+ approval, rate limiting, unsupported external matchmake, or authority refusal.
713
+ - unauthenticated: the room server rejected the ticket before welcome.
714
+ - error: malformed host grant, postMessage/socket transport failure, server
715
+ failure, or the 10-second welcome timeout.
716
+
717
+ Use error.detail only when it is a specific documented value such as room_full
718
+ or room_over. Generic rejected has multiple causes; do not blind retry or show
719
+ “already playing elsewhere” without a distinct detail.
720
+
721
+ Multiplayer is curated per game slug. After approval, Bounty Board assigns one
722
+ of three rails: (a) the built-in casual relay, (b) a bespoke Bounty referee
723
+ module, or (c) a registered external authority with a dedicated ticket key.
724
+ Relay is the shared-service default when no bespoke module or external route is
725
+ registered; clients cannot select the tier. External servers keep their existing
726
+ simulation and add only an isolated signed-ticket adapter plus the SDK room
727
+ envelope—never create a second simulation beside one.
728
+
729
+ Local Bounty shared-service development bypasses the host ticket handshake only
730
+ with paired roomUrl and ticket values (for example wrangler dev with
731
+ DEV_ALLOW_UNSIGNED=1). A registered slug resolves to its referee module; another
732
+ well-formed slug resolves to relay:
733
+
734
+ await joinRoom({
735
+ code: 'TEST',
736
+ joinData: { roomSize: 4, avatar: 'golem' },
737
+ roomUrl: 'ws://localhost:8787/parties/rooms/game-slug:TEST',
738
+ ticket: 'dev:1:Alice',
739
+ });
740
+
741
+ Never enable DEV_ALLOW_UNSIGNED in production.
742
+
743
+ External authority contract:
744
+ https://www.bountyboard.gg/arcade/sdk/external-authoritative-servers.txt
745
+
746
+ ## Verification checklist
747
+
748
+ - Run the production artifact with no parent host; the whole game still works.
749
+ - Verify gameLoadingFinished fires only when play is possible.
750
+ - Verify gameplayStart/Stop on play, pause, resume, death prompt, and ad break.
751
+ - Verify integer scoring, realistic caps, and one gameOver per run.
752
+ - Verify guest, no-save, unsupported, too-large, and transport-failure save paths.
753
+ - Verify getPlayer null and avatarUrl null.
754
+ - Mock rewarded ready, unavailable, dismissed, error, and viewed; only viewed grants.
755
+ - Assert prepared.show() is called directly from the player gesture.
756
+ - Test initial room.state/players rendering before the first later event.
757
+ - Test multiplayer reconnect/grace, trySend false, tier-appropriate events/state,
758
+ and leave.
759
+ - Test create/code, and match on the Bounty shared-service tiers.
760
+ - For relay, test roomSize default/range, public fan-out (including sender), host
761
+ succession after grace, byte/rate drops, state.dropped, and absence of end.
762
+ - For referee/external rooms, test viewer-safe snapshots, authoritative results,
763
+ and game-defined start/ready/late-join rules against that authority.
764
+ - Test the identical artifact in its standalone location and inside Bounty Board.
765
+
766
+ ## Troubleshooting map
767
+
768
+ - Boot waits forever -> game startup depends on an SDK promise -> start init
769
+ fire-and-forget and give every promise feature a standalone outcome.
770
+ - save/load unsupported -> not a Bounty-hosted upload -> use own same-origin store.
771
+ - save/load unauthenticated -> guest player -> continue with defaults/fallback.
772
+ - SecurityError touching localStorage/sessionStorage/document.cookie -> hosted
773
+ builds run on an opaque origin -> use save()/load(), or storage.install() for
774
+ an engine export that cannot be rewired.
775
+ - Shimmed storage reads empty at boot -> the cloud read had not landed yet ->
776
+ await BBArcade.storage.ready() before restoring progress.
777
+ - storage.mode is 'memory' -> guest, standalone, or off-host -> storage works
778
+ for the session but is never persisted; keep first-run defaults sane.
779
+ - direct_user_action_required -> show() lost browser activation -> make show()
780
+ the first line of the click/tap handler.
781
+ - host_disabled/unavailable -> host or inventory is not ready -> keep the normal
782
+ fallback; inspect the live bb-arcade-host config before debugging Google.
783
+ - joinRoom unsupported -> standalone/SSR or the room rail is unavailable ->
784
+ hide multiplayer, preserve solo play, and verify the reviewed deployment.
785
+ - joinRoom rejected -> generic invalid request/admission failure -> inspect only a
786
+ documented specific detail, do not infer one cause or blindly retry.
787
+ - trySend false -> disconnected/raced socket or local serialization/send failure
788
+ -> stop sending, show reconnecting when disconnected, and resume only after
789
+ connection.connected is true. A true result still does not prove acceptance.
790
+ - match rejected on an external authority -> external matchmaking is authority-
791
+ owned -> use code/create or that authority's separately documented flow.
792
+
793
+ ## More
794
+
795
+ - Human guide: https://www.bountyboard.gg/arcade/sdk
796
+ - Relay room guide: https://www.bountyboard.gg/arcade/sdk/relay-rooms.txt
797
+ - Standalone types: https://www.bountyboard.gg/arcade-sdk.d.ts
798
+ - Submit a game: https://www.bountyboard.gg/arcade/submit
799
+ - Package README, changelog, AGENTS.md, design playbook, relay-room guide, and
800
+ external-authority guide all ship in the npm package.