Ludo Game TypeScript: Type-Safe Engine with Zod Validation & Jest Testing
Architect a production-grade TypeScript Ludo engine: strict mode enforcement, exhaustive discriminated unions for game events, Zod runtime validation schemas, and full Jest test coverage — catching rule violations at compile time and at the network boundary.
TypeScript Strict Mode Setup
A Ludo game engine demands precision. A single wrong index, reversed player order, or unchecked dice value creates silent corruption that only surfaces as wrong piece positions during gameplay. TypeScript's strict mode catches these at compile time. Enable it in your tsconfig.json with the strict flag, which bundles strictNullChecks, noImplicitAny, strictFunctionTypes, and strictPropertyInitialization.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
The noUncheckedIndexedAccess flag is particularly valuable for the Ludo engine because accessing pieces[playerIdx] without a bounds check would otherwise silently return undefined at runtime. With this flag enabled, TypeScript treats every array index access as potentially T | undefined, forcing you to handle the absent case explicitly. This directly prevents the class of bug where a fifth player's move attempt crashes the server.
Pair strict mode with "noImplicitReturns": true so that every code path in functions returning a value is guaranteed to return something. In validateMove, this ensures you handle all MoveError cases — the compiler will reject the function if the final return is missing.
Type Definitions for Game State
All types live in a dedicated types.ts file. This separation creates a single authoritative source for every type in the game engine, making it trivial to import across server, client, and test files. The architecture follows a layered approach: board constants, domain primitives, and composite state types.
// ===== Board Constants ===== export const OUTER_TRACK_SIZE = 52; export const HOME_COLUMN_SIZE = 5; export const WIN_POSITION = 57; export const PIECES_PER_PLAYER = 4; export const MAX_PLAYERS = 4; export const MAX_CONSECUTIVE_SIXS = 3; export const SAFE_SQUARES: readonly number[] = Object.freeze([0, 8, 13, 21, 26, 34, 39, 47]); export const SAFE_SQUARE_SET: readonly ReadonlySet<number> = new ReadonlySet(SAFE_SQUARES); // Entry positions (absolute track index) for each color's first step onto the outer track export const PLAYER_ENTRY: readonly Record<PlayerColor, number> = Object.freeze({ RED: 0, GREEN: 13, YELLOW: 26, BLUE: 39 }); // Player color — union of literal strings enables exhaustive checking export type PlayerColor = 'RED' | 'GREEN' | 'YELLOW' | 'BLUE'; export const PLAYERS: readonly PlayerColor[] = Object.freeze(['RED', 'GREEN', 'YELLOW', 'BLUE']); export const PLAYER_INDEX: readonly Record<PlayerColor, number> = Object.freeze({ RED: 0, GREEN: 1, YELLOW: 2, BLUE: 3 }); // ===== Domain Primitives ===== // Track position: -1 = in base, 0-56 = on track, 57 = finished (center) export type TrackPosition = number; // Enforced range: -1 to WIN_POSITION export type DiceValue = number; // 1-6 export type PieceId = number; // 0-3 within a player's set export type PlayerCount = 2 | 3 | 4; // A single token belonging to a player at a given position on the board export interface Piece { readonly id: PieceId; readonly player: PlayerColor; trackPosition: TrackPosition; readonly finished: boolean; } // ===== Composite State Types ===== export type GamePhase = | { kind: 'idle' } | { kind: 'selecting'; diceValue: DiceValue; allowedPieces: PieceId[] } | { kind: 'animating'; pieceId: PieceId; from: TrackPosition; to: TrackPosition } | { kind: 'gameOver'; winner: PlayerColor }; export interface GameState { readonly phase: GamePhase; readonly currentPlayer: PlayerColor; readonly pieces: readonly Piece[][]; // [playerIndex][pieceId] readonly playerCount: PlayerCount; readonly consecutiveSixs: number; readonly version: number; // Monotonic — used for optimistic locking readonly moveHistory: readonly GameEvent[]; } // ===== Result Types (Railroad-Oriented Programming) ===== export type MoveError = | 'not_current_player' | 'wrong_phase' | 'piece_in_base_needs_six' | 'overshoots_finish' | 'piece_already_finished' | 'piece_not_found' | 'invalid_piece_for_dice'; export type MoveResult = | { ok: true; captures: Piece[]; finished: boolean; newState: GameState; extraTurn: boolean } | { ok: false; reason: MoveError }; export type RollResult = | { ok: true; value: DiceValue; extraTurn: boolean; forfeited: boolean; allowedPieces: PieceId[] } | { ok: false; reason: RollError }; export type RollError = 'wrong_phase' | 'game_over';
Discriminated Unions for Game Events
Every state transition in Ludo is modeled as an event in a discriminated union. This pattern is superior to plain strings because the kind discriminant narrows the type automatically — inside a case 'piece_moved' block, TypeScript knows event.pieceId and event.to are available without casting.
// ===== Game Event Types ===== export type GameEvent = | { type: 'game_started'; players: PlayerColor[]; timestamp: number } | { type: 'dice_rolled'; player: PlayerColor; value: DiceValue; extraTurn: boolean; timestamp: number } | { type: 'piece_moved'; player: PlayerColor; pieceId: PieceId; from: TrackPosition; to: TrackPosition; captures: PieceId[]; timestamp: number } | { type: 'piece_captured'; victim: PlayerColor; pieceId: PieceId; by: PlayerColor; timestamp: number } | { type: 'piece_finished'; player: PlayerColor; pieceId: PieceId; timestamp: number } | { type: 'turn_advanced'; from: PlayerColor; to: PlayerColor; timestamp: number } | { type: 'forfeit'; player: PlayerColor; reason: 'three_sixes'; timestamp: number } | { type: 'game_over'; winner: PlayerColor; finalState: GameState; timestamp: number }; // Type-safe event emitter — only valid events can be emitted export type GameEventHandler<T extends GameEvent> = (event: T) => void; export class EventEmitter { private handlers: Map<GameEvent['type'], GameEventHandler<GameEvent>[]> = new Map(); on<T extends GameEvent>(type: T['type'], handler: GameEventHandler<T>): () => void { const list = this.handlers.get(type) ?? []; list.push(handler as GameEventHandler<GameEvent>); this.handlers.set(type, list); return () => this.off(type, handler); } emit(event: GameEvent): void { const handlers = this.handlers.get(event.type) ?? []; handlers.forEach(h => h(event)); } off<T extends GameEvent>(type: T['type'], handler: GameEventHandler<T>): void { const list = this.handlers.get(type) ?? []; this.handlers.set(type, list.filter(h => h !== handler)); } // Exhaustive handler — TypeScript proves all event types are covered handleExhaustive(event: GameEvent): string { switch (event.type) { case 'game_started': return `Game started with ${event.players.join(', ')}`; case 'dice_rolled': return `${event.player} rolled ${event.value}`; case 'piece_moved': return `${event.player} moved piece ${event.pieceId} from ${event.from} to ${event.to}`; case 'piece_captured': return `${event.by} captured ${event.victim}'s piece ${event.pieceId}`; case 'piece_finished': return `${event.player} finished piece ${event.pieceId}`; case 'turn_advanced': return `Turn from ${event.from} to ${event.to}`; case 'forfeit': return `${event.player} forfeited due to ${event.reason}`; case 'game_over': return `Game over. Winner: ${event.winner}`; } } }
The exhaustive switch pattern at the bottom of the EventEmitter class demonstrates one of TypeScript's most powerful features: if you add a new event type to GameEvent but forget to handle it in the switch, the compiler produces an error. This makes refactoring safe — you cannot silently miss a case when adding new event types.
Zod Validation Schemas
TypeScript types are compile-time only — they vanish after transpilation. When data crosses a boundary (HTTP request body, WebSocket message, localStorage read, database row), you need runtime validation. Zod schemas provide TypeScript inference from a single source of truth, meaning your validation schema and your type definition are always in sync.
import { z } from 'zod'; import type { GameState, PlayerColor, Piece, GamePhase } from './types'; // ===== Primitive Schemas ===== export const PlayerColorSchema = z.enum(['RED', 'GREEN', 'YELLOW', 'BLUE']); export const DiceValueSchema = z.number().int().min(1).max(6); export const PieceIdSchema = z.number().int().min(0).max(3); export const TrackPositionSchema = z.number().int().min(-1).max(57); // ===== Composite Schemas ===== export const PieceSchema = z.object({ id: PieceIdSchema, player: PlayerColorSchema, trackPosition: TrackPositionSchema, finished: z.boolean() }); // Discriminated union schema for GamePhase — Zod validates the discriminant first export const GamePhaseSchema: z.ZodType<GamePhase> = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('idle') }), z.object({ kind: z.literal('selecting'), diceValue: DiceValueSchema, allowedPieces: z.array(PieceIdSchema) }), z.object({ kind: z.literal('animating'), pieceId: PieceIdSchema, from: TrackPositionSchema, to: TrackPositionSchema }), z.object({ kind: z.literal('gameOver'), winner: PlayerColorSchema }) ]); export const GameStateSchema: z.ZodType<GameState> = z.object({ phase: GamePhaseSchema, currentPlayer: PlayerColorSchema, pieces: z.array(z.array(PieceSchema)), playerCount: z.union([z.literal(2), z.literal(3), z.literal(4)]), consecutiveSixs: z.number().int().min(0).max(3), version: z.number().int().nonnegative(), moveHistory: z.array(z.unknown()) // Validate separately with GameEventSchema }); // ===== Inbound Request Schemas (API / WebSocket) ===== export const RollDiceRequestSchema = z.object({ playerId: PlayerColorSchema, roomId: z.string().min(1).max(64), version: z.number().int().nonnegative() }); export const MovePieceRequestSchema = z.object({ playerId: PlayerColorSchema, roomId: z.string().min(1).max(64), pieceId: PieceIdSchema, version: z.number().int().nonnegative() }); // Safe parser: wraps Zod with typed error responses for HTTP handlers export function parseRequest<T>(schema: z.ZodType<T>, data: unknown): { ok: true; data: T } | { ok: false; errors: z.ZodError['errors'] } { const result = schema.safeParse(data); if (result.success) return { ok: true, data: result.data }; return { ok: false, errors: result.error.errors }; }
The parseRequest helper at the bottom converts Zod's result into a simple discriminated union, making it clean to use inside Express or Fastify route handlers. This approach eliminates manual validation boilerplate — one line of code replaces a dozen conditional checks, and the TypeScript inference means you get fully typed request bodies with zero casting.
Full Game Engine Implementation
The LudoEngine class ties types, events, and validation together into a cohesive game logic layer. It uses immutable update patterns (spread operators on state) so every state snapshot is independently queryable. The engine is framework-agnostic — drop it into a Node.js WebSocket server, a React browser client, or a Deno edge function.
import { GameState, Piece, PlayerColor, PlayerCount, GamePhase, MoveResult, RollResult, MoveError, GameEvent, EventEmitter, WIN_POSITION, PLAYERS, PLAYER_ENTRY, PIECES_PER_PLAYER, SAFE_SQUARE_SET, MAX_CONSECUTIVE_SIXS } from './types'; import { PieceSchema, GameStateSchema } from './validation'; function createInitialState(numPlayers: PlayerCount): GameState { const pieces: Piece[][] = PLAYERS.slice(0, numPlayers).map((player, playerIdx) => Array.from({ length: PIECES_PER_PLAYER }, (_, i) => ({ id: i as number, player, trackPosition: -1 as const, finished: false })) ); return { phase: { kind: 'idle' }, currentPlayer: PLAYERS[0], pieces: Object.freeze(pieces), playerCount: numPlayers, consecutiveSixs: 0, version: 0, moveHistory: [] }; } function validateMove(piece: Piece, dice: number): { ok: true } | { ok: false; reason: MoveError } { if (piece.finished) return { ok: false, reason: 'piece_already_finished' }; if (piece.trackPosition === -1 && dice !== 6) return { ok: false, reason: 'piece_in_base_needs_six' }; if (piece.trackPosition + dice > WIN_POSITION) return { ok: false, reason: 'overshoots_finish' }; return { ok: true }; } export class LudoEngine { private state: GameState; private emitter: EventEmitter; constructor(numPlayers: PlayerCount = 4) { this.state = createInitialState(numPlayers); this.emitter = new EventEmitter(); this.emitEvent({ type: 'game_started', players: PLAYERS.slice(0, numPlayers), timestamp: Date.now() }); } rollDice(value: number): RollResult { if (value < 1 || value > 6) throw new RangeError(`Dice value ${value} outside 1-6 range`); const { phase } = this.state; if (phase.kind === 'gameOver') return { ok: false, reason: 'game_over' }; if (phase.kind !== 'idle') return { ok: false, reason: 'wrong_phase' }; const extraTurn = value === 6; let consecutiveSixs = extraTurn ? this.state.consecutiveSixs + 1 : 0; const forfeited = consecutiveSixs >= MAX_CONSECUTIVE_SIXS; if (forfeited) consecutiveSixs = 0; const playerIdx = PLAYERS.indexOf(this.state.currentPlayer); const allowedPieces: number[] = []; if (!forfeited) { this.state.pieces[playerIdx]?.forEach((piece, idx) => { if (validateMove(piece, value).ok) allowedPieces.push(idx); }); } const nextState: GameState = { ...this.state, phase: { kind: 'selecting', diceValue: value, allowedPieces }, consecutiveSixs, version: this.state.version + 1, moveHistory: this.state.moveHistory }; this.state = nextState; this.emitEvent({ type: 'dice_rolled', player: this.state.currentPlayer, value, extraTurn, timestamp: Date.now() }); if (forfeited) { this.advanceTurn(this.state.currentPlayer); this.emitEvent({ type: 'forfeit', player: this.state.currentPlayer, reason: 'three_sixes', timestamp: Date.now() }); } return { ok: true, value, extraTurn, forfeited, allowedPieces }; } movePiece(pieceIndex: number): MoveResult { const { phase, currentPlayer } = this.state; if (phase.kind !== 'selecting') return { ok: false, reason: 'wrong_phase' }; const playerIdx = PLAYERS.indexOf(currentPlayer); const piece = this.state.pieces[playerIdx]?.[pieceIndex]; if (!piece) return { ok: false, reason: 'piece_not_found' }; if (!phase.allowedPieces.includes(pieceIndex)) return { ok: false, reason: 'invalid_piece_for_dice' }; const validation = validateMove(piece, phase.diceValue); if (!validation.ok) return validation; const from = piece.trackPosition; const to = piece.trackPosition + phase.diceValue; const finished = to === WIN_POSITION; // Capture detection (simplified — real implementation needs per-player relative position) const captures: Piece[] = []; if (from >= 0 && !SAFE_SQUARE_SET.has(from)) { // Scan all other players' pieces for collision at 'to' } const updatedPieces = this.state.pieces.map((playerPieces, pi) => pi === playerIdx ? playerPieces.map((pc, ci) => ci === pieceIndex ? { ...pc, trackPosition: finished ? WIN_POSITION : to, finished } : pc ) : playerPieces ); const winner = finished && updatedPieces[playerIdx].every(p => p.finished) ? currentPlayer : null; const extraTurn = phase.diceValue === 6; const nextPlayer = winner ? currentPlayer : extraTurn ? currentPlayer : PLAYERS[(playerIdx + 1) % this.state.pieces.length]; const newPhase: GamePhase = winner ? { kind: 'gameOver', winner } : { kind: 'idle' }; this.state = { ...this.state, pieces: Object.freeze(updatedPieces), currentPlayer: nextPlayer, phase: newPhase, version: this.state.version + 1, moveHistory: [...this.state.moveHistory] }; this.emitEvent({ type: 'piece_moved', player: currentPlayer, pieceId: pieceIndex, from, to, captures: [], timestamp: Date.now() }); if (finished) { this.emitEvent({ type: 'piece_finished', player: currentPlayer, pieceId: pieceIndex, timestamp: Date.now() }); } if (winner) { this.emitEvent({ type: 'game_over', winner, finalState: this.state, timestamp: Date.now() }); } return { ok: true, captures, finished, newState: this.state, extraTurn }; } private advanceTurn(from: PlayerColor): void { const fromIdx = PLAYERS.indexOf(from); const nextPlayer = PLAYERS[(fromIdx + 1) % this.state.pieces.length]; this.state = { ...this.state, currentPlayer: nextPlayer, phase: { kind: 'idle' }, consecutiveSixs: 0 }; this.emitEvent({ type: 'turn_advanced', from, to: nextPlayer, timestamp: Date.now() }); } private emitEvent(event: GameEvent): void { this.emitter.emit(event); } // ===== Public Query API ===== getState(): GameState { return this.state; } getMovablePieces(): Piece[] { const { phase, currentPlayer } = this.state; if (phase.kind !== 'selecting') return []; const playerIdx = PLAYERS.indexOf(currentPlayer); return this.state.pieces[playerIdx].filter((p, i) => phase.allowedPieces.includes(i)); } on<T extends GameEvent>(type: T['type'], handler: (e: T) => void): () => void { return this.emitter.on(type, handler); } // Serialize for network transmission or persistence serialize(): string { const plain = createInitialState(this.state.playerCount); // type-level only return JSON.stringify(this.state); } static deserialize(json: string): LudoEngine { const parsed = JSON.parse(json); const validated = GameStateSchema.parse(parsed); // Zod runtime check const engine = new LudoEngine(validated.playerCount); engine.state = validated; return engine; } }
Jest Testing Patterns
Testing the Ludo engine requires covering both happy paths and the full matrix of invalid inputs. Jest's table-driven test syntax pairs naturally with TypeScript's type system — you can generate test cases from typed data structures, ensuring complete coverage without duplicating type information.
import { describe, it, expect, beforeEach } from '@jest/globals'; import { LudoEngine } from '../src/game-engine'; import type { PlayerColor } from '../src/types'; describe('LudoEngine', () => { let engine: LudoEngine; beforeEach(() => { engine = new LudoEngine(4); }); describe('rollDice', () => { it('returns a valid roll result from idle phase', () => { const result = engine.rollDice(4); expect(result.ok).toBe(true); expect(result.ok && result.value).toBe(4); expect(result.ok && result.extraTurn).toBe(false); }); it('grants extra turn when rolling a six', () => { const result = engine.rollDice(6); expect(result.ok && result.extraTurn).toBe(true); }); it('throws on invalid dice value', () => { expect(() => engine.rollDice(0)).toThrow('outside 1-6 range'); expect(() => engine.rollDice(7)).toThrow('outside 1-6 range'); }); it('forfeits turn after three consecutive sixes', () => { engine.rollDice(6); engine.rollDice(6); const third = engine.rollDice(6); expect(third.ok && third.forfeited).toBe(true); expect(engine.getState().currentPlayer).toBe('GREEN'); // Turn advanced }); }); describe('movePiece', () => { it('moves a piece from base when rolling six', () => { engine.rollDice(6); const result = engine.movePiece(0); expect(result.ok).toBe(true); expect(result.ok && result.newState.pieces[0][0].trackPosition).toBe(6); }); it('prevents moving from base without a six', () => { engine.rollDice(3); const result = engine.movePiece(0); expect(result.ok).toBe(false); expect(result.ok === false && result.reason).toBe('piece_in_base_needs_six'); }); it('returns error in wrong phase', () => { const result = engine.movePiece(0); // No roll first expect(result.ok).toBe(false); expect(result.ok === false && result.reason).toBe('wrong_phase'); }); it('gives extra turn after rolling six', () => { engine.rollDice(6); engine.movePiece(0); expect(engine.getState().currentPlayer).toBe('RED'); // Same player — extra turn const nextRoll = engine.rollDice(3); expect(nextRoll.ok).toBe(true); engine.movePiece(0); expect(engine.getState().currentPlayer).toBe('GREEN'); // Turn advanced after non-six }); }); describe('serialization', () => { it('roundtrips through JSON without data loss', () => { engine.rollDice(5); engine.movePiece(1); const restored = LudoEngine.deserialize(engine.serialize()); expect(restored.getState().version).toBe(engine.getState().version); expect(restored.getState().currentPlayer)..toBe(engine.getState().currentPlayer); }); it('rejects malformed JSON via Zod validation', () => { expect(() => LudoEngine.deserialize('{"version":"not-a-number"}')) .toThrow(); }); }); describe('event emission', () => { it('emits game_started and dice_rolled events', () => { const events: unknown[] = []; engine.on('game_started', e => events.push(e)); engine.on('dice_rolled', e => events.push(e)); engine.rollDice(4); expect(events.length).toBe(2); expect(events[0]).toMatchObject({ type: 'game_started' }); expect(events[1]).toMatchObject({ type: 'dice_rolled', value: 4 }); }); }); });
Frequently Asked Questions
type or kind field that TypeScript uses to narrow the type. When you check event.type === 'piece_moved', TypeScript knows event.from and event.to exist without casting. A plain interface with optional fields would make those properties accessible (and tempt you to use them) even when they don't apply to the current event type. The discriminated union is a compile-time contract that enforces this automatically. See the JavaScript reference for comparison — JavaScript can't enforce this at compile time, so you must manually check property existence.GameStateSchema.parse() call in LudoEngine.deserialize() is the critical boundary — it catches corrupted saves, man-in-the-middle modified payloads, and database schema mismatches that TypeScript cannot catch. The REST API guide covers how to integrate Zod validation into your request pipeline.esbuild src/game-engine.ts --bundle --outfile=dist/game.js --platform=browser. This compiles the entire engine (including types, which are erased) to a single ~15KB bundle. Alternatively, use Vite for a full development server with hot module replacement. The Ludo game tutorial covers the full project setup including build tooling.version field is a monotonically increasing integer incremented on every state change. Clients send the version number they believe is current along with each action. The server compares it to the canonical version: if the client's version is stale, the action is rejected with the current state, and the client must reconcile before retrying. This optimistic locking pattern prevents race conditions where a player's click arrives at the server after the turn has already advanced. See the multiplayer architecture guide for the full room lifecycle and reconnection flow.state field is updated with spread operators ({ ...state, pieces: newPieces }) to create a new object rather than mutating the existing one. Combined with Object.freeze() on the pieces array, this makes the state tree deeply immutable from the outside. Immutability enables time-travel debugging (inspect any previous state by holding snapshots), undo/redo with zero copying, and reliable change detection in React via useEffect dependency arrays. It also makes the event log accurate — each event corresponds to exactly one immutable snapshot.LudoEngine instance per game room on the server. Wire the engine's event emitter to Socket.IO broadcasts so every state change propagates to all clients. Clients should never instantiate their own engine for multiplayer — they are rendering layers only. The Socket.IO multiplayer implementation shows the exact server-side integration pattern with room management, client authentication, and state broadcasting on every engine event.validateMove and rollDice — these contain all the rule logic and are the highest-risk functions. Run jest --coverage to see per-branch results. Key test scenarios: valid moves in base (needs six), valid moves on track, valid moves to finish exactly, invalid overshoot, invalid piece (wrong player), wrong phase rejection, three-sixes forfeit, extra turn on six, no extra turn on non-six, win detection when all four pieces finish, event emission for every state transition, and serialization roundtrip. Each expect clause should verify the typed result (using TypeScript's narrowing) rather than inspecting internal properties.Need Help Building the Type-Safe Engine?
Get personalized guidance on TypeScript architecture, Zod validation patterns, Jest test setup, and multiplayer integration for your Ludo game.