docs: major corrections to ecast API docs from second round of testing

Key corrections based on testing with fresh room SCWX:
- connections count includes ALL ever-joined players, not just active ones
  (slots persist for reconnection, count never decreases)
- here field also includes disconnected players (slot reservation model)
- client/connected and client/disconnected confirmed as NOT delivered to
  player connections after extensive testing
- Jackbox has no concept of "leaving" — player disconnect is invisible
  to the API
- Added reconnection URL format (secret + id query params)
- Added error code 2027 (REST/WebSocket state divergence)
- Added ws-lifecycle-test.js for systematic protocol testing

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-20 09:51:24 -04:00
parent af5e8cbd94
commit 7b0dc5c015
2 changed files with 240 additions and 29 deletions

View File

@@ -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.

View File

@@ -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); });