@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/docs/llms.txt ADDED
@@ -0,0 +1,715 @@
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.1.0
6
+ Current browser v1/source addition: public quick match (queued for npm 1.2.0)
7
+ Wire protocol: 1
8
+
9
+ This file is the complete integration contract for coding agents integrating an
10
+ 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.
19
+
20
+ ## Non-negotiable integration rules
21
+
22
+ 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
24
+ save, and no multiplayer authority.
25
+ 2. Call gameOver() exactly once per run and use integer scores. The host/server
26
+ enforces per-game plausibility caps.
27
+ 3. Bracket active play with gameplayStart()/gameplayStop(). Menus, pauses,
28
+ death prompts, and ad breaks are not active play.
29
+ 4. getPlayer() can be null and avatarUrl can be null. The SDK never exposes
30
+ 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
34
+ status is ready, call prepared.show() directly from the click/tap handler,
35
+ and grant only when the final status is viewed.
36
+ 7. Multiplayer has three rails. Referee modules and external authorities own
37
+ simulation/results and receive client inputs. The casual relay owns signed
38
+ 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
+ joinRoom() rejection and keep solo/standalone play available.
41
+ 8. lockToHost() is an optional anti-rehosting deterrent, not DRM. Call it before boot.
42
+
43
+ ## Install and distribution
44
+
45
+ Preferred for games with a build step:
46
+
47
+ npm install @bountyboard/arcade-sdk
48
+ import { BBArcade } from '@bountyboard/arcade-sdk';
49
+
50
+ The package is zero-dependency, typed, ESM + CommonJS, and SSR-safe. Importing
51
+ the module does not install a window.BBArcade global.
52
+
53
+ No-build script tag:
54
+
55
+ <script src="https://www.bountyboard.gg/arcade-sdk/v1.js"></script>
56
+
57
+ This installs window.BBArcade. Standalone declarations:
58
+ https://www.bountyboard.gg/arcade-sdk.d.ts
59
+
60
+ Do not mix npm and the script tag in one page. A bundler that specifically
61
+ wants the global may import @bountyboard/arcade-sdk/global.
62
+
63
+ Multiplayer module import:
64
+
65
+ import { joinRoom } from '@bountyboard/arcade-sdk/multiplayer';
66
+
67
+ Script-tag multiplayer is BBArcade.multiplayer.joinRoom(...).
68
+
69
+ Public package exports:
70
+
71
+ - @bountyboard/arcade-sdk: default BBArcade, named BBArcade,
72
+ PROTOCOL_VERSION, and the core public types.
73
+ - @bountyboard/arcade-sdk/multiplayer: joinRoom, multiplayer, and all BBArcadeMp*
74
+ types.
75
+ - @bountyboard/arcade-sdk/global: installs window.BBArcade for bundlers that
76
+ explicitly need the global. The normal module import does not install it.
77
+
78
+ ## Runtime capability matrix
79
+
80
+ Distribution format does not determine capabilities; the embedding host does.
81
+
82
+ - Bounty-hosted upload:
83
+ 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.
87
+ - Approved external URL embed inside the Bounty Board player:
88
+ lifecycle/scores, identity, variants, and approved multiplayer (including the
89
+ Bounty relay tier) are supported; rewarded ads are unavailable (no payable
90
+ per-game attribution yet); cloud save/load is unsupported.
91
+ - Standalone or opened directly on the game's own site:
92
+ fire-and-forget calls no-op; init resolves immediately; getPlayer returns
93
+ null; getVariant returns the alphabetical control; host-only promise APIs
94
+ reject unsupported or return an unavailable outcome. An unanswered non-Bounty
95
+ embed uses an approximately 1.5-second init/getPlayer grace instead.
96
+
97
+ Guests are normal. getPlayer resolves null and account-backed calls may reject
98
+ with code unauthenticated.
99
+
100
+ ## Minimal correct lifecycle
101
+
102
+ import { BBArcade } from '@bountyboard/arcade-sdk';
103
+
104
+ BBArcade.lockToHost({ allow: ['play.yourgame.com'] }); // optional, before boot
105
+ void BBArcade.init(); // never gate boot on it
106
+ BBArcade.gameLoadingFinished(); // first playable scene ready
107
+
108
+ BBArcade.gameplayStart();
109
+ BBArcade.submitScore(Math.trunc(score)); // may repeat; host throttles
110
+ BBArcade.gameplayStop(); // pause/death/menu/ad
111
+ BBArcade.gameOver(Math.trunc(finalScore)); // exactly once per run
112
+
113
+ For a shared-seed Daily, pass { mode: 'daily' } to submitScore and gameOver.
114
+
115
+ ## Exact shared types and configuration
116
+
117
+ All rejected SDK promises use a real Error. Stable npm 1.1.0 has this shape:
118
+
119
+ type BBArcadeErrorCode =
120
+ | 'unsupported' // no compatible host/capability
121
+ | 'unauthenticated' // login/ticket identity required or invalid
122
+ | 'too_large' // save blob exceeds the cap
123
+ | 'rejected' // host/authority refused the payload or room
124
+ | 'error'; // timeout, transport, or server failure
125
+
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
+ interface BBArcadeError extends Error {
133
+ code: BBArcadeErrorCode;
134
+ detail?: string; // raw authority reason when available
135
+ }
136
+
137
+ Core configuration shapes:
138
+
139
+ interface BBArcadeRewardedAdsConfig {
140
+ enabled?: boolean; // default true; host approval still wins
141
+ adChannel?: string; // legacy; ignored for Bounty attribution
142
+ testMode?: boolean; // defaults true on localhost/file:
143
+ preload?: boolean; // default false
144
+ loadTimeoutMs?: number; // default 6000, maximum 15000
145
+ }
146
+
147
+ interface BBArcadeConfig {
148
+ rewardedAds?: BBArcadeRewardedAdsConfig;
149
+ };
150
+
151
+ interface BBArcadeLockToHostOptions {
152
+ allow?: string[]; // hosts/host:port/full URLs; subdomains match
153
+ signed?: boolean;
154
+ onBlocked?: () => void;
155
+ redirect?: string;
156
+ }
157
+
158
+ lockToHost defaults allow bountyboard.gg and its subdomains, the fixed Bounty
159
+ staging host, localhost, and 127.0.0.1. allow extends rather than replaces that
160
+ 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
163
+ blocks.
164
+
165
+ Player and score shapes:
166
+
167
+ interface BBArcadePlayer { name: string; avatarUrl: string | null }
168
+ // submitScore/gameOver use the inline option shape { mode?: 'daily' }.
169
+
170
+ Rewarded-ad options and results:
171
+
172
+ interface BBArcadeRewardedAdOptions {
173
+ placement?: string; name?: string; reward?: string; adBreakId?: string;
174
+ size?: 'small' | 'medium' | 'large'; // legacy SDK analytics only
175
+ adChannel?: string; testMode?: boolean; loadTimeoutMs?: number;
176
+ beforeReward?: (showAd: () => void) => void; // low-level API only
177
+ adViewed?: (result: BBArcadeRewardedAdResult) => void;
178
+ adDismissed?: (result: BBArcadeRewardedAdResult) => void;
179
+ adBreakDone?: (placementInfo: unknown) => void;
180
+ onReward?: (result: BBArcadeRewardedAdResult) => void; // adViewed alias
181
+ onDismissed?: (result: BBArcadeRewardedAdResult) => void; // dismissed alias
182
+ onDone?: (result: BBArcadeRewardedAdResult) => void;
183
+ onError?: (result: BBArcadeRewardedAdResult) => void;
184
+ onUnavailable?: (result: BBArcadeRewardedAdResult) => void;
185
+ }
186
+
187
+ type BBArcadePrepareRewardedAdOptions =
188
+ Omit<BBArcadeRewardedAdOptions, 'beforeReward'> & {
189
+ onStart?: () => void;
190
+ };
191
+
192
+ type BBArcadeRewardedAdStatus =
193
+ 'viewed' | 'dismissed' | 'ready' | 'unavailable' | 'error';
194
+
195
+ interface BBArcadeRewardedAdResult {
196
+ status: BBArcadeRewardedAdStatus;
197
+ placement: string; reward: string; adBreakId: string;
198
+ size?: 'small' | 'medium' | 'large';
199
+ error?: string; breakStatus?: string;
200
+ }
201
+
202
+ interface BBArcadePreparedRewardedAd {
203
+ status: 'ready'; placement: string; reward: string; adBreakId: string;
204
+ size?: 'small' | 'medium' | 'large';
205
+ show(): Promise<BBArcadeRewardedAdResult>; // one-shot
206
+ }
207
+
208
+ type BBArcadeRewardedAdPrepareFailure = BBArcadeRewardedAdResult & {
209
+ status: 'unavailable' | 'error';
210
+ };
211
+
212
+ type BBArcadeRewardedAdPreparation =
213
+ | BBArcadePreparedRewardedAd
214
+ | BBArcadeRewardedAdPrepareFailure;
215
+
216
+ interface BBArcadeRewardedBreakOptions extends BBArcadeRewardedAdOptions {
217
+ onStart?: () => void;
218
+ }
219
+
220
+ Defaults are placement 'rewarded' and reward 'reward'; ids are generated unless
221
+ supplied. BBArcadePreparedRewardedAd.show is one-shot; repeated calls share its
222
+ final promise.
223
+ The Bounty host's ad config overrides per-call adChannel/testMode when present.
224
+
225
+ ## API surface
226
+
227
+ BBArcadeSDK is the aggregate interface implemented by the named/default
228
+ BBArcade export and window.BBArcade. Its methods are all listed below. The
229
+ interface also has version: number and multiplayer?: BBArcadeMultiplayer;
230
+ multiplayer is installed by the script/global build, while normal module users
231
+ import the dedicated multiplayer subpath.
232
+
233
+ Lifecycle and scoring:
234
+
235
+ - lockToHost({ allow?: string[], signed?: boolean, onBlocked?, redirect? }): void
236
+ Early anti-rehosting check. Defaults allow Bounty Board hosts and localhost.
237
+ When the embedder cannot be read from browser signals (opaque-origin
238
+ sandbox), the SDK asks the embedding page and blocks if nothing answers
239
+ within about 5 seconds.
240
+ - init({ rewardedAds? }?): Promise<void>
241
+ Announces the game and receives host config. Resolves when answered,
242
+ immediately when top-level/standalone, or after about 1.5 seconds in an
243
+ unanswered embed; awaiting is optional and must not gate boot.
244
+ - configure(options?): void
245
+ Applies the same module configuration without another host handshake.
246
+ - ready() / gameLoadingFinished(): void
247
+ Signals that the game is loaded and the player can start.
248
+ - gameplayStart() / gameplayStop(): void
249
+ Brackets active play.
250
+ - submitScore(score: number, { mode?: 'daily' }?): void
251
+ Current integer score; transport is throttled by the host. Fractional
252
+ values are truncated toward zero (Math.trunc semantics).
253
+ - gameOver(score: number, { mode?: 'daily' }?): void
254
+ Final integer score; call exactly once per run. Same truncation as
255
+ submitScore.
256
+ - xrSessionStart() / xrSessionEnd(): void
257
+ Brackets immersive WebXR play.
258
+
259
+ Player data and experiments:
260
+
261
+ - save(blob: string): Promise<void>
262
+ One blob, about 1 MB. Requires a Bounty-hosted upload and logged-in player.
263
+ Oversized blobs reject too_large immediately via a client-side precheck
264
+ against the same 1 MiB cap the server enforces.
265
+ - load(): Promise<string | null>
266
+ Returns null when no save exists. Rejects like save().
267
+ - 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).
271
+ The handler receives the same { name, avatarUrl } | null shape as
272
+ getPlayer() and runs only when the identity actually changes; subscribing
273
+ does not replay the current value. Returns an unsubscribe function.
274
+ - getVariant(key: string, variants: string[]): Promise<string | null>
275
+ Deterministic even split per player+game+key. Alphabetical first is the
276
+ standalone/error control. At least 2 variants are needed for a host
277
+ assignment; an empty list resolves null and a one-item list resolves that
278
+ item. Do not re-randomize client-side.
279
+
280
+ 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.
284
+
285
+ unsupported | unauthenticated | too_large | rejected | error
286
+
287
+ Rewarded ads:
288
+
289
+ - prepareRewardedAd(options?): Promise<BBArcadeRewardedAdPreparation>
290
+ Recommended. Alias: prepareRewardedBreak(). Prepared show() is one-shot.
291
+ - rewardedAd(options?): Promise<BBArcadeRewardedAdResult>
292
+ Low-level structured-result API. Alias: showRewardedAd().
293
+ - rewardedBreak(options | onStart): Promise<boolean>
294
+ Deprecated compatibility helper. New games must use the prepared flow.
295
+ - preloadRewardedAds(options?): Promise<boolean>
296
+ Warms the ads library. Alias: preloadRewardedAd().
297
+
298
+ Rewarded result status:
299
+
300
+ viewed | dismissed | ready | unavailable | error
301
+
302
+ Only final viewed grants. A prepared ad whose break ends before show() is ever
303
+ called resolves dismissed (never the non-terminal ready); ready survives as a
304
+ terminal state only in the legacy edge where the ad WAS shown but Google sent
305
+ no view/dismiss callback. Useful error detail includes host_disabled,
306
+ ad_break_unavailable, ad_break_timeout, no_rewarded_ad, ad_in_progress,
307
+ show_ad_error, direct_user_action_required, before_reward_error, and
308
+ ad_break_error.
309
+
310
+ Multiplayer (current browser v1/source shape; stable npm 1.1.0 omits match):
311
+
312
+ type BBArcadeMpJoinOptions = {
313
+ code?: string;
314
+ create?: boolean;
315
+ match?: boolean; // current browser/source; npm 1.2.0 when released
316
+ joinData?: Readonly<Record<string, unknown>>;
317
+ roomUrl?: string; // local development override; provide with ticket
318
+ ticket?: string; // local development override; provide with roomUrl
319
+ timeoutMs?: number; // current browser/source; npm 1.2.0 when released
320
+ };
321
+
322
+ interface BBArcadeMpPlayer {
323
+ id: string; // room-scoped, never a Bounty account id
324
+ name: string;
325
+ avatarUrl: string | null;
326
+ }
327
+
328
+ interface BBArcadeMpResult {
329
+ playerId: string;
330
+ name: string;
331
+ placement: number;
332
+ score: number;
333
+ outcome: 'win' | 'loss' | 'draw';
334
+ }
335
+
336
+ interface BBArcadeMpRoomEvents {
337
+ snapshot: { tick: number; state: unknown };
338
+ playerJoin: { player: BBArcadeMpPlayer };
339
+ playerLeave: { playerId: string };
340
+ event: unknown;
341
+ end: { results: BBArcadeMpResult[] };
342
+ error: { code: string };
343
+ connection: { connected: boolean; reconnecting: boolean };
344
+ close: { reconnecting: boolean };
345
+ }
346
+
347
+ interface BBArcadeMpRoom {
348
+ code: string;
349
+ playerId: string;
350
+ players: BBArcadeMpPlayer[];
351
+ state: unknown;
352
+ connected: boolean;
353
+ latencyMs: number | null; // current browser/source; npm 1.2.0 when released
354
+ send(input: unknown): void;
355
+ trySend(input: unknown): boolean;
356
+ on<K extends keyof BBArcadeMpRoomEvents>(
357
+ event: K,
358
+ handler: (data: BBArcadeMpRoomEvents[K]) => void
359
+ ): () => void;
360
+ leave(): void;
361
+ }
362
+
363
+ interface BBArcadeMultiplayer {
364
+ joinRoom(options?: BBArcadeMpJoinOptions): Promise<BBArcadeMpRoom>;
365
+ }
366
+
367
+ Relay runtime payloads use these exact shapes through the otherwise-unknown
368
+ room.state and room.on('event') values:
369
+
370
+ interface BBArcadeRelayState {
371
+ mode: 'relay';
372
+ hostId: string | null;
373
+ size: number;
374
+ dropped: number;
375
+ }
376
+
377
+ type BBArcadeRelayEvent =
378
+ | {
379
+ type: 'relay';
380
+ messages: Array<{ from: string; data: unknown }>;
381
+ }
382
+ | { type: 'relay_host'; hostId: string | null };
383
+
384
+ BBArcadeRelayState and BBArcadeRelayEvent are documentation names, not package
385
+ exports; the public SDK intentionally types game/tier-defined state and events
386
+ as unknown.
387
+
388
+ - joinRoom(options?): Promise<BBArcadeMpRoom>
389
+ No options and { create: true } both generate a 4-character invite code from
390
+ ABCDEFGHJKLMNPQRSTUVWXYZ23456789. A supplied code is uppercased. match: true
391
+ is Bounty-hosted public quick match and excludes code, create, roomUrl, and
392
+ ticket. roomUrl+ticket bypass the host handshake for local development only;
393
+ provide both (a lone value is not an override).
394
+ - joinData must be a non-null, non-array JSON-serializable object whose UTF-8
395
+ JSON encoding is at most 1 KiB. Cycles, arrays, primitives, and oversized data
396
+ reject with code rejected. Never include credentials or secrets.
397
+ - joinRoom resolves only after the authority sends welcome (10-second default
398
+ welcome timeout; timeoutMs overrides it, clamped to 1000–60000 ms, and
399
+ applies to every automatic reconnect attempt too).
400
+ At resolution, code/playerId/players/state/connected already hold the initial
401
+ lobby state. There is no replayed initial snapshot: render those fields first,
402
+ then subscribe to future events.
403
+ - room.latencyMs reports the join-handshake latency in ms (socket open to
404
+ server welcome: ticket verification plus one round trip), refreshed on every
405
+ 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
408
+ treats the value as an input; a relay room treats it as a public game
409
+ message. send discards local write status. trySend
410
+ returns true only when JSON was written to an open socket; it does NOT mean
411
+ the authority accepted the input. false means disconnected, raced closed, or
412
+ serialization/WebSocket.send failed. The authority still validates,
413
+ sequences, and may rate-drop inputs.
414
+ - on returns an unsubscribe function. The SDK updates room.players before
415
+ playerJoin/playerLeave handlers and room.state before snapshot handlers.
416
+ - leave intentionally closes the Room and permanently disables reconnect for it.
417
+ A welcome that arrives after leave() (e.g. racing a reconnect) is ignored and
418
+ 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 }).
423
+ Connection loss emits connection/close events and may start automatic
424
+ reconnect.
425
+
426
+ BBArcade.version and the exported PROTOCOL_VERSION are the core bb-arcade
427
+ postMessage wire-protocol version. They are not npm semver and are not a
428
+ version field on multiplayer WebSocket frames.
429
+
430
+ ## Cloud-save recipe
431
+
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.
435
+
436
+ const blob = JSON.stringify(progress);
437
+ try {
438
+ await BBArcade.save(blob);
439
+ } catch (error) {
440
+ if (error.code === 'unsupported') localStorage.setItem('progress', blob);
441
+ }
442
+
443
+ try {
444
+ const blob = await BBArcade.load();
445
+ restore(blob ? JSON.parse(blob) : defaults);
446
+ } catch {
447
+ restoreStandaloneProgress();
448
+ }
449
+
450
+ Do not assume load() resolves null for a guest; it can reject unauthenticated.
451
+
452
+ ## Safe rewarded-ad recipe
453
+
454
+ Prepare at the natural break. Keep the game-owned button disabled until ready.
455
+ Call show() as the first operation in the direct click/tap handler: no await,
456
+ timer, microtask, animation, state transition, or network call before it.
457
+
458
+ const prepared = await BBArcade.prepareRewardedAd({
459
+ placement: 'death_revive',
460
+ reward: 'extra_life',
461
+ onStart: () => pauseGameAndAudio(),
462
+ });
463
+
464
+ if (prepared.status === 'ready') {
465
+ button.disabled = false;
466
+ button.addEventListener('click', () => {
467
+ const resultPromise = prepared.show(); // keep first
468
+ button.disabled = true;
469
+ void resultPromise.then(result => {
470
+ resumeGameAndAudio();
471
+ if (result.status === 'viewed') revivePlayer();
472
+ else keepNormalFallbackAvailable();
473
+ });
474
+ }, { once: true });
475
+ }
476
+
477
+ Never auto-trigger, loop, or auto-retry. Unavailable ad inventory on localhost
478
+ is normal even though test mode is enabled automatically.
479
+
480
+ ## Multiplayer room and lobby contract
481
+
482
+ import type { BBArcadeError } from '@bountyboard/arcade-sdk';
483
+ import { joinRoom } from '@bountyboard/arcade-sdk/multiplayer';
484
+
485
+ try {
486
+ const room = await joinRoom({
487
+ create: true, // or code: 'ABCD'; browser v1/npm 1.2 adds match: true
488
+ // Only a relay founder uses roomSize; every joinData field is untrusted.
489
+ joinData: { roomSize: 4, avatar: 'golem' },
490
+ });
491
+
492
+ // welcome is already reflected here; no initial snapshot event is replayed.
493
+ renderLobby(room.code, room.players, room.state);
494
+ room.on('playerJoin', () => renderPlayers(room.players));
495
+ room.on('playerLeave', () => renderPlayers(room.players));
496
+ room.on('snapshot', ({ tick, state }) => renderRoomState(tick, state));
497
+ room.on('event', event => handleRoomEvent(event));
498
+ room.on('connection', ({ connected, reconnecting }) =>
499
+ setConnectionState({ connected, reconnecting })
500
+ );
501
+ let ended = false;
502
+ room.on('close', ({ reconnecting }) => {
503
+ if (!reconnecting && !ended) showConnectionClosed();
504
+ });
505
+ room.on('end', ({ results }) => {
506
+ ended = true;
507
+ showResults(results); // finite refereed rooms; relay never emits end
508
+ });
509
+ if (!room.trySend(input)) {
510
+ if (!room.connected) showReconnecting(true);
511
+ else showSendFailure(); // serialization/WebSocket.send failed locally
512
+ }
513
+ onExit(() => room.leave());
514
+ } catch (error) {
515
+ const { code } = error as BBArcadeError;
516
+ showSoloFallback(code);
517
+ }
518
+
519
+ The SDK is room transport, not a universal lobby rules engine. Bounty Board
520
+ selects exactly one tier for the approved game slug: built-in relay, bespoke
521
+ Bounty referee module, or registered external authority. The signed ticket
522
+ binds that tier. joinData cannot select or downgrade it; a ticket/registry
523
+ mismatch fails closed, and a registered external slug never falls back to
524
+ relay. All tiers use the same joinRoom transport, room codes, and client
525
+ connection lifecycle; tier/game-specific state and event schemas still differ.
526
+
527
+ create/code/match select a room. Relay supplies the host contract below, but
528
+ 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
530
+ own state, input, event, spectator, lobby, and rematch schemas. A module's
531
+ 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.
534
+
535
+ joinData is always untrusted JSON. The SDK only validates its shape and 1 KiB
536
+ cap. A relay reads roomSize only from the founding seat. Referee modules and
537
+ external authorities validate any game-specific admission fields themselves.
538
+
539
+ ### Built-in Bounty relay tier
540
+
541
+ The relay is the default Bounty shared-service tier for a multiplayer-approved
542
+ 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
544
+ simulation or anti-cheat boundary.
545
+
546
+ - 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
548
+ may operate with one current occupant. Later joiners cannot change size. Once
549
+ 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.
551
+ - Welcome and later 1 Hz metadata snapshots are identical for every viewer and
552
+ expose exactly
553
+ { mode: 'relay', hostId: string | null, size: number, dropped: number }.
554
+ - The oldest retained seat is host. A transient disconnect preserves its seat
555
+ 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
557
+ { type: 'relay_host', hostId } through room.on('event'). The founding welcome
558
+ already carries hostId, so no initial relay_host event is required.
559
+ - room.send(data) and room.trySend(data) publicly fan the JSON payload to EVERY
560
+ player, including the sender. There are no private messages, hidden state, or
561
+ viewer filtering. Batches arrive at 20 Hz through room.on('event') as
562
+ { type: 'relay', messages: [{ from, data }] }, where from is a room-scoped
563
+ player id. Tick batching adds at most about 50 ms before network latency.
564
+ - Each relay payload's UTF-8 JSON encoding is capped at 1024 bytes. The relay
565
+ accepts at most 15 messages per player per second and 120 per room per second.
566
+ Byte/rate excess is silently dropped and increments cumulative state.dropped,
567
+ visible on a later metadata snapshot. state.dropped does not count malformed,
568
+ stale-sequence, or outer-envelope drops.
569
+ - The common outer room guard still caps a client frame at 4096 characters and
570
+ 90 accepted envelopes per player per second; server frames are capped at
571
+ 256 KiB.
572
+ - Relay rooms are endless. They never emit match_end/room.on('end'), never close
573
+ 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
575
+ trusted rewards, standings, or anti-cheat decisions. Implement round boundaries
576
+ in game messages and call room.leave() when the player exits.
577
+
578
+ ### Refereed Bounty modules and external authorities
579
+
580
+ 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.
582
+ 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.
584
+ There is no SDK reset/rematch method. Endless authorities do not fabricate
585
+ match_end. Registered external authorities define their own socket shutdown and
586
+ subsequent-join behavior.
587
+
588
+ Bounty referee modules declare integer min/max players with
589
+ 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
591
+ 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
594
+ snapshot. Relay game payloads instead arrive in the public 20 Hz event batches
595
+ described above; its 1 Hz snapshot contains metadata only.
596
+
597
+ ### Common Bounty shared-service room behavior
598
+
599
+ - Generated rooms use four-character invite codes. match: true works for relay
600
+ and referee rooms: it uses the current public room while live occupancy plus
601
+ conservative 15-second reservations show a safe seat. Full/ended rooms,
602
+ failed occupancy probes, or reservations roll a new room, so live occupancy
603
+ can roll before reaching the room's capacity. A matched room.code is also an
604
+ 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.
609
+ - When a finite Bounty referee module ends, it broadcasts match_end once, closes
610
+ 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.
612
+ - Disconnected seats are reserved for 15 seconds. The SDK obtains fresh tickets
613
+ and makes up to 3 reconnect attempts. During grace, the player remains in
614
+ room.players and playerLeave is delayed until the seat expires. room.leave()
615
+ disables client reconnect, but other players still observe leave after the
616
+ server processes the closed seat/grace.
617
+ - At most 2 concurrent sockets may occupy one seat. An excess connection is a
618
+ terminal generic admission rejection; the public SDK does not expose the
619
+ WebSocket close code/reason, so do not label every rejected error as this case.
620
+
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.
626
+
627
+ joinRoom rejection map:
628
+
629
+ - unsupported: SSR/no WebSocket, standalone play, or the room rail is not
630
+ configured (including a temporary 503 from the shared service).
631
+ - rejected: invalid option combinations, joinData, room code/session, disabled
632
+ approval, rate limiting, unsupported external matchmake, or authority refusal.
633
+ - unauthenticated: the room server rejected the ticket before welcome.
634
+ - error: malformed host grant, postMessage/socket transport failure, server
635
+ failure, or the 10-second welcome timeout.
636
+
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.
641
+
642
+ Multiplayer is curated per game slug. After approval, Bounty Board assigns one
643
+ of three rails: (a) the built-in casual relay, (b) a bespoke Bounty referee
644
+ 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
646
+ 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.
649
+
650
+ Local Bounty shared-service development bypasses the host ticket handshake only
651
+ with paired roomUrl and ticket values (for example wrangler dev with
652
+ DEV_ALLOW_UNSIGNED=1). A registered slug resolves to its referee module; another
653
+ well-formed slug resolves to relay:
654
+
655
+ await joinRoom({
656
+ code: 'TEST',
657
+ joinData: { roomSize: 4, avatar: 'golem' },
658
+ roomUrl: 'ws://localhost:8787/parties/rooms/game-slug:TEST',
659
+ ticket: 'dev:1:Alice',
660
+ });
661
+
662
+ Never enable DEV_ALLOW_UNSIGNED in production.
663
+
664
+ External authority contract:
665
+ https://www.bountyboard.gg/arcade/sdk/external-authoritative-servers.txt
666
+
667
+ ## Verification checklist
668
+
669
+ - Run the production artifact with no parent host; the whole game still works.
670
+ - Verify gameLoadingFinished fires only when play is possible.
671
+ - Verify gameplayStart/Stop on play, pause, resume, death prompt, and ad break.
672
+ - Verify integer scoring, realistic caps, and one gameOver per run.
673
+ - Verify guest, no-save, unsupported, too-large, and transport-failure save paths.
674
+ - Verify getPlayer null and avatarUrl null.
675
+ - Mock rewarded ready, unavailable, dismissed, error, and viewed; only viewed grants.
676
+ - Assert prepared.show() is called directly from the player gesture.
677
+ - Test initial room.state/players rendering before the first later event.
678
+ - Test multiplayer reconnect/grace, trySend false, tier-appropriate events/state,
679
+ 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
682
+ 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.
685
+ - Test the identical artifact in its standalone location and inside Bounty Board.
686
+
687
+ ## Troubleshooting map
688
+
689
+ - Boot waits forever -> game startup depends on an SDK promise -> start init
690
+ 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.
693
+ - direct_user_action_required -> show() lost browser activation -> make show()
694
+ the first line of the click/tap handler.
695
+ - host_disabled/unavailable -> host or inventory is not ready -> keep the normal
696
+ fallback; inspect the live bb-arcade-host config before debugging Google.
697
+ - joinRoom unsupported -> standalone/SSR or the room rail is unavailable ->
698
+ hide multiplayer, preserve solo play, and verify the reviewed deployment.
699
+ - joinRoom rejected -> generic invalid request/admission failure -> inspect only a
700
+ documented specific detail, do not infer one cause or blindly retry.
701
+ - trySend false -> disconnected/raced socket or local serialization/send failure
702
+ -> stop sending, show reconnecting when disconnected, and resume only after
703
+ connection.connected is true. A true result still does not prove acceptance.
704
+ - match rejected on an external authority -> external matchmaking is authority-
705
+ owned -> use code/create or that authority's separately documented flow.
706
+
707
+ ## More
708
+
709
+ - Human guide: https://www.bountyboard.gg/arcade/sdk
710
+ - Relay room guide: https://www.bountyboard.gg/arcade/sdk/relay-rooms.txt
711
+ - Standalone types: https://www.bountyboard.gg/arcade-sdk.d.ts
712
+ - 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.