@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.
- package/CHANGELOG.md +19 -1
- package/README.md +8 -8
- package/dist/global.js +4 -1
- package/dist/multiplayer.cjs +4 -1
- package/dist/multiplayer.cjs.map +1 -1
- package/dist/multiplayer.js +4 -1
- package/dist/multiplayer.js.map +1 -1
- package/docs/external-authoritative-servers.md +21 -21
- package/docs/game-design-playbook.md +25 -25
- package/docs/llms.txt +147 -102
- package/docs/relay-rooms.md +22 -22
- package/package.json +2 -2
package/dist/multiplayer.js
CHANGED
|
@@ -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) =>
|
|
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 };
|
package/dist/multiplayer.js.map
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
35
|
+
- the room code format and maximum length your server accepts;
|
|
36
36
|
- whether missing room codes may create rooms;
|
|
37
|
-
- any validated, game
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
214
|
+
client supplied player id or reconnect token as the proof of identity.
|
|
215
215
|
|
|
216
|
-
Send the latest complete viewer
|
|
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
|
|
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
|
|
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
|
|
252
|
-
- Test both guest and logged
|
|
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
|
|
257
|
-
- Rate
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
14
|
-
- **Time
|
|
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
|
|
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
|
|
25
|
-
- **Design the "one point short" feeling.** Near
|
|
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
|
|
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
|
|
29
|
+
retention surface on the platform. Everyone plays the SAME level, so the board is fair chat.
|
|
30
30
|
|
|
31
|
-
## Multiplayer design (for room
|
|
31
|
+
## Multiplayer design (for room based games)
|
|
32
32
|
|
|
33
|
-
- **Latency
|
|
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
|
|
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
|
|
39
|
-
- **2-minute rounds, drop
|
|
40
|
-
forgive mid
|
|
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
|
|
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
|
|
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
|
|
58
|
-
targets, no hover dependence, portrait
|
|
59
|
-
- **Performance budget: 60fps on a mid
|
|
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
|
|
62
|
-
fork builds; feature
|
|
63
|
-
- **Cloud saves make your game feel native.** Load on boot, save on checkpoint/game
|
|
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
|
|
74
|
+
- [ ] Playable one thumb on a phone at 60fps
|
|
75
75
|
- [ ] Boots and plays with the SDK fully offline
|