@bountyboard/arcade-sdk 1.3.0 → 1.4.1

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.
@@ -344,7 +344,10 @@ function joinRoom(options) {
344
344
  return new RoomConnection(code, devGrant, joinData, timeoutMs).connect(devGrant);
345
345
  }
346
346
  return requestTicket({ roomId: code }).then(
347
- (grant) => new RoomConnection(code, null, joinData, timeoutMs).connect(grant)
347
+ (grant) => {
348
+ var _a;
349
+ return new RoomConnection((_a = grant.roomId) != null ? _a : code, null, joinData, timeoutMs).connect(grant);
350
+ }
348
351
  );
349
352
  }
350
353
  var multiplayer = { joinRoom };
@@ -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 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":[]}
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(grant.roomId ?? code, null, joinData, timeoutMs).connect(grant)\n );\n}\n\nexport const multiplayer: BBArcadeMultiplayer = { joinRoom };\n"],"mappings":"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","names":[]}
@@ -7,7 +7,7 @@ adapter contract; it does not relay traffic or run a second simulation.
7
7
 
8
8
  External authorities are reviewed and enabled per game. Registration is not
9
9
  self-service. Contact Bounty Board before implementing the adapter so the game
10
- slug, endpoints, room-code rules, ticket keys, and result model can be agreed
10
+ slug, endpoints, room code rules, ticket keys, and result model can be agreed
11
11
  for staging and production.
12
12
 
13
13
  ## What stays authoritative
@@ -23,7 +23,7 @@ Your server remains the only source of truth. It must:
23
23
  - keep endless rooms endless instead of inventing a terminal result.
24
24
 
25
25
  The SDK handles ticket acquisition, WebSocket connection, reconnection, and a
26
- small room-message envelope. It never grants a client authority over identity,
26
+ small room message envelope. It never grants a client authority over identity,
27
27
  state, roles, or results.
28
28
 
29
29
  ## Registration information
@@ -32,9 +32,9 @@ Provide Bounty Board with the following for each environment:
32
32
 
33
33
  - the exact Arcade game slug;
34
34
  - one fixed `wss:` endpoint dedicated to authenticated SDK rooms;
35
- - the room-code format and maximum length your server accepts;
35
+ - the room code format and maximum length your server accepts;
36
36
  - whether missing room codes may create rooms;
37
- - any validated, game-defined `joinData` fields;
37
+ - any validated, game defined `joinData` fields;
38
38
  - whether the game has finite matches or endless sessions; and
39
39
  - an operational contact for key rotation or incident response.
40
40
 
@@ -42,9 +42,9 @@ The registered endpoint must not contain userinfo, query parameters, or a
42
42
  fragment. Use `ws:` only for loopback development. Staging and production need
43
43
  different endpoints and different secrets.
44
44
 
45
- Bounty Board provisions a dedicated ticket-verification secret for the game
45
+ Bounty Board provisions a dedicated ticket verification secret for the game
46
46
  and environment. Store it only on servers. Do not put it in the game bundle,
47
- browser storage, logs, analytics, crash reports, or client-visible environment
47
+ browser storage, logs, analytics, crash reports, or client visible environment
48
48
  variables. Do not reuse credentials from another game or room service.
49
49
 
50
50
  ## Connection flow
@@ -100,7 +100,7 @@ Before accepting a connection, verify all of the following:
100
100
  - `sub` is non-empty; and
101
101
  - required claim types and lengths are valid.
102
102
 
103
- Treat `sub` as an opaque game-scoped identity. Never attempt to map it to a
103
+ Treat `sub` as an opaque game scoped identity. Never attempt to map it to a
104
104
  Bounty Board account or correlate it with another game. `name` and `avatarUrl`
105
105
  are display data, not authorization data. `avatarUrl` may be `null` for any
106
106
  player.
@@ -129,12 +129,12 @@ if (!room.trySend({ type: 'move', x: 1, y: 0 })) {
129
129
  }
130
130
  ```
131
131
 
132
- Script-tag builds use `BBArcade.multiplayer.joinRoom(...)`.
132
+ Script tag builds use `BBArcade.multiplayer.joinRoom(...)`.
133
133
 
134
134
  `joinData` is an optional JSON object capped at 1 KiB. Validate every field,
135
135
  reject unknown or oversized values, and never accept credentials or identity
136
136
  claims from it. If your server distinguishes create from join, use a documented
137
- game-defined intent field and rate-limit room creation. That field is still
137
+ game defined intent field and rate limit room creation. That field is still
138
138
  untrusted and grants no role or permission by itself.
139
139
 
140
140
  ## WebSocket URL
@@ -147,7 +147,7 @@ wss://multiplayer.example.com/bountyboard?ticket=<encoded>&join=<encoded-json>
147
147
 
148
148
  The `join` parameter is omitted when no `joinData` was supplied. Keep this path
149
149
  separate from any anonymous or legacy socket endpoint so a guessed room code
150
- cannot cross the authentication boundary. Redact or disable request-URI logging
150
+ cannot cross the authentication boundary. Redact or disable request URI logging
151
151
  on this route because the short-lived ticket is in the query string.
152
152
 
153
153
  ## SDK room envelope
@@ -174,7 +174,7 @@ Send authoritative state snapshots as:
174
174
  }
175
175
  ```
176
176
 
177
- Send game-defined transient events without changing their payload:
177
+ Send game defined transient events without changing their payload:
178
178
 
179
179
  ```json
180
180
  { "t": "event", "data": { "type": "round_started", "round": 2 } }
@@ -204,23 +204,23 @@ Reject an invalid ticket before `welcome` with an error and close the socket:
204
204
  ```
205
205
 
206
206
  Use `rejected` for invalid admission or `joinData`. After admission, error codes
207
- remain game-defined strings and are surfaced through `room.on('error', ...)`.
207
+ remain game defined strings and are surfaced through `room.on('error', ...)`.
208
208
 
209
209
  ## Reconnection
210
210
 
211
211
  The SDK automatically makes a small number of reconnect attempts. It requests
212
212
  a fresh ticket for the same room and reuses the original `joinData`. Rebind the
213
213
  seat using the verified `(ticket.sub, ticket.roomId)` pair. Never trust a
214
- client-supplied player id or reconnect token as the proof of identity.
214
+ client supplied player id or reconnect token as the proof of identity.
215
215
 
216
- Send the latest complete viewer-safe state in the new `welcome` frame. If your
216
+ Send the latest complete viewer safe state in the new `welcome` frame. If your
217
217
  client uses prediction, include the last processed input sequence in that state
218
218
  so it can discard acknowledged inputs. Clear held controls while the socket is
219
219
  absent.
220
220
 
221
221
  ## Finite matches and endless rooms
222
222
 
223
- Only a server-authoritative finite game may emit `match_end`:
223
+ Only a server authoritative finite game may emit `match_end`:
224
224
 
225
225
  ```json
226
226
  {
@@ -239,7 +239,7 @@ Only a server-authoritative finite game may emit `match_end`:
239
239
 
240
240
  The client receives this as `room.on('end', ({ results }) => ...)`. Persistent
241
241
  Bounty Board results, when enabled, use a separately provisioned
242
- server-to-server reporting credential. Never accept a client-originated result
242
+ server to server reporting credential. Never accept a client originated result
243
243
  or reporting credential.
244
244
 
245
245
  Endless rooms must not emit `match_end`. Death, respawn, a round transition, or
@@ -248,18 +248,18 @@ a player leaving is not automatically a terminal match result.
248
248
  ## Production checklist
249
249
 
250
250
  - Use an isolated staging endpoint and secrets before production.
251
- - Confirm tampered, expired, wrong-slug, and wrong-room tickets fail closed.
252
- - Test both guest and logged-in opaque subjects.
251
+ - Confirm tampered, expired, wrong slug, and wrong room tickets fail closed.
252
+ - Test both guest and logged in opaque subjects.
253
253
  - Reject malformed, unknown, or oversized `joinData`.
254
254
  - Send `welcome` only after ticket and admission validation succeeds.
255
255
  - Verify every snapshot is safe for its specific viewer.
256
- - Test disconnect, held-input cleanup, fresh-ticket reconnect, and `leave()`.
257
- - Rate-limit upgrades, room creation, joins, and inputs.
256
+ - Test disconnect, held input cleanup, fresh ticket reconnect, and `leave()`.
257
+ - Rate limit upgrades, room creation, joins, and inputs.
258
258
  - Redact tickets and join payloads from access logs and error telemetry.
259
259
  - Rotate the dedicated ticket secret without reusing another environment's key.
260
260
  - Emit final results only from a real authoritative terminal condition.
261
261
  - Keep the game playable when multiplayer is unsupported or temporarily down.
262
262
 
263
263
  For SDK integration basics, see https://www.bountyboard.gg/arcade/sdk. For the
264
- agent-readable API contract, see
264
+ agent readable API contract, see
265
265
  https://www.bountyboard.gg/arcade/sdk/llms.txt.
@@ -1,17 +1,17 @@
1
1
  # Bounty Board game design playbook (the "design brain")
2
2
 
3
3
  What actually performs on the Bounty Board arcade, distilled from operating it. Read this
4
- BEFORE designing a new game or porting one in — the SDK wiring is the easy part; these choices
4
+ BEFORE designing a new game or porting one in. The SDK wiring is the easy part; these choices
5
5
  decide whether the game earns plays, retention, and revenue.
6
6
 
7
- > Draft co-owned with our partner studios — challenge anything here with data.
7
+ > Draft co-owned with our partner studios. Challenge anything here with data.
8
8
 
9
9
  ## Session shape
10
10
 
11
- - **Target a 60–180 second core loop.** Arcade traffic arrives mid-browse; games that deliver a
11
+ - **Target a 60 to 180 second core loop.** Arcade traffic arrives mid browse; games that deliver a
12
12
  complete emotional arc (start → tension → payoff) in under three minutes get replays, and
13
- replays drive feed ranking. Longer-form games need checkpointed sessions via cloud saves.
14
- - **Time-to-first-input under 5 seconds.** Call `gameLoadingFinished()` honestly; players who
13
+ replays drive feed ranking. Longer form games need checkpointed sessions via cloud saves.
14
+ - **Time to first input under 5 seconds.** Call `gameLoadingFinished()` honestly; players who
15
15
  bounce on a spinner never come back. Defer heavy assets past the first playable moment.
16
16
  - **Instant restart.** Death → new run should be ONE input and under a second. Restart friction
17
17
  is the top killer of "one more run".
@@ -19,31 +19,31 @@ decide whether the game earns plays, retention, and revenue.
19
19
  ## Score design (this is leaderboard design)
20
20
 
21
21
  - **Scores must be integers with a meaningful gradient.** A good score curve separates a casual
22
- run from a great one by 10–100x, not 2x — that's what makes a board worth climbing.
22
+ run from a great one by 10 to 100x, not 2x. That's what makes a board worth climbing.
23
23
  - **Skill ceiling over grind ceiling.** If score scales with time played rather than skill, the
24
- board saturates and goes stale. Cap or decay pure-survival scoring; reward risk.
25
- - **Design the "one point short" feeling.** Near-miss visibility (show the player's best and the
24
+ board saturates and goes stale. Cap or decay pure survival scoring; reward risk.
25
+ - **Design the "one point short" feeling.** Near miss visibility (show the player's best and the
26
26
  next board rank in-game via your own UI) measurably lifts replays.
27
- - **Daily mode**: if your game has procedural content, ship a shared-seed daily run and submit
27
+ - **Daily mode**: if your game has procedural content, ship a shared seed daily run and submit
28
28
  it with `{ mode: 'daily' }`. Daily boards reset at midnight UTC and are the strongest
29
- retention surface on the platform — everyone plays the SAME level, so the board is fair chat.
29
+ retention surface on the platform. Everyone plays the SAME level, so the board is fair chat.
30
30
 
31
- ## Multiplayer design (for room-based games)
31
+ ## Multiplayer design (for room based games)
32
32
 
33
- - **Latency-tolerant mechanics win.** Positional games at 10–20Hz snapshots with client
33
+ - **Latency tolerant mechanics win.** Positional games at 10 to 20Hz snapshots with client
34
34
  interpolation feel great for chase/tag/social deduction; twitch duels don't. Design around
35
- prediction-friendly movement (momentum, grid steps) rather than instant-hit actions.
35
+ prediction friendly movement (momentum, grid steps) rather than instant hit actions.
36
36
  - **Information asymmetry is a server feature.** The room server sends each player only what
37
37
  they may know (hiders invisible to the seeker). Lean into designs where hidden information IS
38
- the game — it's cheat-proof by construction here.
39
- - **2-minute rounds, drop-in lobbies.** Rooms fill from friends sharing codes; short rounds
40
- forgive mid-round joins as spectators and keep groups cycling.
38
+ the game. It's cheat proof by construction here.
39
+ - **2-minute rounds, drop in lobbies.** Rooms fill from friends sharing codes; short rounds
40
+ forgive mid round joins as spectators and keep groups cycling.
41
41
  - **Send inputs, not outcomes.** If your design needs the client to decide who got tagged, the
42
- design is wrong — move the rule server-side.
42
+ design is wrong. Move the rule server-side.
43
43
 
44
44
  ## Monetization etiquette (rewarded ads)
45
45
 
46
- - **Ads are a player's trade, never a toll.** Best-performing placements: revive ("continue this
46
+ - **Ads are a player's trade, never a toll.** Best performing placements: revive ("continue this
47
47
  run?"), doubler ("2x this run's coins"), cosmetic unlock. Never gate core progression.
48
48
  - **One organic placement beats three pushy ones.** Interrupting flow trains players to leave;
49
49
  prepare one rewarded placement at a natural fail state and offer it once.
@@ -54,13 +54,13 @@ decide whether the game earns plays, retention, and revenue.
54
54
 
55
55
  ## Platform fit
56
56
 
57
- - **Mobile-first inputs.** Most arcade sessions are touch. One-thumb controls, generous hit
58
- targets, no hover dependence, portrait-friendly if possible. Keyboard is the enhancement.
59
- - **Performance budget: 60fps on a mid-range phone.** Cap DPR, pool objects, avoid layout
57
+ - **Mobile first inputs.** Most arcade sessions are touch. One thumb controls, generous hit
58
+ targets, no hover dependence, portrait friendly if possible. Keyboard is the enhancement.
59
+ - **Performance budget: 60fps on a mid range phone.** Cap DPR, pool objects, avoid layout
60
60
  thrash. Players don't report jank, they just leave.
61
- - **Own your standalone build.** The same bundle must run off-platform (the SDK no-ops). Don't
62
- fork builds; feature-detect through the SDK's own fallbacks.
63
- - **Cloud saves make your game feel native.** Load on boot, save on checkpoint/game-over, and
61
+ - **Own your standalone build.** The same bundle must run off platform (the SDK no-ops). Don't
62
+ fork builds; feature detect through the SDK's own fallbacks.
63
+ - **Cloud saves make your game feel native.** Load on boot, save on checkpoint/game over, and
64
64
  greet returning players with their progress (pair with `getPlayer()` for the name). Hosted
65
65
  builds have no localStorage, so wire this early, not as a retrofit.
66
66
 
@@ -71,5 +71,5 @@ decide whether the game earns plays, retention, and revenue.
71
71
  - [ ] The score of a great run embarrasses the score of a lucky run
72
72
  - [ ] Daily mode if content is procedural
73
73
  - [ ] Rewarded placement is a trade the player initiates
74
- - [ ] Playable one-thumb on a phone at 60fps
74
+ - [ ] Playable one thumb on a phone at 60fps
75
75
  - [ ] Boots and plays with the SDK fully offline