Architecture Overview

Ludo multiplayer is fundamentally simpler than real-time shooters — it is turn-based, meaning only one player acts at any given moment. This eliminates the hardest problems in multiplayer game development: physics prediction, collision reconciliation, and continuous state interpolation. However, Ludo introduces its own challenges: turn timer enforcement, simultaneous reconnection races, and the UX gap between server latency and player clicks.

The architecture follows a layered model: the RoomManager owns room lifecycle, the LudoEngine owns game logic, the GameServer bridges Socket.IO events to engine commands, and the StateSync module on the client reconciles incoming snapshots. This separation ensures each layer has a single responsibility and can be tested, replaced, or scaled independently.

Room Lifecycle: Create → Join → Playing → Ended

Every multiplayer game exists inside a room. The room lifecycle governs when players can join, when the game begins, when it pauses, and when it terminates. Managing this lifecycle correctly prevents the two most common multiplayer bugs: players joining mid-game (seeing a board they can't understand) and players acting after the game has ended.

TypeScript — room-manager.ts
import type { PlayerColor, GameState, PlayerCount } from './types';
import type { Socket from 'socket.io';

type RoomPhase =
    | { state: 'lobby'; playerCount: number; maxPlayers: PlayerCount }
    | { state: 'starting'; countdown: number }
    | { state: 'playing'; engine: LudoEngine; turnTimer: NodeJS.Timeout | null }
    | { state: 'ended'; winner: PlayerColor; reason: 'normal' | 'abandoned' | 'kicked' };

export interface PlayerSlot {
    socketId: string;
    playerColor: PlayerColor;
    displayName: string;
    connected: boolean;
    lastSeen: number;
}

export class RoomManager {
    readonly roomId: string;
    private phase: RoomPhase;
    private players: Map<PlayerColor, PlayerSlot>;
    private socketToPlayer: Map<string, PlayerColor>;
    private creatorSocketId: string;
    private turnTimeLimit: number; // milliseconds
    private disconnectWindow: number; // milliseconds before marking disconnected
    private disconnectTimers: Map<PlayerColor, NodeJS.Timeout>;

    constructor(roomId: string, creatorSocketId: string, options?: {
        turnTimeLimit?: number;
        disconnectWindow?: number;
    }) {
        this.roomId = roomId;
        this.creatorSocketId = creatorSocketId;
        this.turnTimeLimit = options?.turnTimeLimit ?? 30000; // 30 seconds
        this.disconnectWindow = options?.disconnectWindow ?? 30000; // 30 seconds
        this.players = new Map();
        this.socketToPlayer = new Map();
        this.disconnectTimers = new Map();
        this.phase = { state: 'lobby', playerCount: 0, maxPlayers: 4 };
    }

    // ===== Lobby Phase =====
    join(socketId: string, displayName: string): { ok: true; playerColor: PlayerColor; players: PlayerSlot[] } | { ok: false; reason: string } {
        if (this.phase.state !== 'lobby')
            return { ok: false, reason: 'Game already in progress' };

        if (this.phase.playerCount >= this.phase.maxPlayers)
            return { ok: false, reason: 'Room is full' };

        if (this.socketToPlayer.has(socketId))
            return { ok: false, reason: 'Already in this room' };

        const availableColor = this.findAvailableColor();
        if (!availableColor)
            return { ok: false, reason: 'No available player slots' };

        const slot: PlayerSlot = {
            socketId, playerColor: availableColor, displayName,
            connected: true, lastSeen: Date.now()
        };
        this.players.set(availableColor, slot);
        this.socketToPlayer.set(socketId, availableColor);
        this.phase = {
            ...this.phase,
            playerCount: this.phase.playerCount + 1
        };

        return { ok: true, playerColor: availableColor, players: [...this.players.values()] };
    }

    startGame(initiatorSocketId: string): { ok: true; engine: LudoEngine } | { ok: false; reason: string } {
        if (this.phase.state !== 'lobby')
            return { ok: false, reason: 'Not in lobby state' };
        if (this.phase.playerCount < 2)
            return { ok: false, reason: 'Need at least 2 players to start' };

        const playerCount = this.phase.playerCount as PlayerCount;
        const engine = new LudoEngine(playerCount);

        this.phase = { state: 'starting', countdown: 3 };

        // Countdown before transition to playing
        setTimeout(() => {
            if (this.phase.state !== 'starting') return;
            this.phase = { state: 'playing', engine, turnTimer: null };
            this.startTurnTimer();
        }, 3000);

        return { ok: true, engine };
    }

    // ===== Playing Phase =====
    handleRoll(socketId: string, diceValue: number): { ok: true; result: RollResult; newState: GameState } | { ok: false; reason: string } {
        if (this.phase.state !== 'playing')
            return { ok: false, reason: 'Game not in playing state' };

        const player = this.socketToPlayer.get(socketId);
        if (!player) return { ok: false, reason: 'Player not in room' };

        const engine = this.phase.engine;
        const state = engine.getState();
        if (state.currentPlayer !== player)
            return { ok: false, reason: 'Not your turn' };

        const result = engine.rollDice(diceValue);
        if (!result.ok) return { ok: false, reason: result.reason };

        this.resetTurnTimer();
        return { ok: true, result, newState: engine.getState() };
    }

    handleMove(socketId: string, pieceId: number): { ok: true; result: MoveResult; newState: GameState } | { ok: false; reason: string } {
        if (this.phase.state !== 'playing')
            return { ok: false, reason: 'Game not in playing state' };

        const engine = this.phase.engine;
        const result = engine.movePiece(pieceId);
        if (!result.ok) return { ok: false, reason: result.reason };

        this.resetTurnTimer();

        if (result.newState.phase.kind === 'gameOver') {
            this.endGame(result.newState.phase.winner, 'normal');
        }

        return { ok: true, result, newState: result.newState };
    }

    private startTurnTimer(): void {
        if (this.phase.state !== 'playing') return;
        const timer = setTimeout(() => {
            this.handleTurnTimeout();
        }, this.turnTimeLimit);
        this.phase.turnTimer = timer;
    }

    private resetTurnTimer(): void {
        if (this.phase.state !== 'playing') return;
        if (this.phase.turnTimer) clearTimeout(this.phase.turnTimer);
        this.startTurnTimer();
    }

    private handleTurnTimeout(): void {
        if (this.phase.state !== 'playing') return;
        const { engine } = this.phase;
        const currentPlayer = engine.getState().currentPlayer;
        // Auto-skip: treat timeout as a pass (no move, advance turn)
        engine.rollDice(0); // Intentional invalid roll to trigger forfeit-like behavior
        // Emit timeout event to all clients for UX notification
        this.endGame(currentPlayer, 'abandoned'); // Or: skip turn and let others continue
    }

    private endGame(winner: PlayerColor, reason: 'normal' | 'abandoned' | 'kicked'): void {
        if (this.phase.state === 'ended' || this.phase.state === 'lobby') return;
        if (this.phase.state === 'playing' && this.phase.turnTimer) {
            clearTimeout(this.phase.turnTimer);
        }
        this.phase = { state: 'ended', winner, reason };
    }

    // ===== Reconnection =====
    handleDisconnect(socketId: string): void {
        const player = this.socketToPlayer.get(socketId);
        if (!player) return;

        const slot = this.players.get(player);
        if (slot) { slot.connected = false; slot.lastSeen = Date.now(); }
        this.socketToPlayer.delete(socketId);

        // Start disconnect grace period
        const timer = setTimeout(() => {
            if (this.phase.state === 'playing' && !slot?.connected) {
                this.endGame(player, 'abandoned');
            }
        }, this.disconnectWindow);
        this.disconnectTimers.set(player, timer);
    }

    handleReconnect(socketId: string, displayName: string): { ok: true; playerColor: PlayerColor; gameState: GameState | null } | { ok: false; reason: string } {
        const existingColor = [...this.players.entries()]
            .find(([, slot]) => slot.displayName === displayName && !slot.connected)?.[0];

        if (!existingColor)
            return { ok: false, reason: 'No matching disconnected session found' };

        const slot = this.players.get(existingColor)!;
        slot.socketId = socketId;
        slot.connected = true;
        slot.lastSeen = Date.now();
        this.socketToPlayer.set(socketId, existingColor);

        const timer = this.disconnectTimers.get(existingColor);
        if (timer) { clearTimeout(timer); this.disconnectTimers.delete(existingColor); }

        const gameState = this.phase.state === 'playing'
            ? this.phase.engine.getState() : null;

        return { ok: true, playerColor: existingColor, gameState };
    }

    // ===== Helpers =====
    private findAvailableColor(): PlayerColor | undefined {
        const all: PlayerColor[] = ['RED', 'GREEN', 'YELLOW', 'BLUE'];
        return all.find(c => !this.players.has(c));
    }

    getState(): { phase: RoomPhase; players: PlayerSlot[] } {
        return { phase: this.phase, players: [...this.players.values()] };
    }
}

Player State Synchronization

The server broadcasts game state to all connected clients after every action. Clients never modify state locally — they are pure renderers. The StateSync class on the client manages version tracking, gap detection, and smooth interpolation between snapshots.

TypeScript — state-sync.ts (client)
import type { GameState } from '../../shared/types';

export interface StateSnapshot {
    state: GameState;
    receivedAt: number; // performance.now() timestamp
}

export class StateSync {
    private currentVersion = 0;
    private currentSnapshot: StateSnapshot | null = null;
    private targetSnapshot: StateSnapshot | null = null;
    private interpolating = false;
    private interpolationDuration = 150; // ms — smooth transition window
    private pendingActions: PendingAction[] = [];
    private listeners: Set<(state: GameState) => void> = new Set();

    applyServerState(newState: GameState): void {
        const snapshot: StateSnapshot = { state: newState, receivedAt: performance.now() };

        // Detect version gap — missed one or more updates
        if (newState.version > this.currentVersion + 1 && this.currentVersion > 0) {
            console.warn(`State gap detected: server=${newState.version}, local=${this.currentVersion}. Requesting full resync.`);
            socket.emit('game:requestFullSync', { roomId: this.roomId });
            return;
        }

        if (newState.version <= this.currentVersion && this.currentVersion > 0) {
            // Stale update — server may have rejected our action and sent the canonical state
            console.debug(`Stale update v${newState.version} (current v${this.currentVersion}), replaying pending`);
            this.replayPendingActions(newState);
            return;
        }

        // Advance version — either first update or consecutive
        this.currentVersion = newState.version;
        this.targetSnapshot = snapshot;
        this.startInterpolation();
    }

    private startInterpolation(): void {
        if (this.interpolating) return;
        this.interpolating = true;

        const startTime = performance.now();
        const from = this.currentSnapshot;

        const tick = () => {
            const elapsed = performance.now() - startTime;
            const t = Math.min(elapsed / this.interpolationDuration, 1);
            const eased = this.easeOutCubic(t);

            const interpolated = from && this.targetSnapshot
                ? this.interpolateState(from.state, this.targetSnapshot.state, eased)
                : this.targetSnapshot?.state;

            if (interpolated) {
                this.currentSnapshot = { state: interpolated, receivedAt: performance.now() };
                this.listeners.forEach(l => l(interpolated));
            }

            if (t < 1) {
                requestAnimationFrame(tick);
            } else {
                this.interpolating = false;
                this.currentSnapshot = this.targetSnapshot;
            }
        };

        requestAnimationFrame(tick);
    }

    private interpolateState(from: GameState, to: GameState, t: number): GameState {
        // Linear interpolation of piece track positions for smooth movement animation
        const interpolatedPieces = to.pieces.map((playerPieces, pi) =>
            playerPieces.map((piece, ci) => {
                const fromPiece = from.pieces[pi]?.[ci];
                if (!fromPiece) return piece;
                // Only interpolate positions when piece is actively moving
                if (fromPiece.trackPosition !== piece.trackPosition) {
                    return {
                        ...piece,
                        trackPosition: fromPiece.trackPosition + (piece.trackPosition - fromPiece.trackPosition) * t
                    };
                }
                return piece;
            })
        );
        return { ...to, pieces: interpolatedPieces };
    }

    private easeOutCubic(t: number): number {
        return 1 - Math.pow(1 - t, 3);
    }

    private replayPendingActions(baseState: GameState): void {
        // Replay locally queued actions against the authoritative state from the server
        const pending = [...this.pendingActions];
        this.pendingActions = [];
        pending.forEach(action => {
            socket.emit(action.type, action.payload);
        });
    }

    subscribe(listener: (state: GameState) => void): () => void {
        this.listeners.add(listener);
        return () => this.listeners.delete(listener);
    }

    getCurrentState(): GameState | null {
        return this.currentSnapshot?.state ?? null;
    }

    requestFullSync(socket: Socket, roomId: string): void {
        this.currentVersion = 0;
        socket.emit('game:requestFullSync', { roomId });
    }
}

Latency Compensation & Ghost Piece Patterns

Even in a turn-based game, latency matters. When a player clicks "Roll Dice," they expect immediate visual feedback — a spinning dice animation, not a frozen screen for 300ms. Ghost piece patterns solve this by rendering optimistic updates locally while the server confirms the action, then reconciling when the authoritative response arrives.

The ghost piece pattern works in three phases: First, on player click, immediately render a semi-transparent ghost piece at the predicted destination. Second, send the action to the server and disable the "Roll" button to prevent double-sends. Third, when the server confirms, remove the ghost and snap the real piece to the confirmed position (which should match the ghost exactly). If the server rejects the action (e.g., a network hiccup caused a duplicate action), the ghost disappears and the UI returns to the previous state without jarring teleportation.

TypeScript — latency-compensation.ts (client)
export interface GhostPiece {
    pieceId: number;
    fromPosition: number;
    toPosition: number;
    opacity: number; // Fades from 0.4 → 0 as real piece approaches
    predicted: boolean; // True while awaiting server confirmation
    createdAt: number;
}

export class LatencyCompensator {
    private ghosts: Map<string, GhostPiece> = new Map();
    private pendingRoll: string | null = null;
    private pendingMove: string | null = null;
    private ghostFadeDuration = 400; // ms — how long ghost is visible
    private listeners: Set<() => void> = new Set();

    // Optimistically create ghost piece while awaiting server confirmation
    predictRoll(diceValue: number, playerId: string): void {
        this.pendingRoll = playerId;
        // Ghost dice animation runs client-side immediately
        this.notify();
    }

    predictMove(pieceId: number, fromPosition: number, toPosition: number, playerId: string): void {
        const key = `${playerId}:${pieceId}`;
        this.ghosts.set(key, {
            pieceId, fromPosition, toPosition,
            opacity: 0.4, predicted: true, createdAt: performance.now()
        });
        this.pendingMove = key;
        this.notify();
    }

    // Called when server confirms the action — ghost dissolves and real state takes over
    confirm(actionType: 'roll' | 'move'): void {
        if (actionType === 'roll') {
            this.pendingRoll = null;
        } else if (actionType === 'move' && this.pendingMove) {
            this.ghosts.delete(this.pendingMove);
            this.pendingMove = null;
        }
        this.notify();
    }

    // Called when server rejects the action — rollback ghost immediately
    reject(actionType: 'roll' | 'move', reason: string): void {
        console.warn(`Action rejected: ${actionType} — ${reason}`);
        if (actionType === 'roll') {
            this.pendingRoll = null;
        } else if (actionType === 'move' && this.pendingMove) {
            this.ghosts.delete(this.pendingMove);
            this.pendingMove = null;
        }
        this.notify();
    }

    // Called each animation frame — updates ghost opacity for fade-out effect
    updateGhosts(now: number): void {
        let changed = false;
        this.ghosts.forEach((ghost, key) => {
            const age = now - ghost.createdAt;
            if (age > this.ghostFadeDuration && ghost.predicted) {
                ghost.opacity = 0;
                ghost.predicted = false;
                changed = true;
            } else if (ghost.predicted) {
                const fadeProgress = age / this.ghostFadeDuration;
                ghost.opacity = 0.4 * (1 - fadeProgress);
                changed = true;
            }
        });
        if (changed) this.notify();
    }

    getGhosts(): GhostPiece[] { return [...this.ghosts.values()]; }
    hasPending(): boolean { return this.pendingRoll !== null || this.pendingMove !== null; }
    subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
    private notify(): void { this.listeners.forEach(l => l()); }
}

Rollback System for Out-of-Order Events

WebSocket delivery is generally ordered, but reconnection, proxy retries, and mobile network handoffs can cause a later action to arrive before an earlier one. The rollback system handles this by maintaining an action history buffer and reverting state when out-of-order arrivals are detected.

TypeScript — rollback-system.ts
import type { GameState } from '../../shared/types';

export interface RollbackEntry {
    version: number;
    state: GameState;
    actionId: string;
    timestamp: number;
}

export class RollbackSystem {
    private history: RollbackEntry[] = [];
    private currentVersion = 0;
    private maxHistorySize = 50;
    private listeners: Set<(state: GameState, reason: string) => void> = new Set();

    applyServerState(state: GameState, actionId: string): void {
        // Out-of-order: server is sending a state older than what we have
        if (state.version < this.currentVersion) {
            console.warn(`Out-of-order state: server v${state.version} < local v${this.currentVersion}`);
            const rollbackEntry = this.findRollbackTarget(state.version);
            if (rollbackEntry) {
                this.rollbackTo(rollbackEntry.version, 'Out-of-order event detected');
            }
        }

        // Gap detection: missed one or more states
        if (state.version > this.currentVersion + 1) {
            console.error(`State gap: expected v${this.currentVersion + 1}, got v${state.version}`);
            this.history = []; // Clear history — cannot safely replay
        }

        // Record snapshot for future rollback
        this.history.push({ version: state.version, state, actionId, timestamp: Date.now() });
        if (this.history.length > this.maxHistorySize) {
            this.history.shift();
        }

        this.currentVersion = state.version;
    }

    private findRollbackTarget(targetVersion: number): RollbackEntry | undefined {
        return this.history.find(entry => entry.version === targetVersion);
    }

    private rollbackTo(targetVersion: number, reason: string): void {
        const entry = this.findRollbackTarget(targetVersion);
        if (!entry) {
            console.error(`Cannot rollback to v${targetVersion} — snapshot not in history`);
            return;
        }

        // Truncate history to the rollback target
        this.history = this.history.filter(e => e.version <= targetVersion);
        this.currentVersion = entry.version;

        this.listeners.forEach(l => l(entry.state, reason));
    }

    subscribe(listener: (state: GameState, reason: string) => void): () => void {
        this.listeners.add(listener);
        return () => this.listeners.delete(listener);
    }
}

Reconnection Flow

Mobile networks drop connections constantly. A player mid-turn who loses WiFi expects to return to the exact same board state, not a blank screen or a "game not found" error. The reconnection flow stores session tokens server-side and matches reconnecting socket IDs to existing player slots within the disconnect window.

The flow: when a client detects disconnection (Socket.IO fires disconnect), it waits 2 seconds for automatic reconnection. If that fails, it transitions to a "reconnecting" UI state showing the room ID and player name. On the server, the RoomManager.handleDisconnect starts a 30-second timer. If the player reconnects with matching credentials before the timer expires, their session is restored — they get the current game state, their pieces are visible, and the turn timer resets. If the timer expires, the game ends as abandoned.

TypeScript — reconnect-client.ts
export class ReconnectManager {
    private sessionToken: string | null = null;
    private roomId: string | null = null;
    private displayName: string | null = null;
    private reconnectAttempts = 0;
    private maxReconnectAttempts = 5;
    private listeners: Map<string, (data: unknown) => void> = new Map();

    startSession(roomId: string, displayName: string): string {
        this.roomId = roomId;
        this.displayName = displayName;
        this.sessionToken = crypto.randomUUID();
        localStorage.setItem('ludo_session', JSON.stringify({
            token: this.sessionToken, roomId, displayName
        }));
        return this.sessionToken;
    }

    async attemptReconnect(socket: Socket): Promise<{ ok: true; gameState: GameState } | { ok: false; reason: string }> {
        const stored = localStorage.getItem('ludo_session');
        if (!stored) return { ok: false, reason: 'No stored session' };

        const session = JSON.parse(stored);
        this.roomId = session.roomId;
        this.displayName = session.displayName;
        this.sessionToken = session.token;

        socket.connect();

        return new Promise(resolve => {
            socket.emit('game:reconnect', {
                roomId: this.roomId,
                displayName: this.displayName,
                sessionToken: this.sessionToken
            });

            socket.once('game:reconnectResult', (data: unknown) => {
                const result = data as { ok: boolean; reason?: string; gameState?: GameState };
                resolve({ ok: result.ok, reason: result.reason ?? 'Unknown error', gameState: result.gameState! });
            });

            // Timeout after 10 seconds
            setTimeout(() => {
                resolve({ ok: false, reason: 'Reconnection timed out' });
            }, 10000);
        });
    }

    async fullReconnectFlow(socket: Socket, stateSync: StateSync): Promise<GameState | null> {
        const result = await this.attemptReconnect(socket);

        if (!result.ok) {
            if (result.reason === 'No matching disconnected session found' ||
                result.reason === 'Game already ended') {
                console.error(`Reconnection failed: ${result.reason}`);
                return null;
            }
            this.reconnectAttempts++;
            if (this.reconnectAttempts < this.maxReconnectAttempts) {
                await new Promise(r => setTimeout(r, Math.pow(2, this.reconnectAttempts) * 1000));
                return this.fullReconnectFlow(socket, stateSync);
            }
            return null;
        }

        stateSync.applyServerState(result.gameState!);
        return result.gameState!;
    }
}

Frequently Asked Questions

The room lifecycle is a state machine with four phases: lobby (players join and the creator configures settings), starting (countdown after the creator clicks Start), playing (active game with turn timer), and ended (winner declared or game abandoned). Explicit state transitions prevent invalid operations — you cannot join a playing game, you cannot start with one player, and you cannot act after game over. Each transition is a single point of validation. The RoomManager class above enforces all these constraints in its method entry points. See the realtime API reference for the full event protocol.
The server starts a 30-second timer (setTimeout) after every turn transition and after every move completion. When the timer fires, handleTurnTimeout is called — it marks the game as abandoned with the current player as the winner (forfeiting for inactivity). The client also runs a local countdown timer synced to the server's turn start time, displaying a visual timer bar. If the client timer reaches zero before the server confirms a state change, it shows a warning but does not auto-forfeit — only the server can make that decision. The resetTurnTimer method clears the old timer and starts a fresh one whenever a valid action is received. See the latency compensation guide for client-side timer synchronization.
Any client-side dice generation is trivially manipulable — a cheating player can modify the JavaScript to always roll 6. The server must generate every dice value using crypto.randomInt(1, 7) in Node.js and broadcast the result to all clients simultaneously. The client shows a spinning animation immediately (latency compensation via the LatencyCompensator), but the actual value comes from the server. This is the non-negotiable foundation of fair multiplayer — no client ever generates game-affecting random values. The Socket.IO implementation shows the exact server-side dice generation and broadcast pattern.
In Ludo, simultaneous actions are architecturally impossible during normal play — the turn system enforces that only one player acts at a time. However, during reconnection, a player might have queued an action locally that was never sent (network drop), and the server state advanced past their version while they were disconnected. When they reconnect and receive the current state (which is ahead of their local version), the rollback system detects the version gap and clears pending local actions. The RollbackSystem then uses the server's authoritative state as the new baseline. This ensures the player sees the correct board without seeing phantom pieces from their queued but unsent action.
The 30-second disconnect window is a trade-off between player experience and game throughput. During this window, the game can either pause (simpler but frustrating for other players) or continue with the disconnected player's pieces frozen. The recommended approach: pieces are visually grayed out and non-interactive, the turn timer continues running for other players, but the turn does not advance to the disconnected player until they reconnect or the window expires. If the timer runs out, the game ends as abandoned. When the player reconnects within the window, handleReconnect restores their session, sends them the current state via StateSync.applyServerState, and the game resumes immediately. The multiplayer API documents the exact reconnection event protocol.
The LatencyCompensator sets pendingMove immediately when a piece is clicked, and the UI disables the piece selector until the server responds. This prevents the player from clicking a second piece while the first action is in flight. If the server confirms the move, the pending flag is cleared and the UI re-enables. If the server rejects (version mismatch, wrong piece, stale state), the pending flag is cleared and the UI re-renders to the correct state. The ghost piece opacity provides immediate visual feedback that the action is "in progress" without blocking the player from understanding what happened. This is the optimistic UI pattern — assume success, render immediately, reconcile on server confirmation.
A minimum viable product needs: (1) Socket.IO server with RoomManager handling lobby/playing/ended states, (2) server-side LudoEngine for dice generation and move validation (never trust the client), (3) state broadcast on every rollDice and movePiece call, (4) client StateSync for version tracking and gap detection, (5) basic reconnection flow with session token matching. This fits in approximately 400 lines of server code and 300 lines of client code. The ghost piece pattern, rollback system, and turn timer are all important for a polished product but are not required for a functional MVP. The multiplayer API docs provide the exact event names and payload shapes.

Building Ludo Multiplayer?

Expert guidance on multiplayer architecture, state synchronization, latency compensation, turn timer implementation, and Socket.IO integration for your Ludo game.