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:
@@ -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.
|
||||
|
||||
157
scripts/ws-lifecycle-test.js
Normal file
157
scripts/ws-lifecycle-test.js
Normal 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); });
|
||||
Reference in New Issue
Block a user