This guide covers the WebSocket layer: connection lifecycle, typed events, event pipeline, broadcasting, and client-side hooks.
Overview
WebSocket communication is built on Socket.io. The server is set up in src/websocket/index.ts. All game interactions (joining a table, making a bet, receiving cards) happen over WebSocket.
Event types are defined in a single source of truth: src/websocket/types/events.ts.
Connection Lifecycle
Source: src/websocket/connection-handler.ts
Authentication
Authentication runs as Socket.io middleware before any handlers are registered:
// src/websocket/index.ts
io.use(async (socket, next) => {
// Handshake auth first, then the HttpOnly access_token cookie
const token = socket.handshake.auth.token /* or cookie */;
if (!token) {
// Guest: lobby-only, max 5 per IP
socket.data.isGuest = true;
return next();
}
const context = await validateAccessTokenContext(token, ip);
populateSocketData(socket, context.payload);
next();
});
Sockets with no token join as guests. An invalid token is rejected with Authentication failed: Invalid or expired token.
Connection Events
io.on('connection', (socket: PokerSocket) => {
handleConnection(socket, mergedConfig);
// Guests: lobby and ping handlers only
// Users: restore a disconnected session, then register all handlers
socket.on('disconnect', async (reason) => {
// Store session for reconnection window
// Start removal timer (configurable grace period)
});
});
Reconnection Handling
When a player disconnects, their session is stored in session-manager.ts and a removal timer starts using the admin-configured grace period. If they reconnect within the admin-configured reconnect window (reconnectWindowSeconds):
- Session is retrieved
- Player is re-joined to their table room
- A
reconnectedevent is sent with the table state and sequence gap information
Typed Events
Source: src/websocket/types/events.ts
All events are fully typed. The ServerToClientEvents and ClientToServerEvents interfaces define every event and its payload:
interface ClientToServerEvents {
join_table: (data: JoinTableRequest, callback?: (response: JoinTableResponse) => void) => void;
leave_table: (data: LeaveTableRequest, callback?: (response: LeaveTableResponse) => void) => void;
player_action: (data: PlayerActionRequest, callback?: (response: PlayerActionResponse) => void) => void;
sit_out: (data: SitOutRequest) => void;
sit_in: (data: SitInRequest) => void;
use_time_bank: (data: UseTimeBankRequest, callback?: (response: UseTimeBankResponse) => void) => void;
add_chips: (data: AddChipsRequest, callback?: (response: AddChipsResponse) => void) => void;
chat_message: (data: ChatMessageRequest) => void;
request_table_state: (data: RequestTableStateRequest, callback?: (response: RequestTableStateResponse) => void) => void;
subscribe_lobby: () => void;
unsubscribe_lobby: () => void;
// ...
}
interface ServerToClientEvents {
table_state: (state: TableStatePayload) => void;
player_joined: (data: PlayerJoinedPayload) => void;
player_left: (data: PlayerLeftPayload) => void;
game_started: (data: GameStartedPayload) => void;
hole_cards: (data: HoleCardsPayload) => void;
action_required: (data: ActionRequiredPayload) => void;
player_acted: (data: PlayerActedPayload) => void;
community_cards: (data: CommunityCardsPayload) => void;
hand_result: (data: HandResultPayload) => void;
chat_message: (data: ChatMessagePayload) => void;
error: (data: ErrorPayload) => void;
// Lobby events
lobby_state: (state: LobbyStatePayload) => void;
lobby_update: (update: LobbyUpdatePayload) => void;
// ...
}
Typed Socket Wrapper
Source: src/websocket/typed-socket.ts
The TypedServer class wraps Socket.io with compile-time type checking for emits:
import { createTypedServer } from './typed-socket.js';
import { getPlayerRoom } from './rooms.js';
const typedServer = createTypedServer(io);
// Compile-time error if payload type doesn't match
typedServer.to(getPlayerRoom(tableId)).emit('table_state', tableState);
Event Pipeline
Source: src/websocket/pipeline/
The pipeline modules define these stages for the game pipeline. Connection handlers call the rate limit stage and idempotency helpers directly; see WebSocket Pipeline.
Client Event
│
▼
Zod validation — createValidationMiddleware, schemas/client-events.ts
│
▼
1. Validation Stage — user is authenticated, basic data checks
│
▼
2. Authorization Stage — player is at a table, for table-required events
│
▼
3. Rate Limit Stage — prevents event flooding (per event type)
│
▼
4. Idempotency Stage — deduplicates retried events (by idempotency key)
│
▼
5. Logging and Completion Stages
Pipeline Types
There are three pipelines:
| Pipeline | Events |
|---|---|
game | join_table, leave_table, player_action, sit_out, sit_in, add_chips, chat_message, request_table_state |
lobby | subscribe_lobby, unsubscribe_lobby, request_lobby_state |
state-sync | Internal state broadcasts: full_state_broadcast, showdown_broadcast, player_notification |
Event Context
Every event gets an EventContext with correlation ID, timestamp, socket reference, and metadata for logging:
interface EventContext {
correlationId: string; // UUID for tracing
timestamp: number;
socket: PokerSocket;
userId: string;
username: string;
currentTableId?: string;
metadata: {
eventName: string;
pipeline: PipelineType;
stages: string[]; // Stages completed
warnings: string[];
processingTimeMs?: number;
};
}
Idempotency
player_action accepts an idempotencyKey. If the same key is seen again within the 5-minute store TTL, the cached response is returned without re-execution. tournament_rebuy, tournament_addon and tournament_reentry also accept one.
Guaranteed Delivery
Source: src/websocket/pipeline/guaranteed-delivery-stage.ts
Events in CRITICAL_EVENTS (hole_cards, action_required, hand_result, pot_update, showdown, game_started, forced_bets, player_acted) are tracked per user with a delivery ID. The client acknowledges receipt; unacknowledged deliveries are retried up to 3 times with a 5-second acknowledgment timeout:
// Client acknowledges
socket.emit('ack_delivery', { deliveryId: payload._deliveryId });
// Retried deliveries carry _isRetry: true
Event Handlers
Source: src/websocket/game-events/
Each event type has a dedicated handler function. Handlers read the user from socket.data and return a response for the event callback:
// src/websocket/game-events/player-actions.ts
export async function handleJoinTable(
socket: PokerSocket,
request: JoinTableRequest
): Promise<JoinTableResponse> {
const userId = socket.data.userId;
// Validate, seat the player, broadcast state
}
Broadcasting
Source: src/websocket/broadcast.ts
import { broadcastToTable, sendToUser } from './broadcast.js';
// Broadcast to the table's player and spectator rooms (adds _seq and _ts)
await broadcastToTable(tableId, 'table_state', tableState);
// Send privately to one user via the user:{userId} room
await sendToUser(userId, 'hole_cards', holeCardsPayload);
// Broadcast to lobby subscribers
notifyTableUpdated(tableId, tableInfo);
Table Rooms
Socket.io rooms are used for table-scoped broadcasts:
// src/websocket/rooms.ts
const playerRoom = getPlayerRoom(tableId); // table:{tableId}:players
const spectatorRoom = getSpectatorRoom(tableId); // table:{tableId}:spectators
// Join room
socket.join(playerRoom);
// Leave room
socket.leave(playerRoom);
// Broadcast to room
io.to(playerRoom).emit('player_acted', payload);
Table Manager
Source: src/websocket/table-manager.ts
Game state is kept in memory (not in the database) for performance. The table-manager.ts maintains a map of active tables:
// Create a table and register it as active
const table = createAndRegisterTable(config);
// Get an active table
const activeTable = getTable(tableId);
// Get the table ID for a player (which table they are sitting at)
const tableId = getPlayerTableId(userId);
// Get table state formatted for a specific viewer
const state = getTableStateForViewer(tableId, viewingUserId);
State Synchronization
Source: src/websocket/state-sync.ts
The state sync pipeline handles:
- Full table state broadcasts after each action
- Sequence numbers to detect missed updates
- Showdown state with appropriate card visibility per player
Client-Side Hooks (React)
Source: client/src/hooks/
Base Socket Hook
import { useSocket } from '@/hooks';
const { socket, isConnected, userId, sessionId, error, connect, disconnect } = useSocket();
Exposes the shared socket connection managed by client/src/lib/socket.ts.
Game Socket Hook
import { useGameSocket } from '@/hooks';
const {
isConnected,
isJoined,
error,
remoteStyleId,
joinTable, // (seatIndex: number, buyIn: number, password?: string) => Promise<boolean>
leaveTable, // (keepWatching?: boolean) => Promise<{ success: boolean; cashOut?: number; error?: string }>
sendAction, // (action: string, amount?: number) => Promise<boolean>
sitOut,
sitIn,
useTimeBank,
addChips,
tournamentRebuy,
tournamentAddOn,
tournamentReentry,
sendChat,
} = useGameSocket({ tableId });
Handles join/leave, game actions, and subscribes to all table events.
Lobby Socket Hook
import { useLobbySocket } from '@/hooks';
const { isConnected, isSubscribed, subscribe, unsubscribe, refreshLobby } = useLobbySocket();
Subscribes to lobby state updates (table list, player counts).
Spectator Socket Hook
import { useSpectatorSocket } from '@/hooks/useSpectatorSocket';
const { isConnected, isWatching, tableState, error, watchTable, stopWatching } = useSpectatorSocket({ tableId });
Watches a table without taking a seat.
Development Proxy
In development, the Vite dev server proxies WebSocket connections to avoid CORS:
// client/vite.config.ts
server: {
proxy: {
'/socket.io': {
target: 'http://localhost:3000',
ws: true,
},
},
}
Debugging WebSocket Events
Server-Side
Event tracing is on outside production and enabled in production with EVENT_TRACING=true. Query traces through the admin-only GET /api/v1/debug/events routes.
// Manually log all incoming events
io.on('connection', (socket) => {
socket.onAny((event, ...args) => {
logger.debug({ event, args }, 'Socket event received');
});
});
Client-Side (Browser Console)
// Enable Socket.io client debug logging
localStorage.debug = 'socket.io-client:socket';
Event Reference
| Event | Direction | Payload | Notes |
|---|---|---|---|
join_table | C→S | { tableId, seatIndex, buyIn, password? } | |
leave_table | C→S | { tableId } | |
player_action | C→S | { action, amount?, handNumber?, street?, bettingRound?, idempotencyKey? } | action: fold/check/call/bet/raise/all-in |
sit_out | C→S | { tableId } | |
sit_in | C→S | { tableId } | |
use_time_bank | C→S | { tableId, seconds? } | |
add_chips | C→S | { tableId, amount } | |
chat_message | C→S | { tableId, message } | Rate limited |
request_table_state | C→S | { tableId } | |
watch_table / stop_watching | C→S | { tableId } | |
ack_delivery | C→S | { deliveryId } | |
tournament_rebuy / tournament_addon / tournament_reentry | C→S | { tableId, idempotencyKey? } | |
logout_intent, ping | C→S | None | Callback only |
table_state | S→C | TableState | Full state broadcast |
player_joined | S→C | PlayerJoinedPayload | |
player_left | S→C | PlayerLeftPayload | |
game_started | S→C | GameStartedPayload | Guaranteed delivery |
hole_cards | S→C | HoleCardsPayload | Private; count by variant; guaranteed delivery |
action_required | S→C | ActionRequiredPayload | Sent to the player to act; guaranteed delivery |
player_acted | S→C | PlayerActedPayload | Guaranteed delivery |
community_cards | S→C | CommunityCardsPayload | |
hand_result | S→C | HandResultPayload | Winners, pot distribution; guaranteed delivery |
lobby_state | S→C | LobbyStatePayload | Full lobby on subscribe |
lobby_update | S→C | LobbyUpdatePayload | Delta update |
error | S→C | { code, message } |
See the WebSocket reference for the full list.
Back to Developer Guide