@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.
@@ -1,16 +1,20 @@
1
1
  import {
2
2
  BBArcade
3
- } from "./chunk-OWOESH4X.js";
3
+ } from "./chunk-PYW5F45R.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;
8
10
  var MAX_JOIN_DATA_BYTES = 1024;
11
+ var SEAT_FULL_CLOSE_CODE = 4005;
9
12
  var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
10
13
  var CODE_LENGTH = 4;
11
- function sdkError(code) {
14
+ function sdkError(code, detail) {
12
15
  const err = new Error(code);
13
16
  err.code = code;
17
+ if (detail) err.detail = detail;
14
18
  return err;
15
19
  }
16
20
  function randomChars(length) {
@@ -27,23 +31,13 @@ function randomChars(length) {
27
31
  function generateCode() {
28
32
  return randomChars(CODE_LENGTH);
29
33
  }
34
+ var cachedTextEncoder = null;
30
35
  function utf8ByteLength(value) {
31
- let bytes = 0;
32
- for (let i = 0; i < value.length; i++) {
33
- const code = value.charCodeAt(i);
34
- if (code < 128) bytes += 1;
35
- else if (code < 2048) bytes += 2;
36
- else if (code >= 55296 && code <= 56319 && i + 1 < value.length) {
37
- const next = value.charCodeAt(i + 1);
38
- if (next >= 56320 && next <= 57343) {
39
- bytes += 4;
40
- i += 1;
41
- } else {
42
- bytes += 3;
43
- }
44
- } else bytes += 3;
36
+ if (typeof TextEncoder !== "undefined") {
37
+ cachedTextEncoder = cachedTextEncoder || new TextEncoder();
38
+ return cachedTextEncoder.encode(value).length;
45
39
  }
46
- return bytes;
40
+ return new Blob([value]).size;
47
41
  }
48
42
  function serializeJoinData(data) {
49
43
  if (data === void 0) return null;
@@ -59,23 +53,29 @@ function serializeJoinData(data) {
59
53
  }
60
54
  return serialized;
61
55
  }
62
- function requestTicket(roomId) {
56
+ function requestTicket(payload) {
63
57
  const request = BBArcade._request;
64
58
  if (typeof request !== "function") return Promise.reject(sdkError("unsupported"));
65
- return request("mp_ticket", { roomId }).then((grant) => {
59
+ return request("mp_ticket", payload).then((grant) => {
66
60
  const g = grant;
67
61
  if (!g || typeof g.ticket !== "string" || typeof g.roomUrl !== "string") {
68
62
  throw sdkError("error");
69
63
  }
70
- 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
+ };
71
70
  });
72
71
  }
73
72
  var RoomConnection = class {
74
- constructor(code, devGrant, joinData) {
73
+ constructor(code, devGrant, joinData, welcomeTimeoutMs = WELCOME_TIMEOUT_MS) {
75
74
  this.playerId = "";
76
75
  this.players = [];
77
76
  this.state = null;
78
77
  this.connected = false;
78
+ this.latencyMs = null;
79
79
  this.ws = null;
80
80
  // Keyed by event name; values are erased to (unknown) => void internally and
81
81
  // retyped at the on()/emit() boundary, which keeps the public API strictly
@@ -88,15 +88,20 @@ var RoomConnection = class {
88
88
  // or the welcome timed out — permanent outcomes that must not reconnect.
89
89
  this.terminal = false;
90
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;
91
94
  this.code = code;
92
95
  this.devGrant = devGrant;
93
96
  this.joinData = joinData;
97
+ this.welcomeTimeoutMs = welcomeTimeoutMs;
94
98
  }
95
99
  /** Connect and resolve on the server's welcome (post-ticket-verification). */
96
100
  connect(grant) {
97
101
  return new Promise((resolve, reject) => {
98
102
  let settled = false;
99
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();
100
105
  let ws;
101
106
  try {
102
107
  ws = new WebSocket(url);
@@ -107,13 +112,15 @@ var RoomConnection = class {
107
112
  const welcomeTimer = setTimeout(() => {
108
113
  if (settled) return;
109
114
  settled = true;
115
+ this.pendingConnect = null;
110
116
  this.terminal = true;
111
117
  try {
112
118
  ws.close();
113
119
  } catch (err) {
114
120
  }
115
121
  reject(sdkError("error"));
116
- }, WELCOME_TIMEOUT_MS);
122
+ }, this.welcomeTimeoutMs);
123
+ this.pendingConnect = { reject, timer: welcomeTimer };
117
124
  ws.onmessage = (event) => {
118
125
  var _a, _b, _c, _d, _e;
119
126
  let msg;
@@ -124,14 +131,23 @@ var RoomConnection = class {
124
131
  }
125
132
  if (!msg || typeof msg.t !== "string") return;
126
133
  if (msg.t === "welcome") {
134
+ if (this.intentionalClose) {
135
+ try {
136
+ ws.close();
137
+ } catch (err) {
138
+ }
139
+ return;
140
+ }
127
141
  const reconnected = this.reconnectAttempts > 0;
128
142
  this.playerId = String((_a = msg.playerId) != null ? _a : "");
129
143
  this.players = Array.isArray(msg.players) ? msg.players : [];
130
144
  this.state = (_b = msg.state) != null ? _b : null;
131
145
  this.connected = true;
132
146
  this.reconnectAttempts = 0;
147
+ this.latencyMs = Math.max(0, Date.now() - connectStartedAt);
133
148
  if (!settled) {
134
149
  settled = true;
150
+ this.pendingConnect = null;
135
151
  clearTimeout(welcomeTimer);
136
152
  resolve(this);
137
153
  }
@@ -173,21 +189,24 @@ var RoomConnection = class {
173
189
  if (!settled) {
174
190
  settled = true;
175
191
  this.terminal = true;
192
+ this.pendingConnect = null;
176
193
  clearTimeout(welcomeTimer);
177
194
  try {
178
195
  ws.close();
179
196
  } catch (err) {
180
197
  }
181
- reject(sdkError(code === "unauthenticated" ? "unauthenticated" : "rejected"));
198
+ reject(sdkError(code === "unauthenticated" ? "unauthenticated" : "rejected", code));
182
199
  return;
183
200
  }
184
201
  this.emit("error", { code });
185
202
  }
186
203
  };
187
- ws.onclose = () => {
204
+ ws.onclose = (event) => {
188
205
  this.connected = false;
206
+ if (event && event.code === SEAT_FULL_CLOSE_CODE) this.terminal = true;
189
207
  if (!settled) {
190
208
  settled = true;
209
+ this.pendingConnect = null;
191
210
  clearTimeout(welcomeTimer);
192
211
  reject(sdkError("error"));
193
212
  return;
@@ -216,7 +235,7 @@ var RoomConnection = class {
216
235
  await new Promise((r) => setTimeout(r, 400 * this.reconnectAttempts));
217
236
  if (this.intentionalClose || this.ended || this.terminal) return;
218
237
  try {
219
- const grant = (_a = this.devGrant) != null ? _a : await requestTicket(this.code);
238
+ const grant = (_a = this.devGrant) != null ? _a : await requestTicket({ roomId: this.code });
220
239
  if (this.intentionalClose || this.ended || this.terminal) return;
221
240
  await this.connect(grant);
222
241
  if (this.intentionalClose) this.leave();
@@ -251,6 +270,12 @@ var RoomConnection = class {
251
270
  var _a;
252
271
  this.intentionalClose = true;
253
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
+ }
254
279
  try {
255
280
  (_a = this.ws) == null ? void 0 : _a.close(1e3);
256
281
  } catch (err) {
@@ -270,23 +295,57 @@ var RoomConnection = class {
270
295
  }
271
296
  }
272
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
+ }
273
318
  function joinRoom(options) {
274
319
  const opts = options != null ? options : {};
275
320
  if (typeof window === "undefined" || typeof WebSocket === "undefined") {
276
321
  return Promise.reject(sdkError("unsupported"));
277
322
  }
278
- const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();
279
323
  let joinData;
280
324
  try {
281
325
  joinData = serializeJoinData(opts.joinData);
282
326
  } catch (err) {
283
327
  return Promise.reject(err);
284
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
+ }
336
+ const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();
285
337
  if (opts.roomUrl && opts.ticket) {
286
- const devGrant = { ticket: opts.ticket, roomUrl: opts.roomUrl, expiresAt: 0 };
287
- return new RoomConnection(code, devGrant, joinData).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);
288
345
  }
289
- return requestTicket(code).then((grant) => new RoomConnection(code, null, joinData).connect(grant));
346
+ return requestTicket({ roomId: code }).then(
347
+ (grant) => new RoomConnection(code, null, joinData, timeoutMs).connect(grant)
348
+ );
290
349
  }
291
350
  var multiplayer = { joinRoom };
292
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;\nconst MAX_JOIN_DATA_BYTES = 1_024;\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\nfunction utf8ByteLength(value: string): number {\n let bytes = 0;\n for (let i = 0; i < value.length; i++) {\n const code = value.charCodeAt(i);\n if (code < 0x80) bytes += 1;\n else if (code < 0x800) bytes += 2;\n else if (code >= 0xd800 && code <= 0xdbff && i + 1 < value.length) {\n const next = value.charCodeAt(i + 1);\n if (next >= 0xdc00 && next <= 0xdfff) {\n bytes += 4;\n i += 1;\n } else {\n bytes += 3;\n }\n } else bytes += 3;\n }\n return bytes;\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(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 }).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 connected = false;\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\n constructor(code: string, devGrant: TicketGrant | null, joinData: string | null) {\n this.code = code;\n this.devGrant = devGrant;\n this.joinData = joinData;\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 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 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 if (!settled) {\n settled = true;\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 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 this.connected = false;\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('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(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 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 let joinData: string | null;\n try {\n joinData = serializeJoinData(opts.joinData);\n } catch (err) {\n return Promise.reject(err);\n }\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, joinData).connect(devGrant);\n }\n\n return requestTicket(code).then(grant => new RoomConnection(code, null, joinData).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":"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","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 {
@@ -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 };