# WebSocket Protocol

## 1. Overview

The WebSocket API provides real-time updates for Jackbox gaming sessions. Use it to:

- Receive notifications when sessions start, end, or when games are added
- Monitor Jackbox room state in real-time (lobby, player joins, game start/end)
- Track player counts automatically via shard connection
- Receive live vote updates (upvotes/downvotes) as viewers vote
- Avoid polling REST endpoints for session state changes

The WebSocket server runs on the same host and port as the HTTP API. Connect to `/api/sessions/live` to establish a live connection.

---

## 2. Connection Setup

**URL:** `ws://host:port/api/sessions/live`

- Use `ws://` for HTTP and `wss://` for HTTPS
- No query parameters are required
- Connection can be established without authentication (auth happens via a message after connect)

**JavaScript example:**

```javascript
const host = 'localhost';
const port = 5000;
const protocol = 'ws';
const ws = new WebSocket(`${protocol}://${host}:${port}/api/sessions/live`);

ws.onopen = () => {
  console.log('Connected');
};
```

---

## 3. Authentication

Authentication is required for subscribing to sessions and for receiving most events. Send your JWT token in an `auth` message after connecting.

**Send (client → server):**
```json
{ "type": "auth", "token": "<jwt>" }
```

**Success response:**
```json
{ "type": "auth_success", "message": "Authenticated successfully" }
```

**Failure responses:**
```json
{ "type": "auth_error", "message": "Invalid or expired token" }
```
```json
{ "type": "auth_error", "message": "Token required" }
```

**JavaScript example:**

```javascript
// After opening the connection...
ws.send(JSON.stringify({
  type: 'auth',
  token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
}));

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'auth_success') {
    console.log('Authenticated');
  } else if (msg.type === 'auth_error') {
    console.error('Auth failed:', msg.message);
  }
};
```

Obtain a JWT by calling `POST /api/auth/login` with your admin key.

---

## 4. Message Types — Client to Server

| Type        | Required Fields | Description                          |
|-------------|-----------------|--------------------------------------|
| `auth`      | `token`         | Authenticate with a JWT              |
| `subscribe` | `sessionId`     | Subscribe to a session's events      |
| `unsubscribe`| `sessionId`    | Unsubscribe from a session           |
| `ping`      | —               | Heartbeat; server responds with `pong` |
| `poll.leading` | `sessionId`, `gameId`, `label`, `votes` | Report current poll leader (rebroadcast to subscribers) |

### auth
```json
{ "type": "auth", "token": "<jwt>" }
```

### subscribe
Must be authenticated. You can subscribe to multiple sessions.

```json
{ "type": "subscribe", "sessionId": 3 }
```

### unsubscribe
Must be authenticated.

```json
{ "type": "unsubscribe", "sessionId": 3 }
```

### ping
```json
{ "type": "ping" }
```

---

## 5. Message Types — Server to Client

| Type          | Description                              |
|---------------|------------------------------------------|
| `auth_success`| Authentication succeeded                 |
| `auth_error`  | Authentication failed                    |
| `subscribed`  | Successfully subscribed to a session     |
| `unsubscribed`| Successfully unsubscribed from a session |
| `pong`        | Response to client `ping`                |
| `error`       | General error (e.g., not authenticated)  |
| `session.started` | New session created (broadcast to all authenticated clients) |
| `game.added`  | Game added to a session (broadcast to subscribers) |
| `session.ended` | Session closed (broadcast to subscribers) |
| `room.connected` | Shard connected to Jackbox room (broadcast to subscribers) |
| `lobby.player-joined` | Player joined the Jackbox lobby (broadcast to subscribers) |
| `lobby.updated` | Lobby state changed (broadcast to subscribers) |
| `game.started` | Game transitioned to Gameplay (broadcast to subscribers) |
| `game.ended` | Game finished (broadcast to subscribers) |
| `room.disconnected` | Shard lost connection to Jackbox room (broadcast to subscribers) |
| `game.status` | Periodic game state heartbeat every 20s (broadcast to subscribers) |
| `player-count.updated` | Manual player count override (broadcast to subscribers) |
| `vote.received`        | Live vote recorded (broadcast to subscribers)   |
| `voting.ended`         | Host ended the voting/polling period (broadcast to subscribers) |
| `poll.start`           | Host started a new poll (broadcast to subscribers) |
| `poll.leading`         | Current poll leader updated (broadcast to subscribers) |

---

## 6. Event Reference

All server-sent events use this envelope:

```json
{
  "type": "<event-type>",
  "timestamp": "2026-03-15T20:30:00.000Z",
  "data": { ... }
}
```

### session.started

- **Broadcast to:** All authenticated clients (not session-specific)
- **Triggered by:** `POST /api/sessions` (creating a new session)

**Data:**
```json
{
  "session": {
    "id": 3,
    "is_active": 1,
    "created_at": "2026-03-15T20:00:00",
    "notes": "Friday game night"
  }
}
```

### game.added

- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** `POST /api/sessions/{id}/games` (adding a game)

**Data:**
```json
{
  "session": {
    "id": 3,
    "is_active": true,
    "games_played": 5
  },
  "game": {
    "id": 42,
    "title": "Quiplash 3",
    "pack_name": "Jackbox Party Pack 7",
    "min_players": 3,
    "max_players": 8,
    "manually_added": false,
    "room_code": "ABCD"
  }
}
```

### session.ended

- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** `POST /api/sessions/{id}/close` (closing a session)

**Data:**
```json
{
  "session": {
    "id": 3,
    "is_active": 0,
    "games_played": 8
  }
}
```

### room.connected

- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Shard WebSocket successfully connecting to a Jackbox room (after `POST .../start-player-check` or adding a game with a room code)

**Data:**
```json
{
  "sessionId": 1,
  "gameId": 5,
  "roomCode": "LSBN",
  "appTag": "drawful2international",
  "maxPlayers": 8,
  "playerCount": 2,
  "players": ["Alice", "Bob"],
  "lobbyState": "CanStart",
  "gameState": "Lobby"
}
```

### lobby.player-joined

- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** A new player joining the Jackbox room lobby (detected via `textDescriptions` entity updates or `client/connected` messages)

**Data:**
```json
{
  "sessionId": 1,
  "gameId": 5,
  "roomCode": "LSBN",
  "playerName": "Charlie",
  "playerCount": 3,
  "players": ["Alice", "Bob", "Charlie"],
  "maxPlayers": 8
}
```

### lobby.updated

- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Lobby state change in the Jackbox room (e.g., enough players to start, countdown started)

**Data:**
```json
{
  "sessionId": 1,
  "gameId": 5,
  "roomCode": "LSBN",
  "lobbyState": "Countdown",
  "gameCanStart": true,
  "gameIsStarting": true,
  "playerCount": 4
}
```

### game.started

- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Jackbox game transitioning from Lobby to Gameplay state

**Data:**
```json
{
  "sessionId": 1,
  "gameId": 5,
  "roomCode": "LSBN",
  "playerCount": 4,
  "players": ["Alice", "Bob", "Charlie", "Diana"],
  "maxPlayers": 8
}
```

### game.ended

- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Jackbox game finishing (`gameFinished: true`) or room closing

**Data:**
```json
{
  "sessionId": 1,
  "gameId": 5,
  "roomCode": "LSBN",
  "playerCount": 4,
  "players": ["Alice", "Bob", "Charlie", "Diana"]
}
```

### room.disconnected

- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Shard losing connection to the Jackbox room (room closed, connection failed, manually stopped)

**Data:**
```json
{
  "sessionId": 1,
  "gameId": 5,
  "roomCode": "LSBN",
  "reason": "room_closed",
  "finalPlayerCount": 4
}
```

Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`, `role_rejected`, `manually_stopped`.

### game.status

- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Periodic 20-second heartbeat from an active shard monitor. Also available on demand via `GET /api/sessions/{sessionId}/games/{sessionGameId}/status-live`.

**Data:**
```json
{
  "sessionId": 1,
  "gameId": 5,
  "roomCode": "LSBN",
  "appTag": "drawful2international",
  "maxPlayers": 8,
  "playerCount": 4,
  "players": ["Alice", "Bob", "Charlie", "Diana"],
  "lobbyState": "CanStart",
  "gameState": "Lobby",
  "gameStarted": false,
  "gameFinished": false,
  "monitoring": true
}
```

### player-count.updated

- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** `PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count` (manual override only)

**Data:**
```json
{
  "sessionId": "3",
  "gameId": "7",
  "playerCount": 6,
  "status": "completed"
}
```

### vote.received

- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** `POST /api/votes/live` (recording a live vote). Only fires for live votes, NOT chat-import.

**Data:**
```json
{
  "sessionId": 5,
  "game": {
    "id": 42,
    "title": "Quiplash 3",
    "pack_name": "Party Pack 7"
  },
  "vote": {
    "username": "viewer123",
    "type": "up",
    "timestamp": "2026-03-15T20:29:55.000Z"
  },
  "totals": {
    "upvotes": 14,
    "downvotes": 3,
    "popularity_score": 11
  }
}
```

### voting.ended

- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** `POST /api/sessions/:id/voting/end` (host ends the voting/polling period)

**Data:**
```json
{
  "sessionId": 3
}
```

### poll.start

- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** `POST /api/sessions/:id/voting/start` (host starts a new poll)

**Data:**
```json
{
  "sessionId": 3
}
```

### poll.leading

- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Downstream voting client sends `poll.leading` message (rebroadcast to all session subscribers)

**Data:**
```json
{
  "sessionId": 3,
  "gameId": 42,
  "label": "Quiplash 3",
  "votes": 7
}
```

---

## 7. Error Handling

| Type         | Message                               | When                                    |
|--------------|----------------------------------------|-----------------------------------------|
| `error`      | `Not authenticated`                   | subscribe/unsubscribe without auth      |
| `error`      | `Session ID required`                 | subscribe without `sessionId`           |
| `error`      | `Unknown message type: foo`          | Unknown `type` in client message        |
| `error`      | `Invalid message format`              | Unparseable or non-JSON message         |
| `auth_error` | `Token required`                      | auth without token                      |
| `auth_error` | `Invalid or expired token`            | auth with invalid/expired JWT            |

---

## 8. Heartbeat and Timeout

- **Client → Server:** Send `{ "type": "ping" }` periodically
- **Server → Client:** Responds with `{ "type": "pong" }`
- **Timeout:** If no ping is received for **60 seconds**, the server terminates the connection
- **Server check:** The server checks for stale connections every **30 seconds**

Implement a heartbeat on the client to keep the connection alive:

```javascript
let pingInterval;

function startHeartbeat() {
  pingInterval = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ type: 'ping' }));
    }
  }, 30000); // every 30 seconds
}

ws.onopen = () => {
  startHeartbeat();
};

ws.onclose = () => {
  clearInterval(pingInterval);
};
```

---

## 9. Reconnection

The server does **not** maintain state across disconnects. After reconnecting:

1. **Re-authenticate** with an `auth` message
2. **Re-subscribe** to any sessions you were tracking

Implement exponential backoff for reconnection attempts:

```javascript
let reconnectAttempts = 0;
const maxReconnectAttempts = 10;
const baseDelay = 1000;

function connect() {
  const ws = new WebSocket('ws://localhost:5000/api/sessions/live');

  ws.onopen = () => {
    reconnectAttempts = 0;
    ws.send(JSON.stringify({ type: 'auth', token: jwt }));
    // After auth_success, re-subscribe to sessions...
  };

  ws.onclose = () => {
    if (reconnectAttempts < maxReconnectAttempts) {
      const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts), 60000);
      reconnectAttempts++;
      setTimeout(connect, delay);
    }
  };
}

connect();
```

---

## 10. Complete Example

Full session lifecycle from connect to disconnect:

```javascript
const JWT = 'your-jwt-token';
const WS_URL = 'ws://localhost:5000/api/sessions/live';

const ws = new WebSocket(WS_URL);
let pingInterval;
let subscribedSessions = new Set();

function send(msg) {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify(msg));
  }
}

ws.onopen = () => {
  console.log('Connected');
  send({ type: 'auth', token: JWT });

  pingInterval = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
      send({ type: 'ping' });
    }
  }, 30000);
};

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);

  switch (msg.type) {
    case 'auth_success':
      console.log('Authenticated');
      send({ type: 'subscribe', sessionId: 3 });
      break;

    case 'auth_error':
      console.error('Auth failed:', msg.message);
      break;

    case 'subscribed':
      subscribedSessions.add(msg.sessionId);
      console.log('Subscribed to session', msg.sessionId);
      break;

    case 'unsubscribed':
      subscribedSessions.delete(msg.sessionId);
      console.log('Unsubscribed from session', msg.sessionId);
      break;

    case 'pong':
      // Heartbeat acknowledged
      break;

    case 'session.started':
      console.log('New session:', msg.data.session);
      break;

    case 'game.added':
      console.log('Game added:', msg.data.game.title, 'to session', msg.data.session.id);
      break;

    case 'session.ended':
      console.log('Session ended:', msg.data.session.id);
      subscribedSessions.delete(msg.data.session.id);
      break;

    case 'room.connected':
      console.log('Room connected:', msg.data.roomCode, '- players:', msg.data.players.join(', '));
      break;

    case 'lobby.player-joined':
      console.log('Player joined:', msg.data.playerName, '- count:', msg.data.playerCount);
      break;

    case 'lobby.updated':
      console.log('Lobby:', msg.data.lobbyState);
      break;

    case 'game.started':
      console.log('Game started with', msg.data.playerCount, 'players');
      break;

    case 'game.ended':
      console.log('Game ended with', msg.data.playerCount, 'players');
      break;

    case 'room.disconnected':
      console.log('Room disconnected:', msg.data.reason);
      break;

    case 'game.status':
      console.log('Status heartbeat:', msg.data.roomCode, '- players:', msg.data.playerCount, '- state:', msg.data.gameState);
      break;

    case 'player-count.updated':
      console.log('Player count:', msg.data.playerCount, 'for game', msg.data.gameId);
      break;

    case 'vote.received':
      console.log('Vote:', msg.data.vote.type, 'from', msg.data.vote.username, 'for', msg.data.game.title, '- totals:', msg.data.totals);
      break;

    case 'error':
    case 'auth_error':
      console.error('Error:', msg.message);
      break;

    default:
      console.log('Unknown message:', msg);
  }
};

ws.onerror = (err) => console.error('WebSocket error:', err);
ws.onclose = () => {
  clearInterval(pingInterval);
  console.log('Disconnected');
};

// Later: unsubscribe and close
function disconnect() {
  subscribedSessions.forEach((sessionId) => {
    send({ type: 'unsubscribe', sessionId });
  });
  ws.close();
}
```
