Build a Ludo Game in JavaScript with Canvas API
A comprehensive guide to building a Ludo game in vanilla JavaScript using the HTML5 Canvas API — board rendering with a pixel-based coordinate system, smooth token animation via requestAnimationFrame, a deterministic game state machine, dice roll UI, keyboard and multi-touch input handling, and multiplayer-ready architecture.
Jump to Section
Canvas Setup and the Board Coordinate System
The HTML5 Canvas API provides a rasterized drawing surface where every pixel is addressable. For a Ludo board, the standard layout is a 15×15 conceptual grid, which maps cleanly to a 600×600 pixel canvas with 40px cells. Understanding the coordinate system is critical before writing a single drawing function — it determines how every piece, cell, and interaction is computed.
The board divides into four quadrants of equal size (6×6 cells each, with a 3-cell gap for the center). Each quadrant belongs to a player: Red occupies the top-left home zone, Green the top-right, Yellow the bottom-right, and Blue the bottom-left. The outer track of 52 cells surrounds all quadrants in a clockwise path, and each player has a private home column of 5 cells leading to the central goal.
A 15×15 grid means row and column indices range from 0 to 14. The center 3×3 area (rows 6–8, columns 6–8) forms the finish zone. Safe squares are strategically placed at track indices 0, 8, 13, 21, 26, 34, 39, and 47 — these are marked with star indicators and prevent captures.
// Canvas dimensions: 15 cells × 40px = 600px board const canvas = document.getElementById('ludoBoard'); const ctx = canvas.getContext('2d'); const CELL = 40; // pixels per grid cell const GRID = 15; // 15×15 conceptual grid const BOARD = GRID * CELL; // 600px // High-DPI scaling: renders crisply on Retina/2x displays const dpr = window.devicePixelRatio || 1; canvas.width = BOARD * dpr; canvas.height = BOARD * dpr; canvas.style.width = BOARD + 'px'; canvas.style.height = BOARD + 'px'; ctx.scale(dpr, dpr); // Player color palette — distinct and accessible on dark backgrounds const PLAYER_COLORS = { RED: '#ef4444', GREEN: '#22c55e', YELLOW: '#eab308', BLUE: '#3b82f6', }; const PLAYER_NAMES = ['RED', 'GREEN', 'YELLOW', 'BLUE']; // Track constants — derived from Ludo rules const OUTER_TRACK = 52; // clockwise cells around the board const HOME_COLUMN = 5; // private cells per player inside their quadrant const WIN_POSITION = 57; // OUTER_TRACK + HOME_COLUMN - 1 = 52 + 5 = 57 const SAFE_INDICES = new Set([0, 8, 13, 21, 26, 34, 39, 47]); // Home entry index for each player on the outer track const HOME_ENTRY = { RED: 0, GREEN: 13, YELLOW: 26, BLUE: 39 };
High-DPI scaling deserves special attention. On a Retina display, window.devicePixelRatio returns 2 (or higher). Without scaling, your 600×600 canvas renders at half resolution on these devices. Multiplying the canvas internal dimensions by DPR and scaling the context accordingly ensures sharp rendering. The canvas.style.width/height keeps the visual size consistent while the internal buffer doubles in resolution.
Board Rendering Step-by-Step
Board rendering follows a strict draw order: background → home quadrants → goal triangle → outer track cells → safe markers → tokens. This layering ensures tokens always appear on top of the board, the goal triangle overlays the center, and home quadrants fill their zones correctly.
The outer track coordinate mapping is the most complex part of rendering. Each of the 52 track cells maps to a specific [row, col] on the 15×15 grid. The path starts at Red's home entry (top-right edge, row 6, cols 9–14), proceeds up the right edge, across the top through the top-right quadrant, down the left edge of the top-right quadrant, across the middle through the top-right corner, and continues clockwise through all four quadrants until it loops back.
// Pre-computed track cells: 52 [row, col] pairs in clockwise order const TRACK_MAP = buildTrackMap(); // computed once at startup function buildTrackMap() { const t = []; // Red entry: top of the right column (row 6) for (let c = 9; c <= 14; c++) t.push([6, c]); // Right column going up (col 13, rows 7→0) for (let r = 7; r >= 0; r--) t.push([r, 13]); // Top-right corner: go through top-right quadrant for (let r = 1; r <= 6; r++) t.push([r, 8]); // Top row going left (row 0) for (let c = 7; c >= 0; c--) t.push([0, c]); // Left side of top-right quadrant going down for (let r = 1; r <= 6; r++) t.push([r, 6]); // Green entry: top-right quadrant (row 6, going right) for (let c = 0; c <= 5; c++) t.push([6, c]); // Continue 26 more cells through bottom quadrants... // Yellow: rows 8-13, cols 0-5 → Blue: rows 8-13, cols 9-14 return t; // length must be exactly 52 } function renderBoard() { // Layer 1: Dark slate background ctx.fillStyle = '#0f172a'; ctx.fillRect(0, 0, BOARD, BOARD); // Layer 2: Four home quadrants (6×6 cells each) drawQuadrant(0, 0, PLAYER_COLORS.RED); // top-left drawQuadrant(0, 9, PLAYER_COLORS.GREEN); // top-right drawQuadrant(9, 9, PLAYER_COLORS.YELLOW); // bottom-right drawQuadrant(9, 0, PLAYER_COLORS.BLUE); // bottom-left // Layer 3: Center goal zone with diagonal triangles drawGoalZone(); // Layer 4: Outer track cells TRACK_MAP.forEach(([row, col], idx) => { const px = col * CELL, py = row * CELL; // Track cell background ctx.fillStyle = '#1e293b'; ctx.fillRect(px + 1, py + 1, CELL - 2, CELL - 2); // Home column entries (colored starting cells) const homeCols = [1, 6, 8, 13]; if (homeCols.includes(col) && (row === 6 || row === 8)) { ctx.fillStyle = '#334155'; ctx.fillRect(px + 1, py + 1, CELL - 2, CELL - 2); } // Safe square star marker if (SAFE_INDICES.has(idx)) { ctx.fillStyle = '#f59e0b'; ctx.beginPath(); ctx.arc(px + CELL/2, py + CELL/2, 6, 0, Math.PI * 2); ctx.fill(); } }); // Layer 5: Tokens (drawn last so they appear on top) gameState.pieces.flat().forEach(drawToken); } function drawQuadrant(startRow, startCol, color) { const size = 6 * CELL; // Main quadrant fill ctx.fillStyle = color + '22'; // 13% opacity overlay ctx.fillRect(startCol * CELL, startRow * CELL, size, size); // Colored border ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.strokeRect(startCol * CELL + 1, startRow * CELL + 1, size - 2, size - 2); // Token base positions (2×2 arrangement inside quadrant) const basePositions = [[1,1],[1,4],[4,1],[4,4]]; basePositions.forEach(([br, bc]) => { const bx = (startCol + bc) * CELL + CELL/2; const by = (startRow + br) * CELL + CELL/2; ctx.fillStyle = '#1e293b'; ctx.beginPath(); ctx.arc(bx, by, CELL/2 - 4, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = color; ctx.stroke(); }); } function drawGoalZone() { // Center 3×3 area: rows 6-8, cols 6-8 const cx = 6 * CELL, cy = 6 * CELL; ctx.fillStyle = '#f8fafc'; ctx.fillRect(cx, cy, 3*CELL, 3*CELL); // Four colored triangles pointing inward const triH = CELL * 1.5; // Red triangle (top-left) ctx.fillStyle = PLAYER_COLORS.RED; ctx.beginPath(); ctx.moveTo(cx, cy + triH); ctx.lineTo(cx + triH, cy + triH); ctx.lineTo(cx, cy); ctx.closePath(); ctx.fill(); // Green triangle (top-right) ctx.fillStyle = PLAYER_COLORS.GREEN; ctx.beginPath(); ctx.moveTo(cx + 3*CELL - triH, cy); ctx.lineTo(cx + 3*CELL, cy); ctx.lineTo(cx + 3*CELL, cy + triH); ctx.closePath(); ctx.fill(); // Yellow triangle (bottom-right) ctx.fillStyle = PLAYER_COLORS.YELLOW; ctx.beginPath(); ctx.moveTo(cx + 3*CELL, cy + 3*CELL - triH); ctx.lineTo(cx + 3*CELL, cy + 3*CELL); ctx.lineTo(cx + 3*CELL - triH, cy + 3*CELL); ctx.closePath(); ctx.fill(); // Blue triangle (bottom-left) ctx.fillStyle = PLAYER_COLORS.BLUE; ctx.beginPath(); ctx.moveTo(cx + triH, cy + 3*CELL); ctx.lineTo(cx, cy + 3*CELL); ctx.lineTo(cx, cy + 3*CELL - triH); ctx.closePath(); ctx.fill(); } function drawToken(piece) { const pos = getTokenPixelPosition(piece); const color = PLAYER_COLORS[PLAYER_NAMES[piece.playerId]]; ctx.beginPath(); ctx.arc(pos.x, pos.y, 14, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.stroke(); // Token number ctx.fillStyle = '#ffffff'; ctx.font = 'bold 12px system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(piece.pieceId + 1, pos.x, pos.y); }
The drawQuadrant function renders each player's home territory with a colored border and four circular base positions where tokens rest when not on the track. The drawGoalZone fills the center 3×3 cells with white and overlays four colored triangles pointing inward — the classic Ludo star pattern. Token rendering uses ctx.arc for circular pieces with a white stroke border for visual separation.
Game State Machine
The game state machine controls every interaction through a defined set of phases. Unlike ad-hoc conditional logic scattered throughout event handlers, a state machine provides a single authoritative source for what actions are valid at any moment. This approach eliminates entire classes of bugs — a piece cannot move before the dice is rolled because the state machine enforces that phase must be 'selectToken'.
The five phases are: idle (waiting for the current player to roll the dice), rolling (dice animation in progress), selectToken (player must pick a movable token), animating (token is moving along the track), and gameOver (a player has won). Transitions between phases are the only places where state changes occur, making the game logic traceable and testable.
const GamePhase = Object.freeze({ IDLE: 'idle', ROLLING: 'rolling', SELECT_TOKEN:'selectToken', ANIMATING: 'animating', GAME_OVER: 'gameOver', }); const gameState = { phase: GamePhase.IDLE, currentPlayer: 0, diceValue: null, consecutiveSixs: 0, pieces: [], scores: [0, 0, 0, 0], // tokens finished per player init() { // 4 players × 4 tokens each this.pieces = [0,1,2,3].map(pid => [0,1,2,3].map(tid => ({ playerId: pid, tokenId: tid, trackPos: -1, // -1=base, 0-51=outer, 52-56=home, 57=finished finished: false, animPos: null, // {x, y} for smooth animation interpolation })) ); this.phase = GamePhase.IDLE; this.currentPlayer = 0; this.diceValue = null; this.consecutiveSixs = 0; this.scores = [0,0,0,0]; }, transition(newPhase) { // Phase transition guard: only valid transitions are allowed const valid = { [GamePhase.IDLE]: [GamePhase.ROLLING], [GamePhase.ROLLING]: [GamePhase.SELECT_TOKEN, GamePhase.ROLLING, GamePhase.IDLE], [GamePhase.SELECT_TOKEN]:[GamePhase.ANIMATING, GamePhase.IDLE], [GamePhase.ANIMATING]: [GamePhase.IDLE, GamePhase.GAME_OVER], [GamePhase.GAME_OVER]: [], }; if (!valid[this.phase]?.includes(newPhase)) { console.warn(`Invalid phase transition: ${this.phase} → ${newPhase}`); return false; } this.phase = newPhase; return true; }, getMovableTokens() { if (this.diceValue === null) return []; return this.pieces[this.currentPlayer].filter(t => canTokenMove(t, this.diceValue)); }, setDiceValue(value) { this.diceValue = value; if (value === 6) { this.consecutiveSixs++; if (this.consecutiveSixs >= 3) { // Three consecutive sixes: forfeit turn this.consecutiveSixs = 0; this.advanceTurn(); return; } } else { this.consecutiveSixs = 0; } const movable = this.getMovableTokens(); if (movable.length === 0) { this.advanceTurn(); } else { this.transition(GamePhase.SELECT_TOKEN); } }, advanceTurn() { this.currentPlayer = (this.currentPlayer + 1) % 4; this.diceValue = null; this.transition(GamePhase.IDLE); }, checkWin() { if (this.scores[this.currentPlayer] === 4) { this.transition(GamePhase.GAME_OVER); return true; } return false; }, }; function canTokenMove(token, dice) { if (token.finished) return false; if (token.trackPos === -1) return dice === 6; // must roll 6 to leave base return token.trackPos + dice <= WIN_POSITION; } function getTokenPixelPosition(token) { if (token.trackPos === -1) { // In base: compute from player's base position offset by tokenId const offsets = [[1,1],[1,4],[4,1],[4,4]]; const [or, oc] = offsets[token.tokenId]; const [sr, sc] = { RED: [0,0], GREEN: [0,9], YELLOW: [9,9], BLUE: [9,0] }[PLAYER_NAMES[token.playerId]]; return { x: (sc + oc) * CELL + CELL/2, y: (sr + or) * CELL + CELL/2 }; } if (token.trackPos >= OUTER_TRACK) { // In home column: calculate position within the player's home path const homeIdx = token.trackPos - OUTER_TRACK; const homeCoords = { RED: [[7,1],[7,2],[7,3],[7,4],[7,5]], GREEN: [[1,7],[2,7],[3,7],[4,7],[5,7]], YELLOW: [[7,7],[7,6],[7,5],[7,4],[7,3]], BLUE: [[7,7],[6,7],[5,7],[4,7],[3,7]] }[PLAYER_NAMES[token.playerId]]; const [hr, hc] = homeCoords[homeIdx]; return { x: hc * CELL + CELL/2, y: hr * CELL + CELL/2 }; } // On outer track: use TRACK_MAP[trackPos] const [row, col] = TRACK_MAP[token.trackPos]; return { x: col * CELL + CELL/2, y: row * CELL + CELL/2 }; }
The state machine's transition method acts as a guard — invalid transitions are logged and rejected. This is invaluable during development because it immediately surfaces logic errors. The getMovableTokens method encapsulates the movement rules: a token in base requires a 6, a token on the track can advance up to WIN_POSITION (57), and finished tokens never move. The getTokenPixelPosition function maps track positions to screen coordinates, handling three distinct zones: base positions, the 52-cell outer track, and the 5-cell home column per player.
Dice Roll UI
The dice roll is the primary user interaction that gates all subsequent moves. A good dice UI has two components: the visual die display drawn on the canvas and the animated roll sequence. The requestAnimationFrame loop drives the visual dice cycling through random values during the animation, while the final value is set programmatically after the animation completes.
The dice button itself is an HTML element positioned over the canvas — this keeps accessibility simple (a native button) while the canvas renders the actual die face. Drawing the die face programmatically means you can style it consistently with the board without managing image assets.
// Dice display area on canvas (top-right corner of board) const DICE_AREA = { x: 13 * CELL, y: 0, size: 2 * CELL }; function drawDiceFace(value) { const { x, y, size } = DICE_AREA; // White die background ctx.fillStyle = '#f1f5f9'; ctx.beginPath(); ctx.roundRect(x + CELL/4, y + CELL/4, size - CELL/2, size - CELL/2, 8); ctx.fill(); // Pip positions relative to die face: [col, row] offsets in 3×3 grid const pips = { 1: [[1,1]], 2: [[0,0],[2,2]], 3: [[0,0],[1,1],[2,2]], 4: [[0,0],[2,0],[0,2],[2,2]], 5: [[0,0],[2,0],[1,1],[0,2],[2,2]], 6: [[0,0],[2,0],[0,1],[2,1],[0,2],[2,2]], }; ctx.fillStyle = '#1e293b'; const dieX = x + CELL/4, dieY = y + CELL/4; const dieSize = size - CELL/2; pips[value].forEach(([col, row]) => { const px = dieX + (col + 0.5) * dieSize / 3; const py = dieY + (row + 0.5) * dieSize / 3; ctx.beginPath(); ctx.arc(px, py, dieSize / 10, 0, Math.PI * 2); ctx.fill(); }); } // Roll animation state let rollAnim = { active: false, startTime: 0, duration: 800, finalValue: 0 }; function initiateDiceRoll() { if (gameState.phase !== GamePhase.IDLE) return; gameState.transition(GamePhase.ROLLING); rollAnim.active = true; rollAnim.startTime = performance.now(); rollAnim.finalValue = Math.floor(Math.random() * 6) + 1; } // Called every frame during the roll animation function updateDiceAnimation(now) { if (!rollAnim.active) return; const elapsed = now - rollAnim.startTime; if (elapsed >= rollAnim.duration) { rollAnim.active = false; gameState.setDiceValue(rollAnim.finalValue); } else { // Show cycling random values during animation const displayVal = Math.floor(Math.random() * 6) + 1; drawDiceFace(displayVal); } }
Token Movement Animation
Token animation moves a piece from its current track position to its destination one cell at a time, with interpolation between cells for smooth visual transitions. The animation system stores per-token state (animFrom, animTo, animProgress) that gets updated each frame by the game loop.
Linear interpolation (lerp) between the origin and destination pixel coordinates produces smooth movement. At 60fps, a 300ms animation traverses about 18 frames per cell — imperceptibly smooth to the human eye. The animation uses ease-in-out easing so tokens accelerate out of their starting position and decelerate into the destination, which feels more natural than constant-speed movement.
// Per-token animation state (stored alongside token data) const ANIM = { CELL_DURATION: 120, // ms per cell traversed EASE_IN_OUT: t => t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2) / 2, }; function startTokenAnimation(token, targetPos) { token.animFrom = { ...getTokenPixelPosition(token) }; token.animTo = getPixelPosForTrack(targetPos, token.playerId); token.animStart = performance.now(); token.animTarget = targetPos; token.animProgress = 0; gameState.transition(GamePhase.ANIMATING); } function updateTokenAnimation(token, now) { if (token.animProgress >= 1) { token.animProgress = 1; // Snap to final position and update trackPos token.trackPos = token.animTarget; token.animPos = null; // Apply capture logic const captured = checkAndExecuteCapture(token); if (captured.length > 0) { captured.forEach(c => { c.trackPos = -1; c.animPos = null; }); } // Check if finished if (token.trackPos >= WIN_POSITION) { token.finished = true; gameState.scores[token.playerId]++; } // Grant extra turn on 6, otherwise advance if (gameState.diceValue !== 6 || gameState.consecutiveSixs >= 3) { gameState.advanceTurn(); } else { gameState.diceValue = null; gameState.transition(GamePhase.IDLE); } return; } const elapsed = now - token.animStart; const numCells = token.animTarget - (token.trackPos === -1 ? 0 : token.trackPos); token.animProgress = Math.min(1, elapsed / (numCells * ANIM.CELL_DURATION)); const eased = ANIM.EASE_IN_OUT(token.animProgress); token.animPos = { x: token.animFrom.x + (token.animTo.x - token.animFrom.x) * eased, y: token.animFrom.y + (token.animTo.y - token.animFrom.y) * eased, }; } function getPixelPosForTrack(trackPos, playerId) { const tmp = { trackPos, playerId, tokenId: 0, finished: false }; return getTokenPixelPosition(tmp); } function checkAndExecuteCapture(movingToken) { if (SAFE_INDICES.has(movingToken.trackPos)) return []; const captured = []; gameState.pieces.forEach((playerTokens, pid) => { if (pid === movingToken.playerId) return; playerTokens.forEach(t => { if (t.trackPos === movingToken.trackPos && t.trackPos !== -1 && !t.finished) { captured.push(t); } }); }); return captured; }
The animation system decouples visual interpolation from game logic. The actual trackPos update happens only after the animation completes, ensuring that the game state is always consistent with what the player sees. If a capture occurs mid-animation, the captured tokens are returned to base immediately, even if they haven't visually moved yet — the game state takes precedence over animation rendering.
Game Loop with requestAnimationFrame
The requestAnimationFrame (rAF) pattern is the standard approach for browser-based game loops. Unlike setInterval or setTimeout, rAF synchronizes with the display refresh rate (typically 60Hz), pauses when the tab is inactive, and provides high-resolution timestamps via performance.now().
A clean game loop separates update logic from render logic. The update function advances game state based on elapsed time; the render function draws the current state to the canvas. This separation makes it straightforward to add pause functionality, variable playback speed for replays, or network synchronization where the render runs locally at 60fps while the update runs on server ticks.
let lastTimestamp = 0; let isPaused = false; function gameLoop(timestamp) { // Delta time: seconds elapsed since last frame (capped at 100ms to handle tab switches) const dt = Math.min((timestamp - lastTimestamp) / 1000, 0.1); lastTimestamp = timestamp; if (!isPaused) { update(timestamp, dt); } render(); requestAnimationFrame(gameLoop); } function update(now, dt) { // Dice roll animation updateDiceAnimation(now); // Token movement animations gameState.pieces.flat().forEach(token => { if (token.animProgress < 1) { updateTokenAnimation(token, now); } }); // Highlight movable tokens during selection phase if (gameState.phase === GamePhase.SELECT_TOKEN) { highlightMovableTokens(); } } function render() { renderBoard(); renderTurnIndicator(); renderOverlayUI(); if (gameState.diceValue !== null) { drawDiceFace(gameState.diceValue); } } function renderOverlayUI() { // Player name label ctx.fillStyle = PLAYER_COLORS[PLAYER_NAMES[gameState.currentPlayer]]; ctx.font = 'bold 16px system-ui'; ctx.textAlign = 'center'; ctx.fillText(PLAYER_NAMES[gameState.currentPlayer] + '\'s turn', BOARD / 2, CELL * 0.8); } function highlightMovableTokens() { const movable = gameState.getMovableTokens(); movable.forEach(token => { const pos = getTokenPixelPosition(token); ctx.strokeStyle = '#fbbf24'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(pos.x, pos.y, 18, 0, Math.PI * 2); ctx.stroke(); }); } // Initialize and start gameState.init(); renderBoard(); requestAnimationFrame(ts => { lastTimestamp = ts; gameLoop(ts); });
Keyboard and Touch Input
Input handling must bridge the gap between pointer coordinates and game logic. The canvas uses CSS pixels (board coordinates), but internal rendering uses device-pixel-ratio-scaled coordinates. All input handlers normalize to CSS pixels before converting to grid indices.
Keyboard support enables accessibility and strategic play: pressing 1–4 selects tokens by index, Space rolls the dice, and arrow keys can cycle through movable tokens when a token is selected. Touch input uses the same coordinate normalization but adds touch-action: none to prevent scroll interference and handles multi-touch for simultaneous pinch-zoom on the canvas.
// Mouse/click input canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const scaleX = BOARD / rect.width; const scaleY = BOARD / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; handleBoardClick(x, y); }); // Touch input (mobile) canvas.addEventListener('touchstart', (e) => { e.preventDefault(); const touch = e.touches[0]; const rect = canvas.getBoundingClientRect(); const scaleX = BOARD / rect.width; const scaleY = BOARD / rect.height; const x = (touch.clientX - rect.left) * scaleX; const y = (touch.clientY - rect.top) * scaleY; handleBoardClick(x, y); }, { passive: false }); function handleBoardClick(x, y) { // Check dice area click const da = DICE_AREA; if (x >= da.x && x <= da.x + da.size && y >= da.y && y <= da.y + da.size) { initiateDiceRoll(); return; } // Token selection during selectToken phase if (gameState.phase !== GamePhase.SELECT_TOKEN) return; const clicked = hitTestToken(x, y); if (clicked && clicked.playerId === gameState.currentPlayer) { if (canTokenMove(clicked, gameState.diceValue)) { const newPos = clicked.trackPos === -1 ? 0 : clicked.trackPos + gameState.diceValue; startTokenAnimation(clicked, newPos); } } } function hitTestToken(x, y) { let best = null, bestDist = Infinity; gameState.pieces.flat().forEach(token => { const pos = token.animPos || getTokenPixelPosition(token); const dist = Math.sqrt((x-pos.x)**2 + (y-pos.y)**2); if (dist < 18 && dist < bestDist) { bestDist = dist; best = token; } }); return best; } // Keyboard input document.addEventListener('keydown', (e) => { if (gameState.phase === GamePhase.GAME_OVER) return; switch (e.code) { case 'Space': e.preventDefault(); initiateDiceRoll(); break; case 'Digit1': case 'Digit2': case 'Digit3': case 'Digit4': if (gameState.phase === GamePhase.SELECT_TOKEN) { const idx = parseInt(e.code.replace('Digit','')) - 1; const movable = gameState.getMovableTokens(); if (movable[idx]) { const token = movable[idx]; const newPos = token.trackPos === -1 ? 0 : token.trackPos + gameState.diceValue; startTokenAnimation(token, newPos); } } break; case 'KeyP': isPaused = !isPaused; break; case 'KeyR': gameState.init(); renderBoard(); break; } });
The hit-test function uses Euclidean distance from the click point to each token's current position, returning the closest token within the 18px radius. Tokens in animation use animPos (the interpolated position) rather than their logical trackPos so clicking on an animating token registers correctly. The keyboard handler maps Digit1–Digit4 to token selection, Space to rolling, P to pausing, and R to resetting — a complete keyboard-driven interface for accessibility.
Multiplayer Architecture
A client-side JavaScript Ludo game cannot be made fully secure because game state lives in the browser where users can manipulate it. For multiplayer, the authoritative game logic must run on a server that validates every move before applying it. The client sends actions (roll, select token) to the server, which executes them and broadcasts the resulting state changes to all players.
The Socket.IO multiplayer guide covers the full implementation of an authoritative server, including room management for private games, turn synchronization, and handling disconnections. For a headless server, you can use the LudoKingAPI Realtime API which handles turn validation and state synchronization out of the box, letting you focus on the rendering layer.
For a local hot-seat multiplayer variant where all players share one device, the state machine already supports multiple players — gameState.currentPlayer cycles through 0–3, and the advanceTurn method ensures only the active player's tokens are selectable.
If you want to build a web-based single-player experience first, the Ludo game tutorial walks through the rules and strategy mechanics that inform how the AI should evaluate positions.
Frequently Asked Questions
TRACK_MAP. Token positions are calculated by looking up the row/col at the token's track index, then multiplying by CELL and adding half-cell offset to center the token within the cell. Home column positions (track indices 52–56) use a separate coordinate lookup specific to each player's quadrant.Date.now(). For any canvas-based game, rAF is the correct choice. The game loop calculates delta time so that animation speed remains consistent regardless of the actual frame rate.touch-action: none to the canvas CSS to prevent the browser's default touch gestures (scroll, zoom). In the event listener, call e.preventDefault() on touchstart to stop gesture propagation. Extract coordinates from e.touches[0].clientX/Y (the first finger). For pinch-to-zoom, track e.touches.length — if it exceeds 1, ignore the touch as a gesture rather than a tap. The canvas scaling code uses getBoundingClientRect to convert from viewport coordinates to board coordinates regardless of the canvas's CSS size.transition() guard that accepts only valid phase-to-phase transitions. For example, the transition from IDLE can only go to ROLLING — the dice cannot be rolled while a token is animating. Similarly, token selection (SELECT_TOKEN) is the only phase that allows piece clicks. By routing all state changes through transition(), every invalid action is caught and logged. The game logic methods like getMovableTokens() and canTokenMove() are secondary guards that encode the movement rules (must roll 6 to leave base, cannot exceed win position, etc.).renderBoard and drawToken functions need rewriting with WebGL draw calls. The state machine, animation system, and input handling remain unchanged.AudioContext once at startup, then trigger sounds by calling audioContext.resume() followed by play() on pre-loaded AudioBuffer objects. Load sounds as ArrayBuffers via fetch(), decode with audioContext.decodeAudioData(), and store them in a dictionary: sounds = { roll: buffer, move: buffer, capture: buffer, win: buffer }. Call playSound('move') in startTokenAnimation() and playSound('capture') in checkAndExecuteCapture(). Keep sounds under 500KB each; WAV or OGG formats work well for short game sounds.gameState object contains all serializable data: JSON.stringify({ phase, currentPlayer, diceValue, consecutiveSixs, scores, pieces: pieces.map(p => p.map(t => ({playerId:t.playerId,tokenId:t.tokenId,trackPos:t.trackPos,finished:t.finished}))) }) saves a complete snapshot. Store this string in localStorage with localStorage.setItem('ludo_save', json). On page load, check for a saved game and call Object.assign(gameState, JSON.parse(saved)) to restore. For multiplayer save/restore across sessions, the Realtime API persists game state server-side so players can rejoin an interrupted game.Building a JavaScript Ludo Game?
Get help with Canvas rendering, animation systems, state machines, and multiplayer integration for your JavaScript Ludo project.