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.

HTML + JavaScript — Canvas initialization and constants
// 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.

JavaScript — Complete board rendering system
// 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.

JavaScript — Complete game state machine
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.

JavaScript — Dice rolling UI with animated display
// 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.

JavaScript — Token animation with easing and frame interpolation
// 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.

JavaScript — requestAnimationFrame game loop
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.

JavaScript — Keyboard, mouse, and touch input handling
// 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

The canvas uses a 15×15 conceptual grid with 40px cells (600×600 total). Each cell maps to [row, col] coordinates where row 0 is the top and col 0 is the left. The 52-cell outer track is pre-computed into an ordered array of [row, col] pairs stored in 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.
requestAnimationFrame synchronizes with the display's refresh rate (typically 60fps), which means the canvas redraws at the exact moment the display is ready — eliminating tearing artifacts. It also automatically pauses when the browser tab loses focus, saving CPU and battery. setInterval runs independently of the display, can fire during tab-inactive periods, and provides millisecond (not sub-millisecond) resolution via 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.
Apply 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.
The state machine defines five exclusive phases and a 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.).
WebGL would be overkill for Ludo — the 2D Canvas API handles the modest draw call count (under 200 per frame) with headroom to spare on any device from the last decade. WebGL becomes relevant for games with thousands of sprites, particle effects, or 3D geometry. That said, if you want to port the architecture to WebGL, keep the same game state machine and coordinate system — only the renderBoard and drawToken functions need rewriting with WebGL draw calls. The state machine, animation system, and input handling remain unchanged.
Use the Web Audio API for low-latency sound without blocking the render loop. Create an 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.
Serialize the game state to JSON and persist it. The 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.