@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.
@@ -1,15 +1,20 @@
1
1
  import {
2
2
  BBArcade
3
- } from "./chunk-OWOESH4X.js";
3
+ } from "./chunk-KDBBR532.js";
4
4
 
5
5
  // src/multiplayer.ts
6
6
  var WELCOME_TIMEOUT_MS = 1e4;
7
+ var MIN_WELCOME_TIMEOUT_MS = 1e3;
8
+ var MAX_WELCOME_TIMEOUT_MS = 6e4;
7
9
  var MAX_RECONNECT_ATTEMPTS = 3;
10
+ var MAX_JOIN_DATA_BYTES = 1024;
11
+ var SEAT_FULL_CLOSE_CODE = 4005;
8
12
  var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
9
13
  var CODE_LENGTH = 4;
10
- function sdkError(code) {
14
+ function sdkError(code, detail) {
11
15
  const err = new Error(code);
12
16
  err.code = code;
17
+ if (detail) err.detail = detail;
13
18
  return err;
14
19
  }
15
20
  function randomChars(length) {
@@ -26,27 +31,51 @@ function randomChars(length) {
26
31
  function generateCode() {
27
32
  return randomChars(CODE_LENGTH);
28
33
  }
29
- var guestKey = null;
30
- function getGuestKey() {
31
- if (!guestKey) guestKey = randomChars(16);
32
- return guestKey;
34
+ var cachedTextEncoder = null;
35
+ function utf8ByteLength(value) {
36
+ if (typeof TextEncoder !== "undefined") {
37
+ cachedTextEncoder = cachedTextEncoder || new TextEncoder();
38
+ return cachedTextEncoder.encode(value).length;
39
+ }
40
+ return new Blob([value]).size;
41
+ }
42
+ function serializeJoinData(data) {
43
+ if (data === void 0) return null;
44
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw sdkError("rejected");
45
+ let serialized;
46
+ try {
47
+ serialized = JSON.stringify(data);
48
+ } catch (e) {
49
+ throw sdkError("rejected");
50
+ }
51
+ if (!serialized || utf8ByteLength(serialized) > MAX_JOIN_DATA_BYTES) {
52
+ throw sdkError("rejected");
53
+ }
54
+ return serialized;
33
55
  }
34
- function requestTicket(roomId) {
56
+ function requestTicket(payload) {
35
57
  const request = BBArcade._request;
36
58
  if (typeof request !== "function") return Promise.reject(sdkError("unsupported"));
37
- return request("mp_ticket", { roomId, guestKey: getGuestKey() }).then((grant) => {
59
+ return request("mp_ticket", payload).then((grant) => {
38
60
  const g = grant;
39
61
  if (!g || typeof g.ticket !== "string" || typeof g.roomUrl !== "string") {
40
62
  throw sdkError("error");
41
63
  }
42
- return { ticket: g.ticket, roomUrl: g.roomUrl, expiresAt: Number(g.expiresAt) || 0 };
64
+ return {
65
+ ticket: g.ticket,
66
+ roomUrl: g.roomUrl,
67
+ expiresAt: Number(g.expiresAt) || 0,
68
+ roomId: typeof g.roomId === "string" && g.roomId ? g.roomId : null
69
+ };
43
70
  });
44
71
  }
45
72
  var RoomConnection = class {
46
- constructor(code, devGrant) {
73
+ constructor(code, devGrant, joinData, welcomeTimeoutMs = WELCOME_TIMEOUT_MS) {
47
74
  this.playerId = "";
48
75
  this.players = [];
49
76
  this.state = null;
77
+ this.connected = false;
78
+ this.latencyMs = null;
50
79
  this.ws = null;
51
80
  // Keyed by event name; values are erased to (unknown) => void internally and
52
81
  // retyped at the on()/emit() boundary, which keeps the public API strictly
@@ -59,14 +88,20 @@ var RoomConnection = class {
59
88
  // or the welcome timed out — permanent outcomes that must not reconnect.
60
89
  this.terminal = false;
61
90
  this.reconnectAttempts = 0;
91
+ // The in-flight connect()'s settlement, so leave() can reject a join that
92
+ // is still waiting on its welcome instead of stranding it on the timeout.
93
+ this.pendingConnect = null;
62
94
  this.code = code;
63
95
  this.devGrant = devGrant;
96
+ this.joinData = joinData;
97
+ this.welcomeTimeoutMs = welcomeTimeoutMs;
64
98
  }
65
99
  /** Connect and resolve on the server's welcome (post-ticket-verification). */
66
100
  connect(grant) {
67
101
  return new Promise((resolve, reject) => {
68
102
  let settled = false;
69
- const url = grant.roomUrl + (grant.roomUrl.indexOf("?") === -1 ? "?" : "&") + "ticket=" + encodeURIComponent(grant.ticket);
103
+ const url = grant.roomUrl + (grant.roomUrl.indexOf("?") === -1 ? "?" : "&") + "ticket=" + encodeURIComponent(grant.ticket) + (this.joinData === null ? "" : "&join=" + encodeURIComponent(this.joinData));
104
+ const connectStartedAt = Date.now();
70
105
  let ws;
71
106
  try {
72
107
  ws = new WebSocket(url);
@@ -77,13 +112,15 @@ var RoomConnection = class {
77
112
  const welcomeTimer = setTimeout(() => {
78
113
  if (settled) return;
79
114
  settled = true;
115
+ this.pendingConnect = null;
80
116
  this.terminal = true;
81
117
  try {
82
118
  ws.close();
83
119
  } catch (err) {
84
120
  }
85
121
  reject(sdkError("error"));
86
- }, WELCOME_TIMEOUT_MS);
122
+ }, this.welcomeTimeoutMs);
123
+ this.pendingConnect = { reject, timer: welcomeTimer };
87
124
  ws.onmessage = (event) => {
88
125
  var _a, _b, _c, _d, _e;
89
126
  let msg;
@@ -94,15 +131,27 @@ var RoomConnection = class {
94
131
  }
95
132
  if (!msg || typeof msg.t !== "string") return;
96
133
  if (msg.t === "welcome") {
134
+ if (this.intentionalClose) {
135
+ try {
136
+ ws.close();
137
+ } catch (err) {
138
+ }
139
+ return;
140
+ }
141
+ const reconnected = this.reconnectAttempts > 0;
97
142
  this.playerId = String((_a = msg.playerId) != null ? _a : "");
98
143
  this.players = Array.isArray(msg.players) ? msg.players : [];
99
144
  this.state = (_b = msg.state) != null ? _b : null;
145
+ this.connected = true;
100
146
  this.reconnectAttempts = 0;
147
+ this.latencyMs = Math.max(0, Date.now() - connectStartedAt);
101
148
  if (!settled) {
102
149
  settled = true;
150
+ this.pendingConnect = null;
103
151
  clearTimeout(welcomeTimer);
104
152
  resolve(this);
105
153
  }
154
+ if (reconnected) this.emit("connection", { connected: true, reconnecting: false });
106
155
  return;
107
156
  }
108
157
  if (msg.t === "snapshot") {
@@ -140,25 +189,30 @@ var RoomConnection = class {
140
189
  if (!settled) {
141
190
  settled = true;
142
191
  this.terminal = true;
192
+ this.pendingConnect = null;
143
193
  clearTimeout(welcomeTimer);
144
194
  try {
145
195
  ws.close();
146
196
  } catch (err) {
147
197
  }
148
- reject(sdkError(code === "unauthenticated" ? "unauthenticated" : "rejected"));
198
+ reject(sdkError(code === "unauthenticated" ? "unauthenticated" : "rejected", code));
149
199
  return;
150
200
  }
151
201
  this.emit("error", { code });
152
202
  }
153
203
  };
154
- ws.onclose = () => {
204
+ ws.onclose = (event) => {
205
+ this.connected = false;
206
+ if (event && event.code === SEAT_FULL_CLOSE_CODE) this.terminal = true;
155
207
  if (!settled) {
156
208
  settled = true;
209
+ this.pendingConnect = null;
157
210
  clearTimeout(welcomeTimer);
158
211
  reject(sdkError("error"));
159
212
  return;
160
213
  }
161
214
  if (this.intentionalClose || this.ended || this.terminal) {
215
+ this.emit("connection", { connected: false, reconnecting: false });
162
216
  this.emit("close", { reconnecting: false });
163
217
  return;
164
218
  }
@@ -176,11 +230,12 @@ var RoomConnection = class {
176
230
  return;
177
231
  }
178
232
  this.reconnectAttempts += 1;
233
+ this.emit("connection", { connected: false, reconnecting: true });
179
234
  this.emit("close", { reconnecting: true });
180
235
  await new Promise((r) => setTimeout(r, 400 * this.reconnectAttempts));
181
236
  if (this.intentionalClose || this.ended || this.terminal) return;
182
237
  try {
183
- const grant = (_a = this.devGrant) != null ? _a : await requestTicket(this.code);
238
+ const grant = (_a = this.devGrant) != null ? _a : await requestTicket({ roomId: this.code });
184
239
  if (this.intentionalClose || this.ended || this.terminal) return;
185
240
  await this.connect(grant);
186
241
  if (this.intentionalClose) this.leave();
@@ -189,11 +244,16 @@ var RoomConnection = class {
189
244
  }
190
245
  }
191
246
  send(input) {
192
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
247
+ this.trySend(input);
248
+ }
249
+ trySend(input) {
250
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;
193
251
  this.seq += 1;
194
252
  try {
195
253
  this.ws.send(JSON.stringify({ t: "input", seq: this.seq, data: input }));
254
+ return true;
196
255
  } catch (err) {
256
+ return false;
197
257
  }
198
258
  }
199
259
  on(event, handler) {
@@ -209,6 +269,13 @@ var RoomConnection = class {
209
269
  leave() {
210
270
  var _a;
211
271
  this.intentionalClose = true;
272
+ this.connected = false;
273
+ if (this.pendingConnect) {
274
+ const pending = this.pendingConnect;
275
+ this.pendingConnect = null;
276
+ clearTimeout(pending.timer);
277
+ pending.reject(sdkError("error"));
278
+ }
212
279
  try {
213
280
  (_a = this.ws) == null ? void 0 : _a.close(1e3);
214
281
  } catch (err) {
@@ -228,17 +295,57 @@ var RoomConnection = class {
228
295
  }
229
296
  }
230
297
  };
298
+ var MATCH_ATTEMPTS = 3;
299
+ function normalizeWelcomeTimeoutMs(value) {
300
+ if (typeof value !== "number" || !isFinite(value)) return WELCOME_TIMEOUT_MS;
301
+ return Math.min(MAX_WELCOME_TIMEOUT_MS, Math.max(MIN_WELCOME_TIMEOUT_MS, Math.floor(value)));
302
+ }
303
+ async function matchJoin(joinData, timeoutMs) {
304
+ let lastError = sdkError("error");
305
+ for (let attempt = 0; attempt < MATCH_ATTEMPTS; attempt++) {
306
+ const grant = await requestTicket({ matchmake: true });
307
+ if (!grant.roomId) throw sdkError("error");
308
+ try {
309
+ return await new RoomConnection(grant.roomId, null, joinData, timeoutMs).connect(grant);
310
+ } catch (error) {
311
+ lastError = error;
312
+ const detail = error == null ? void 0 : error.detail;
313
+ if (detail !== "room_full" && detail !== "room_over") throw error;
314
+ }
315
+ }
316
+ throw lastError;
317
+ }
231
318
  function joinRoom(options) {
232
319
  const opts = options != null ? options : {};
233
320
  if (typeof window === "undefined" || typeof WebSocket === "undefined") {
234
321
  return Promise.reject(sdkError("unsupported"));
235
322
  }
323
+ let joinData;
324
+ try {
325
+ joinData = serializeJoinData(opts.joinData);
326
+ } catch (err) {
327
+ return Promise.reject(err);
328
+ }
329
+ const timeoutMs = normalizeWelcomeTimeoutMs(opts.timeoutMs);
330
+ if (opts.match) {
331
+ if (opts.code || opts.create || opts.roomUrl || opts.ticket) {
332
+ return Promise.reject(sdkError("rejected"));
333
+ }
334
+ return matchJoin(joinData, timeoutMs);
335
+ }
236
336
  const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();
237
337
  if (opts.roomUrl && opts.ticket) {
238
- const devGrant = { ticket: opts.ticket, roomUrl: opts.roomUrl, expiresAt: 0 };
239
- return new RoomConnection(code, devGrant).connect(devGrant);
338
+ const devGrant = {
339
+ ticket: opts.ticket,
340
+ roomUrl: opts.roomUrl,
341
+ expiresAt: 0,
342
+ roomId: code
343
+ };
344
+ return new RoomConnection(code, devGrant, joinData, timeoutMs).connect(devGrant);
240
345
  }
241
- return requestTicket(code).then((grant) => new RoomConnection(code, null).connect(grant));
346
+ return requestTicket({ roomId: code }).then(
347
+ (grant) => new RoomConnection(code, null, joinData, timeoutMs).connect(grant)
348
+ );
242
349
  }
243
350
  var multiplayer = { joinRoom };
244
351
  export {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/multiplayer.ts"],"sourcesContent":["/**\n * Multiplayer client for the Bounty Board arcade room server\n * (packages/arcade-mp-server — authoritative Durable-Object rooms).\n *\n * Trust model: the game NEVER holds credentials. joinRoom() asks the embedding\n * Bounty Board host for a short-lived signed ticket (postMessage `mp_ticket`,\n * answered by the host frame which holds the player's session), then opens a\n * WebSocket to the room URL the ticket API returned. The room server verifies\n * the ticket before accepting, runs the simulation itself, and reports match\n * results to Bounty Board over a server-to-server channel — so nothing a\n * modified client sends can forge an identity or a recorded outcome. Clients\n * send INPUTS; the server sends per-viewer-filtered snapshots.\n */\nimport { BBArcade } from './index';\nimport type {\n BBArcadeErrorCode,\n BBArcadeMpJoinOptions,\n BBArcadeMpPlayer,\n BBArcadeMpResult,\n BBArcadeMpRoom,\n BBArcadeMpRoomEvents,\n BBArcadeMultiplayer,\n} from './types';\n\nexport type {\n BBArcadeMpJoinOptions,\n BBArcadeMpPlayer,\n BBArcadeMpResult,\n BBArcadeMpRoom,\n BBArcadeMpRoomEvents,\n BBArcadeMultiplayer,\n} from './types';\n\nconst WELCOME_TIMEOUT_MS = 10_000;\nconst MAX_RECONNECT_ATTEMPTS = 3;\n// Unambiguous alphabet (no 0/O/1/I) for share codes friends read aloud.\nconst CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';\nconst CODE_LENGTH = 4;\n\ninterface TicketGrant {\n ticket: string;\n roomUrl: string;\n expiresAt: number;\n}\n\ninterface SdkError extends Error {\n code: BBArcadeErrorCode;\n}\n\nfunction sdkError(code: BBArcadeErrorCode): SdkError {\n const err = new Error(code) as SdkError;\n err.code = code;\n return err;\n}\n\nfunction randomChars(length: number): string {\n const indices = new Uint8Array(length);\n if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {\n crypto.getRandomValues(indices);\n } else {\n for (let i = 0; i < length; i++) indices[i] = Math.floor(Math.random() * 256);\n }\n let out = '';\n // 256 is an exact multiple of the 32-char alphabet, so no modulo bias.\n for (let i = 0; i < length; i++) out += CODE_ALPHABET[indices[i] % CODE_ALPHABET.length];\n return out;\n}\n\nfunction generateCode(): string {\n return randomChars(CODE_LENGTH);\n}\n\n// Stable for the page load so a GUEST's reconnect (which mints a fresh ticket)\n// resumes the same room seat: the ticket API folds this key into the guest\n// subject instead of rolling a new random one per ticket. Logged-in players\n// don't need it (their subject is the stable profile id).\nlet guestKey: string | null = null;\nfunction getGuestKey(): string {\n if (!guestKey) guestKey = randomChars(16);\n return guestKey;\n}\n\n/** The host-frame handshake, riding the core SDK's request/response transport. */\nfunction requestTicket(roomId: string): Promise<TicketGrant> {\n const request = (\n BBArcade as unknown as { _request?: (type: string, payload?: unknown) => Promise<unknown> }\n )._request;\n if (typeof request !== 'function') return Promise.reject(sdkError('unsupported'));\n return request('mp_ticket', { roomId, guestKey: getGuestKey() }).then(grant => {\n const g = grant as Partial<TicketGrant> | null;\n if (!g || typeof g.ticket !== 'string' || typeof g.roomUrl !== 'string') {\n throw sdkError('error');\n }\n return { ticket: g.ticket, roomUrl: g.roomUrl, expiresAt: Number(g.expiresAt) || 0 };\n });\n}\n\nclass RoomConnection implements BBArcadeMpRoom {\n code: string;\n playerId = '';\n players: BBArcadeMpPlayer[] = [];\n state: unknown = null;\n\n private ws: WebSocket | null = null;\n // Keyed by event name; values are erased to (unknown) => void internally and\n // retyped at the on()/emit() boundary, which keeps the public API strictly\n // typed without fighting generic index-assignment rules.\n private handlers: Record<string, Array<(data: unknown) => void> | undefined> = {};\n private seq = 0;\n private intentionalClose = false;\n private ended = false;\n // Set when the server definitively rejected us (bad ticket, room full/over)\n // or the welcome timed out — permanent outcomes that must not reconnect.\n private terminal = false;\n private reconnectAttempts = 0;\n private readonly devGrant: TicketGrant | null;\n\n constructor(code: string, devGrant: TicketGrant | null) {\n this.code = code;\n this.devGrant = devGrant;\n }\n\n /** Connect and resolve on the server's welcome (post-ticket-verification). */\n connect(grant: TicketGrant): Promise<this> {\n return new Promise((resolve, reject) => {\n let settled = false;\n const url =\n grant.roomUrl +\n (grant.roomUrl.indexOf('?') === -1 ? '?' : '&') +\n 'ticket=' +\n encodeURIComponent(grant.ticket);\n let ws: WebSocket;\n try {\n ws = new WebSocket(url);\n } catch (err) {\n return reject(sdkError('error'));\n }\n this.ws = ws;\n\n const welcomeTimer = setTimeout(() => {\n if (settled) return;\n settled = true;\n this.terminal = true; // the join already failed — closing must not retry\n try {\n ws.close();\n } catch (err) {\n /* already closing */\n }\n reject(sdkError('error'));\n }, WELCOME_TIMEOUT_MS);\n\n ws.onmessage = event => {\n let msg: { t?: string } & Record<string, unknown>;\n try {\n msg = JSON.parse(String(event.data));\n } catch (err) {\n return; // non-JSON frames are not part of the protocol\n }\n if (!msg || typeof msg.t !== 'string') return;\n\n if (msg.t === 'welcome') {\n this.playerId = String(msg.playerId ?? '');\n this.players = Array.isArray(msg.players) ? (msg.players as BBArcadeMpPlayer[]) : [];\n this.state = msg.state ?? null;\n this.reconnectAttempts = 0;\n if (!settled) {\n settled = true;\n clearTimeout(welcomeTimer);\n resolve(this);\n }\n return;\n }\n if (msg.t === 'snapshot') {\n this.state = msg.state ?? this.state;\n this.emit('snapshot', { tick: Number(msg.tick) || 0, state: this.state });\n return;\n }\n if (msg.t === 'player_join') {\n const player = msg.player as BBArcadeMpPlayer;\n if (player && player.id && !this.players.some(p => p.id === player.id)) {\n this.players.push(player);\n }\n this.emit('playerJoin', { player });\n return;\n }\n if (msg.t === 'player_leave') {\n const playerId = String(msg.playerId ?? '');\n this.players = this.players.filter(p => p.id !== playerId);\n this.emit('playerLeave', { playerId });\n return;\n }\n if (msg.t === 'event') {\n this.emit('event', msg.data);\n return;\n }\n if (msg.t === 'match_end') {\n this.ended = true;\n this.emit('end', {\n results: Array.isArray(msg.results) ? (msg.results as BBArcadeMpResult[]) : [],\n });\n return;\n }\n if (msg.t === 'error') {\n const code = String(msg.code ?? 'error');\n if (!settled) {\n // A pre-welcome error is a DEFINITIVE rejection (bad ticket, room\n // full/over, unknown game) — reconnecting would just repeat it.\n settled = true;\n this.terminal = true;\n clearTimeout(welcomeTimer);\n try {\n ws.close();\n } catch (err) {\n /* already closing */\n }\n reject(sdkError(code === 'unauthenticated' ? 'unauthenticated' : 'rejected'));\n return;\n }\n this.emit('error', { code });\n }\n };\n\n ws.onclose = () => {\n if (!settled) {\n settled = true;\n clearTimeout(welcomeTimer);\n reject(sdkError('error'));\n return;\n }\n if (this.intentionalClose || this.ended || this.terminal) {\n this.emit('close', { reconnecting: false });\n return;\n }\n void this.reconnect();\n };\n\n ws.onerror = () => {\n /* onclose always follows and carries the handling */\n };\n });\n }\n\n private async reconnect(): Promise<void> {\n if (this.intentionalClose || this.ended || this.terminal) return;\n if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {\n this.emit('close', { reconnecting: false });\n return;\n }\n this.reconnectAttempts += 1;\n this.emit('close', { reconnecting: true });\n await new Promise(r => setTimeout(r, 400 * this.reconnectAttempts));\n // leave() may have been called during the backoff — opening a new socket\n // then would strand a connection the caller can no longer close.\n if (this.intentionalClose || this.ended || this.terminal) return;\n try {\n // Tickets are 60s single-purpose grants — always fetch a fresh one\n // (except in dev-override mode, where the dev ticket is reused).\n const grant = this.devGrant ?? (await requestTicket(this.code));\n if (this.intentionalClose || this.ended || this.terminal) return;\n await this.connect(grant);\n // leave() raced the reconnect while the socket was opening: close it.\n if (this.intentionalClose) this.leave();\n } catch (err) {\n void this.reconnect();\n }\n }\n\n send(input: unknown): void {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;\n this.seq += 1;\n try {\n this.ws.send(JSON.stringify({ t: 'input', seq: this.seq, data: input }));\n } catch (err) {\n /* socket raced shut — the close handler owns recovery */\n }\n }\n\n on<K extends keyof BBArcadeMpRoomEvents>(\n event: K,\n handler: (data: BBArcadeMpRoomEvents[K]) => void\n ): () => void {\n const list = (this.handlers[event] ??= []);\n const entry = handler as (data: unknown) => void;\n list.push(entry);\n return () => {\n const idx = list.indexOf(entry);\n if (idx !== -1) list.splice(idx, 1);\n };\n }\n\n leave(): void {\n this.intentionalClose = true;\n try {\n this.ws?.close(1000);\n } catch (err) {\n /* already closed */\n }\n }\n\n private emit<K extends keyof BBArcadeMpRoomEvents>(event: K, data: BBArcadeMpRoomEvents[K]) {\n const list = this.handlers[event];\n if (!list) return;\n for (const handler of list.slice()) {\n try {\n handler(data);\n } catch (err) {\n setTimeout(() => {\n throw err;\n }, 0);\n }\n }\n }\n}\n\nexport function joinRoom(options?: BBArcadeMpJoinOptions): Promise<BBArcadeMpRoom> {\n const opts = options ?? {};\n if (typeof window === 'undefined' || typeof WebSocket === 'undefined') {\n return Promise.reject(sdkError('unsupported'));\n }\n\n const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();\n\n // Dev override: straight to a local room server, no host handshake.\n if (opts.roomUrl && opts.ticket) {\n const devGrant: TicketGrant = { ticket: opts.ticket, roomUrl: opts.roomUrl, expiresAt: 0 };\n return new RoomConnection(code, devGrant).connect(devGrant);\n }\n\n return requestTicket(code).then(grant => new RoomConnection(code, null).connect(grant));\n}\n\nexport const multiplayer: BBArcadeMultiplayer = { joinRoom };\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/multiplayer.ts"],"sourcesContent":["/**\n * Multiplayer client for the Bounty Board arcade room server\n * (packages/arcade-mp-server — authoritative Durable-Object rooms).\n *\n * Trust model: the game NEVER holds credentials. joinRoom() asks the embedding\n * Bounty Board host for a short-lived signed ticket (postMessage `mp_ticket`,\n * answered by the host frame which holds the player's session), then opens a\n * WebSocket to the room URL the ticket API returned. The room server verifies\n * the ticket before accepting, runs the simulation itself, and reports match\n * results to Bounty Board over a server-to-server channel — so nothing a\n * modified client sends can forge an identity or a recorded outcome. Clients\n * send INPUTS; the server sends per-viewer-filtered snapshots.\n */\nimport { BBArcade } from './index';\nimport type {\n BBArcadeErrorCode,\n BBArcadeMpJoinOptions,\n BBArcadeMpPlayer,\n BBArcadeMpResult,\n BBArcadeMpRoom,\n BBArcadeMpRoomEvents,\n BBArcadeMultiplayer,\n} from './types';\n\nexport type {\n BBArcadeMpJoinOptions,\n BBArcadeMpPlayer,\n BBArcadeMpResult,\n BBArcadeMpRoom,\n BBArcadeMpRoomEvents,\n BBArcadeMultiplayer,\n} from './types';\n\nconst WELCOME_TIMEOUT_MS = 10_000;\nconst MIN_WELCOME_TIMEOUT_MS = 1_000;\nconst MAX_WELCOME_TIMEOUT_MS = 60_000;\nconst MAX_RECONNECT_ATTEMPTS = 3;\nconst MAX_JOIN_DATA_BYTES = 1_024;\n/** Server closes with this code when the seat already has its max sockets. */\nconst SEAT_FULL_CLOSE_CODE = 4005;\n// Unambiguous alphabet (no 0/O/1/I) for share codes friends read aloud.\nconst CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';\nconst CODE_LENGTH = 4;\n\ninterface TicketGrant {\n ticket: string;\n roomUrl: string;\n expiresAt: number;\n /** The room the ticket is bound to — how quick match learns its code. */\n roomId: string | null;\n}\n\ninterface SdkError extends Error {\n code: BBArcadeErrorCode;\n detail?: string;\n}\n\nfunction sdkError(code: BBArcadeErrorCode, detail?: string): SdkError {\n const err = new Error(code) as SdkError;\n err.code = code;\n if (detail) err.detail = detail;\n return err;\n}\n\nfunction randomChars(length: number): string {\n const indices = new Uint8Array(length);\n if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {\n crypto.getRandomValues(indices);\n } else {\n for (let i = 0; i < length; i++) indices[i] = Math.floor(Math.random() * 256);\n }\n let out = '';\n // 256 is an exact multiple of the 32-char alphabet, so no modulo bias.\n for (let i = 0; i < length; i++) out += CODE_ALPHABET[indices[i] % CODE_ALPHABET.length];\n return out;\n}\n\nfunction generateCode(): string {\n return randomChars(CODE_LENGTH);\n}\n\n// Lazy: import must stay clean even where TextEncoder is missing (some\n// test/SSR shims); Blob gives the same exact UTF-8 byte count as a fallback.\nlet cachedTextEncoder: TextEncoder | null = null;\n\nfunction utf8ByteLength(value: string): number {\n if (typeof TextEncoder !== 'undefined') {\n cachedTextEncoder = cachedTextEncoder || new TextEncoder();\n return cachedTextEncoder.encode(value).length;\n }\n return new Blob([value]).size;\n}\n\nfunction serializeJoinData(data: BBArcadeMpJoinOptions['joinData']): string | null {\n if (data === undefined) return null;\n if (!data || typeof data !== 'object' || Array.isArray(data)) throw sdkError('rejected');\n\n let serialized: string;\n try {\n serialized = JSON.stringify(data);\n } catch {\n throw sdkError('rejected');\n }\n if (!serialized || utf8ByteLength(serialized) > MAX_JOIN_DATA_BYTES) {\n throw sdkError('rejected');\n }\n return serialized;\n}\n\n/** The host-frame handshake, riding the core SDK's request/response transport. */\nfunction requestTicket(payload: { roomId: string } | { matchmake: true }): Promise<TicketGrant> {\n const request = (\n BBArcade as unknown as { _request?: (type: string, payload?: unknown) => Promise<unknown> }\n )._request;\n if (typeof request !== 'function') return Promise.reject(sdkError('unsupported'));\n return request('mp_ticket', payload).then(grant => {\n const g = grant as Partial<TicketGrant> | null;\n if (!g || typeof g.ticket !== 'string' || typeof g.roomUrl !== 'string') {\n throw sdkError('error');\n }\n return {\n ticket: g.ticket,\n roomUrl: g.roomUrl,\n expiresAt: Number(g.expiresAt) || 0,\n roomId: typeof g.roomId === 'string' && g.roomId ? g.roomId : null,\n };\n });\n}\n\nclass RoomConnection implements BBArcadeMpRoom {\n code: string;\n playerId = '';\n players: BBArcadeMpPlayer[] = [];\n state: unknown = null;\n connected = false;\n latencyMs: number | null = null;\n\n private ws: WebSocket | null = null;\n // Keyed by event name; values are erased to (unknown) => void internally and\n // retyped at the on()/emit() boundary, which keeps the public API strictly\n // typed without fighting generic index-assignment rules.\n private handlers: Record<string, Array<(data: unknown) => void> | undefined> = {};\n private seq = 0;\n private intentionalClose = false;\n private ended = false;\n // Set when the server definitively rejected us (bad ticket, room full/over)\n // or the welcome timed out — permanent outcomes that must not reconnect.\n private terminal = false;\n private reconnectAttempts = 0;\n private readonly devGrant: TicketGrant | null;\n private readonly joinData: string | null;\n private readonly welcomeTimeoutMs: number;\n // The in-flight connect()'s settlement, so leave() can reject a join that\n // is still waiting on its welcome instead of stranding it on the timeout.\n private pendingConnect: {\n reject: (err: SdkError) => void;\n timer: ReturnType<typeof setTimeout>;\n } | null = null;\n\n constructor(\n code: string,\n devGrant: TicketGrant | null,\n joinData: string | null,\n welcomeTimeoutMs: number = WELCOME_TIMEOUT_MS\n ) {\n this.code = code;\n this.devGrant = devGrant;\n this.joinData = joinData;\n this.welcomeTimeoutMs = welcomeTimeoutMs;\n }\n\n /** Connect and resolve on the server's welcome (post-ticket-verification). */\n connect(grant: TicketGrant): Promise<this> {\n return new Promise((resolve, reject) => {\n let settled = false;\n const url =\n grant.roomUrl +\n (grant.roomUrl.indexOf('?') === -1 ? '?' : '&') +\n 'ticket=' +\n encodeURIComponent(grant.ticket) +\n (this.joinData === null ? '' : '&join=' + encodeURIComponent(this.joinData));\n const connectStartedAt = Date.now();\n let ws: WebSocket;\n try {\n ws = new WebSocket(url);\n } catch (err) {\n return reject(sdkError('error'));\n }\n this.ws = ws;\n\n const welcomeTimer = setTimeout(() => {\n if (settled) return;\n settled = true;\n this.pendingConnect = null;\n this.terminal = true; // the join already failed — closing must not retry\n try {\n ws.close();\n } catch (err) {\n /* already closing */\n }\n reject(sdkError('error'));\n }, this.welcomeTimeoutMs);\n this.pendingConnect = { reject, timer: welcomeTimer };\n\n ws.onmessage = event => {\n let msg: { t?: string } & Record<string, unknown>;\n try {\n msg = JSON.parse(String(event.data));\n } catch (err) {\n return; // non-JSON frames are not part of the protocol\n }\n if (!msg || typeof msg.t !== 'string') return;\n\n if (msg.t === 'welcome') {\n // leave() beat the welcome: the join already rejected, so this late\n // welcome must not resurrect a room the caller walked away from.\n if (this.intentionalClose) {\n try {\n ws.close();\n } catch (err) {\n /* already closing */\n }\n return;\n }\n const reconnected = this.reconnectAttempts > 0;\n this.playerId = String(msg.playerId ?? '');\n this.players = Array.isArray(msg.players) ? (msg.players as BBArcadeMpPlayer[]) : [];\n this.state = msg.state ?? null;\n this.connected = true;\n this.reconnectAttempts = 0;\n // Join-handshake latency: socket open → server welcome (ticket verify\n // + one round trip). A tuning estimate, not a measured RTT.\n this.latencyMs = Math.max(0, Date.now() - connectStartedAt);\n if (!settled) {\n settled = true;\n this.pendingConnect = null;\n clearTimeout(welcomeTimer);\n resolve(this);\n }\n if (reconnected) this.emit('connection', { connected: true, reconnecting: false });\n return;\n }\n if (msg.t === 'snapshot') {\n this.state = msg.state ?? this.state;\n this.emit('snapshot', { tick: Number(msg.tick) || 0, state: this.state });\n return;\n }\n if (msg.t === 'player_join') {\n const player = msg.player as BBArcadeMpPlayer;\n if (player && player.id && !this.players.some(p => p.id === player.id)) {\n this.players.push(player);\n }\n this.emit('playerJoin', { player });\n return;\n }\n if (msg.t === 'player_leave') {\n const playerId = String(msg.playerId ?? '');\n this.players = this.players.filter(p => p.id !== playerId);\n this.emit('playerLeave', { playerId });\n return;\n }\n if (msg.t === 'event') {\n this.emit('event', msg.data);\n return;\n }\n if (msg.t === 'match_end') {\n this.ended = true;\n this.emit('end', {\n results: Array.isArray(msg.results) ? (msg.results as BBArcadeMpResult[]) : [],\n });\n return;\n }\n if (msg.t === 'error') {\n const code = String(msg.code ?? 'error');\n if (!settled) {\n // A pre-welcome error is a DEFINITIVE rejection (bad ticket, room\n // full/over, unknown game) — reconnecting would just repeat it.\n settled = true;\n this.terminal = true;\n this.pendingConnect = null;\n clearTimeout(welcomeTimer);\n try {\n ws.close();\n } catch (err) {\n /* already closing */\n }\n // The raw server code rides along as `detail` so quick match can\n // distinguish a lost seat race (room_full/room_over) from a hard no.\n reject(sdkError(code === 'unauthenticated' ? 'unauthenticated' : 'rejected', code));\n return;\n }\n this.emit('error', { code });\n }\n };\n\n ws.onclose = (event: CloseEvent) => {\n this.connected = false;\n // 4005: the server rejected/superseded this socket because the seat is\n // at capacity. Reconnecting would immediately re-trigger it (or evict a\n // sibling), so it is permanent for this Room instance.\n if (event && event.code === SEAT_FULL_CLOSE_CODE) this.terminal = true;\n if (!settled) {\n settled = true;\n this.pendingConnect = null;\n clearTimeout(welcomeTimer);\n reject(sdkError('error'));\n return;\n }\n if (this.intentionalClose || this.ended || this.terminal) {\n this.emit('connection', { connected: false, reconnecting: false });\n this.emit('close', { reconnecting: false });\n return;\n }\n void this.reconnect();\n };\n\n ws.onerror = () => {\n /* onclose always follows and carries the handling */\n };\n });\n }\n\n private async reconnect(): Promise<void> {\n if (this.intentionalClose || this.ended || this.terminal) return;\n if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {\n this.emit('close', { reconnecting: false });\n return;\n }\n this.reconnectAttempts += 1;\n this.emit('connection', { connected: false, reconnecting: true });\n this.emit('close', { reconnecting: true });\n await new Promise(r => setTimeout(r, 400 * this.reconnectAttempts));\n // leave() may have been called during the backoff — opening a new socket\n // then would strand a connection the caller can no longer close.\n if (this.intentionalClose || this.ended || this.terminal) return;\n try {\n // Tickets are 60s single-purpose grants — always fetch a fresh one\n // (except in dev-override mode, where the dev ticket is reused).\n const grant = this.devGrant ?? (await requestTicket({ roomId: this.code }));\n if (this.intentionalClose || this.ended || this.terminal) return;\n await this.connect(grant);\n // leave() raced the reconnect while the socket was opening: close it.\n if (this.intentionalClose) this.leave();\n } catch (err) {\n void this.reconnect();\n }\n }\n\n send(input: unknown): void {\n this.trySend(input);\n }\n\n trySend(input: unknown): boolean {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;\n this.seq += 1;\n try {\n this.ws.send(JSON.stringify({ t: 'input', seq: this.seq, data: input }));\n return true;\n } catch (err) {\n /* socket raced shut — the close handler owns recovery */\n return false;\n }\n }\n\n on<K extends keyof BBArcadeMpRoomEvents>(\n event: K,\n handler: (data: BBArcadeMpRoomEvents[K]) => void\n ): () => void {\n const list = (this.handlers[event] ??= []);\n const entry = handler as (data: unknown) => void;\n list.push(entry);\n return () => {\n const idx = list.indexOf(entry);\n if (idx !== -1) list.splice(idx, 1);\n };\n }\n\n leave(): void {\n this.intentionalClose = true;\n this.connected = false;\n // A join still waiting on its welcome settles now (not on the welcome\n // timeout), and the welcome handler's intentionalClose guard keeps a late\n // welcome from resurrecting the room.\n if (this.pendingConnect) {\n const pending = this.pendingConnect;\n this.pendingConnect = null;\n clearTimeout(pending.timer);\n pending.reject(sdkError('error'));\n }\n try {\n this.ws?.close(1000);\n } catch (err) {\n /* already closed */\n }\n }\n\n private emit<K extends keyof BBArcadeMpRoomEvents>(event: K, data: BBArcadeMpRoomEvents[K]) {\n const list = this.handlers[event];\n if (!list) return;\n for (const handler of list.slice()) {\n try {\n handler(data);\n } catch (err) {\n setTimeout(() => {\n throw err;\n }, 0);\n }\n }\n }\n}\n\n/**\n * Quick match can lose the last seat of the room it was pointed at (another\n * player connected first, or the match just ended). Those two outcomes are\n * races, not verdicts — re-matchmake and land in the next open room.\n */\nconst MATCH_ATTEMPTS = 3;\n\nfunction normalizeWelcomeTimeoutMs(value: unknown): number {\n if (typeof value !== 'number' || !isFinite(value)) return WELCOME_TIMEOUT_MS;\n return Math.min(MAX_WELCOME_TIMEOUT_MS, Math.max(MIN_WELCOME_TIMEOUT_MS, Math.floor(value)));\n}\n\nasync function matchJoin(joinData: string | null, timeoutMs: number): Promise<BBArcadeMpRoom> {\n let lastError: unknown = sdkError('error');\n for (let attempt = 0; attempt < MATCH_ATTEMPTS; attempt++) {\n // Host/ticket failures reject immediately: they are not seat races.\n const grant = await requestTicket({ matchmake: true });\n if (!grant.roomId) throw sdkError('error');\n try {\n return await new RoomConnection(grant.roomId, null, joinData, timeoutMs).connect(grant);\n } catch (error) {\n lastError = error;\n const detail = (error as SdkError | null)?.detail;\n if (detail !== 'room_full' && detail !== 'room_over') throw error;\n }\n }\n throw lastError;\n}\n\nexport function joinRoom(options?: BBArcadeMpJoinOptions): Promise<BBArcadeMpRoom> {\n const opts = options ?? {};\n if (typeof window === 'undefined' || typeof WebSocket === 'undefined') {\n return Promise.reject(sdkError('unsupported'));\n }\n\n let joinData: string | null;\n try {\n joinData = serializeJoinData(opts.joinData);\n } catch (err) {\n return Promise.reject(err);\n }\n\n const timeoutMs = normalizeWelcomeTimeoutMs(opts.timeoutMs);\n\n if (opts.match) {\n // Quick match picks the room itself — no code, no create, no dev override.\n if (opts.code || opts.create || opts.roomUrl || opts.ticket) {\n return Promise.reject(sdkError('rejected'));\n }\n return matchJoin(joinData, timeoutMs);\n }\n\n const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();\n\n // Dev override: straight to a local room server, no host handshake.\n if (opts.roomUrl && opts.ticket) {\n const devGrant: TicketGrant = {\n ticket: opts.ticket,\n roomUrl: opts.roomUrl,\n expiresAt: 0,\n roomId: code,\n };\n return new RoomConnection(code, devGrant, joinData, timeoutMs).connect(devGrant);\n }\n\n return requestTicket({ roomId: code }).then(grant =>\n new RoomConnection(code, null, joinData, timeoutMs).connect(grant)\n );\n}\n\nexport const multiplayer: BBArcadeMultiplayer = { joinRoom };\n"],"mappings":";;;;;AAiCA,IAAM,qBAAqB;AAC3B,IAAM,yBAAyB;AAC/B,IAAM,yBAAyB;AAC/B,IAAM,yBAAyB;AAC/B,IAAM,sBAAsB;AAE5B,IAAM,uBAAuB;AAE7B,IAAM,gBAAgB;AACtB,IAAM,cAAc;AAepB,SAAS,SAAS,MAAyB,QAA2B;AACpE,QAAM,MAAM,IAAI,MAAM,IAAI;AAC1B,MAAI,OAAO;AACX,MAAI,OAAQ,KAAI,SAAS;AACzB,SAAO;AACT;AAEA,SAAS,YAAY,QAAwB;AAC3C,QAAM,UAAU,IAAI,WAAW,MAAM;AACrC,MAAI,OAAO,WAAW,eAAe,OAAO,OAAO,oBAAoB,YAAY;AACjF,WAAO,gBAAgB,OAAO;AAAA,EAChC,OAAO;AACL,aAAS,IAAI,GAAG,IAAI,QAAQ,IAAK,SAAQ,CAAC,IAAI,KAAK,MAAM,KAAK,OAAO,IAAI,GAAG;AAAA,EAC9E;AACA,MAAI,MAAM;AAEV,WAAS,IAAI,GAAG,IAAI,QAAQ,IAAK,QAAO,cAAc,QAAQ,CAAC,IAAI,cAAc,MAAM;AACvF,SAAO;AACT;AAEA,SAAS,eAAuB;AAC9B,SAAO,YAAY,WAAW;AAChC;AAIA,IAAI,oBAAwC;AAE5C,SAAS,eAAe,OAAuB;AAC7C,MAAI,OAAO,gBAAgB,aAAa;AACtC,wBAAoB,qBAAqB,IAAI,YAAY;AACzD,WAAO,kBAAkB,OAAO,KAAK,EAAE;AAAA,EACzC;AACA,SAAO,IAAI,KAAK,CAAC,KAAK,CAAC,EAAE;AAC3B;AAEA,SAAS,kBAAkB,MAAwD;AACjF,MAAI,SAAS,OAAW,QAAO;AAC/B,MAAI,CAAC,QAAQ,OAAO,SAAS,YAAY,MAAM,QAAQ,IAAI,EAAG,OAAM,SAAS,UAAU;AAEvF,MAAI;AACJ,MAAI;AACF,iBAAa,KAAK,UAAU,IAAI;AAAA,EAClC,SAAQ;AACN,UAAM,SAAS,UAAU;AAAA,EAC3B;AACA,MAAI,CAAC,cAAc,eAAe,UAAU,IAAI,qBAAqB;AACnE,UAAM,SAAS,UAAU;AAAA,EAC3B;AACA,SAAO;AACT;AAGA,SAAS,cAAc,SAAyE;AAC9F,QAAM,UACJ,SACA;AACF,MAAI,OAAO,YAAY,WAAY,QAAO,QAAQ,OAAO,SAAS,aAAa,CAAC;AAChF,SAAO,QAAQ,aAAa,OAAO,EAAE,KAAK,WAAS;AACjD,UAAM,IAAI;AACV,QAAI,CAAC,KAAK,OAAO,EAAE,WAAW,YAAY,OAAO,EAAE,YAAY,UAAU;AACvE,YAAM,SAAS,OAAO;AAAA,IACxB;AACA,WAAO;AAAA,MACL,QAAQ,EAAE;AAAA,MACV,SAAS,EAAE;AAAA,MACX,WAAW,OAAO,EAAE,SAAS,KAAK;AAAA,MAClC,QAAQ,OAAO,EAAE,WAAW,YAAY,EAAE,SAAS,EAAE,SAAS;AAAA,IAChE;AAAA,EACF,CAAC;AACH;AAEA,IAAM,iBAAN,MAA+C;AAAA,EA8B7C,YACE,MACA,UACA,UACA,mBAA2B,oBAC3B;AAjCF,oBAAW;AACX,mBAA8B,CAAC;AAC/B,iBAAiB;AACjB,qBAAY;AACZ,qBAA2B;AAE3B,SAAQ,KAAuB;AAI/B;AAAA;AAAA;AAAA,SAAQ,WAAuE,CAAC;AAChF,SAAQ,MAAM;AACd,SAAQ,mBAAmB;AAC3B,SAAQ,QAAQ;AAGhB;AAAA;AAAA,SAAQ,WAAW;AACnB,SAAQ,oBAAoB;AAM5B;AAAA;AAAA,SAAQ,iBAGG;AAQT,SAAK,OAAO;AACZ,SAAK,WAAW;AAChB,SAAK,WAAW;AAChB,SAAK,mBAAmB;AAAA,EAC1B;AAAA;AAAA,EAGA,QAAQ,OAAmC;AACzC,WAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAI,UAAU;AACd,YAAM,MACJ,MAAM,WACL,MAAM,QAAQ,QAAQ,GAAG,MAAM,KAAK,MAAM,OAC3C,YACA,mBAAmB,MAAM,MAAM,KAC9B,KAAK,aAAa,OAAO,KAAK,WAAW,mBAAmB,KAAK,QAAQ;AAC5E,YAAM,mBAAmB,KAAK,IAAI;AAClC,UAAI;AACJ,UAAI;AACF,aAAK,IAAI,UAAU,GAAG;AAAA,MACxB,SAAS,KAAK;AACZ,eAAO,OAAO,SAAS,OAAO,CAAC;AAAA,MACjC;AACA,WAAK,KAAK;AAEV,YAAM,eAAe,WAAW,MAAM;AACpC,YAAI,QAAS;AACb,kBAAU;AACV,aAAK,iBAAiB;AACtB,aAAK,WAAW;AAChB,YAAI;AACF,aAAG,MAAM;AAAA,QACX,SAAS,KAAK;AAAA,QAEd;AACA,eAAO,SAAS,OAAO,CAAC;AAAA,MAC1B,GAAG,KAAK,gBAAgB;AACxB,WAAK,iBAAiB,EAAE,QAAQ,OAAO,aAAa;AAEpD,SAAG,YAAY,WAAS;AA5M9B;AA6MQ,YAAI;AACJ,YAAI;AACF,gBAAM,KAAK,MAAM,OAAO,MAAM,IAAI,CAAC;AAAA,QACrC,SAAS,KAAK;AACZ;AAAA,QACF;AACA,YAAI,CAAC,OAAO,OAAO,IAAI,MAAM,SAAU;AAEvC,YAAI,IAAI,MAAM,WAAW;AAGvB,cAAI,KAAK,kBAAkB;AACzB,gBAAI;AACF,iBAAG,MAAM;AAAA,YACX,SAAS,KAAK;AAAA,YAEd;AACA;AAAA,UACF;AACA,gBAAM,cAAc,KAAK,oBAAoB;AAC7C,eAAK,WAAW,QAAO,SAAI,aAAJ,YAAgB,EAAE;AACzC,eAAK,UAAU,MAAM,QAAQ,IAAI,OAAO,IAAK,IAAI,UAAiC,CAAC;AACnF,eAAK,SAAQ,SAAI,UAAJ,YAAa;AAC1B,eAAK,YAAY;AACjB,eAAK,oBAAoB;AAGzB,eAAK,YAAY,KAAK,IAAI,GAAG,KAAK,IAAI,IAAI,gBAAgB;AAC1D,cAAI,CAAC,SAAS;AACZ,sBAAU;AACV,iBAAK,iBAAiB;AACtB,yBAAa,YAAY;AACzB,oBAAQ,IAAI;AAAA,UACd;AACA,cAAI,YAAa,MAAK,KAAK,cAAc,EAAE,WAAW,MAAM,cAAc,MAAM,CAAC;AACjF;AAAA,QACF;AACA,YAAI,IAAI,MAAM,YAAY;AACxB,eAAK,SAAQ,SAAI,UAAJ,YAAa,KAAK;AAC/B,eAAK,KAAK,YAAY,EAAE,MAAM,OAAO,IAAI,IAAI,KAAK,GAAG,OAAO,KAAK,MAAM,CAAC;AACxE;AAAA,QACF;AACA,YAAI,IAAI,MAAM,eAAe;AAC3B,gBAAM,SAAS,IAAI;AACnB,cAAI,UAAU,OAAO,MAAM,CAAC,KAAK,QAAQ,KAAK,OAAK,EAAE,OAAO,OAAO,EAAE,GAAG;AACtE,iBAAK,QAAQ,KAAK,MAAM;AAAA,UAC1B;AACA,eAAK,KAAK,cAAc,EAAE,OAAO,CAAC;AAClC;AAAA,QACF;AACA,YAAI,IAAI,MAAM,gBAAgB;AAC5B,gBAAM,WAAW,QAAO,SAAI,aAAJ,YAAgB,EAAE;AAC1C,eAAK,UAAU,KAAK,QAAQ,OAAO,OAAK,EAAE,OAAO,QAAQ;AACzD,eAAK,KAAK,eAAe,EAAE,SAAS,CAAC;AACrC;AAAA,QACF;AACA,YAAI,IAAI,MAAM,SAAS;AACrB,eAAK,KAAK,SAAS,IAAI,IAAI;AAC3B;AAAA,QACF;AACA,YAAI,IAAI,MAAM,aAAa;AACzB,eAAK,QAAQ;AACb,eAAK,KAAK,OAAO;AAAA,YACf,SAAS,MAAM,QAAQ,IAAI,OAAO,IAAK,IAAI,UAAiC,CAAC;AAAA,UAC/E,CAAC;AACD;AAAA,QACF;AACA,YAAI,IAAI,MAAM,SAAS;AACrB,gBAAM,OAAO,QAAO,SAAI,SAAJ,YAAY,OAAO;AACvC,cAAI,CAAC,SAAS;AAGZ,sBAAU;AACV,iBAAK,WAAW;AAChB,iBAAK,iBAAiB;AACtB,yBAAa,YAAY;AACzB,gBAAI;AACF,iBAAG,MAAM;AAAA,YACX,SAAS,KAAK;AAAA,YAEd;AAGA,mBAAO,SAAS,SAAS,oBAAoB,oBAAoB,YAAY,IAAI,CAAC;AAClF;AAAA,UACF;AACA,eAAK,KAAK,SAAS,EAAE,KAAK,CAAC;AAAA,QAC7B;AAAA,MACF;AAEA,SAAG,UAAU,CAAC,UAAsB;AAClC,aAAK,YAAY;AAIjB,YAAI,SAAS,MAAM,SAAS,qBAAsB,MAAK,WAAW;AAClE,YAAI,CAAC,SAAS;AACZ,oBAAU;AACV,eAAK,iBAAiB;AACtB,uBAAa,YAAY;AACzB,iBAAO,SAAS,OAAO,CAAC;AACxB;AAAA,QACF;AACA,YAAI,KAAK,oBAAoB,KAAK,SAAS,KAAK,UAAU;AACxD,eAAK,KAAK,cAAc,EAAE,WAAW,OAAO,cAAc,MAAM,CAAC;AACjE,eAAK,KAAK,SAAS,EAAE,cAAc,MAAM,CAAC;AAC1C;AAAA,QACF;AACA,aAAK,KAAK,UAAU;AAAA,MACtB;AAEA,SAAG,UAAU,MAAM;AAAA,MAEnB;AAAA,IACF,CAAC;AAAA,EACH;AAAA,EAEA,MAAc,YAA2B;AAlU3C;AAmUI,QAAI,KAAK,oBAAoB,KAAK,SAAS,KAAK,SAAU;AAC1D,QAAI,KAAK,qBAAqB,wBAAwB;AACpD,WAAK,KAAK,SAAS,EAAE,cAAc,MAAM,CAAC;AAC1C;AAAA,IACF;AACA,SAAK,qBAAqB;AAC1B,SAAK,KAAK,cAAc,EAAE,WAAW,OAAO,cAAc,KAAK,CAAC;AAChE,SAAK,KAAK,SAAS,EAAE,cAAc,KAAK,CAAC;AACzC,UAAM,IAAI,QAAQ,OAAK,WAAW,GAAG,MAAM,KAAK,iBAAiB,CAAC;AAGlE,QAAI,KAAK,oBAAoB,KAAK,SAAS,KAAK,SAAU;AAC1D,QAAI;AAGF,YAAM,SAAQ,UAAK,aAAL,YAAkB,MAAM,cAAc,EAAE,QAAQ,KAAK,KAAK,CAAC;AACzE,UAAI,KAAK,oBAAoB,KAAK,SAAS,KAAK,SAAU;AAC1D,YAAM,KAAK,QAAQ,KAAK;AAExB,UAAI,KAAK,iBAAkB,MAAK,MAAM;AAAA,IACxC,SAAS,KAAK;AACZ,WAAK,KAAK,UAAU;AAAA,IACtB;AAAA,EACF;AAAA,EAEA,KAAK,OAAsB;AACzB,SAAK,QAAQ,KAAK;AAAA,EACpB;AAAA,EAEA,QAAQ,OAAyB;AAC/B,QAAI,CAAC,KAAK,MAAM,KAAK,GAAG,eAAe,UAAU,KAAM,QAAO;AAC9D,SAAK,OAAO;AACZ,QAAI;AACF,WAAK,GAAG,KAAK,KAAK,UAAU,EAAE,GAAG,SAAS,KAAK,KAAK,KAAK,MAAM,MAAM,CAAC,CAAC;AACvE,aAAO;AAAA,IACT,SAAS,KAAK;AAEZ,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEA,GACE,OACA,SACY;AA/WhB;AAgXI,UAAM,QAAQ,gBAAK,UAAL,mCAAyB,CAAC;AACxC,UAAM,QAAQ;AACd,SAAK,KAAK,KAAK;AACf,WAAO,MAAM;AACX,YAAM,MAAM,KAAK,QAAQ,KAAK;AAC9B,UAAI,QAAQ,GAAI,MAAK,OAAO,KAAK,CAAC;AAAA,IACpC;AAAA,EACF;AAAA,EAEA,QAAc;AAzXhB;AA0XI,SAAK,mBAAmB;AACxB,SAAK,YAAY;AAIjB,QAAI,KAAK,gBAAgB;AACvB,YAAM,UAAU,KAAK;AACrB,WAAK,iBAAiB;AACtB,mBAAa,QAAQ,KAAK;AAC1B,cAAQ,OAAO,SAAS,OAAO,CAAC;AAAA,IAClC;AACA,QAAI;AACF,iBAAK,OAAL,mBAAS,MAAM;AAAA,IACjB,SAAS,KAAK;AAAA,IAEd;AAAA,EACF;AAAA,EAEQ,KAA2C,OAAU,MAA+B;AAC1F,UAAM,OAAO,KAAK,SAAS,KAAK;AAChC,QAAI,CAAC,KAAM;AACX,eAAW,WAAW,KAAK,MAAM,GAAG;AAClC,UAAI;AACF,gBAAQ,IAAI;AAAA,MACd,SAAS,KAAK;AACZ,mBAAW,MAAM;AACf,gBAAM;AAAA,QACR,GAAG,CAAC;AAAA,MACN;AAAA,IACF;AAAA,EACF;AACF;AAOA,IAAM,iBAAiB;AAEvB,SAAS,0BAA0B,OAAwB;AACzD,MAAI,OAAO,UAAU,YAAY,CAAC,SAAS,KAAK,EAAG,QAAO;AAC1D,SAAO,KAAK,IAAI,wBAAwB,KAAK,IAAI,wBAAwB,KAAK,MAAM,KAAK,CAAC,CAAC;AAC7F;AAEA,eAAe,UAAU,UAAyB,WAA4C;AAC5F,MAAI,YAAqB,SAAS,OAAO;AACzC,WAAS,UAAU,GAAG,UAAU,gBAAgB,WAAW;AAEzD,UAAM,QAAQ,MAAM,cAAc,EAAE,WAAW,KAAK,CAAC;AACrD,QAAI,CAAC,MAAM,OAAQ,OAAM,SAAS,OAAO;AACzC,QAAI;AACF,aAAO,MAAM,IAAI,eAAe,MAAM,QAAQ,MAAM,UAAU,SAAS,EAAE,QAAQ,KAAK;AAAA,IACxF,SAAS,OAAO;AACd,kBAAY;AACZ,YAAM,SAAU,+BAA2B;AAC3C,UAAI,WAAW,eAAe,WAAW,YAAa,OAAM;AAAA,IAC9D;AAAA,EACF;AACA,QAAM;AACR;AAEO,SAAS,SAAS,SAA0D;AACjF,QAAM,OAAO,4BAAW,CAAC;AACzB,MAAI,OAAO,WAAW,eAAe,OAAO,cAAc,aAAa;AACrE,WAAO,QAAQ,OAAO,SAAS,aAAa,CAAC;AAAA,EAC/C;AAEA,MAAI;AACJ,MAAI;AACF,eAAW,kBAAkB,KAAK,QAAQ;AAAA,EAC5C,SAAS,KAAK;AACZ,WAAO,QAAQ,OAAO,GAAG;AAAA,EAC3B;AAEA,QAAM,YAAY,0BAA0B,KAAK,SAAS;AAE1D,MAAI,KAAK,OAAO;AAEd,QAAI,KAAK,QAAQ,KAAK,UAAU,KAAK,WAAW,KAAK,QAAQ;AAC3D,aAAO,QAAQ,OAAO,SAAS,UAAU,CAAC;AAAA,IAC5C;AACA,WAAO,UAAU,UAAU,SAAS;AAAA,EACtC;AAEA,QAAM,OAAO,KAAK,UAAU,CAAC,KAAK,OAAO,aAAa,IAAI,KAAK,KAAK,YAAY;AAGhF,MAAI,KAAK,WAAW,KAAK,QAAQ;AAC/B,UAAM,WAAwB;AAAA,MAC5B,QAAQ,KAAK;AAAA,MACb,SAAS,KAAK;AAAA,MACd,WAAW;AAAA,MACX,QAAQ;AAAA,IACV;AACA,WAAO,IAAI,eAAe,MAAM,UAAU,UAAU,SAAS,EAAE,QAAQ,QAAQ;AAAA,EACjF;AAEA,SAAO,cAAc,EAAE,QAAQ,KAAK,CAAC,EAAE;AAAA,IAAK,WAC1C,IAAI,eAAe,MAAM,MAAM,UAAU,SAAS,EAAE,QAAQ,KAAK;AAAA,EACnE;AACF;AAEO,IAAM,cAAmC,EAAE,SAAS;","names":[]}
@@ -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 {
@@ -73,7 +75,17 @@ interface BBArcadeLockToHostOptions {
73
75
  redirect?: string;
74
76
  }
75
77
  /** Outcome of a rewarded placement. */
76
- type BBArcadeRewardedAdStatus = 'viewed' | 'dismissed' | 'ready' | 'unavailable' | 'error';
78
+ type BBArcadeRewardedAdStatus =
79
+ /** The player watched the ad to completion — the only status that grants. */
80
+ 'viewed'
81
+ /** The player closed the ad early, or a prepared ad ended before it was ever shown. */
82
+ | 'dismissed'
83
+ /** An ad is ready to show (preparation result; terminal only in the legacy shown-but-unreported edge). */
84
+ | 'ready'
85
+ /** No ad could be offered. */
86
+ | 'unavailable'
87
+ /** The placement failed. */
88
+ | 'error';
77
89
  /** Resolution value of rewardedAd()/showRewardedAd(). */
78
90
  interface BBArcadeRewardedAdResult {
79
91
  /** 'viewed' is the only status that should grant the reward. */
@@ -196,6 +208,22 @@ interface BBArcadeMpJoinOptions {
196
208
  code?: string;
197
209
  /** Create a new room (a share code is generated for you). */
198
210
  create?: boolean;
211
+ /**
212
+ * Public quick match: join your game's open public room, or start a fresh
213
+ * one when no seat is free. Mutually exclusive with code/create. Rooms fill
214
+ * to the game's player cap (at most 64), and a matched room still exposes
215
+ * room.code so the player can invite friends into the same room. If the
216
+ * last free seat is lost to a race, the SDK re-matchmakes automatically a
217
+ * couple of times before rejecting.
218
+ */
219
+ match?: boolean;
220
+ /**
221
+ * Game-defined, JSON-serializable data supplied to the authoritative module
222
+ * before it admits this player (avatar/loadout/mode selections, for example).
223
+ * The room server treats this as untrusted input and limits it to 1 KiB.
224
+ * Never put credentials or other secrets here.
225
+ */
226
+ joinData?: Readonly<Record<string, unknown>>;
199
227
  /**
200
228
  * Local development only: connect straight to a room server (e.g. wrangler
201
229
  * dev with DEV_ALLOW_UNSIGNED=1) instead of asking the Bounty Board host for
@@ -203,6 +231,12 @@ interface BBArcadeMpJoinOptions {
203
231
  */
204
232
  roomUrl?: string;
205
233
  ticket?: string;
234
+ /**
235
+ * How long to wait for the room server's welcome before the join rejects
236
+ * with code 'error', in ms (default 10000, clamped to 1000–60000). Applies
237
+ * to the initial join and to every automatic reconnect attempt.
238
+ */
239
+ timeoutMs?: number;
206
240
  }
207
241
  /** Events a multiplayer room emits. */
208
242
  interface BBArcadeMpRoomEvents {
@@ -226,6 +260,11 @@ interface BBArcadeMpRoomEvents {
226
260
  error: {
227
261
  code: string;
228
262
  };
263
+ /** Connection state, including successful automatic reconnection. */
264
+ connection: {
265
+ connected: boolean;
266
+ reconnecting: boolean;
267
+ };
229
268
  /** Connection dropped. reconnecting: true means the SDK is retrying. */
230
269
  close: {
231
270
  reconnecting: boolean;
@@ -241,8 +280,19 @@ interface BBArcadeMpRoom {
241
280
  players: BBArcadeMpPlayer[];
242
281
  /** Latest authoritative state snapshot (as filtered for you). */
243
282
  state: unknown;
283
+ /** Whether the room currently has an accepted WebSocket connection. */
284
+ connected: boolean;
285
+ /**
286
+ * Join-handshake latency in ms (socket open → server welcome, so ticket
287
+ * verification plus one round trip), refreshed on every successful
288
+ * (re)connect; null until the first welcome arrives. A tuning estimate for
289
+ * render-side smoothing, not a measured RTT.
290
+ */
291
+ latencyMs: number | null;
244
292
  /** Send an INPUT to the server (never authoritative state — the server simulates). */
245
293
  send(input: unknown): void;
294
+ /** Like send(), but reports false when disconnected or the socket raced closed. */
295
+ trySend(input: unknown): boolean;
246
296
  /** Subscribe to a room event; returns an unsubscribe function. */
247
297
  on<K extends keyof BBArcadeMpRoomEvents>(event: K, handler: (data: BBArcadeMpRoomEvents[K]) => void): () => void;
248
298
  /** Leave the room and close the connection (no reconnect). */
@@ -270,6 +320,9 @@ interface BBArcadeSDK {
270
320
  /**
271
321
  * Refuse to run unless embedded on a Bounty Board host (anti
272
322
  * scrape-and-reupload deterrent — not DRM). Call once, early, before boot.
323
+ * When the embedder can't be determined from browser signals (opaque-origin
324
+ * sandboxed builds), the SDK asks the embedding page and blocks if nothing
325
+ * answers within ~5 seconds.
273
326
  */
274
327
  lockToHost(options?: BBArcadeLockToHostOptions): void;
275
328
  /**
@@ -337,12 +390,16 @@ interface BBArcadeSDK {
337
390
  * Save the player's progress (a string blob, ~1MB max) to their Bounty Board
338
391
  * account. Requires a Bounty-Board-hosted build upload and a logged-in
339
392
  * player; rejects with a BBArcadeError otherwise (code 'unsupported' /
340
- * 'unauthenticated' / 'too_large' / 'rejected' / 'error').
393
+ * 'unauthenticated' / 'too_large' / 'rejected' / 'error'). Oversized blobs
394
+ * reject 'too_large' immediately (client-side precheck against the same 1
395
+ * MiB cap the server enforces); a host that never answers rejects 'error'
396
+ * after a 15s request timeout.
341
397
  */
342
398
  save(blob: string): Promise<void>;
343
399
  /**
344
400
  * Load the player's saved blob. Resolves with the string, or null when there
345
- * is no save yet. Rejects like save() ('unauthenticated' when logged out).
401
+ * is no save yet. Rejects like save() ('unauthenticated' when logged out,
402
+ * 'error' after the same 15s request timeout when no host answers).
346
403
  */
347
404
  load(): Promise<string | null>;
348
405
  /**
@@ -353,6 +410,15 @@ interface BBArcadeSDK {
353
410
  * display name + avatar only, never ids, emails, or roles.
354
411
  */
355
412
  getPlayer(): Promise<BBArcadePlayer | null>;
413
+ /**
414
+ * Subscribe to CHANGES in the player's display identity (e.g. the player
415
+ * logs in or out mid-session and the host pushes a fresh config). The
416
+ * handler runs only when the identity actually changes — subscribing does
417
+ * not replay the current value; call getPlayer() for that. Receives the
418
+ * same { name, avatarUrl } | null shape as getPlayer(). Returns an
419
+ * unsubscribe function.
420
+ */
421
+ onPlayerChange(handler: (player: BBArcadePlayer | null) => void): () => void;
356
422
  /**
357
423
  * Deterministic A/B variant for this player (even split; stable per
358
424
  * player+game+key). Resolves the alphabetically-first variant as the
@@ -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 {
@@ -73,7 +75,17 @@ interface BBArcadeLockToHostOptions {
73
75
  redirect?: string;
74
76
  }
75
77
  /** Outcome of a rewarded placement. */
76
- type BBArcadeRewardedAdStatus = 'viewed' | 'dismissed' | 'ready' | 'unavailable' | 'error';
78
+ type BBArcadeRewardedAdStatus =
79
+ /** The player watched the ad to completion — the only status that grants. */
80
+ 'viewed'
81
+ /** The player closed the ad early, or a prepared ad ended before it was ever shown. */
82
+ | 'dismissed'
83
+ /** An ad is ready to show (preparation result; terminal only in the legacy shown-but-unreported edge). */
84
+ | 'ready'
85
+ /** No ad could be offered. */
86
+ | 'unavailable'
87
+ /** The placement failed. */
88
+ | 'error';
77
89
  /** Resolution value of rewardedAd()/showRewardedAd(). */
78
90
  interface BBArcadeRewardedAdResult {
79
91
  /** 'viewed' is the only status that should grant the reward. */
@@ -196,6 +208,22 @@ interface BBArcadeMpJoinOptions {
196
208
  code?: string;
197
209
  /** Create a new room (a share code is generated for you). */
198
210
  create?: boolean;
211
+ /**
212
+ * Public quick match: join your game's open public room, or start a fresh
213
+ * one when no seat is free. Mutually exclusive with code/create. Rooms fill
214
+ * to the game's player cap (at most 64), and a matched room still exposes
215
+ * room.code so the player can invite friends into the same room. If the
216
+ * last free seat is lost to a race, the SDK re-matchmakes automatically a
217
+ * couple of times before rejecting.
218
+ */
219
+ match?: boolean;
220
+ /**
221
+ * Game-defined, JSON-serializable data supplied to the authoritative module
222
+ * before it admits this player (avatar/loadout/mode selections, for example).
223
+ * The room server treats this as untrusted input and limits it to 1 KiB.
224
+ * Never put credentials or other secrets here.
225
+ */
226
+ joinData?: Readonly<Record<string, unknown>>;
199
227
  /**
200
228
  * Local development only: connect straight to a room server (e.g. wrangler
201
229
  * dev with DEV_ALLOW_UNSIGNED=1) instead of asking the Bounty Board host for
@@ -203,6 +231,12 @@ interface BBArcadeMpJoinOptions {
203
231
  */
204
232
  roomUrl?: string;
205
233
  ticket?: string;
234
+ /**
235
+ * How long to wait for the room server's welcome before the join rejects
236
+ * with code 'error', in ms (default 10000, clamped to 1000–60000). Applies
237
+ * to the initial join and to every automatic reconnect attempt.
238
+ */
239
+ timeoutMs?: number;
206
240
  }
207
241
  /** Events a multiplayer room emits. */
208
242
  interface BBArcadeMpRoomEvents {
@@ -226,6 +260,11 @@ interface BBArcadeMpRoomEvents {
226
260
  error: {
227
261
  code: string;
228
262
  };
263
+ /** Connection state, including successful automatic reconnection. */
264
+ connection: {
265
+ connected: boolean;
266
+ reconnecting: boolean;
267
+ };
229
268
  /** Connection dropped. reconnecting: true means the SDK is retrying. */
230
269
  close: {
231
270
  reconnecting: boolean;
@@ -241,8 +280,19 @@ interface BBArcadeMpRoom {
241
280
  players: BBArcadeMpPlayer[];
242
281
  /** Latest authoritative state snapshot (as filtered for you). */
243
282
  state: unknown;
283
+ /** Whether the room currently has an accepted WebSocket connection. */
284
+ connected: boolean;
285
+ /**
286
+ * Join-handshake latency in ms (socket open → server welcome, so ticket
287
+ * verification plus one round trip), refreshed on every successful
288
+ * (re)connect; null until the first welcome arrives. A tuning estimate for
289
+ * render-side smoothing, not a measured RTT.
290
+ */
291
+ latencyMs: number | null;
244
292
  /** Send an INPUT to the server (never authoritative state — the server simulates). */
245
293
  send(input: unknown): void;
294
+ /** Like send(), but reports false when disconnected or the socket raced closed. */
295
+ trySend(input: unknown): boolean;
246
296
  /** Subscribe to a room event; returns an unsubscribe function. */
247
297
  on<K extends keyof BBArcadeMpRoomEvents>(event: K, handler: (data: BBArcadeMpRoomEvents[K]) => void): () => void;
248
298
  /** Leave the room and close the connection (no reconnect). */
@@ -270,6 +320,9 @@ interface BBArcadeSDK {
270
320
  /**
271
321
  * Refuse to run unless embedded on a Bounty Board host (anti
272
322
  * scrape-and-reupload deterrent — not DRM). Call once, early, before boot.
323
+ * When the embedder can't be determined from browser signals (opaque-origin
324
+ * sandboxed builds), the SDK asks the embedding page and blocks if nothing
325
+ * answers within ~5 seconds.
273
326
  */
274
327
  lockToHost(options?: BBArcadeLockToHostOptions): void;
275
328
  /**
@@ -337,12 +390,16 @@ interface BBArcadeSDK {
337
390
  * Save the player's progress (a string blob, ~1MB max) to their Bounty Board
338
391
  * account. Requires a Bounty-Board-hosted build upload and a logged-in
339
392
  * player; rejects with a BBArcadeError otherwise (code 'unsupported' /
340
- * 'unauthenticated' / 'too_large' / 'rejected' / 'error').
393
+ * 'unauthenticated' / 'too_large' / 'rejected' / 'error'). Oversized blobs
394
+ * reject 'too_large' immediately (client-side precheck against the same 1
395
+ * MiB cap the server enforces); a host that never answers rejects 'error'
396
+ * after a 15s request timeout.
341
397
  */
342
398
  save(blob: string): Promise<void>;
343
399
  /**
344
400
  * Load the player's saved blob. Resolves with the string, or null when there
345
- * is no save yet. Rejects like save() ('unauthenticated' when logged out).
401
+ * is no save yet. Rejects like save() ('unauthenticated' when logged out,
402
+ * 'error' after the same 15s request timeout when no host answers).
346
403
  */
347
404
  load(): Promise<string | null>;
348
405
  /**
@@ -353,6 +410,15 @@ interface BBArcadeSDK {
353
410
  * display name + avatar only, never ids, emails, or roles.
354
411
  */
355
412
  getPlayer(): Promise<BBArcadePlayer | null>;
413
+ /**
414
+ * Subscribe to CHANGES in the player's display identity (e.g. the player
415
+ * logs in or out mid-session and the host pushes a fresh config). The
416
+ * handler runs only when the identity actually changes — subscribing does
417
+ * not replay the current value; call getPlayer() for that. Receives the
418
+ * same { name, avatarUrl } | null shape as getPlayer(). Returns an
419
+ * unsubscribe function.
420
+ */
421
+ onPlayerChange(handler: (player: BBArcadePlayer | null) => void): () => void;
356
422
  /**
357
423
  * Deterministic A/B variant for this player (even split; stable per
358
424
  * player+game+key). Resolves the alphabetically-first variant as the