@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.
@@ -22,6 +22,8 @@ type BBArcadeErrorCode =
22
22
  /** Rejection reason for save()/load(): a real Error that also carries `code`. */
23
23
  interface BBArcadeError extends Error {
24
24
  code: BBArcadeErrorCode;
25
+ /** Optional server detail for 'rejected' errors (e.g. 'room_full'). */
26
+ detail?: string;
25
27
  }
26
28
  /** Rewarded-ads settings accepted by init()/configure(). */
27
29
  interface BBArcadeRewardedAdsConfig {
@@ -40,6 +42,46 @@ interface BBArcadeRewardedAdsConfig {
40
42
  interface BBArcadeConfig {
41
43
  rewardedAds?: BBArcadeRewardedAdsConfig;
42
44
  }
45
+ /**
46
+ * What is backing `window.localStorage` right now.
47
+ * - `native` — a real Storage works here; the SDK changed nothing.
48
+ * - `cloud` — the shim is installed and syncs to the player's Bounty Board save.
49
+ * - `memory` — the shim is installed but nothing can persist (guest, standalone,
50
+ * off-host); reads and writes work for the session and are then discarded.
51
+ */
52
+ type BBArcadeStorageMode = 'native' | 'cloud' | 'memory';
53
+ /**
54
+ * localStorage compatibility for hosted builds. Bounty-Board-hosted uploads run
55
+ * in an opaque-origin sandbox where `localStorage` THROWS rather than returning
56
+ * empty, which breaks engine exports (GameMaker `ini_*`, Godot, Unity) that
57
+ * assume synchronous storage. The shim replaces it with a Storage-shaped object
58
+ * backed by the SDK's own cloud save, so those builds persist per player and
59
+ * across devices with no engine changes.
60
+ *
61
+ * The script-tag build installs it automatically at load. Module consumers keep
62
+ * a side-effect-free import and call `install()` themselves before boot.
63
+ */
64
+ interface BBArcadeStorage {
65
+ /**
66
+ * Install the shim if this origin has no working localStorage, and return the
67
+ * resulting mode. Idempotent, and a no-op when native storage works. Call it
68
+ * BEFORE the engine/game script runs.
69
+ */
70
+ install(): BBArcadeStorageMode;
71
+ /**
72
+ * Resolves once the cloud read has landed (or settled as unpersistable).
73
+ * `getItem` is synchronous but the cloud read is not, so a game that reads
74
+ * saved progress during boot should await this first.
75
+ */
76
+ ready(): Promise<BBArcadeStorageMode>;
77
+ /**
78
+ * Force pending writes out now instead of waiting for the debounce. Rejects
79
+ * with the same BBArcadeError codes as save().
80
+ */
81
+ flush(): Promise<void>;
82
+ /** The current backing mode. */
83
+ readonly mode: BBArcadeStorageMode;
84
+ }
43
85
  /**
44
86
  * The logged-in player's public display identity, as delivered by the Bounty
45
87
  * Board host. Display name + avatar ONLY — the host never sends ids, emails,
@@ -73,7 +115,17 @@ interface BBArcadeLockToHostOptions {
73
115
  redirect?: string;
74
116
  }
75
117
  /** Outcome of a rewarded placement. */
76
- type BBArcadeRewardedAdStatus = 'viewed' | 'dismissed' | 'ready' | 'unavailable' | 'error';
118
+ type BBArcadeRewardedAdStatus =
119
+ /** The player watched the ad to completion — the only status that grants. */
120
+ 'viewed'
121
+ /** The player closed the ad early, or a prepared ad ended before it was ever shown. */
122
+ | 'dismissed'
123
+ /** An ad is ready to show (preparation result; terminal only in the legacy shown-but-unreported edge). */
124
+ | 'ready'
125
+ /** No ad could be offered. */
126
+ | 'unavailable'
127
+ /** The placement failed. */
128
+ | 'error';
77
129
  /** Resolution value of rewardedAd()/showRewardedAd(). */
78
130
  interface BBArcadeRewardedAdResult {
79
131
  /** 'viewed' is the only status that should grant the reward. */
@@ -196,6 +248,15 @@ interface BBArcadeMpJoinOptions {
196
248
  code?: string;
197
249
  /** Create a new room (a share code is generated for you). */
198
250
  create?: boolean;
251
+ /**
252
+ * Public quick match: join your game's open public room, or start a fresh
253
+ * one when no seat is free. Mutually exclusive with code/create. Rooms fill
254
+ * to the game's player cap (at most 64), and a matched room still exposes
255
+ * room.code so the player can invite friends into the same room. If the
256
+ * last free seat is lost to a race, the SDK re-matchmakes automatically a
257
+ * couple of times before rejecting.
258
+ */
259
+ match?: boolean;
199
260
  /**
200
261
  * Game-defined, JSON-serializable data supplied to the authoritative module
201
262
  * before it admits this player (avatar/loadout/mode selections, for example).
@@ -210,6 +271,12 @@ interface BBArcadeMpJoinOptions {
210
271
  */
211
272
  roomUrl?: string;
212
273
  ticket?: string;
274
+ /**
275
+ * How long to wait for the room server's welcome before the join rejects
276
+ * with code 'error', in ms (default 10000, clamped to 1000–60000). Applies
277
+ * to the initial join and to every automatic reconnect attempt.
278
+ */
279
+ timeoutMs?: number;
213
280
  }
214
281
  /** Events a multiplayer room emits. */
215
282
  interface BBArcadeMpRoomEvents {
@@ -255,6 +322,13 @@ interface BBArcadeMpRoom {
255
322
  state: unknown;
256
323
  /** Whether the room currently has an accepted WebSocket connection. */
257
324
  connected: boolean;
325
+ /**
326
+ * Join-handshake latency in ms (socket open → server welcome, so ticket
327
+ * verification plus one round trip), refreshed on every successful
328
+ * (re)connect; null until the first welcome arrives. A tuning estimate for
329
+ * render-side smoothing, not a measured RTT.
330
+ */
331
+ latencyMs: number | null;
258
332
  /** Send an INPUT to the server (never authoritative state — the server simulates). */
259
333
  send(input: unknown): void;
260
334
  /** Like send(), but reports false when disconnected or the socket raced closed. */
@@ -286,6 +360,9 @@ interface BBArcadeSDK {
286
360
  /**
287
361
  * Refuse to run unless embedded on a Bounty Board host (anti
288
362
  * scrape-and-reupload deterrent — not DRM). Call once, early, before boot.
363
+ * When the embedder can't be determined from browser signals (opaque-origin
364
+ * sandboxed builds), the SDK asks the embedding page and blocks if nothing
365
+ * answers within ~5 seconds.
289
366
  */
290
367
  lockToHost(options?: BBArcadeLockToHostOptions): void;
291
368
  /**
@@ -353,14 +430,23 @@ interface BBArcadeSDK {
353
430
  * Save the player's progress (a string blob, ~1MB max) to their Bounty Board
354
431
  * account. Requires a Bounty-Board-hosted build upload and a logged-in
355
432
  * player; rejects with a BBArcadeError otherwise (code 'unsupported' /
356
- * 'unauthenticated' / 'too_large' / 'rejected' / 'error').
433
+ * 'unauthenticated' / 'too_large' / 'rejected' / 'error'). Oversized blobs
434
+ * reject 'too_large' immediately (client-side precheck against the same 1
435
+ * MiB cap the server enforces); a host that never answers rejects 'error'
436
+ * after a 15s request timeout.
357
437
  */
358
438
  save(blob: string): Promise<void>;
359
439
  /**
360
440
  * Load the player's saved blob. Resolves with the string, or null when there
361
- * is no save yet. Rejects like save() ('unauthenticated' when logged out).
441
+ * is no save yet. Rejects like save() ('unauthenticated' when logged out,
442
+ * 'error' after the same 15s request timeout when no host answers).
362
443
  */
363
444
  load(): Promise<string | null>;
445
+ /**
446
+ * localStorage compatibility for hosted builds — the escape hatch for engine
447
+ * exports that can't be rewired to call save()/load(). See BBArcadeStorage.
448
+ */
449
+ storage: BBArcadeStorage;
364
450
  /**
365
451
  * The logged-in player's display identity for in-game UI. Resolves
366
452
  * { name, avatarUrl } once the host's config handshake completes (or after
@@ -369,6 +455,15 @@ interface BBArcadeSDK {
369
455
  * display name + avatar only, never ids, emails, or roles.
370
456
  */
371
457
  getPlayer(): Promise<BBArcadePlayer | null>;
458
+ /**
459
+ * Subscribe to CHANGES in the player's display identity (e.g. the player
460
+ * logs in or out mid-session and the host pushes a fresh config). The
461
+ * handler runs only when the identity actually changes — subscribing does
462
+ * not replay the current value; call getPlayer() for that. Receives the
463
+ * same { name, avatarUrl } | null shape as getPlayer(). Returns an
464
+ * unsubscribe function.
465
+ */
466
+ onPlayerChange(handler: (player: BBArcadePlayer | null) => void): () => void;
372
467
  /**
373
468
  * Deterministic A/B variant for this player (even split; stable per
374
469
  * player+game+key). Resolves the alphabetically-first variant as the
@@ -386,4 +481,4 @@ interface BBArcadeSDK {
386
481
  version: number;
387
482
  }
388
483
 
389
- export type { BBArcadeSDK as B, BBArcadeConfig as a, BBArcadeError as b, BBArcadeErrorCode as c, BBArcadeLockToHostOptions as d, BBArcadePlayer as e, BBArcadePrepareRewardedAdOptions as f, BBArcadePreparedRewardedAd as g, BBArcadeRewardedAdOptions as h, BBArcadeRewardedAdPreparation as i, BBArcadeRewardedAdPrepareFailure as j, BBArcadeRewardedAdResult as k, BBArcadeRewardedAdStatus as l, BBArcadeRewardedAdsConfig as m, BBArcadeRewardedBreakOptions as n, BBArcadeMpJoinOptions as o, BBArcadeMpRoom as p, BBArcadeMultiplayer as q, BBArcadeMpPlayer as r, BBArcadeMpResult as s, BBArcadeMpRoomEvents as t };
484
+ export type { BBArcadeSDK as B, BBArcadeConfig as a, BBArcadeError as b, BBArcadeErrorCode as c, BBArcadeLockToHostOptions as d, BBArcadePlayer as e, BBArcadePrepareRewardedAdOptions as f, BBArcadePreparedRewardedAd as g, BBArcadeRewardedAdOptions as h, BBArcadeRewardedAdPreparation as i, BBArcadeRewardedAdPrepareFailure as j, BBArcadeRewardedAdResult as k, BBArcadeRewardedAdStatus as l, BBArcadeRewardedAdsConfig as m, BBArcadeRewardedBreakOptions as n, BBArcadeStorage as o, BBArcadeStorageMode as p, BBArcadeMpJoinOptions as q, BBArcadeMpRoom as r, BBArcadeMultiplayer as s, BBArcadeMpPlayer as t, BBArcadeMpResult as u, BBArcadeMpRoomEvents as v };
@@ -0,0 +1,265 @@
1
+ # External authoritative multiplayer servers
2
+
3
+ Use this integration when your game already has a mature authoritative server
4
+ that must remain responsible for admission, inputs, simulation, state, and
5
+ results. Bounty Board connects the Arcade SDK to that server through a small
6
+ adapter contract; it does not relay traffic or run a second simulation.
7
+
8
+ External authorities are reviewed and enabled per game. Registration is not
9
+ self-service. Contact Bounty Board before implementing the adapter so the game
10
+ slug, endpoints, room-code rules, ticket keys, and result model can be agreed
11
+ for staging and production.
12
+
13
+ ## What stays authoritative
14
+
15
+ Your server remains the only source of truth. It must:
16
+
17
+ - admit a socket only after verifying its Bounty Board ticket;
18
+ - treat `joinData` and every client input as untrusted;
19
+ - run the only game simulation;
20
+ - clear held controls and other transient input on disconnect;
21
+ - send each player only the state that player may see;
22
+ - decide when a finite match is over and author the final standings; and
23
+ - keep endless rooms endless instead of inventing a terminal result.
24
+
25
+ The SDK handles ticket acquisition, WebSocket connection, reconnection, and a
26
+ small room-message envelope. It never grants a client authority over identity,
27
+ state, roles, or results.
28
+
29
+ ## Registration information
30
+
31
+ Provide Bounty Board with the following for each environment:
32
+
33
+ - the exact Arcade game slug;
34
+ - one fixed `wss:` endpoint dedicated to authenticated SDK rooms;
35
+ - the room-code format and maximum length your server accepts;
36
+ - whether missing room codes may create rooms;
37
+ - any validated, game-defined `joinData` fields;
38
+ - whether the game has finite matches or endless sessions; and
39
+ - an operational contact for key rotation or incident response.
40
+
41
+ The registered endpoint must not contain userinfo, query parameters, or a
42
+ fragment. Use `ws:` only for loopback development. Staging and production need
43
+ different endpoints and different secrets.
44
+
45
+ Bounty Board provisions a dedicated ticket-verification secret for the game
46
+ and environment. Store it only on servers. Do not put it in the game bundle,
47
+ browser storage, logs, analytics, crash reports, or client-visible environment
48
+ variables. Do not reuse credentials from another game or room service.
49
+
50
+ ## Connection flow
51
+
52
+ 1. The game calls `joinRoom()` from the Arcade SDK.
53
+ 2. The SDK asks the trusted Bounty Board host for a room ticket.
54
+ 3. Bounty Board verifies the game approval, multiplayer approval, active play
55
+ session, player or guest identity, game slug, and requested room code.
56
+ 4. Bounty Board returns a 60-second ticket and the registered WebSocket URL.
57
+ 5. The SDK appends the encoded ticket and optional encoded `joinData`, then
58
+ opens the socket.
59
+ 6. Your server verifies the ticket before admitting the socket and sends a
60
+ `welcome` frame within 10 seconds.
61
+
62
+ The sandboxed game never receives a Bounty Board session cookie and never calls
63
+ the ticket HTTP endpoint itself.
64
+
65
+ ## Ticket format
66
+
67
+ Tickets are compact HMAC envelopes, not JWTs:
68
+
69
+ ```text
70
+ payloadB64 = base64url(UTF8(JSON(payload)))
71
+ signatureB64 = base64url(HMAC-SHA256(payloadB64, ticketSecret))
72
+ ticket = payloadB64 + "." + signatureB64
73
+ ```
74
+
75
+ The HMAC input is the UTF-8 byte sequence of the base64url payload string.
76
+ `iat` and `exp` are Unix milliseconds. The current lifetime is 60 seconds.
77
+
78
+ ```json
79
+ {
80
+ "v": 1,
81
+ "sub": "player:<opaque-game-scoped-value>",
82
+ "name": "Display Name",
83
+ "avatarUrl": null,
84
+ "slug": "your-game-slug",
85
+ "roomId": "ROOM-CODE",
86
+ "guest": false,
87
+ "iat": 1700000000000,
88
+ "exp": 1700000060000
89
+ }
90
+ ```
91
+
92
+ Before accepting a connection, verify all of the following:
93
+
94
+ - the ticket contains exactly one separator and both parts decode;
95
+ - the HMAC matches using a constant-time comparison;
96
+ - `v` is the supported ticket version;
97
+ - `exp` is still in the future and `iat` is reasonable;
98
+ - `slug` exactly matches the registered game;
99
+ - `roomId` exactly matches the requested room and your registered rules;
100
+ - `sub` is non-empty; and
101
+ - required claim types and lengths are valid.
102
+
103
+ Treat `sub` as an opaque game-scoped identity. Never attempt to map it to a
104
+ Bounty Board account or correlate it with another game. `name` and `avatarUrl`
105
+ are display data, not authorization data. `avatarUrl` may be `null` for any
106
+ player.
107
+
108
+ ## SDK call
109
+
110
+ Module builds import multiplayer from its dedicated entry point:
111
+
112
+ ```ts
113
+ import { joinRoom } from '@bountyboard/arcade-sdk/multiplayer';
114
+
115
+ const room = await joinRoom({
116
+ code: 'ROOM-CODE', // or create: true to ask the SDK for a new code
117
+ joinData: {
118
+ schemaVersion: 1,
119
+ loadout: 'starter',
120
+ mode: 'friends',
121
+ },
122
+ });
123
+
124
+ room.on('snapshot', ({ state }) => renderAuthoritativeState(state));
125
+ room.on('connection', ({ connected }) => setReconnecting(!connected));
126
+
127
+ if (!room.trySend({ type: 'move', x: 1, y: 0 })) {
128
+ setReconnecting(true);
129
+ }
130
+ ```
131
+
132
+ Script-tag builds use `BBArcade.multiplayer.joinRoom(...)`.
133
+
134
+ `joinData` is an optional JSON object capped at 1 KiB. Validate every field,
135
+ reject unknown or oversized values, and never accept credentials or identity
136
+ claims from it. If your server distinguishes create from join, use a documented
137
+ game-defined intent field and rate-limit room creation. That field is still
138
+ untrusted and grants no role or permission by itself.
139
+
140
+ ## WebSocket URL
141
+
142
+ The SDK opens the registered endpoint with URL-encoded query parameters:
143
+
144
+ ```text
145
+ wss://multiplayer.example.com/bountyboard?ticket=<encoded>&join=<encoded-json>
146
+ ```
147
+
148
+ The `join` parameter is omitted when no `joinData` was supplied. Keep this path
149
+ separate from any anonymous or legacy socket endpoint so a guessed room code
150
+ cannot cross the authentication boundary. Redact or disable request-URI logging
151
+ on this route because the short-lived ticket is in the query string.
152
+
153
+ ## SDK room envelope
154
+
155
+ All frames are JSON text. After successful verification and admission, send a
156
+ `welcome` frame within 10 seconds:
157
+
158
+ ```json
159
+ {
160
+ "t": "welcome",
161
+ "playerId": "room-scoped-player-id",
162
+ "players": [{ "id": "room-scoped-player-id", "name": "Display Name", "avatarUrl": null }],
163
+ "state": { "phase": "lobby" }
164
+ }
165
+ ```
166
+
167
+ Send authoritative state snapshots as:
168
+
169
+ ```json
170
+ {
171
+ "t": "snapshot",
172
+ "tick": 42,
173
+ "state": { "phase": "playing", "players": [] }
174
+ }
175
+ ```
176
+
177
+ Send game-defined transient events without changing their payload:
178
+
179
+ ```json
180
+ { "t": "event", "data": { "type": "round_started", "round": 2 } }
181
+ ```
182
+
183
+ The SDK also recognizes player roster events:
184
+
185
+ ```json
186
+ { "t": "player_join", "player": { "id": "p2", "name": "Guest", "avatarUrl": null } }
187
+ { "t": "player_leave", "playerId": "p2" }
188
+ ```
189
+
190
+ Clients send inputs, never state:
191
+
192
+ ```json
193
+ { "t": "input", "seq": 7, "data": { "type": "move", "x": 1, "y": 0 } }
194
+ ```
195
+
196
+ Validate the envelope, sequence, payload shape, value ranges, rate, current
197
+ player state, and game rules before applying an input. Bound both inbound and
198
+ outbound frame sizes.
199
+
200
+ Reject an invalid ticket before `welcome` with an error and close the socket:
201
+
202
+ ```json
203
+ { "t": "error", "code": "unauthenticated" }
204
+ ```
205
+
206
+ Use `rejected` for invalid admission or `joinData`. After admission, error codes
207
+ remain game-defined strings and are surfaced through `room.on('error', ...)`.
208
+
209
+ ## Reconnection
210
+
211
+ The SDK automatically makes a small number of reconnect attempts. It requests
212
+ a fresh ticket for the same room and reuses the original `joinData`. Rebind the
213
+ seat using the verified `(ticket.sub, ticket.roomId)` pair. Never trust a
214
+ client-supplied player id or reconnect token as the proof of identity.
215
+
216
+ Send the latest complete viewer-safe state in the new `welcome` frame. If your
217
+ client uses prediction, include the last processed input sequence in that state
218
+ so it can discard acknowledged inputs. Clear held controls while the socket is
219
+ absent.
220
+
221
+ ## Finite matches and endless rooms
222
+
223
+ Only a server-authoritative finite game may emit `match_end`:
224
+
225
+ ```json
226
+ {
227
+ "t": "match_end",
228
+ "results": [
229
+ {
230
+ "playerId": "p1",
231
+ "name": "Display Name",
232
+ "placement": 1,
233
+ "score": 1200,
234
+ "outcome": "win"
235
+ }
236
+ ]
237
+ }
238
+ ```
239
+
240
+ The client receives this as `room.on('end', ({ results }) => ...)`. Persistent
241
+ Bounty Board results, when enabled, use a separately provisioned
242
+ server-to-server reporting credential. Never accept a client-originated result
243
+ or reporting credential.
244
+
245
+ Endless rooms must not emit `match_end`. Death, respawn, a round transition, or
246
+ a player leaving is not automatically a terminal match result.
247
+
248
+ ## Production checklist
249
+
250
+ - Use an isolated staging endpoint and secrets before production.
251
+ - Confirm tampered, expired, wrong-slug, and wrong-room tickets fail closed.
252
+ - Test both guest and logged-in opaque subjects.
253
+ - Reject malformed, unknown, or oversized `joinData`.
254
+ - Send `welcome` only after ticket and admission validation succeeds.
255
+ - Verify every snapshot is safe for its specific viewer.
256
+ - Test disconnect, held-input cleanup, fresh-ticket reconnect, and `leave()`.
257
+ - Rate-limit upgrades, room creation, joins, and inputs.
258
+ - Redact tickets and join payloads from access logs and error telemetry.
259
+ - Rotate the dedicated ticket secret without reusing another environment's key.
260
+ - Emit final results only from a real authoritative terminal condition.
261
+ - Keep the game playable when multiplayer is unsupported or temporarily down.
262
+
263
+ For SDK integration basics, see https://www.bountyboard.gg/arcade/sdk. For the
264
+ agent-readable API contract, see
265
+ https://www.bountyboard.gg/arcade/sdk/llms.txt.