Work spanning May 7-10 across multiple sessions:
Poll winner detection + source column (May 7):
- Fix race condition in handleEndPolling where WS voting.ended cleared
leadingGame before the setTimeout could capture the winner
- Add pollActiveRef guard to prevent late poll.leading messages from
re-activating an ended poll
- Add 'source' column to session_games (dice/manual/poll) with backward-
compatible fallback from manually_added flag
- Show indigo "Poll" badge in game lists (Picker, Home, SessionDetail)
- Include source in session export (JSON and text formats)
Multi-admin poll state sync (May 9):
- Enrich poll.start broadcast with pollStartedAt timestamp so all admin
clients can start their timers from the correct time
- Enrich voting.ended broadcast with winnerGameId/Label/Votes so all
admins see the winner prompt, not just the one who clicked End Poll
- Add poll.start WS handler in SessionInfo so Admin B sees polls started
by Admin A without refreshing
- Make handleStartPolling optimistic with rollback on failure
WebSocket keepalive + auto-reconnect (May 9):
- Add 30s ping interval to SessionInfo WS connection (matching server's
60s timeout) to prevent silent disconnects
- Add auto-reconnect on close with 3s delay
- Proper cleanup of ping interval, reconnect timeout, and onclose handler
Sync selected game across admin clients (May 10):
- New POST/DELETE /sessions/:id/game-selection endpoints with DB
persistence (pending_game_id, pending_game_source columns)
- Broadcast game.picked/game.dismissed WS events to session subscribers
- handleDismissGame replaces inline setSelectedGame(null) calls
- Restore pending game selection on page load for late-joining admins
- Clear pending selection when game is formally added to session
Poll ending countdown timer (May 10):
- POST /:id/voting/end now accepts optional { delay } (0-300 seconds)
- New POST /:id/voting/cancel-end to abort a scheduled end
- New poll.ending and poll.ending.cancelled WS events
- poll_ending_at column on sessions table for crash recovery
- rescheduleEndingPolls() called on server startup to resume countdowns
- End Poll button opens popover with End Now / 5s / 10s / 30s / custom
- Red "Poll Ending" card with countdown display and Cancel button
- Document new WS events in docs/api/websocket.md
Co-authored-by: Cursor <cursoragent@cursor.com>
683 lines
17 KiB
Markdown
683 lines
17 KiB
Markdown
# 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) |
|
|
| `poll.ending` | Poll is ending after a countdown (broadcast to subscribers) |
|
|
| `poll.ending.cancelled`| Scheduled poll end was cancelled (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, either immediately or when a countdown reaches zero)
|
|
|
|
**Data:**
|
|
```json
|
|
{
|
|
"sessionId": 3,
|
|
"winnerGameId": 42,
|
|
"winnerLabel": "Quiplash 3",
|
|
"winnerVotes": 7
|
|
}
|
|
```
|
|
|
|
### 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
|
|
}
|
|
```
|
|
|
|
### poll.ending
|
|
|
|
- **Broadcast to:** Clients subscribed to the session
|
|
- **Triggered by:** `POST /api/sessions/:id/voting/end` with `{ "delay": N }` where N > 0
|
|
|
|
Signals that a countdown has started and the poll will automatically end when the timer reaches zero. All connected admin clients should display the countdown. The `endsAt` timestamp is authoritative; derive the remaining time on each client by comparing against the local clock.
|
|
|
|
**Data:**
|
|
```json
|
|
{
|
|
"sessionId": 3,
|
|
"endsAt": "2026-05-10T20:15:30.000Z",
|
|
"delaySeconds": 30
|
|
}
|
|
```
|
|
|
|
| Field | Type | Description |
|
|
|----------------|---------|--------------------------------------------------|
|
|
| `sessionId` | number | The session the poll belongs to |
|
|
| `endsAt` | string | ISO 8601 timestamp when the poll will auto-end |
|
|
| `delaySeconds` | number | Original delay requested (seconds, 1-300) |
|
|
|
|
### poll.ending.cancelled
|
|
|
|
- **Broadcast to:** Clients subscribed to the session
|
|
- **Triggered by:** `POST /api/sessions/:id/voting/cancel-end`
|
|
|
|
The scheduled poll end was cancelled by an admin. Clients should stop displaying the countdown and revert to the normal "Voting In Progress" state.
|
|
|
|
**Data:**
|
|
```json
|
|
{
|
|
"sessionId": 3
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 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();
|
|
}
|
|
```
|