docs: comprehensive Jackbox ecast API reverse engineering
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
This commit is contained in:
876
docs/jackbox-ecast-api.md
Normal file
876
docs/jackbox-ecast-api.md
Normal file
@@ -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": <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 |
|
||||||
|
| 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": "<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
|
||||||
|
|
||||||
|
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)
|
||||||
109
scripts/ws-probe.js
Normal file
109
scripts/ws-probe.js
Normal file
@@ -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); });
|
||||||
Reference in New Issue
Block a user