WebSocket

This document describes all WebSocket events used in the Poker Engine for real-time communication between clients and the server.

Server: Socket.io v4.x

Total Events: 69 (20 Client→Server, 49 Server→Client). The canonical types are ClientToServerEvents and ServerToClientEvents in src/websocket/types/events.ts; client-side copies live in packages/shared/src/types/events.ts.

Table of Contents

  1. Connection Setup
  2. Authentication
  3. Client → Server Events
  4. Server → Client Events
  5. Room Management
  6. State Synchronization
  7. Error Handling
  8. Reconnection

Connection Setup

Connecting to the Server

import { io } from 'socket.io-client';

const socket = io('wss://your-server.com', {
  auth: {
    token: 'your-jwt-access-token'
  },
  transports: ['websocket'],
  reconnection: true,
  reconnectionAttempts: 5,
  reconnectionDelay: 1000
});

Configuration

SettingValueDescription
Ping Timeout60,000msTime before connection considered dead
Ping Interval25,000msHeartbeat frequency
Reconnection Window300,000ms defaultFallback; the admin-configured reconnectWindowSeconds takes precedence
CORS OriginsCORS_ORIGINSFalls back to localhost:3000 and localhost:5173

Authentication

The server reads a JWT from the auth.token handshake parameter, then falls back to the HttpOnly access_token cookie.

Connections with no token join as guests with lobby-only access, limited to 5 per IP address. Guests have an ID of the form guest-<uuid> and socket.data.isGuest set to true.

Token Validation

The server validates the JWT token on connection and extracts:

interface SocketData {
  userId: string;
  username: string;
  email: string;
  groupId: string;
  permissions: string;
  currentTableId?: string;
  connectedAt: number;
  lastActivity: number;
  sessionId: string;
  adminSessionId?: string;
  isGuest?: boolean;
  isSpectator?: boolean;
  spectatingTableId?: string;
  intentionalLogout?: boolean;
}

Connection Established

Upon successful authentication, the server emits:

socket.on('connection_established', (data) => {
  console.log('Connected:', data);
  // { userId: 'uuid', sessionId: 'uuid' }
});

Authentication Failure

If authentication fails, the connection is rejected with an error:

socket.on('connect_error', (error) => {
  console.error('Connection failed:', error.message);
  // 'Authentication failed: Invalid or expired token'
  // 'Too many connections from this IP address' (guests)
});

Client → Server Events

Game Events

join_table

Join a poker table and take a seat.

Payload:

{
  tableId: string;      // Table UUID
  seatIndex: number;    // Seat position (0-9)
  buyIn: number;        // Chip amount to buy in
  password?: string;    // Required for private tables
}

Callback Response:

{
  success: boolean;
  error?: string;       // Error message if failed
  tableState?: TableState;
}

Example:

socket.emit('join_table', {
  tableId: 'abc123',
  seatIndex: 3,
  buyIn: 1000
}, (response) => {
  if (response.success) {
    console.log('Joined table successfully');
  } else {
    console.error('Failed to join:', response.error);
  }
});

leave_table

Leave the current table and cash out.

Payload:

{
  tableId: string;
}

Callback Response:

{
  success: boolean;
  error?: string;
  cashOut?: number;     // Final chip amount returned
}

Example:

socket.emit('leave_table', { tableId: 'abc123' }, (response) => {
  if (response.success) {
    console.log('Cashed out:', response.cashOut);
  }
});

player_action

Submit a betting action during your turn.

Payload:

{
  action: 'fold' | 'check' | 'call' | 'bet' | 'raise' | 'all-in';
  amount?: number;      // Required for bet/raise
  // Optional prompt context from the latest action_required payload
  handNumber?: number;
  street?: string | null;
  bettingRound?: string | null;
  idempotencyKey?: string; // Max 128 characters
}

Callback Response:

{
  success: boolean;
  error?: string;
  errorCode?: string;   // e.g. STALE_HAND, STALE_STREET, DUPLICATE_ACTION
  cached?: boolean;     // True if an idempotent retry returned the cached result
}

Example:

// Fold
socket.emit('player_action', { action: 'fold' }, callback);

// Call
socket.emit('player_action', { action: 'call' }, callback);

// Raise to 100
socket.emit('player_action', { action: 'raise', amount: 100 }, callback);

// All-in
socket.emit('player_action', { action: 'all-in' }, callback);

Offer the actions in validActions from the latest action_required payload.

sit_out

Temporarily sit out from the game (skip hands).

Payload:

{
  tableId: string;
}

Example:

socket.emit('sit_out', { tableId: 'abc123' });

sit_in

Return to active play after sitting out.

Payload:

{
  tableId: string;
}

Example:

socket.emit('sit_in', { tableId: 'abc123' });

add_chips

Add more chips to your stack (rebuy).

Payload:

{
  tableId: string;
  amount: number;
}

Callback Response:

{
  success: boolean;
  error?: string;
  errorCode?: string;
}

Constraints:

  • Total stack cannot exceed table’s max buy-in

chat_message

Send a chat message to the table.

Payload:

{
  tableId: string;
  message: string;      // Schema max 500 characters; stored message truncated to 200
}

Example:

socket.emit('chat_message', {
  tableId: 'abc123',
  message: 'Nice hand!'
});

use_time_bank

Spend time-bank seconds on your current action.

Payload:

{
  tableId: string;
  seconds?: number;     // Positive integer, max 600
}

Callback Response:

{
  success: boolean;
  error?: string;
  secondsUsed?: number;
  remainingSeconds?: number;
  actionTimeRemainingMs?: number | null;
}

logout_intent

Tell the server the next disconnect is an intentional logout.

Payload: None. Callback Response: { success: boolean; error?: string }

request_table_state

Request the current table state for a table you are seated at. Requires a seat at that table.

Payload: { tableId: string }

Callback Response: { success: boolean; error?: string; sequenceNumber?: number }

ack_delivery

Acknowledge a guaranteed-delivery event (see Guaranteed Delivery).

Payload: { deliveryId: string } (max 64 characters)

Callback Response: { success: boolean; error?: string }

watch_table / stop_watching

Start or stop spectating a table.

Payload: { tableId: string }

Callback Response (watch_table): { success: boolean; error?: string; tableState?: TableState; sequenceNumber?: number }

Callback Response (stop_watching): { success: boolean; error?: string }

tournament_rebuy / tournament_addon / tournament_reentry

Tournament chip actions for the player’s current table.

Payload:

{
  tableId: string;
  idempotencyKey?: string; // 1-128 characters
}

Callback Response:

{
  success: boolean;
  error?: string;
  errorCode?: string;
  queued?: boolean;
  replayed?: boolean;
}

ping

Heartbeat for latency measurement.

Payload: None

Callback Response:

{
  pong: boolean;
  timestamp: number;
}

Example:

const start = Date.now();
socket.emit('ping', (response) => {
  const latency = Date.now() - start;
  console.log('Latency:', latency, 'ms');
});

Lobby Events

subscribe_lobby

Subscribe to real-time lobby updates.

Payload: None

Example:

socket.emit('subscribe_lobby');

After subscribing, you’ll receive:

  • lobby_state – Initial full state
  • lobby_update / lobby_updates – Real-time changes

unsubscribe_lobby

Stop receiving lobby updates.

Payload: None

Example:

socket.emit('unsubscribe_lobby');

request_lobby_state

Request immediate lobby state refresh.

Payload: None

Example:

socket.emit('request_lobby_state');
// Server responds with 'lobby_state' event

Server → Client Events

Connection Events

connection_established

Confirms successful authentication.

Payload:

{
  userId: string;
  sessionId: string;
}

reconnected

Player rejoined table after disconnect.

Payload:

{
  tableId: string;
  previousTableId?: string;
  tableMove?: {
    fromTableId: string;
    toTableId: string;
    reason: 'reconnect_after_table_move';
  };
  state: TableState | { message: string };
  sequenceGap?: {
    hasMissedEvents: boolean;
    lastReceivedSequence: number;
    currentSequence: number;
    missedCount: number;
  };
  _seq?: number;
  _ts?: number;
}

error

Error notification.

Payload:

{
  code: string;
  message: string;
}

Error Codes:

CodeDescription
JOIN_FAILEDUnable to join table
LEAVE_FAILEDUnable to leave table
ACTION_FAILEDInvalid or rejected action
ADD_CHIPS_FAILEDCannot add chips
TABLE_NOT_FOUNDTable doesn’t exist
TIME_BANK_FAILEDCannot use time bank
WATCH_FAILEDCannot watch table
STOP_WATCHING_FAILEDCannot stop watching
CHAT_DISABLEDChat is off
CHAT_FORBIDDENNot allowed to chat
PLAYER_PROTECTIONBlocked by account protection
ACCOUNT_CHECK_FAILEDAccount state could not be checked
DUPLICATE_SESSIONSession already active elsewhere
MAINTENANCEMaintenance mode is on
SESSION_EXPIREDSession revoked or expired
REFUND_FAILEDBuy-in refund failed
RATE_LIMITToo many requests

server_migrating

The server is draining for a live session migration.

Payload:

{
  resumeExpectedMs: number;
  reason: 'redeploy' | 'operator' | 'sigusr2';
  drainDeadlineAt: string | null;
}

hand_voided

A hand was voided after a server failure.

Payload: { tableId: string; handNumber?: number; reason: 'server_failure'; message: string }

Table State Events

table_state

Complete or delta table state update.

Payload:

interface TableState {
  id: string;
  name: string;
  mode: string;
  type: string;
  tournamentId?: string | null;
  gameType?: string;
  maxPlayers: number;
  minBuyIn: number;
  maxBuyIn: number;
  smallBlind: number;
  bigBlind: number;
  ante: number;
  gameState: string;
  isPaused: boolean;
  handNumber: number;
  dealerSeat: number;
  smallBlindSeat: number;
  bigBlindSeat: number;
  currentPlayerSeat: number;
  bettingRound?: string | null;
  street?: string | null;
  communityCards: string[];
  pot: number;
  currentBet: number;
  players: TablePlayer[];
  actionTimeRemaining: number | null;
  bettingLimit?: string;
  minRaise?: number;
  showdownDelayMs?: number;
  _seq?: number;        // Sequence number
  _ts?: number;         // Server timestamp
  // Also: ruleProfile, tournamentRules, handForHand, buttonBlind, bringIn,
  // completionBet, smallBet, bigBet, bringInSeat, isBettingRound, mixedGame
}

interface TablePlayer {
  id: string;
  username?: string;
  stack: number;
  seatIndex: number;
  isSittingOut: boolean;
  hasFolded: boolean;
  isAllIn: boolean;
  currentBet: number;
  totalBet: number;
  holeCards: string[] | null;
  studCards?: StudVisibleCard[] | null;
  hasActed: boolean;
  timeBankRemaining: number;
  isBot: boolean;
  avatar?: string;
  isWaitingForNextHand?: boolean;
  isBringIn?: boolean;
}

Card Visibility:

  • State is filtered per viewer, so opponents’ hole cards are hidden
  • At showdown: non-mucked cards are revealed

player_joined

New player seated at table.

Payload:

{
  userId: string;
  username: string;
  seatIndex: number;
  stack: number;
}

player_left

Player left the table.

Payload:

{
  userId: string;
}

game_started

A new hand has begun.

Payload:

{
  handNumber: number;
  dealerSeat: number;
}

hole_cards

Your private hole cards (sent only to you).

Payload:

{
  cards: string[];          // Count depends on variant, e.g. ['AS', 'KH']
  tableId: string;
  handNumber: number;
  gameType: string;
  street: string | null;
  bettingRound: string | null;
  _seq?: number;
  _ts?: number;
  _deliveryId?: string;
  _isRetry?: boolean;
}

Card Format:

  • Rank: 2-9, T (10), J, Q, K, A
  • Suit: S (Spades), H (Hearts), D (Diamonds), C (Clubs)
  • Examples: AS (Ace of Spades), TH (Ten of Hearts)

action_required

It’s your turn to act.

Payload:

{
  tableId: string;
  userId: string;
  timeout: number;
  timeBankRemaining?: number;
  timeBankEnabled?: boolean;
  timeBankIncrementSeconds?: number;
  timeoutPolicy?: string;
  validActions: string[];   // ['fold', 'call', 'raise']
  callAmount?: number;
  minBet?: number;
  maxBet?: number;
  minRaise?: number;
  maxRaise?: number;
  bettingLimit?: string;
  handNumber: number;
  gameType: string;
  street: string | null;
  bettingRound: string | null;
}

Example:

socket.on('action_required', (data) => {
  if (data.userId === myUserId) {
    showActionButtons(data.validActions);
    startTimer(data.timeout);
  }
});

player_acted

A player made an action (broadcast to all).

Payload:

{
  userId: string;
  action: string;
  amount?: number;
  tableId: string;
  handNumber: number;
  gameType: string;
  street: string | null;
  bettingRound: string | null;
}

community_cards

Community cards revealed.

Payload:

{
  cards: string[];
  allCards?: string[];
  stage: string;
  previousBets?: Array<{ playerId: string; amount: number }>;
  tableId: string;
  handNumber: number;
  gameType: string;
  street: string | null;
  bettingRound: string | null;
}

Example:

pot_update

Pot amount changed.

Payload:

{
  pot: number;
  sidePots?: Array<{
    amount: number;
    eligiblePlayers: string[];
  }>;
  idempotencyKey?: string;
  _deliveryId?: string;
  _isRetry?: boolean;
}

showdown

Cards revealed at showdown.

Payload:

{
  players: Array<{
    playerId: string;
    cards: string[];
    handName: string;
    handRank?: number;
    bestHandCards?: string[];
    wasMucked?: boolean;
  }>;
  showOrder?: string[];
  allInPlayerIds?: string[];
  muckedPlayerIds?: string[];
  hiLo?: ShowdownHiLoPayload;
  muckedHandRevealDelayMs?: number;
  revealTiming?: 'all_in_before_runout' | 'final_showdown';
  tableId: string;
  handNumber: number;
  gameType: string;
  street: string | null;
  bettingRound: string | null;
  _deliveryId?: string;
  _isRetry?: boolean;
}

hand_result

Final result of the hand.

Payload:

{
  tableId: string;
  handNumber: number;
  gameType: string;
  street: string | null;
  bettingRound: string | null;
  winners: Array<{
    playerId: string;
    amount: number;
    hand?: string;          // Winning hand description
    handName?: string;
    winType?: 'high' | 'low';
    bestCards?: string[];
    isUncalledReturn?: boolean;
  }>;
  showOrder?: string[];
  allInPlayerIds?: string[];
  muckedPlayerIds?: string[];
  hiLo?: ShowdownHiLoPayload;
  muckedHandRevealDelayMs?: number;
  idempotencyKey?: string;
  _deliveryId?: string;
  _isRetry?: boolean;
}

Example:

socket.on('hand_result', (data) => {
  data.winners.forEach(winner => {
    console.log(`${winner.playerId} won ${winner.amount} with ${winner.hand}`);
  });
});

Other hand events

EventPayload
time_bank_usedplayerId, secondsUsed, remainingSeconds, actionTimeRemainingMs, policy?, plus hand context
deck_procedureprocedure (deck/deal correction or fatal block), plus hand context
betting_startedactivePlayerId, activePlayerSeat, currentBet, pot, plus hand context
forced_betsbets[] (type, playerId, amount, seatIndex, isAllIn?, remainingStack?), pot, total, plus hand context
blinds_postedsmallBlind?, bigBlind?, buttonBlind?, antes?, plus hand context
player_eliminateduserId, tableId, canAddChips, minBuyIn, maxBuyIn, tournamentId?, timeoutSeconds?, rebuy?, reentry?
player_away / player_activeuserId

Hand context is tableId, handNumber, gameType, street, bettingRound.

Chat Events

chat_message

Chat message received at table.

Payload:

{
  userId: string;
  username: string;
  message: string;
  timestamp: number;
}

chat_history

Recent chat for the table. Payload: { messages: ChatMessagePayload[] }

Lobby Events

lobby_state

Full lobby state (sent on subscribe).

Payload:

{
  tables: TableInfo[];
  totalPlayers: number;
  timestamp: number;
}

interface TableInfo {
  id: string;
  name: string;
  mode: string;
  type: string;
  maxPlayers: number;
  playerCount: number;
  smallBlind: number;
  bigBlind: number;
  minBuyIn: number;
  maxBuyIn: number;
  isPrivate: boolean;
  gameState: string;
  gameType: string;
  bettingLimit?: string;
  ruleProfile?: RuleProfileSummary;
}

lobby_update

Single table change.

Payload:

{
  type: LobbyEventType;
  tableId: string;
  table?: TableInfo;
  timestamp: number;
}

LobbyEventType Values:

TypeDescription
table_createdNew public table appeared
table_removedTable closed/removed
table_updatedTable configuration changed
player_joinedPlayer sat at table
player_leftPlayer left table
player_sat_outPlayer sat out
player_sat_inPlayer sat in
game_startedHand began
game_endedHand completed

lobby_updates

Batched lobby updates (debounced 100ms).

Payload:

Array<{
  type: LobbyEventType;
  tableId: string;
  table?: TableInfo;
  timestamp: number;
}>

lobby_stats

Lobby statistics update.

Payload:

{
  totalTables: number;
  totalPlayers: number;
  activeTables: number;
  timestamp: number;
}

Admin Events

table_settings_updated

Table settings changed by admin (broadcast to all players at the table).

Payload:

{
  tableId: string;
  name: string;
  smallBlind: number;
  bigBlind: number;
  minBuyIn: number;
  maxBuyIn: number;
  rake: number;
  maxPlayers: number;
  tableType?: string;
  currency?: string;
  styleId?: string | null;
  moveTimeSeconds?: number | null;
  showdownDelaySeconds?: number | null;
}

Example:

socket.on('table_settings_updated', (data) => {
  console.log('Table settings updated:', data.name);
  updateTableSettings(data);
});

Account and Platform Events

EventPayloadNotes
balance_updatebalance, virtualBalance, tournamentTickets?
account_updatedreasonRefetch the current user (GET /auth/me); reason is informational
force_disconnectreasonServer is closing this session
reality_checkelapsedMinutes, netResult, handsPlayedResponsible-gaming reminder
maintenance_statusenabled, message?
feature_toggles_updatedtoggles, changed, timestamp
tier_updatedallowedByTier, tierName, variationId, featuresIncluded, featuresTotal, disabledFeatures, timestampLicense tier changed
wagering_progressuserId, totalRequired, totalCompleted, totalRemaining, percentComplete, hasActiveWagering, bonusCredits, updatedAt

Tournament Events

EventPayload
tournament_registration_updatetournamentId, registeredCount, maxPlayers
tournament_startingtournamentId, startsIn
tournament_startedtournamentId, tableId, tableAssignments?
tournament_pausedtournamentId, pausedAt, pausedBy, timeRemaining, currentLevel, reason?
tournament_resumedtournamentId, resumedAt, resumedBy, currentLevel, timeRemaining
tournament_break_pendingtournamentId, requestedBy, requestedAt, durationSeconds, reason?, pendingTableIds
tournament_floor_messagetournamentId, operation, correlationId, message, severity, actorId, reason, affectedPlayerIds, affectedTableIds, createdAt
tournament_table_movetournamentId, playerId, fromTableId, fromSeatIndex, toTableId, toSeatIndex, newSeatIndex, reason
tournament_final_tabletournamentId, finalTableId, playerCount
tournament_blind_changetournamentId, level, smallBlind, bigBlind, ante, nextLevelIn, effectiveAt?, appliedTableIds?, pendingTableIds?, policy?

Room Management

Table Rooms

Players at a table join table:{tableId}:players; spectators join table:{tableId}:spectators. broadcastToTable delivers to both.

  • Auto-join: When you emit join_table or watch_table successfully
  • Auto-leave: When you emit leave_table or stop_watching

User Rooms

Each user also has user:{userId}, used for private delivery such as sendToUser.

Lobby Room

Global room for lobby subscribers: lobby

  • Join: Emit subscribe_lobby
  • Leave: Emit unsubscribe_lobby
  • Only receives updates about PUBLIC tables

State Synchronization

Sequence Numbers

Events broadcast with broadcastToTable carry _seq (per-table sequence number) and _ts (server timestamp) for ordering:

let lastSeq = 0;

socket.on('table_state', (state) => {
  if (state._seq !== undefined && state._seq <= lastSeq) {
    // Outdated update, ignore
    return;
  }
  lastSeq = state._seq ?? lastSeq;
  updateUI(state);
});

Resynchronizing

table_state is always a complete snapshot; there are no delta updates. Clients that detect a gap (reconnected.sequenceGap) can emit request_table_state.

Guaranteed Delivery

hole_cards, action_required, hand_result, pot_update, showdown, game_started, forced_bets and player_acted may carry a _deliveryId. Respond with ack_delivery. Unacknowledged deliveries are retried up to 3 times, 5 seconds apart, and retries carry _isRetry: true.

Error Handling

Connection Errors

socket.on('connect_error', (error) => {
  if (error.message.startsWith('Authentication failed')) {
    refreshToken().then(() => socket.connect());
  }
});

Game Errors

socket.on('error', (error) => {
  switch (error.code) {
    case 'JOIN_FAILED':
      showError('Could not join table: ' + error.message);
      break;
    case 'ACTION_FAILED':
      showError('Invalid action: ' + error.message);
      break;
    // Handle other errors...
  }
});

Reconnection

Automatic Reconnection

Socket.io handles reconnection automatically. The React client uses:

const socket = io(url, {
  reconnection: true,
  reconnectionAttempts: Infinity,
  reconnectionDelay: 1000,
  reconnectionDelayMax: 10000
});

Session Recovery

If disconnected while at a table, you can reconnect within the admin-configured reconnect window (reconnectWindowSeconds):

  1. Reconnect with same token
  2. Server recognizes your session
  3. reconnected event sent with current table state
  4. You’re back in the game at your seat
socket.on('reconnected', (data) => {
  console.log('Rejoined table:', data.tableId);
  updateTableState(data.state);
});

Session Timeout

A disconnect also starts a removal timer using the admin-configured disconnect grace period; when it fires, the player is removed from the table.

Client Implementation Example

import { io } from 'socket.io-client';

class PokerClient {
  constructor(serverUrl, token) {
    this.socket = io(serverUrl, {
      auth: { token }
    });

    this.setupEventHandlers();
  }

  setupEventHandlers() {
    // Connection
    this.socket.on('connection_established', this.onConnected.bind(this));
    this.socket.on('connect_error', this.onConnectError.bind(this));
    this.socket.on('reconnected', this.onReconnected.bind(this));

    // Game state
    this.socket.on('table_state', this.onTableState.bind(this));
    this.socket.on('hole_cards', this.onHoleCards.bind(this));
    this.socket.on('community_cards', this.onCommunityCards.bind(this));
    this.socket.on('action_required', this.onActionRequired.bind(this));
    this.socket.on('player_acted', this.onPlayerActed.bind(this));
    this.socket.on('hand_result', this.onHandResult.bind(this));

    // Players
    this.socket.on('player_joined', this.onPlayerJoined.bind(this));
    this.socket.on('player_left', this.onPlayerLeft.bind(this));

    // Chat
    this.socket.on('chat_message', this.onChatMessage.bind(this));

    // Errors
    this.socket.on('error', this.onError.bind(this));
  }

  // Actions
  joinTable(tableId, seatIndex, buyIn, password) {
    return new Promise((resolve, reject) => {
      this.socket.emit('join_table',
        { tableId, seatIndex, buyIn, password },
        (response) => {
          if (response.success) resolve();
          else reject(new Error(response.error));
        }
      );
    });
  }

  leaveTable(tableId) {
    return new Promise((resolve, reject) => {
      this.socket.emit('leave_table', { tableId }, (response) => {
        if (response.success) resolve(response.cashOut);
        else reject(new Error(response.error));
      });
    });
  }

  act(action, amount) {
    return new Promise((resolve, reject) => {
      this.socket.emit('player_action', { action, amount }, (response) => {
        if (response.success) resolve();
        else reject(new Error(response.error));
      });
    });
  }

  sendChat(tableId, message) {
    this.socket.emit('chat_message', { tableId, message });
  }

  subscribeLobby() {
    this.socket.emit('subscribe_lobby');
  }

  unsubscribeLobby() {
    this.socket.emit('unsubscribe_lobby');
  }

  // Event handlers (implement in your app)
  onConnected(data) { /* ... */ }
  onConnectError(error) { /* ... */ }
  onReconnected(data) { /* ... */ }
  onTableState(state) { /* ... */ }
  onHoleCards(data) { /* ... */ }
  onCommunityCards(data) { /* ... */ }
  onActionRequired(data) { /* ... */ }
  onPlayerActed(data) { /* ... */ }
  onHandResult(data) { /* ... */ }
  onPlayerJoined(data) { /* ... */ }
  onPlayerLeft(data) { /* ... */ }
  onChatMessage(data) { /* ... */ }
  onError(error) { /* ... */ }
}

Security Notes

  1. JWT or Guest – Connections authenticate with a valid JWT, or join as rate-limited guests with lobby-only access
  2. Server-Side Validation – All actions validated server-side
  3. Card Security – Table state is filtered per viewer; opponent cards are hidden until showdown
  4. Rate Limiting – Per-user limits apply, e.g. player_action 2/second and chat_message 3/6 seconds
  5. Message Sanitization – Chat messages are stripped of HTML, filtered, and truncated to 200 characters
  6. Token Expiration – Expired tokens rejected on connection

React Client Implementation

The React client uses custom hooks for WebSocket management.

Available Hooks

HookPurposeLocation
useSocketBase socket connection managementclient/src/hooks/useSocket.ts
useGameSocketGame table events and actionsclient/src/hooks/useGameSocket.ts
useLobbySocketLobby subscription and updatesclient/src/hooks/useLobbySocket.ts
useSpectatorSocketWatching a table as a spectatorclient/src/hooks/useSpectatorSocket.ts

useSocket

Base hook for socket connection lifecycle:

import { useSocket } from '@/hooks';

function MyComponent() {
  const { socket, isConnected, userId, sessionId, error, connect, disconnect } = useSocket();

  // Socket connects automatically when user is authenticated
  // Disconnects on logout or unmount
}

useGameSocket

Game-specific hook with table actions:

import { useGameSocket } from '@/hooks';

function TablePage({ tableId }) {
  const {
    isConnected,
    isJoined,
    error,
    joinTable,
    leaveTable,
    sendAction,
    sitOut,
    sitIn,
    useTimeBank,
    addChips,
    tournamentRebuy,
    tournamentAddOn,
    tournamentReentry,
    sendChat,
  } = useGameSocket({ tableId });

  // Join table
  const handleJoin = async (seatIndex: number, buyIn: number) => {
    const success = await joinTable(seatIndex, buyIn);
  };

  // Send action
  const handleAction = async (action: string, amount?: number) => {
    await sendAction(action, amount);
  };
}

useLobbySocket

Lobby subscription hook:

import { useLobbySocket } from '@/hooks';

function LobbyPage() {
  const { isConnected, isSubscribed, subscribe, unsubscribe, refreshLobby } = useLobbySocket();

  // Automatically subscribes to lobby updates
  // Updates lobbyStore with real-time table changes
}

Socket Singleton

The socket connection is managed as a singleton in client/src/lib/socket.ts:

import { connectSocket, disconnectSocket, getSocket } from '@/lib/socket';

// Connect (called automatically by useSocket)
const socket = connectSocket();

// Get existing socket
const socket = getSocket();

// Disconnect
disconnectSocket();

Development Proxy

In development, the Vite dev server proxies WebSocket connections to avoid CORS issues:

// vite.config.ts
server: {
  proxy: {
    '/socket.io': {
      target: 'http://localhost:3000',
      ws: true,
    },
  },
}

The client automatically uses the proxy in development mode.

Try the live demo Pricing

Have questions about Poker Engine? We reply fast.