From af5e8cbd94a31988d669ff28763e721f626eb2a0 Mon Sep 17 00:00:00 2001 From: cottongin Date: Fri, 20 Mar 2026 09:39:17 -0400 Subject: [PATCH] docs: comprehensive Jackbox ecast API reverse engineering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds complete documentation of the ecast platform covering: - REST API (8 endpoints including newly discovered /connections, /info, /status) - WebSocket protocol (connection, message format, 40+ opcodes) - Entity model (room, player, audience, textDescriptions) - Game lifecycle (lobby → start → gameplay → end) - Player/room management answers (counting, join/leave detection, etc.) Also adds scripts/ws-probe.js utility for direct WebSocket probing. Made-with: Cursor --- docs/jackbox-ecast-api.md | 876 ++++++++++++++++++++++++++++++++++++++ scripts/ws-probe.js | 109 +++++ 2 files changed, 985 insertions(+) create mode 100644 docs/jackbox-ecast-api.md create mode 100644 scripts/ws-probe.js diff --git a/docs/jackbox-ecast-api.md b/docs/jackbox-ecast-api.md new file mode 100644 index 0000000..2f2617c --- /dev/null +++ b/docs/jackbox-ecast-api.md @@ -0,0 +1,876 @@ +# Jackbox Ecast API Reference + +Reverse-engineered documentation of the Jackbox Games ecast platform API. This covers the REST API at `ecast.jackboxgames.com` and the WebSocket protocol used by `jackbox.tv` clients to participate in game rooms. + +**Last updated:** 2026-03-20 +**Tested with:** Drawful 2 (appTag: `drawful2international`) + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [REST API Reference](#rest-api-reference) +3. [WebSocket Protocol Reference](#websocket-protocol-reference) +4. [Entity Model](#entity-model) +5. [Game Lifecycle](#game-lifecycle) +6. [Player & Room Management](#player--room-management) +7. [Appendix: Raw Captures](#appendix-raw-captures) + +--- + +## Overview + +Jackbox Games uses a platform called **ecast** to manage game rooms and real-time communication between the game host (running on a console/PC) and players (connecting via `jackbox.tv` in a browser). + +### Architecture + +``` +┌──────────────┐ HTTPS/WSS ┌──────────────────────────┐ WSS ┌──────────────┐ +│ Game Host │◄───────────────────►│ ecast.jackboxgames.com │◄────────────►│ Players │ +│ (Console) │ │ (load balancer) │ │ (Browser) │ +└──────────────┘ └──────────┬───────────────┘ └──────────────┘ + │ + ┌──────────▼───────────────┐ + │ ecast-prod-{region}. │ + │ jackboxgames.com │ + │ (game server) │ + └──────────────────────────┘ +``` + +- **Load balancer** (`ecast.jackboxgames.com`): Routes REST requests and resolves room codes to specific game servers. +- **Game server** (e.g., `ecast-prod-use2.jackboxgames.com`): Hosts WebSocket connections and manages room state. Returned in the `host` / `audienceHost` fields of room info. +- **Room code**: 4-letter alphanumeric code (e.g., `LSBN`) that identifies an active game session. + +### Base URLs + +| Purpose | URL | +|---------|-----| +| REST API (rooms) | `https://ecast.jackboxgames.com/api/v2/` | +| WebSocket (player) | `wss://{host}/api/v2/rooms/{code}/play` | +| WebSocket (audience) | `wss://{host}/api/v2/audience/{code}/play` | + +The `{host}` value comes from the REST API room info response (`host` or `audienceHost` field). + +--- + +## REST API Reference + +All REST endpoints return JSON. Successful responses have `{"ok": true, "body": ...}`. Errors have `{"ok": false, "error": "..."}`. + +### Response Headers + +| Header | Value | Notes | +|--------|-------|-------| +| `access-control-allow-origin` | `https://jackbox.tv` | CORS restricted to jackbox.tv | +| `access-control-allow-credentials` | `true` | | +| `ecast-room-status` | `valid` | Custom header on room endpoints | + +### GET /api/v2 + +Health check endpoint. + +**Response:** `{"ok": true, "body": "hello"}` + +### GET /api/v2/rooms/{code} + +Full room information. This is the primary endpoint for checking room status. + +**Response:** + +```json +{ + "ok": true, + "body": { + "appId": "fdac94cc-cade-41ff-b4aa-e52e29418e8a", + "appTag": "drawful2international", + "audienceEnabled": true, + "code": "LSBN", + "host": "ecast-prod-use2.jackboxgames.com", + "audienceHost": "ecast-prod-use2.jackboxgames.com", + "locked": false, + "full": false, + "maxPlayers": 8, + "minPlayers": 0, + "moderationEnabled": false, + "passwordRequired": false, + "twitchLocked": false, + "locale": "en", + "keepalive": false, + "controllerBranch": "" + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `appId` | string (UUID) | Unique application identifier | +| `appTag` | string | Game identifier slug | +| `audienceEnabled` | boolean | Whether audience mode is enabled | +| `code` | string | 4-letter room code | +| `host` | string | Game server hostname for player WebSocket connections | +| `audienceHost` | string | Game server hostname for audience WebSocket connections | +| `locked` | boolean | Whether the room is locked (game started, no new players) | +| `full` | boolean | Whether the room has reached max players | +| `maxPlayers` | number | Maximum number of players allowed | +| `minPlayers` | number | Minimum players required to start | +| `moderationEnabled` | boolean | Whether content moderation is active | +| `passwordRequired` | boolean | Whether a password is needed to join | +| `twitchLocked` | boolean | Whether the room is Twitch-restricted | +| `locale` | string | Language locale | +| `keepalive` | boolean | Whether the room persists after host disconnects | +| `controllerBranch` | string | Controller code branch (empty for production) | + +**Error (room not found):** `{"ok": false, "error": "no such room"}` (HTTP 404) + +### GET /api/v2/rooms/{code}/status + +Lightweight existence check. Returns minimal data. + +**Response:** `{"ok": true, "body": {"code": "LSBN"}}` + +### GET /api/v2/rooms/{code}/info + +Room information with audience data and join role. Uses a different response format (no `ok`/`body` wrapper). + +**Response:** + +```json +{ + "roomid": "LSBN", + "server": "ecast-prod-use2.jackboxgames.com", + "apptag": "drawful2international", + "appid": "fdac94cc-cade-41ff-b4aa-e52e29418e8a", + "numAudience": 0, + "audienceEnabled": true, + "joinAs": "player", + "requiresPassword": false +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `numAudience` | number | Current audience member count | +| `joinAs` | string | Default role for new connections (`"player"`) | +| `requiresPassword` | boolean | Whether password is needed | + +> **Note:** `numAudience` may not update in real-time for WebSocket-connected audience members. Consider using the WebSocket `room/get-audience` opcode for live counts. + +### GET /api/v2/rooms/{code}/connections + +Active connection 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. + +### POST /api/v2/rooms + +Create a new room. Returns a room code and management token. + +**Request:** + +```json +{ + "appTag": "drawful2international", + "userId": "your-user-id" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `appTag` | string | Yes | Game identifier | +| `userId` | string | Yes | Creator's user ID | + +**Response:** + +```json +{ + "ok": true, + "body": { + "host": "ecast-prod-use2.jackboxgames.com", + "code": "XCMG", + "token": "ec356450735cf7d23d42c127" + } +} +``` + +The `token` is required for subsequent PUT and DELETE operations. + +**Errors:** +- `"invalid parameters: missing required field appTag"` (HTTP 400) +- `"invalid parameters: missing required field userId"` (HTTP 400) + +### PUT /api/v2/rooms/{code}?token={token} + +Update room properties. Requires the room token from creation as a **query parameter**. + +**Request:** JSON body with properties to update (e.g., `{"locked": true}`) + +**Response:** `{"ok": true}` (HTTP 200) + +**Errors:** +- `"missing room token"` (HTTP 400) — token not provided or wrong format +- `"bad token"` (HTTP 403) — invalid token + +> **Note:** Token must be passed as a query parameter (`?token=...`), not in the request body, headers, or as a Bearer token. + +### DELETE /api/v2/rooms/{code}?token={token} + +Delete/close a room. Requires the room token as a query parameter. + +**Response:** `ok` (plain text, HTTP 200) + +**Errors:** +- `"no such room"` (HTTP 404) +- `"bad token"` (HTTP 403) + +### API Version Notes + +Only `/api/v2/` is active. Requests to `/api/v1/`, `/api/v3/`, or `/api/v4/` return 403 Forbidden. The root path `/` and `/api/` also return 403. + +--- + +## WebSocket Protocol Reference + +### Connection + +**Player connection URL:** + +``` +wss://{host}/api/v2/rooms/{code}/play?role=player&name={name}&userId={userId}&format=json +``` + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `role` | Yes | `player` | +| `name` | Yes | Display name (URL-encoded) | +| `userId` | Yes | Unique user identifier | +| `format` | Yes | `json` (only known format) | + +**Audience connection URL:** + +``` +wss://{host}/api/v2/audience/{code}/play +``` + +No query parameters required for audience. + +**Required WebSocket sub-protocol:** `ecast-v0` + +**Required headers:** + +| Header | Value | +|--------|-------| +| `Origin` | `https://jackbox.tv` | +| `Sec-WebSocket-Protocol` | `ecast-v0` | + +**Example (Node.js with `ws`):** + +```javascript +const ws = new WebSocket(url, ['ecast-v0'], { + headers: { 'Origin': 'https://jackbox.tv' } +}); +``` + +### Message Format + +**Client → Server:** + +```json +{ + "seq": 1, + "opcode": "object/get", + "params": { + "key": "room" + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `seq` | number | Client-side sequence number (monotonically increasing) | +| `opcode` | string | Operation to perform | +| `params` | object | Operation-specific parameters | + +**Server → Client:** + +```json +{ + "pc": 564, + "opcode": "client/welcome", + "result": { ... } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `pc` | number | Server-side packet counter (monotonically increasing, shared across all clients in the room) | +| `opcode` | string | Message type | +| `result` | object | Operation-specific payload | + +### Opcode Catalog + +#### Connection Lifecycle (Server → Client) + +| 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/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. + +#### Entity Operations (Client → Server) + +**Object entities** (JSON key-value stores): + +| Opcode | Description | +|--------|-------------| +| `object/create` | Create a new object entity | +| `object/get` | Read an object entity's value | +| `object/set` | Replace an object entity's value (host-only for game entities) | +| `object/update` | Partially update an object entity | +| `object/echo` | Echo an object to all clients | + +**Text entities** (string values): + +| Opcode | Description | +|--------|-------------| +| `text/create` | Create a new text entity | +| `text/get` | Read a text entity | +| `text/set` | Set a text entity's value | +| `text/update` | Update a text entity | +| `text/echo` | Echo text to all clients | + +**Number entities** (numeric counters): + +| Opcode | Description | +|--------|-------------| +| `number/create` | Create a new number entity | +| `number/get` | Read a number entity | +| `number/increment` | Increment a number | +| `number/decrement` | Decrement a number | +| `number/update` | Set a number's value | + +**Stack entities** (ordered collections): + +| Opcode | Description | +|--------|-------------| +| `stack/create` | Create a new stack | +| `stack/push` | Push an element | +| `stack/bulkpush` | Push multiple elements | +| `stack/pop` | Pop the top element | +| `stack/peek` | Read the top element without removing | +| `stack/element` | Get a specific element | +| `stack/elements` | Get all elements | + +**Doodle entities** (drawing data): + +| Opcode | Description | +|--------|-------------| +| `doodle/create` | Create a new doodle | +| `doodle/get` | Get doodle data | +| `doodle/line` | Add a line to a doodle | +| `doodle/stroke` | Add a stroke to a doodle | +| `doodle/undo` | Undo the last stroke | + +#### Room Operations (Client → Server) + +| Opcode | Description | Permissions | +|--------|-------------|-------------| +| `room/get-audience` | Get audience connection count | Any client | +| `room/lock` | Lock the room (prevent new joins) | Host only | +| `room/exit` | Close/destroy the room | Host only | +| `room/migrate` | Migrate room to another server | Host only | +| `room/set-password` | Set or change room password | Host only | +| `room/start-audience` | Enable audience connections | Host only | + +**`room/get-audience` response:** `{"connections": 1}` + +#### Client Communication (Client → Server) + +| Opcode | Description | +|--------|-------------| +| `client/send` | Send a message to a specific client by ID | +| `client/kick` | Kick a client from the room | +| `error/observed` | Acknowledge an error was observed | + +**`client/send` params:** `{"to": , "body": {...}}` + +#### Audience Entity Types + +| Type | Description | +|------|-------------| +| `audience/pn-counter` | Positive-negative counter (tracks audience count) | +| `audience/g-counter` | Grow-only counter | +| `audience/count-group` | Grouped count (for audience voting categories) | +| `audience/text-ring` | Ring buffer of text entries | + +### Error Codes + +| Code | Message | Description | +|------|---------|-------------| +| 2000 | `missing Sec-WebSocket-Protocol header` | WebSocket connection missing required protocol | +| 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 | +| — | `only the host can close the room` | Specific error for `room/exit` from non-host | + +--- + +## Entity Model + +Ecast uses a typed entity system. Each entity has a `key`, a `type`, a `val` (value), a `version` counter, and a `locked` flag. + +### Entity Structure in `client/welcome` + +Entities in the welcome message are arrays with three elements: + +```json +["object", {"key": "room", "val": {...}, "version": 0, "from": 1}, {"locked": false}] +``` + +1. **Type identifier** (string): `"object"`, `"audience/pn-counter"`, etc. +2. **Entity data** (object): `key`, `val`, `version`, `from` (originating client ID) +3. **Metadata** (object): `locked` flag + +### Entity Updates (`object` opcode) + +When an entity changes, all subscribed clients receive an `object` message: + +```json +{ + "pc": 569, + "opcode": "object", + "result": { + "key": "room", + "val": { ... }, + "version": 2, + "from": 1 + } +} +``` + +The `version` increments with each update. The `from` field indicates which client made the change (1 = host). + +### Core Entities + +#### `room` (type: object) + +The primary game state entity. Managed by the host. + +```json +{ + "key": "room", + "val": { + "analytics": [{"appid": "drawful2-nx", "appname": "Drawful2", "appversion": "0.0.0", "screen": "drawful2-lobby"}], + "audience": {"playerInfo": {"username": "audience"}, "state": "Logo"}, + "gameCanStart": true, + "gameFinished": false, + "gameIsStarting": false, + "lobbyState": "CanStart", + "locale": "en", + "platformId": "NX", + "state": "Lobby", + "strings": { ... } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `state` | string | Current game phase: `"Lobby"`, `"Gameplay"`, etc. | +| `lobbyState` | string | Lobby sub-state: `"WaitingForMore"`, `"CanStart"`, `"Countdown"`, etc. | +| `gameCanStart` | boolean | Whether minimum player count is met | +| `gameFinished` | boolean | Whether the game has ended | +| `gameIsStarting` | boolean | Whether the start countdown is active | +| `analytics` | array | Game tracking metadata (appid, screen name) | +| `platformId` | string | Host platform (`"NX"` = Nintendo Switch, `"steam"`, etc.) | +| `strings` | object | Localized UI strings | + +#### `player:{id}` (type: object) + +Per-player state. Created when a player joins. `{id}` matches the player's session ID from `client/welcome`. + +```json +{ + "key": "player:2", + "val": { + "playerName": "probe1", + "playerIsVIP": true, + "playerCanStartGame": true, + "playerIndex": 0, + "state": "Draw", + "colors": ["#fb405a", "#7a2259"], + "classes": ["Player0"], + "playerInfo": { + "username": "PROBE1", + "sessionId": 2, + "playerIndex": 0 + }, + "prompt": {"html": "
please draw:
a picture of yourself
"}, + "size": {"height": 320, "width": 240}, + "sketchOptions": { ... } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `playerName` | string | Lowercase display name | +| `playerIsVIP` | boolean | Whether this player is the VIP (first to join) | +| `playerCanStartGame` | boolean | Whether this player can trigger game start | +| `playerIndex` | number | Zero-based player order | +| `state` | string | Player's current state (game-specific) | +| `colors` | array | Assigned player colors | +| `playerInfo.username` | string | Original-case display name | +| `playerInfo.sessionId` | number | WebSocket session ID | + +#### `audience` (type: audience/pn-counter) + +Tracks the audience member count. + +```json +{ + "key": "audience", + "count": 0 +} +``` + +#### `textDescriptions` (type: object) + +Human-readable event descriptions (join/leave notifications). + +```json +{ + "key": "textDescriptions", + "val": { + "data": {"TEXT_DESCRIPTION_PLAYER_VIP": "PROBE1"}, + "latestDescriptions": [ + {"category": "TEXT_DESCRIPTION_PLAYER_JOINED_VIP", "id": 1, "text": "PROBE1 joined and is the VIP."}, + {"category": "TEXT_DESCRIPTION_PLAYER_JOINED", "id": 2, "text": "PROBE3 joined."} + ] + } +} +``` + +Categories observed: +- `TEXT_DESCRIPTION_PLAYER_JOINED_VIP` — First player joined (becomes VIP) +- `TEXT_DESCRIPTION_PLAYER_JOINED` — Subsequent player joined + +#### `audiencePlayer` (type: object) + +Audience-specific state entity. Only sent to audience connections. + +```json +{ + "key": "audiencePlayer", + "val": { + "analytics": [], + "audience": {"playerInfo": {"username": "audience"}, "state": "Logo"}, + "locale": "en", + "platformId": "NX" + } +} +``` + +--- + +## Game Lifecycle + +### Connection and Lobby + +``` +Client Server + │ │ + │──── WebSocket connect ────────────►│ + │ │ + │◄─── client/welcome ───────────────│ (id, secret, entities, here, profile) + │◄─── object: textDescriptions ─────│ ("X joined") + │◄─── object: player:{id} (v0) ─────│ (empty initial entity) + │◄─── object: player:{id} (v1) ─────│ (full player state) + │◄─── object: room ─────────────────│ (lobbyState updated) + │ │ +``` + +### Player Join (from existing player's perspective) + +When a new player joins, existing players receive: + +1. `object` update on `textDescriptions` with `"X joined."` in `latestDescriptions` +2. `object` update on `room` (e.g., `lobbyState` changes from `"WaitingForMore"` to `"CanStart"`) + +There is **no** dedicated `client/connected` message sent to players — join detection relies on entity updates. + +### Game Start + +The `room` entity transitions through these states: + +1. `lobbyState: "WaitingForMore"` → Fewer than minimum players +2. `lobbyState: "CanStart"` / `gameCanStart: true` → Minimum players met, VIP can start +3. `gameIsStarting: true` / `lobbyState: "Countdown"` → Start countdown active +4. `state: "Gameplay"` → Game has started +5. REST: `locked: true` → Room is locked, no new players can join + +### Game End + +1. `room.gameFinished: true` → Game is complete +2. `room.state` returns to `"Lobby"` (for "same players" / "new players" options) +3. Player entities may contain `history` and result data + +### Reconnection + +If a player reconnects with the same `deviceId` (browser cookie/localStorage), the server sends `client/welcome` with: +- `reconnect: true` +- Same `id` and `secret` as the original session +- Full current state of all entities (not just deltas) + +### Disconnection + +Graceful disconnection (WebSocket close code 1000) is processed immediately. Ungraceful disconnection (process kill, network drop) takes 30-60 seconds for the server to detect. + +The `connections` REST endpoint reflects connection count changes. The `here` field in `client/welcome` shows stale connections until the server cleans them up. + +--- + +## Player & Room Management + +Answers to common questions about managing players and rooms. + +### How to detect when players join or leave + +**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. + +**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. + +### How to count players + +**Method 1 — REST `/connections` (simplest, recommended):** + +``` +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`. + +**Method 2 — WebSocket `here` field (most accurate at connection time):** + +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": {}}} +} +``` + +Count entries where `roles` contains `player`. This gives exact player count at connection time but doesn't update in real-time. + +**Method 3 — WebSocket `room/get-audience` (audience count only):** + +Send `{seq: N, opcode: "room/get-audience", params: {}}` → response `{"connections": M}` + +### How to see maximum players allowed + +**REST:** `GET /api/v2/rooms/{code}` → `body.maxPlayers` (e.g., `8` for Drawful 2) + +Also available: `body.minPlayers` (e.g., `0`) + +### How to detect when the game starts or lobby is locked + +**REST (polling):** `GET /api/v2/rooms/{code}` → `body.locked` changes from `false` to `true` + +**WebSocket (real-time):** Watch `room` entity updates for: +- `lobbyState` transitions: `"WaitingForMore"` → `"CanStart"` → `"Countdown"` → (game starts) +- `gameCanStart: true` → minimum player count met +- `gameIsStarting: true` → start countdown active +- `state` changes from `"Lobby"` to `"Gameplay"` + +### How to detect when the game completes + +**WebSocket:** Watch `room` entity for `gameFinished: true` + +**REST:** The room will either remain (if "same players" is chosen) or disappear (404 on room endpoint) if the host closes it. + +### Game stats and player stats + +**During gameplay:** Player entities (`player:{id}`) contain game-specific state including `history`, `playerInfo`, and state-specific data. + +**After game completion:** The `room` entity may contain result data. Player entities may contain `history` arrays with per-round results. The `analytics` array in the room entity tracks game screens/phases. + +Specific stat fields are game-dependent (Drawful 2, Quiplash, etc. have different schemas). + +--- + +## Appendix: Raw Captures + +### Player `client/welcome` (initial connection) + +```json +{ + "pc": 564, + "opcode": "client/welcome", + "result": { + "id": 2, + "name": "PROBE1", + "secret": "14496f20-83ed-4d1f-82bd-f2131f3c3c50", + "reconnect": false, + "deviceId": "0209922833.5aebbb0024f7bc2a3342d1", + "entities": { + "audience": ["audience/pn-counter", {"key": "audience", "count": 0}, {"locked": false}], + "room": ["object", {"key": "room", "val": {"state": "Lobby", "lobbyState": "WaitingForMore", "..."}, "version": 0, "from": 1}, {"locked": false}], + "textDescriptions": ["object", {"key": "textDescriptions", "val": {"..."}, "version": 0, "from": 1}, {"locked": false}] + }, + "here": { + "1": {"id": 1, "roles": {"host": {}}} + }, + "profile": { + "id": 2, + "roles": {"player": {"name": "PROBE1"}} + } + } +} +``` + +### Audience `client/welcome` + +```json +{ + "pc": 777, + "opcode": "client/welcome", + "result": { + "id": 4000001355, + "secret": "10affd0afb35892e73051b15", + "reconnect": false, + "entities": { + "audience": ["audience/pn-counter", {"key": "audience", "count": 0}, {"locked": false}], + "audiencePlayer": ["object", {"key": "audiencePlayer", "val": {"..."}, "version": 1, "from": 1}, {"locked": false}], + "room": ["object", {"key": "room", "val": {"..."}, "version": 4, "from": 1}, {"locked": false}], + "textDescriptions": ["object", {"key": "textDescriptions", "val": {"..."}, "version": 2, "from": 1}, {"locked": false}] + }, + "here": null, + "profile": null + } +} +``` + +Key differences from player welcome: +- `id` is in the 4 billion range (separated from player ID space) +- `here` is `null` (audience can't see the player list) +- `profile` is `null` (audience has no player profile) +- Includes `audiencePlayer` entity (audience-specific state) + +### Reconnection `client/welcome` + +```json +{ + "pc": 641, + "opcode": "client/welcome", + "result": { + "id": 2, + "name": "PROBE1", + "secret": "14496f20-83ed-4d1f-82bd-f2131f3c3c50", + "reconnect": true, + "deviceId": "0209922833.5aebbb0024f7bc2a3342d1", + "entities": { "..." }, + "here": { "..." }, + "profile": { "..." } + } +} +``` + +The `reconnect: true` flag distinguishes reconnections from new joins. Same `id` and `secret` are preserved. + +### Entity Update (player join observed by existing player) + +```json +{ + "pc": 729, + "opcode": "object", + "result": { + "key": "textDescriptions", + "val": { + "data": {"TEXT_DESCRIPTION_PLAYER_VIP": "PROBE1"}, + "latestDescriptions": [ + {"category": "TEXT_DESCRIPTION_PLAYER_JOINED", "id": 2, "text": "PROBE3 joined."} + ] + }, + "version": 2, + "from": 1 + } +} +``` + +### Room State Transition (lobby → can start) + +```json +{ + "pc": 735, + "opcode": "object", + "result": { + "key": "room", + "val": { + "gameCanStart": true, + "gameFinished": false, + "gameIsStarting": false, + "lobbyState": "CanStart", + "state": "Lobby" + }, + "version": 5, + "from": 1 + } +} +``` + +### Error Response + +```json +{ + "pc": 1600, + "opcode": "error", + "result": { + "code": 2003, + "msg": "invalid opcode" + } +} +``` + +### Client → Server Request (object/get) + +```json +{ + "seq": 1, + "opcode": "object/get", + "params": { + "key": "room" + } +} +``` + +### Connected 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": {}}} +} +``` + +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) diff --git a/scripts/ws-probe.js b/scripts/ws-probe.js new file mode 100644 index 0000000..b96bc45 --- /dev/null +++ b/scripts/ws-probe.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +const WebSocket = require('ws'); +const https = require('https'); + +const ROOM_CODE = process.argv[2] || 'LSBN'; +const PLAYER_NAME = process.argv[3] || 'PROBE_WS'; +const ROLE = process.argv[4] || 'player'; // 'player' or 'audience' +const USER_ID = `probe-${Date.now()}`; + +function getRoomInfo(code) { + return new Promise((resolve, reject) => { + https.get(`https://ecast.jackboxgames.com/api/v2/rooms/${code}`, res => { + let data = ''; + res.on('data', c => data += c); + res.on('end', () => { + try { + const json = JSON.parse(data); + if (json.ok) resolve(json.body); + else reject(new Error(json.error || 'Room not found')); + } catch (e) { reject(e); } + }); + }).on('error', reject); + }); +} + +function ts() { + return new Date().toISOString().slice(11, 23); +} + +async function main() { + console.log(`[${ts()}] Fetching room info for ${ROOM_CODE}...`); + const room = await getRoomInfo(ROOM_CODE); + console.log(`[${ts()}] Room: ${room.appTag}, host: ${room.host}, locked: ${room.locked}`); + + let wsUrl; + if (ROLE === 'audience') { + wsUrl = `wss://${room.audienceHost}/api/v2/audience/${ROOM_CODE}/play`; + } else { + wsUrl = `wss://${room.host}/api/v2/rooms/${ROOM_CODE}/play?role=${ROLE}&name=${encodeURIComponent(PLAYER_NAME)}&userId=${USER_ID}&format=json`; + } + + console.log(`[${ts()}] Connecting: ${wsUrl}`); + + const ws = new WebSocket(wsUrl, ['ecast-v0'], { + headers: { + 'Origin': 'https://jackbox.tv', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' + } + }); + + let msgCount = 0; + + ws.on('open', () => { + console.log(`[${ts()}] CONNECTED`); + }); + + ws.on('message', (raw) => { + msgCount++; + try { + const msg = JSON.parse(raw.toString()); + const summary = summarize(msg); + console.log(`[${ts()}] RECV #${msgCount} | pc:${msg.pc} | opcode:${msg.opcode} | ${summary}`); + if (process.env.VERBOSE === 'true') { + console.log(JSON.stringify(msg, null, 2)); + } + } catch (e) { + console.log(`[${ts()}] RECV #${msgCount} | raw: ${raw.toString().slice(0, 200)}`); + } + }); + + ws.on('close', (code, reason) => { + console.log(`[${ts()}] CLOSED code=${code} reason=${reason}`); + process.exit(0); + }); + + ws.on('error', (err) => { + console.error(`[${ts()}] ERROR: ${err.message}`); + }); + + process.on('SIGINT', () => { + console.log(`\n[${ts()}] Closing (${msgCount} messages received)`); + ws.close(); + }); +} + +function summarize(msg) { + if (msg.opcode === 'client/welcome') { + const r = msg.result || {}; + const hereIds = r.here ? Object.keys(r.here) : []; + const entityKeys = r.entities ? Object.keys(r.entities) : []; + return `id=${r.id} name=${r.name} reconnect=${r.reconnect} here=[${hereIds}] entities=[${entityKeys}]`; + } + if (msg.opcode === 'object') { + const r = msg.result || {}; + const valKeys = r.val ? Object.keys(r.val).slice(0, 5).join(',') : 'null'; + return `key=${r.key} v${r.version} from=${r.from} val=[${valKeys}...]`; + } + if (msg.opcode === 'client/connected') { + const r = msg.result || {}; + return `id=${r.id} userId=${r.userId} name=${r.name} role=${r.role}`; + } + if (msg.opcode === 'client/disconnected') { + const r = msg.result || {}; + return `id=${r.id} role=${r.role}`; + } + return JSON.stringify(msg.result || msg).slice(0, 150); +} + +main().catch(e => { console.error(e); process.exit(1); });