Canonical vs Derived State Architecture

Every Ludo game state can be divided into two categories: canonical state and derived state. Canonical state is the authoritative, minimum-sufficient representation of the game that, if persisted and replayed, produces identical game outcomes. Derived state is computed from canonical state and never stored β€” it exists only in memory as a performance optimization for rendering and query operations. Understanding this distinction is the foundation of a maintainable Ludo architecture.

What Goes Into Canonical State

Canonical state contains only the information that cannot be computed from other information in the state. For Ludo, this means: the current game phase, each player's position in the turn order, all four token positions per player (or just one position per token since the others can be derived), the current dice value, the turn counter, and the winner if the game has ended. Nothing else belongs in the canonical state. Specifically, whether a square is "safe" or whether a piece is "movable" is derived information β€” it is computed by applying game rules to canonical state, not stored as canonical state itself.

What Is Derived State

Derived state is computed read-only information that changes whenever canonical state changes. For rendering, you derive pixel coordinates from track positions using the board coordinate system. For AI evaluation, you derive "available moves" by filtering canonical token positions against the dice value. For UI feedback, you derive "is my turn?" from the phase and current player index. Derived state is never serialized β€” it is recomputed on deserialization, on client reconnect, and on every render frame. This ensures that derived state can never become inconsistent with canonical state, because it is always a pure function of canonical state.

TypeScript β€” Full game state type definitions
type PlayerColor = 'RED' | 'GREEN' | 'YELLOW' | 'BLUE';

// Canonical state types β€” these are the only state persisted and transmitted
interface Token {
    id: number;
    trackPos: -1 | 0 | 1 | 2 | 3; // -1=base, 0-51=outer, 52-57=home
    finished: boolean;
}

interface Player {
    id: string;
    name: string;
    color: PlayerColor;
    tokens: [Token, Token, Token, Token];
    consecSixes: number; // reset to 0 on non-6
}

type GamePhase =
    | 'waiting'
    | 'rolling'
    | 'moving'
    | 'animating'
    | 'capturing'
    | 'ended';

interface GameState {
    gameId: string;
    phase: GamePhase;
    players: Player[];
    currentPlayerIdx: 0 | 1 | 2 | 3;
    currentPlayerId: string;
    diceValue: number | null;
    turnNumber: number;
    winnerId: string | null;
    // History for replay β€” stores canonical snapshots or diffs
    history: GameAction[];
    startedAt: number;
    lastModified: number;
}

// Action types for the reducer β€” every state change is an action
type GameAction =
    | { type: 'JOIN'; playerId: string; name: string; color: PlayerColor }
    | { type: 'ROLL'; playerId: string; value: number; timestamp: number }
    | { type: 'MOVE'; playerId: string; tokenIdx: number; fromPos: number; toPos: number }
    | { type: 'CAPTURE'; playerId: string; tokenIdx: number; capturedPlayerId: string; capturedTokenIdx: number }
    | { type: 'FINISH'; playerId: string; tokenIdx: number }
    | { type: 'TURN_ADVANCE'; newPlayerIdx: number; reason: 'no_moves' | 'no_six' | 'three_sixes' }
    | { type: 'GAME_END'; winnerId: string; timestamp: number };

// Derived state β€” NEVER serialized, always computed
interface DerivedState {
    movableTokens: number[];
    captureTargets: Map<number, string>;
    isMyTurn: boolean;
    gameProgress: number; // 0-100% based on finished tokens
    tokenPixelCoords: Map<string, { x: number; y: number }>;
}

The Game Phase State Machine

The Ludo game phase state machine is the control flow engine that governs which actions are legal at any given moment. It encodes the complete game flow from waiting for players to the final winner declaration. Each phase has a defined set of allowed transitions and transition guards that prevent invalid moves. The state machine also handles the rolling-6 extra-turn rule, the three-consecutive-sixes forfeit rule, and the pass-to-next-player logic when no valid moves exist.

State Machine Diagram

STATE MACHINE: Ludo Game Phases

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  all players joined   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚   WAITING    β”‚ ──────────────────▢ β”‚   ROLLING    β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                                              β”‚
                           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                           β”‚         dice rolled = value           β”‚
                           β–Ό                                       β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚    ROLLING   │──(extra turn on 6)──▢ β”‚   ROLLING    β”‚
                    β”‚  (re-roll?)  β”‚                        β”‚  (same turn) β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                        β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚                                         β”‚
                           β”‚ no valid moves                          β”‚ has valid moves
                           β–Ό                                         β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚   CAPTURING  β”‚ ◄── piece landed on   β”‚   MOVING     β”‚
                    β”‚   (pending)  β”‚    opponent's piece   β”‚              β”‚
                    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜                        β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚                                         β”‚
                           β”‚ capture resolved                       β”‚ move complete
                           β–Ό                                         β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚   ANIMATING  β”‚ ◄── play all move     β”‚   ANIMATING  β”‚
                    β”‚              β”‚    animations         β”‚              β”‚
                    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜                        β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚                                         β”‚
                           β”‚                              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                           β”‚                              β”‚  3 consec 6s?         β”‚
                           β–Ό                              β–Ό                      β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”               β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚   ROLLING    │──pass turn──▢│   ENDED    │◀──all───│   ROLLING    β”‚
                    β”‚  (next turn) β”‚               β”‚            β”‚ finishedβ”‚  (next turn) β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜               β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

State Machine Implementation

TypeScript β€” Complete phase state machine
type TransitionGuard = (state: GameState, action: GameAction) => boolean;

interface PhaseConfig {
    allowedTransitions: GamePhase[];
    guards: Record<GamePhase, TransitionGuard[]>;
    onEnter?: (state: GameState) => void;
    onExit?: (state: GameState) => void;
}

const PHASE_CONFIGS: Record<GamePhase, PhaseConfig> = {
    waiting: {
        allowedTransitions: ['rolling'],
        guards: {
            rolling: [(s, a) => a.type === 'ROLL' && s.players.length >= 2]
        },
        onEnter: s => console.log(`Waiting for players: ${s.players.length}/4`)
    },
    rolling: {
        allowedTransitions: ['moving', 'animating', 'capturing', 'rolling', 'ended'],
        guards: {
            moving:       [(s, a) => a.type === 'ROLL' && getMovableTokens(s).length > 0],
            animating:    [(s, a) => a.type === 'ROLL' && getMovableTokens(s).length > 0],
            capturing:    [(s, a) => a.type === 'ROLL'],
            rolling:      [(s, a) => a.type === 'ROLL' && getMovableTokens(s).length === 0],
            ended:        [(s) => checkWinner(s) !== null]
        }
    },
    moving: {
        allowedTransitions: ['animating', 'capturing'],
        guards: {
            animating: [(s, a) => a.type === 'MOVE'],
            capturing: [(s, a) => a.type === 'CAPTURE']
        }
    },
    animating: {
        allowedTransitions: ['rolling', 'ended'],
        guards: {
            rolling: [(s) => checkWinner(s) === null],
            ended:   [(s) => checkWinner(s) !== null]
        }
    },
    capturing: {
        allowedTransitions: ['animating'],
        guards: {
            animating: [(s, a) => a.type === 'CAPTURE']
        }
    },
    ended: {
        allowedTransitions: [],
        guards: {}
    }
};

class LudoStateMachine {
    constructor(private state: GameState) {}

    transition(to: GamePhase, action: GameAction): GameState {
        const from = this.state.phase;
        const config = PHASE_CONFIGS[from];

        // Guard check: all guards for this transition must pass
        const guards = config.guards[to] || [];
        const allPass = guards.every(g => g(this.state, action));
        if (!allPass) {
            throw new Error(
                `Transition guard failed: ${from} β†’ ${to}. Action: ${JSON.stringify(action)}`
            );
        }

        // Check allowed transition list
        if (!config.allowedTransitions.includes(to)) {
            throw new Error(
                `Illegal transition: ${from} β†’ ${to} is not allowed.`
            );
        }

        // onExit hook
        PHASE_CONFIGS[from].onExit?.(this.state);

        // Apply the transition
        this.state = { ...this.state, phase: to, lastModified: Date.now() };

        // onEnter hook
        PHASE_CONFIGS[to].onEnter?.(this.state);

        return this.state;
    }

    roll(playerId: string): number {
        if (this.state.phase !== 'rolling') {
            throw new Error('Dice can only be rolled in the ROLLING phase');
        }
        if (this.state.currentPlayerId !== playerId) {
            throw new Error('It is not your turn');
        }

        const value = Math.floor(Math.random() * 6) + 1;
        const player = this.state.players.find(p => p.id === playerId)!;
        const action: GameAction = { type: 'ROLL', playerId, value, timestamp: Date.now() };

        if (value === 6) {
            player.consecSixes++;
            if (player.consecSixes >= 3) {
                // Three consecutive 6s: forfeit turn
                player.consecSixes = 0;
                return this.passToNextPlayer(action, 'three_sixes');
            }
        } else {
            player.consecSixes = 0;
        }

        this.state.diceValue = value;
        this.state.history.push(action);

        const movable = getMovableTokens(this.state);
        if (movable.length === 0) {
            return this.passToNextPlayer(action, 'no_moves');
        }

        // Move to MOVING phase β€” player selects which token to move
        return this.transition('moving', action).diceValue!;
    }

    passToNextPlayer(action: GameAction, reason: string): number {
        this.state.history.push({ type: 'TURN_ADVANCE', newPlayerIdx: this.state.currentPlayerIdx, reason } as GameAction);
        this.state.currentPlayerIdx = (this.state.currentPlayerIdx + 1) % this.state.players.length;
        this.state.currentPlayerId = this.state.players[this.state.currentPlayerIdx].id;
        this.state.turnNumber++;
        this.state.phase = 'rolling';
        return this.state.diceValue!;
    }
}

Immutable State Patterns

Immutable state means that every state change produces a new state object rather than mutating the existing one. This approach has three critical benefits for Ludo: it enables time-travel debugging (you can reconstruct any past state by replaying the action history from the initial state), it eliminates a class of subtle bugs where state is accidentally shared between turns or clients, and it makes state diffing trivial β€” two states are equal if their references are equal, no deep comparison needed. The reducer pattern enforces immutability at the type level.

TypeScript β€” Immutable reducer for Ludo state
// Immutable reducer: always returns a NEW state object
// NEVER mutate the input state β€” create new objects for changes

function createInitialState(gameId: string): GameState {
    return Object.freeze({
        gameId,
        phase: 'waiting',
        players: [],
        currentPlayerIdx: 0,
        currentPlayerId: '',
        diceValue: null,
        turnNumber: 0,
        winnerId: null,
        history: [],
        startedAt: 0,
        lastModified: Date.now()
    });
}

function ludoReducer(state: GameState, action: GameAction): GameState {
    switch (action.type) {

        case 'JOIN': {
            if (state.phase !== 'waiting') return state;
            if (state.players.length >= 4) return state;
            const newPlayer: Player = {
                id: action.playerId,
                name: action.name,
                color: action.color,
                tokens: [
                    { id: 0, trackPos: -1, finished: false },
                    { id: 1, trackPos: -1, finished: false },
                    { id: 2, trackPos: -1, finished: false },
                    { id: 3, trackPos: -1, finished: false }
                ],
                consecSixes: 0
            };
            return Object.freeze({
                ...state,
                players: [...state.players, newPlayer],
                history: [...state.history, action]
            });
        }

        case 'ROLL': {
            return Object.freeze({
                ...state,
                diceValue: action.value,
                phase: 'moving',
                turnNumber: state.turnNumber + 1,
                history: [...state.history, action],
                lastModified: action.timestamp
            });
        }

        case 'MOVE': {
            // Deep clone players array β€” this is the immutability pattern
            const players = state.players.map((p, idx) => {
                if (idx !== state.currentPlayerIdx) return p;
                return {
                    ...p,
                    tokens: p.tokens.map((t, ti) =>
                        ti === action.tokenIdx
                            ? { ...t, trackPos: action.toPos as Token['trackPos'] }
                            : t
                    )
                };
            });
            return Object.freeze({
                ...state,
                players,
                history: [...state.history, action],
                phase: 'animating'
            });
        }

        case 'CAPTURE': {
            const players = state.players.map(p => {
                if (p.id !== action.capturedPlayerId) return p;
                // All tokens of captured player go back to base
                return {
                    ...p,
                    tokens: p.tokens.map(t => ({ ...t, trackPos: -1 as const, finished: false }))
                };
            });
            return Object.freeze({
                ...state,
                players,
                history: [...state.history, action],
                phase: 'animating'
            });
        }

        case 'FINISH': {
            const players = state.players.map((p, idx) => {
                if (idx !== state.currentPlayerIdx) return p;
                return {
                    ...p,
                    tokens: p.tokens.map((t, ti) =>
                        ti === action.tokenIdx
                            ? { ...t, trackPos: 57 as const, finished: true }
                            : t
                    )
                };
            });
            const currentPlayer = players[state.currentPlayerIdx];
            const allFinished = currentPlayer.tokens.every(t => t.finished);
            return Object.freeze({
                ...state,
                players,
                history: [...state.history, action],
                phase: allFinished ? 'ended' : state.phase,
                winnerId: allFinished ? action.playerId : null
            });
        }

        case 'TURN_ADVANCE': {
            return Object.freeze({
                ...state,
                currentPlayerIdx: action.newPlayerIdx,
                currentPlayerId: state.players[action.newPlayerIdx].id,
                phase: 'rolling',
                diceValue: null,
                history: [...state.history, action]
            });
        }

        case 'GAME_END': {
            return Object.freeze({
                ...state,
                phase: 'ended',
                winnerId: action.winnerId,
                history: [...state.history, action]
            });
        }

        default:
            return state;
    }
}

State Serialization for Save/Load and Replays

Serialization is the process of converting canonical game state into a storable or transmittable format, and deserialization reconstructs the state from that format. For Ludo, serialization serves three purposes: persisting in-progress games to a database so players can resume later, storing completed game histories for replay and analysis, and transmitting state snapshots over WebSocket connections for real-time synchronization. The key constraint is that only canonical state is serialized β€” derived state is recomputed after deserialization.

TypeScript β€” State serializer for save/load and replay
interface SerializedGameState {
        v: number;  // schema version for future migrations
        id: string;
        ph: string;   // phase (shortened keys save bandwidth)
        pl: SerializedPlayer[];
        cpi: number; // currentPlayerIdx
        dv: number | null; // diceValue
        tn: number;  // turnNumber
        w: string | null; // winnerId
        h: (string | number)[]; // history as compact tuples
        sa: number;  // startedAt
        lm: number;  // lastModified
    }

interface SerializedPlayer {
        id: string;
        nm: string;
        cl: string;
        tk: number[]; // token positions encoded as single integers
        cs: number;   // consecSixes
}

class GameStateSerializer {

    // Serialize to compact JSON format for storage/transmission
    serialize(state: GameState): string {
        const compact: SerializedGameState = {
            v: 1,
            id: state.gameId,
            ph: state.phase,
            pl: state.players.map(p => ({
                id: p.id,
                nm: p.name,
                cl: p.color,
                tk: p.tokens.map(t => encodeToken(t)),
                cs: p.consecSixes
            })),
            cpi: state.currentPlayerIdx,
            dv: state.diceValue,
            tn: state.turnNumber,
            w: state.winnerId,
            h: state.history.map(a => encodeAction(a)),
            sa: state.startedAt,
            lm: state.lastModified
        };
        return JSON.stringify(compact);
    }

    // Deserialize from compact JSON, recomputing derived state
    deserialize(data: string): GameState {
        const compact: SerializedGameState = JSON.parse(data);

        // Schema migration if version differs
        if (compact.v < 1) compact = migrateToV1(compact);

        const state: GameState = {
            gameId: compact.id,
            phase: compact.ph as GamePhase,
            players: compact.pl.map((p, idx) => ({
                id: p.id,
                name: p.nm,
                color: p.cl as PlayerColor,
                tokens: p.tk.map((t, ti) => decodeToken(t, ti)),
                consecSixes: p.cs
            })),
            currentPlayerIdx: compact.cpi,
            currentPlayerId: compact.pl[compact.cpi]?.id ?? '',
            diceValue: compact.dv,
            turnNumber: compact.tn,
            winnerId: compact.w,
            history: compact.h.map(a => decodeAction(a)),
            startedAt: compact.sa,
            lastModified: compact.lm
        };

        return Object.freeze(state);
    }

    // Create a replay by replaying all actions from initial state
    createReplay(gameId: string, history: GameAction[]): Generator<GameState> {
        let state = createInitialState(gameId);
        yield state;
        for (const action of history) {
            state = ludoReducer(state, action);
            yield state;
        }
    }
}

function encodeToken(t: Token): number {
    // Encode finished flag in the high bit: trackPos + 128 if finished
    return t.finished ? t.trackPos + 128 : t.trackPos;
}
function decodeToken(val: number, id: number): Token {
    const finished = val >= 128;
    return { id, trackPos: (val % 128) as Token['trackPos'], finished };
}
function encodeAction(a: GameAction): (string | number)[] {
    switch (a.type) {
        case 'JOIN':    return ['J', a.playerId, a.name, a.color];
        case 'ROLL':    return ['R', a.playerId, a.value];
        case 'MOVE':    return ['M', a.playerId, a.tokenIdx, a.fromPos, a.toPos];
        case 'CAPTURE': return ['C', a.playerId, a.tokenIdx, a.capturedPlayerId, a.capturedTokenIdx];
        case 'FINISH':  return ['F', a.playerId, a.tokenIdx];
        case 'TURN_ADVANCE': return ['T', a.newPlayerIdx];
        case 'GAME_END': return ['E', a.winnerId];
    }
}
function decodeAction(tuple: (string | number)[]): GameAction {
    switch (tuple[0]) {
        case 'J': return { type: 'JOIN', playerId: tuple[1], name: tuple[2], color: tuple[3] };
        case 'R': return { type: 'ROLL', playerId: tuple[1], value: tuple[2], timestamp: 0 };
        case 'M': return { type: 'MOVE', playerId: tuple[1], tokenIdx: tuple[2], fromPos: tuple[3], toPos: tuple[4] };
        case 'C': return { type: 'CAPTURE', playerId: tuple[1], tokenIdx: tuple[2], capturedPlayerId: tuple[3], capturedTokenIdx: tuple[4] };
        case 'F': return { type: 'FINISH', playerId: tuple[1], tokenIdx: tuple[2] };
        case 'T': return { type: 'TURN_ADVANCE', newPlayerIdx: tuple[1], reason: 'no_moves' };
        case 'E': return { type: 'GAME_END', winnerId: tuple[1], timestamp: 0 };
    }
}

Optimistic Updates with Rollback

Optimistic updates are a pattern where the client applies a state change immediately (optimistically) before receiving server confirmation, providing a snappy, responsive feel. For Ludo, this means when a player moves a token, the client updates its local state instantly so the UI responds without network latency. However, the server must validate the move and either confirm it or reject it. If rejected, the client rolls back to the pre-optimistic state. This pattern requires careful synchronization to ensure the client and server states never diverge permanently.

TypeScript β€” Optimistic update manager with rollback
class OptimisticUpdateManager {
    constructor(
        private reducer: (s: GameState, a: GameAction) => GameState,
        private sendToServer: (action: GameAction) => Promise<ServerConfirmation>,
        private onStateChange: (s: GameState) => void
    ) {
        this.pendingActions = new Map();
        this.snapshotBeforePending = null;
    }

    private pendingActions: Map<string, GameAction>;
    private snapshotBeforePending: GameState | null;
    private currentState: GameState;

    // Dispatch an optimistic update: apply immediately, confirm/reject async
    async function dispatchOptimistic(action: GameAction, state: GameState): Promise<GameState> {
        const actionId = generateActionId();
        const optimisticState = this.reducer(state, action);

        // Snapshot before pending for rollback
        if (!this.snapshotBeforePending) {
            this.snapshotBeforePending = state;
        }
        this.pendingActions.set(actionId, action);

        // Apply optimistically
        this.currentState = optimisticState;
        this.onStateChange(optimisticState);

        // Send to server for validation
        try {
            const confirmation = await this.sendToServer({ ...action, actionId });
            return await this.handleConfirmation(actionId, confirmation, state);
        } catch (err) {
            console.error('Server unreachable, rolling back:', err);
            return this.rollbackAll();
        }
    }

    private async function handleConfirmation(
        actionId: string,
        confirmation: ServerConfirmation,
        originalState: GameState
    ): Promise<GameState> {
        if (confirmation.status === 'CONFIRMED') {
            // Server accepted: remove from pending, keep optimistic state
            this.pendingActions.delete(actionId);
            if (this.pendingActions.size === 0) {
                this.snapshotBeforePending = null;
            }
            return this.currentState;
        }

        // Server REJECTED: rollback to state before this action
        console.warn(`Action ${actionId} rejected: ${confirmation.reason}`);
        this.pendingActions.delete(actionId);
        if (this.pendingActions.size === 0) {
            this.snapshotBeforePending = null;
        }

        const rolledBackState = this.snapshotBeforePending || originalState;
        this.currentState = rolledBackState;
        this.onStateChange(rolledBackState);

        // Notify UI of rejection
        this.showRollbackNotification(confirmation.reason);
        return rolledBackState;
    }

    private rollbackAll(): GameState {
        const state = this.snapshotBeforePending;
        if (!state) return this.currentState;

        this.pendingActions.clear();
        this.snapshotBeforePending = null;
        this.currentState = state;
        this.onStateChange(state);
        return state;
    }

    private showRollbackNotification(reason: string) {
        console.error(`Move rejected: ${reason}`);
    }
}

interface ServerConfirmation {
    status: 'CONFIRMED' | 'REJECTED';
    reason?: string;
    correctedState?: SerializedGameState;
}

Frequently Asked Questions

Separating canonical from derived state eliminates an entire class of bugs where cached information becomes stale. In a Ludo game, the canonical state is the token positions, dice value, phase, and turn order. Everything else β€” whether a piece is on a safe square, whether a capture is possible, the pixel coordinates for rendering β€” is derived from those values. If you store both canonical and derived information, you risk the two falling out of sync (e.g., a piece moves but its cached pixel position doesn't update). By deriving everything on read, you guarantee that derived state is always consistent with canonical state. This is the CQRS (Command Query Responsibility Segregation) pattern applied to game state β€” commands modify canonical state, queries compute derived state.
The consecSixes counter lives in the canonical player state. When a ROLL action is processed, the reducer increments this counter if the value is 6, or resets it to 0 otherwise. The state machine checks the counter in its transition guard: if player.consecSixes >= 3, the transition target becomes TURN_ADVANCE with reason 'three_sixes', and the player's counter is reset before passing control to the next player. The key insight is that the counter is canonical β€” it must be serialized and shared with all clients. If a player reconnects mid-game, their client needs the exact consecutive-six count to render the game correctly and enforce the rule locally.
Replays are implemented by storing the complete action history (the h field in the serialized format) rather than state snapshots. To replay a game, you start from an empty initial state and apply each action through the reducer sequentially. This approach has two advantages over snapshot-based replay: it uses dramatically less storage (a 100-move game might be 2KB of actions vs. 20KB of snapshots at every turn), and it guarantees the game can always be reconstructed from scratch without relying on potentially corrupted snapshot files. The createReplay generator in the serializer yields the state after every action, enabling a "scrubbable" replay UI where players can seek to any point in the game. For long games, you can periodically store full snapshots as checkpoints and replay from the nearest checkpoint plus subsequent actions.
Rollback triggers in three scenarios: the server rejects the move with a validation error (e.g., the player tried to move a piece they don't own, or the dice value was tampered with), the server returns a different resulting state than the client computed (indicating a race condition or computation mismatch), or the server becomes unreachable before confirming the action (network partition). The rollback restores the snapshot taken before the first pending action in the queue, then replays all remaining pending actions that were not rejected. This cascading rollback handles cases where multiple optimistic updates were queued while waiting for server confirmation β€” only the rejected one and its dependents roll back. For Ludo specifically, the most common rejection reason is that another player captured the target square between the client's move attempt and server processing.
True simultaneous captures cannot occur in turn-based Ludo since only one player acts at a time. However, the server-side validation must check for a subtle race condition: between the time a player sends a move action and the server processes it, another player's action might have already moved a piece onto the same square. The server resolves this by applying a strict ordering to actions based on server-received timestamp. The first action to arrive is applied; subsequent conflicting actions are rejected with a CONFLICT status, triggering a client rollback. In practice, WebSocket's reliable ordered delivery makes this rare, but it must still be handled for correctness. The CAPTURING phase in the state machine handles the sequence: a move that lands on an opponent triggers the CAPTURE sub-action, which resets the captured player's all tokens to base position -1 before the main action is recorded in history.
Yes. The state management system described here is framework-agnostic β€” the reducer, serializer, and optimistic update manager are pure TypeScript classes with no DOM dependencies. For React, you would connect the onStateChange callback to a useReducer hook or a Zustand store. For Vue, you'd update a reactive ref. For vanilla JS, you'd call render(state) directly. The LudoStateMachine class is a pure state-transition engine that works anywhere. The only integration points that vary by framework are: where you store the canonical state (React state, Vue store, Redux, etc.), how you trigger renders on state change, and how you handle WebSocket connection management. The real-time API page covers the WebSocket integration in detail.

Building a Real-Time Ludo Game?

Need help implementing state machines, serialization, or optimistic updates for your Ludo multiplayer platform?