Ludo Game State Management: State Machine, Serialization & Optimistic Updates
A comprehensive guide to authoritative game state management for Ludo: canonical vs derived state architecture, the full phase state machine (waiting, rolling, moving, capturing, ending), immutable state patterns, serialization for save/load and replay systems, and optimistic update patterns with rollback β with complete TypeScript code for every pattern.
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.
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
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.
// 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.
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.
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
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.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.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.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?