diff --git a/docs/jackbox-ecast-api.md b/docs/jackbox-ecast-api.md index 2f2617c..24e66c7 100644 --- a/docs/jackbox-ecast-api.md +++ b/docs/jackbox-ecast-api.md @@ -158,11 +158,11 @@ Room information with audience data and join role. Uses a different response for ### GET /api/v2/rooms/{code}/connections -Active connection count. Includes all connection types (host, players, audience, shards). +Total allocated slot count. Includes all connection types (host, players, audience, shards). **Response:** `{"ok": true, "body": {"connections": 3}}` -> **Important:** This is the most reliable REST-based way to get a real-time count of connected entities. Subtract 1 for the host connection. Note that ungracefully disconnected clients may linger for up to 30-60 seconds before the server detects the dead connection. +> **Important:** This counts allocated **slots**, not currently-active WebSocket connections. Jackbox holds player slots open indefinitely for reconnection — there is no concept of "leaving" a game. A player who closes their browser tab still occupies a slot. The count only decreases if the room itself is destroyed. Therefore, `connections - 1` gives the number of players who have **ever joined** the room, not the number currently online. ### POST /api/v2/rooms @@ -235,7 +235,7 @@ Only `/api/v2/` is active. Requests to `/api/v1/`, `/api/v3/`, or `/api/v4/` ret ### Connection -**Player connection URL:** +**Player connection URL (new join):** ``` wss://{host}/api/v2/rooms/{code}/play?role=player&name={name}&userId={userId}&format=json @@ -248,6 +248,21 @@ wss://{host}/api/v2/rooms/{code}/play?role=player&name={name}&userId={userId}&fo | `userId` | Yes | Unique user identifier | | `format` | Yes | `json` (only known format) | +**Player reconnection URL (existing session):** + +``` +wss://{host}/api/v2/rooms/{code}/play?role=player&name={name}&format=json&secret={secret}&id={id} +``` + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `secret` | Yes | Session secret from original `client/welcome` response | +| `id` | Yes | Session ID from original `client/welcome` response | +| `name` | Yes | Display name (can differ from original) | +| `format` | Yes | `json` | + +On reconnection, the server returns `client/welcome` with `reconnect: true` and the same `id` and `secret`. The player resumes their existing slot rather than consuming a new one. + **Audience connection URL:** ``` @@ -316,13 +331,13 @@ const ws = new WebSocket(url, ['ecast-v0'], { | Opcode | Direction | Description | |--------|-----------|-------------| | `client/welcome` | S→C | Sent immediately after connection. Contains initial state, entities, connected users. | -| `client/connected` | S→C | Notification that another client connected. | -| `client/disconnected` | S→C | Notification that another client disconnected. | +| `client/connected` | S→C | Notification that another client connected (see note below). | +| `client/disconnected` | S→C | Notification that another client disconnected (see note below). | | `client/kicked` | S→C | Notification that you were kicked from the room. | | `ok` | S→C | Generic success response to a client request. | | `error` | S→C | Error response. Contains `code` and `msg`. | -> **Note on `client/connected` and `client/disconnected`:** These opcodes exist in the client JavaScript but were not observed during testing with player-role connections. They may only be delivered to the host connection, or may require specific server configuration. +> **Important: `client/connected` and `client/disconnected` are NOT delivered to player connections.** Extensive testing across multiple rooms confirmed that these opcodes never fire for players joining, leaving, or reconnecting — even with graceful WebSocket close. They exist in the client JavaScript and may be delivered to the **host** connection only, but this could not be verified. Players learn about joins exclusively through `textDescriptions` entity updates ("X joined."). There is **no notification mechanism for player disconnection** — Jackbox's design holds player slots open for reconnection indefinitely, and the concept of "leaving" does not exist in the UI or protocol. #### Entity Operations (Client → Server) @@ -418,6 +433,7 @@ const ws = new WebSocket(url, ['ecast-v0'], { | 2003 | `invalid opcode` | Unrecognized opcode sent | | 2007 | `entity value is not of type {expected}` | Type mismatch (e.g., using `text/get` on an `object` entity) | | 2023 | `permission denied` | Operation not allowed for this client's role | +| 2027 | `the room has already been closed` | Room ended but REST API may still return room info (REST/WS state divergence) | | — | `only the host can close the room` | Specific error for `room/exit` from non-host | --- @@ -624,16 +640,37 @@ The `room` entity transitions through these states: ### Reconnection -If a player reconnects with the same `deviceId` (browser cookie/localStorage), the server sends `client/welcome` with: +Players can reconnect to their existing session using the `secret` and `id` from their original `client/welcome` message. There are two reconnection methods: + +**Method 1 — Via `secret` and `id` query params (programmatic):** + +``` +wss://{host}/api/v2/rooms/{code}/play?role=player&name={name}&format=json&secret={secret}&id={id} +``` + +**Method 2 — Via `deviceId` (browser-based):** + +If the browser retains the same `deviceId` (stored in cookies/localStorage), the server automatically matches the reconnecting client to their previous session. The jackbox.tv UI shows a "RECONNECT" button instead of "PLAY" when it detects an existing session. + +In both cases, the server responds with `client/welcome` containing: - `reconnect: true` - Same `id` and `secret` as the original session -- Full current state of all entities (not just deltas) +- Full current state of all entities (complete snapshot, not deltas) -### Disconnection +### Disconnection (or lack thereof) -Graceful disconnection (WebSocket close code 1000) is processed immediately. Ungraceful disconnection (process kill, network drop) takes 30-60 seconds for the server to detect. +**Jackbox has no concept of "leaving" or "disconnecting."** When a player's WebSocket closes (gracefully or otherwise), their player slot is held open indefinitely for reconnection. From the game's perspective, the player is still "in the game" — just temporarily unreachable. -The `connections` REST endpoint reflects connection count changes. The `here` field in `client/welcome` shows stale connections until the server cleans them up. +Key behaviors: +- The `connections` REST endpoint count does **not** decrease when a player's WebSocket closes. +- The `here` field in `client/welcome` continues to list disconnected players. +- No `client/disconnected` event is sent to other players. +- No `textDescriptions` update is generated for disconnections. +- The player's `player:{id}` entity remains intact with its full state. + +The only way a player slot is freed is if the room itself is destroyed (host closes the game) or the room times out. + +> **REST/WebSocket state divergence:** A room can be reported as existing by the REST API (`GET /rooms/{code}` returns 200) while the WebSocket layer considers it ended (error code 2027). Always verify with a WebSocket connection attempt if the REST response seems stale. --- @@ -645,38 +682,53 @@ Answers to common questions about managing players and rooms. **Join detection:** - **WebSocket (best):** Watch for `textDescriptions` entity updates with `category: "TEXT_DESCRIPTION_PLAYER_JOINED"` or `"TEXT_DESCRIPTION_PLAYER_JOINED_VIP"`. -- **WebSocket (initial):** The `here` field in `client/welcome` lists all currently connected users on first connect. -- **REST (polling):** `GET /api/v2/rooms/{code}/connections` returns the total connection count. +- **WebSocket (initial):** The `here` field in `client/welcome` lists all registered players (see caveats below). +- **REST (polling):** `GET /api/v2/rooms/{code}/connections` — count increases when a new player joins. -**Leave detection:** -- **REST (polling):** Poll `GET /api/v2/rooms/{code}/connections` and compare counts. This is currently the most reliable method. -- **WebSocket:** `client/disconnected` opcode exists in the protocol but was not reliably observed during testing. May only be sent to the host. -- **Caveat:** Ungraceful disconnects can take 30-60 seconds to be reflected. +**Leave detection — the hard problem:** + +Jackbox does not have a concept of "leaving." Player slots are held open indefinitely for reconnection. There is: +- **No** `client/disconnected` event sent to players. +- **No** change in the `connections` REST count when a player's WebSocket closes. +- **No** `textDescriptions` update for disconnections. +- **No** change in the `here` field — disconnected players remain listed. + +In other words, **the ecast API provides no mechanism to detect that a player has disconnected.** The game treats all players as permanently in the room once joined. This is by design — the Jackbox UI has no "leave" button; a player who closes their browser can always reconnect. + +**Possible workarounds:** +- Monitor game-specific player entity state changes (e.g., a drawing game might detect timeouts for players who don't submit). +- The game host (console) may have internal logic to handle absent players, but this is not exposed through the ecast API. ### How to count players -**Method 1 — REST `/connections` (simplest, recommended):** +**Counting total player slots (who has ever joined):** + +**Method 1 — REST `/connections`:** ``` GET /api/v2/rooms/{code}/connections → {"connections": N} ``` -`N` includes host (1) + players + audience + internal connections (shards). To get player count: `N - 1 - audienceCount - shardCount`. For a rough estimate without audience: `N - 1`. +`N` = host(1) + all player slots ever allocated + audience + shards. This count **does not decrease** when players disconnect. `N - 1` gives the approximate number of player slots allocated. -**Method 2 — WebSocket `here` field (most accurate at connection time):** +**Method 2 — WebSocket `here` field:** The `client/welcome` message includes a `here` object mapping session IDs to roles: ```json { "1": {"id": 1, "roles": {"host": {}}}, - "2": {"id": 2, "roles": {"player": {"name": "PROBE1"}}}, - "3": {"id": 3, "roles": {"player": {"name": "PROBE3"}}}, - "4": {"id": 4, "roles": {"shard": {}}} + "2": {"id": 2, "roles": {"player": {"name": "P1"}}}, + "3": {"id": 3, "roles": {"player": {"name": "P2"}}}, + "4": {"id": 4, "roles": {"player": {"name": "P3"}}} } ``` -Count entries where `roles` contains `player`. This gives exact player count at connection time but doesn't update in real-time. +Count entries where `roles` contains `player`. This includes **both connected and disconnected** players — `here` reflects all allocated slots, not just active WebSocket connections. The `here` field excludes the connecting client itself (you don't see yourself). + +**Counting currently-connected players:** + +There is no reliable API method to distinguish between connected and disconnected players. The `connections` count and `here` field both include disconnected players whose slots are held for reconnection. **Method 3 — WebSocket `room/get-audience` (audience count only):** @@ -859,18 +911,20 @@ The `reconnect: true` flag distinguishes reconnections from new joins. Same `id` } ``` -### Connected User Roles in `here` +### Registered User Roles in `here` ```json { "1": {"id": 1, "roles": {"host": {}}}, - "2": {"id": 2, "roles": {"player": {"name": "PROBE1"}}}, - "3": {"id": 3, "roles": {"player": {"name": "PROBE3"}}}, - "4": {"id": 4, "roles": {"shard": {}}} + "2": {"id": 2, "roles": {"player": {"name": "P1"}}}, + "3": {"id": 3, "roles": {"player": {"name": "P2"}}}, + "4": {"id": 4, "roles": {"player": {"name": "P3"}}} } ``` Known roles: - `host` — The game console/PC running the game - `player` — A player joined via jackbox.tv (includes `name`) -- `shard` — Internal connection (possibly audience aggregator) +- `shard` — Internal connection (appears when audience is connected, possibly audience aggregator) + +> **`here` includes disconnected players.** Tested by connecting 3 players, closing all their WebSockets, then having a new player connect. The new player's `here` field listed all 3 previous players despite none of them having active WebSocket connections. This is consistent with Jackbox's slot-reservation model where player slots persist for reconnection. diff --git a/scripts/ws-lifecycle-test.js b/scripts/ws-lifecycle-test.js new file mode 100644 index 0000000..f4e5bd5 --- /dev/null +++ b/scripts/ws-lifecycle-test.js @@ -0,0 +1,157 @@ +#!/usr/bin/env node +const WebSocket = require('ws'); +const https = require('https'); + +const ROOM = process.argv[2] || 'SCWX'; +const HOST = 'ecast-prod-use2.jackboxgames.com'; + +function ts() { return new Date().toISOString().slice(11, 23); } +function log(tag, ...args) { console.log(`[${ts()}][${tag}]`, ...args); } + +function connect(name, opts = {}) { + return new Promise((resolve, reject) => { + let params = `role=player&name=${encodeURIComponent(name)}&format=json`; + if (opts.secret) { + params += `&secret=${opts.secret}`; + params += `&id=${opts.id}`; + } else { + params += `&userId=${name}-${Date.now()}`; + } + const url = `wss://${HOST}/api/v2/rooms/${ROOM}/play?${params}`; + log(name, 'Connecting:', url); + + const ws = new WebSocket(url, ['ecast-v0'], { + headers: { 'Origin': 'https://jackbox.tv' } + }); + + const allMsgs = []; + ws.on('message', (raw) => { + const m = JSON.parse(raw.toString()); + allMsgs.push(m); + + if (m.opcode === 'client/welcome') { + const r = m.result; + const hereList = r.here ? Object.entries(r.here).map(([k, v]) => { + const role = Object.keys(v.roles)[0]; + const detail = v.roles.player ? `(${v.roles.player.name})` : ''; + return `${k}:${role}${detail}`; + }).join(', ') : 'null'; + log(name, `WELCOME id=${r.id} reconnect=${r.reconnect} secret=${r.secret} here=[${hereList}]`); + resolve({ ws, id: r.id, secret: r.secret, msgs: allMsgs, name }); + } else if (m.opcode === 'client/connected') { + const r = m.result; + log(name, `*** CLIENT/CONNECTED id=${r.id} userId=${r.userId} name=${r.name} role=${JSON.stringify(r.roles || r.role)}`); + } else if (m.opcode === 'client/disconnected') { + const r = m.result; + log(name, `*** CLIENT/DISCONNECTED id=${r.id} role=${JSON.stringify(r.roles || r.role)}`); + } else if (m.opcode === 'client/kicked') { + log(name, `*** CLIENT/KICKED:`, JSON.stringify(m.result)); + } else if (m.opcode === 'error') { + log(name, `ERROR code=${m.result.code}: ${m.result.msg}`); + reject(new Error(m.result.msg)); + } else if (m.opcode === 'object') { + const r = m.result; + if (r.key === 'room') { + log(name, `ROOM state=${r.val?.state} lobbyState=${r.val?.lobbyState} gameCanStart=${r.val?.gameCanStart} gameFinished=${r.val?.gameFinished} v${r.version}`); + } else if (r.key === 'textDescriptions') { + const latest = r.val?.latestDescriptions?.[0]; + if (latest) log(name, `TEXT: "${latest.text}" (${latest.category})`); + } else if (r.key?.startsWith('player:')) { + log(name, `PLAYER ${r.key} state=${r.val?.state || 'init'} v${r.version}`); + } else { + log(name, `ENTITY ${r.key} v${r.version}`); + } + } else if (m.opcode === 'ok') { + log(name, `OK seq response`); + } else if (m.opcode === 'room/get-audience') { + log(name, `AUDIENCE connections=${m.result?.connections}`); + } else { + log(name, `OTHER op=${m.opcode}`, JSON.stringify(m.result).slice(0, 200)); + } + }); + + ws.on('close', (code, reason) => { + log(name, `CLOSED code=${code} reason=${reason.toString()}`); + }); + ws.on('error', (e) => { + log(name, `WS_ERROR: ${e.message}`); + reject(e); + }); + }); +} + +function wait(ms) { return new Promise(r => setTimeout(r, ms)); } + +async function main() { + log('TEST', `=== Phase 1: Connect P1 to ${ROOM} ===`); + const p1 = await connect('P1'); + log('TEST', `P1 connected as id=${p1.id}, secret=${p1.secret}`); + + log('TEST', `=== Phase 2: Check connections ===`); + const connBefore = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`); + log('TEST', `Connections: ${JSON.stringify(connBefore)}`); + + await wait(2000); + + log('TEST', `=== Phase 3: Connect P2 (watch P1 for client/connected) ===`); + const p2 = await connect('P2'); + log('TEST', `P2 connected as id=${p2.id}, secret=${p2.secret}`); + + const connAfterJoin = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`); + log('TEST', `Connections after P2 join: ${JSON.stringify(connAfterJoin)}`); + + await wait(2000); + + log('TEST', `=== Phase 4: P2 gracefully disconnects (watch P1 for client/disconnected) ===`); + p2.ws.close(1000, 'test-disconnect'); + await wait(3000); + + const connAfterLeave = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`); + log('TEST', `Connections after P2 leave: ${JSON.stringify(connAfterLeave)}`); + + log('TEST', `=== Phase 5: Reconnect P2 using secret ===`); + const p2r = await connect('P2-RECONNECT', { secret: p2.secret, id: p2.id }); + log('TEST', `P2 reconnected as id=${p2r.id}, reconnect should be true`); + + const connAfterReconnect = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`); + log('TEST', `Connections after P2 reconnect: ${JSON.stringify(connAfterReconnect)}`); + + await wait(2000); + + log('TEST', `=== Phase 6: Connect P3 (reach 3 players for CanStart) ===`); + const p3 = await connect('P3'); + log('TEST', `P3 connected as id=${p3.id}`); + + await wait(2000); + + log('TEST', `=== Phase 7: Query audience count ===`); + p1.ws.send(JSON.stringify({ seq: 100, opcode: 'room/get-audience', params: {} })); + await wait(1000); + + log('TEST', `=== Phase 8: All messages received by P1 ===`); + const p1Opcodes = p1.msgs.map(m => `pc:${m.pc} ${m.opcode}${m.result?.key ? ':' + m.result.key : ''}`); + log('TEST', `P1 received ${p1.msgs.length} messages: ${p1Opcodes.join(', ')}`); + + log('TEST', `=== Cleanup ===`); + p1.ws.close(1000); + p2r.ws.close(1000); + p3.ws.close(1000); + await wait(1000); + + const connFinal = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`); + log('TEST', `Final connections: ${JSON.stringify(connFinal)}`); + log('TEST', `=== DONE ===`); + process.exit(0); +} + +function fetchJSON(url) { + return new Promise((resolve, reject) => { + https.get(url, res => { + let d = ''; + res.on('data', c => d += c); + res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } }); + }).on('error', reject); + }); +} + +main().catch(e => { console.error('FATAL:', e.message); process.exit(1); });