# 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. [Passive Room Monitoring](#passive-room-monitoring)
8. [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).

### Connection Roles

Ecast supports several connection roles, each with different capabilities and visibility:

| Role | Endpoint | `here` visibility | Entity reads | Entity writes | Slot impact |
|------|----------|-------------------|-------------|--------------|-------------|
| `host` | `/rooms/{code}/play?role=host` | Full | Full | Full | Yes (always 1) |
| `player` | `/rooms/{code}/play?role=player` | Full | Own + room | Own entities | Yes (occupies a `maxPlayers` slot) |
| `shard` | `/rooms/{code}/play?role=shard` | Full (sees & is seen) | Full (via `object/get`) | Denied | No (does NOT count toward `full` / `maxPlayers`) |
| `audience` | `/audience/{code}/play` | None (`here: null`) | Receives broadcasts only | None | No (does NOT count toward `full` / `maxPlayers`) |
| `observer` | `/rooms/{code}/play?role=observer` | Unknown | Unknown | Unknown | Unknown |
| `moderator` | `/rooms/{code}/play?role=moderator` | Unknown | Unknown | Unknown | Unknown |

**Notes:**
- **`observer`**: Exists but is password-protected ("observer is for internal use, sorry: password required" — error 2017). Presumably designed for exactly the monitoring use case, but the password is not publicly known.
- **`moderator`**: Exists but requires `moderationEnabled: true` on the room (error 2023: "moderation is not enabled").
- **`spectator`**, empty string, and other arbitrary roles return error 2014: "no such role".
- Both `shard` and `audience` connections increment the raw `/connections` counter and hold their slot after disconnect (same persistence model as players). However, they do **not** count toward the `full` flag or `maxPlayers` limit — only `player` role connections do.

---

## 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

Total occupied slot count across **all** connection types: host (1) + players + audience + shards.

**Response:** `{"ok": true, "body": {"connections": 3}}`

For rooms with only the host and players (the typical case), player count = `connections - 1`. Since Jackbox holds player slots for reconnection (a disconnected player's slot is reserved and unavailable to new players), this directly reflects how many of the room's `maxPlayers` slots are taken. Available slots = `maxPlayers - (connections - 1)`.

> **Important:** This counter includes ALL connection types (audience, shard, etc.), not just players. If external monitoring connections are present, subtract them accordingly. Alternatively, use the `full` field from `GET /rooms/{code}` — this flag only considers player-role connections and is unaffected by audience/shard connections.

### 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 (new join):**

```
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) |

**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:**

```
wss://{host}/api/v2/audience/{code}/play
```

No query parameters required for audience.

**Shard connection URL:**

```
wss://{host}/api/v2/rooms/{code}/play?role=shard&name={name}&userId={userId}&format=json
```

Shard connections use the same `/rooms/{code}/play` endpoint as players but with `role=shard`. They get full `here` visibility and can query entities but cannot write.

**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 (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`. |

> **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)

**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": <clientId>, "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 (or opcode not available for this role) |
| 2007 | `entity value is not of type {expected}` | Type mismatch (e.g., using `text/get` on an `object` entity) |
| 2014 | `no such role: {role}` | Requested role does not exist |
| 2017 | `observer is for internal use, sorry: password required` | Observer role requires a password |
| 2023 | `permission denied` | Operation not allowed for this client's role (also: `moderation is not enabled`, `only the host can close the room`) |
| 2027 | `the room has already been closed` | Room ended but REST API may still return room info (REST/WS state divergence) |

---

## 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": "<div>please draw:</div> <div>a picture of yourself</div>"},
    "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

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 (complete snapshot, not deltas)

### Disconnection and Slot Persistence

**Jackbox has no concept of "leaving."** When a player's WebSocket closes (gracefully or otherwise), their player slot is held open for reconnection. From the game's perspective, the player is still "in the game" and can return at any time. This means a disconnected player's slot is **not** available to a new player — it remains reserved.

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 all registered players (connected or not).
- 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.

Player slots are freed only when the room itself is destroyed (host closes the game) or the room times out.

This slot-persistence model makes player counting straightforward: the number of occupied slots (`connections - 1`, or count of `player` roles in `here`) directly represents how many of the room's `maxPlayers` slots are taken, regardless of whether those players currently have active WebSocket connections.

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

---

## Player & Room Management

Answers to common questions about managing players and rooms.

### How to detect when players join

- **WebSocket (best):** Watch for `textDescriptions` entity updates with `category: "TEXT_DESCRIPTION_PLAYER_JOINED"` or `"TEXT_DESCRIPTION_PLAYER_JOINED_VIP"`.
- **WebSocket (snapshot):** The `here` field in `client/welcome` lists all registered players (includes both active and disconnected — since slots persist, this reflects the true occupied slot count).
- **REST (polling):** `GET /api/v2/rooms/{code}/connections` — count increases when a new player joins. Does not decrease when they disconnect (by design — the slot is reserved).

There is no "leave" event. Jackbox holds player slots for reconnection, so a player who closes their browser still occupies a slot and can return. The `client/disconnected` opcode exists in the client code but is not delivered to player connections.

### How to count players

Since Jackbox holds player slots for reconnection, the number of occupied slots is the effective player count — a held slot is unavailable to a new player.

**Method 1 — REST `/connections` (recommended for polling):**

```
GET /api/v2/rooms/{code}/connections → {"connections": N}
```

Player count = `N - 1` (subtract host). If audience members are present, subtract audience count too (get via `room/get-audience` opcode or `/info` endpoint's `numAudience`).

Available slots = `maxPlayers - (N - 1)`

**Method 2 — WebSocket `here` field (recommended for WebSocket clients):**

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": "P1"}}},
  "3": {"id": 3, "roles": {"player": {"name": "P2"}}},
  "4": {"id": 4, "roles": {"player": {"name": "P3"}}}
}
```

Count entries where `roles` contains `player`. The `here` field excludes the connecting client itself (you don't see yourself in the list), so add 1 if your own connection is also a player.

**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).

---

## Passive Room Monitoring

It is possible to monitor a game room in real-time via WebSocket without joining as a player. There is no truly invisible WebSocket option (all connection types increment `/connections`), but there are approaches that don't consume a player slot or appear in the game UI.

### Option 1: Audience Connection (recommended)

Connect via the audience endpoint: `wss://{host}/api/v2/audience/{code}/play`

**Pros:**
- Invisible to players — does not appear in the `here` map
- Receives real-time entity broadcasts (`room`, `textDescriptions`, `audiencePlayer`)
- Does not count toward `maxPlayers` or trigger `full: true`
- No game UI impact (players don't see an audience member join unless the game has audience UI)

**Cons:**
- Increments the raw `/connections` counter by 1 (slot held after disconnect)
- `here` is `null` — cannot directly see the player list or their roles
- Cannot query entities (opcodes like `object/get` and `room/get-audience` return "invalid opcode")
- Limited to passively receiving whatever entities the host broadcasts

**What you receive:**
- `client/welcome` with full entity snapshot (`room`, `audience`, `textDescriptions`, `audiencePlayer`)
- Real-time `object` updates when entities change (player joins via `textDescriptions`, lobby state changes via `room`, game start/end)

### Option 2: Shard Connection

Connect via: `wss://{host}/api/v2/rooms/{code}/play?role=shard&name={name}&userId={userId}&format=json`

**Pros:**
- Gets the full `here` map with all connections and their roles (can filter for `player` roles to count players)
- Can actively query entities with `object/get` and `room/get-audience`
- Receives real-time entity broadcasts and periodic `shard/sync` CRDT data
- Does not count toward `maxPlayers` or trigger `full: true`

**Cons:**
- Visible to other clients — appears in the `here` map as `{roles: {shard: {}}}`
- Increments the raw `/connections` counter (slot held after disconnect)
- Other players' `here` field includes shard connections, which could confuse client code that counts `here` entries

### Option 3: REST-Only Polling (truly invisible)

Poll REST endpoints (`/rooms/{code}`, `/rooms/{code}/connections`) periodically.

**Pros:**
- Zero footprint — no WebSocket connection, no slot consumed, no `/connections` impact

**Cons:**
- No real-time events — must poll on an interval
- Cannot detect fine-grained state transitions between polls

### Recommendation

For the game picker use case, the **audience connection** is the best balance:
1. It's invisible to players (not in `here`, no game UI effect)
2. It provides real-time entity updates (lobby state, player joins, game start/end)
3. The only side effect is +1 on the raw `/connections` counter, which can be accounted for
4. Use `full` from the REST API for slot availability — this flag only counts player-role connections and is unaffected by audience/shard connections

For player counting with an audience monitor connected: either use `full` / `maxPlayers` from REST (unaffected), or use `connections - 2` (subtract host + your monitor) from the `/connections` endpoint.

---

## 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)

### Shard `client/welcome`

```json
{
  "opcode": "client/welcome",
  "result": {
    "id": 7,
    "secret": "82608030-59fb-46e3-b4ab-b23c7f733526",
    "reconnect": false,
    "entities": {
      "audience": ["crdt/pn-counter", [2,1,1,3,1,1,4,1,1], {"locked": false}],
      "audiencePlayer": ["object", {"key": "audiencePlayer", "val": {"..."}, "version": 1, "from": 1}, {"locked": false}],
      "room": ["object", {"key": "room", "val": {"..."}, "version": 0, "from": 1}, {"locked": false}],
      "scores": ["crdt/rankset", {"ranks": []}, {"locked": false}],
      "textDescriptions": ["object", {"key": "textDescriptions", "val": {"..."}, "version": 0, "from": 1}, {"locked": false}]
    },
    "here": {
      "1": {"id": 1, "roles": {"host": {}}},
      "2": {"id": 2, "roles": {"shard": {}}},
      "8": {"id": 8, "roles": {"player": {"name": "TestPlayer1"}}}
    },
    "profile": null
  }
}
```

Key differences from player welcome:
- `id` is a low single-digit number (separate from player/audience ID spaces)
- Gets the raw CRDT representation of audience counter instead of the friendly `{count: N}` form
- Gets additional entities like `scores` (CRDT rankset)
- `here` includes ALL connections with their roles (host, players, other shards)
- `profile` is `null` (shard has no player profile)
- Receives periodic `shard/sync` messages with CRDT state updates

### 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"
  }
}
```

### Registered User Roles in `here`

```json
{
  "1": {"id": 1, "roles": {"host": {}}},
  "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/infrastructure connection. Externally connectable with `role=shard`. Gets full entity access (read-only), appears in `here` map, receives `shard/sync` CRDT data. IDs are in the low single-digit range.
- `observer` — Internal role (password-protected, not publicly accessible)
- `moderator` — Moderation role (requires `moderationEnabled: true` on the room)

> **`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.
