WebSocket Development

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

  1. Session is retrieved
  2. Player is re-joined to their table room
  3. A reconnected event 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:

PipelineEvents
gamejoin_table, leave_table, player_action, sit_out, sit_in, add_chips, chat_message, request_table_state
lobbysubscribe_lobby, unsubscribe_lobby, request_lobby_state
state-syncInternal 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

EventDirectionPayloadNotes
join_tableC→S{ tableId, seatIndex, buyIn, password? }
leave_tableC→S{ tableId }
player_actionC→S{ action, amount?, handNumber?, street?, bettingRound?, idempotencyKey? }action: fold/check/call/bet/raise/all-in
sit_outC→S{ tableId }
sit_inC→S{ tableId }
use_time_bankC→S{ tableId, seconds? }
add_chipsC→S{ tableId, amount }
chat_messageC→S{ tableId, message }Rate limited
request_table_stateC→S{ tableId }
watch_table / stop_watchingC→S{ tableId }
ack_deliveryC→S{ deliveryId }
tournament_rebuy / tournament_addon / tournament_reentryC→S{ tableId, idempotencyKey? }
logout_intent, pingC→SNoneCallback only
table_stateS→CTableStateFull state broadcast
player_joinedS→CPlayerJoinedPayload
player_leftS→CPlayerLeftPayload
game_startedS→CGameStartedPayloadGuaranteed delivery
hole_cardsS→CHoleCardsPayloadPrivate; count by variant; guaranteed delivery
action_requiredS→CActionRequiredPayloadSent to the player to act; guaranteed delivery
player_actedS→CPlayerActedPayloadGuaranteed delivery
community_cardsS→CCommunityCardsPayload
hand_resultS→CHandResultPayloadWinners, pot distribution; guaranteed delivery
lobby_stateS→CLobbyStatePayloadFull lobby on subscribe
lobby_updateS→CLobbyUpdatePayloadDelta update
errorS→C{ code, message }

See the WebSocket reference for the full list.


Back to Developer Guide

Try the live demo Pricing

Have questions about Poker Engine? We reply fast.