Prerequisites

Node.js 18+ and npm installed. Docker Desktop (or Docker Engine) for the deployment phase. No prior experience with game development is required — this tutorial builds everything from scratch. The complete source for each phase is provided as copy-paste-ready code blocks. If you get stuck, the WhatsApp support line is monitored daily.

Tutorial Repository

All source code from this tutorial is available in the companion repo. Clone it to follow along or jump to the phase that matches your current skill level: Node.js API implementation · Socket.IO game layer · Docker deployment.

Phase 1 — Project Setup

Why a dedicated project structure?

Ludo game logic lives at the intersection of real-time state and turn-based rules. Separating concerns across three layers — server (game engine + API), client (browser UI), and container (Docker) — prevents the codebase from collapsing into callback soup as the feature set grows. This phase sets up that scaffolding so each subsequent phase plugs in cleanly.

Directory layout

bash
ludo-game/
├── src/
│   ├── engine/
│   │   ├── board.js         // board constants, square mapping
│   │   ├── dice.js          // dice roll logic
│   │   ├── tokens.js        // token state management
│   │   ├── moves.js         // move validation rules
│   │   └── game.js          // main Game class orchestrating all engine modules
│   ├── api/
│   │   ├── routes.js        // Express router mounting all endpoints
│   │   └── middleware.js    // auth, error handler, request logger
│   ├── socket/
│   │   └── handler.js       // Socket.IO connection handling and room management
│   └── index.js             // entry point: Express app + HTTP server + Socket.IO bind
├── public/
│   └── index.html           // minimal browser client
├── tests/
│   └── game.test.js         // engine unit tests
├── Dockerfile
├── docker-compose.yml
└── package.json

Initialize the Node project

bash
mkdir ludo-game && cd ludo-game
npm init -y
npm install express socket.io cors uuid dotenv
npm install --save-dev jest

Environment configuration

Store secrets and tunable parameters in a .env file. Never commit this file — add it to .gitignore immediately.

.env
# Server settings
PORT=3000
NODE_ENV=development

# LudoKingAPI credentials (replace with your own)
LUDOKING_API_KEY=your_api_key_here
LUDOKING_API_URL=https://api.ludokingapi.site/v1

# Game settings
MAX_PLAYERS=4
DICE_MAX=6
TOKEN_HOME_SQUARES=57     // squares a token must travel to reach home
BOARD_START_SQUARE=1    // first playable square on the main track

The environment-driven approach means changing DICE_MAX to 8 instantly switches the entire game to an 8-faced die variant without touching any game logic code. This pattern scales to support multiple Ludo variants (classic, quick, team) as separate config profiles.

Phase 2 — Game Logic Engine

The engine is the most critical part of any Ludo implementation. Every other component — REST API, Socket.IO handler, frontend — exists to expose and visualize the decisions made here. This phase builds five focused modules that together form a complete, testable game engine with zero external dependencies.

Phase 2.1 — Board geometry (board.js)

The Ludo board is a 52-square outer track with four home columns branching off at each corner. Instead of hardcoding square numbers, we derive them algebraically so the math works for any number of players. The formula (playerIndex * 13) % 52 gives each player a unique starting square 13 spaces apart — exactly how a real Ludo board is arranged.

src/engine/board.js
// Constants for the classic Ludo board layout
const OUTER_TRACK = 52;
const SQUARES_PER_PLAYER = 13;   // 52 / 4 = 13 squares per quarter
const HOME_COLUMN_LENGTH = 6;

/**
 * getStartSquare — the first playable square on the outer track for a player.
 * Player 0 starts at square 0, player 1 at square 13, player 2 at square 26, etc.
 */
function getStartSquare(playerIndex) {
  return (playerIndex * SQUARES_PER_PLAYER) % OUTER_TRACK;
}

/**
 * getHomeEntrySquare — the square just before a player's home column begins.
 * A token lands here after completing the full outer track circuit.
 */
function getHomeEntrySquare(playerIndex) {
  return (getStartSquare(playerIndex) - 1 + OUTER_TRACK) % OUTER_TRACK;
}

/**
 * normalizeSquare — wraps a square index into the 0-51 outer track range.
 * Handles negative offsets (tokens moving backwards from start) correctly.
 */
function normalizeSquare(square) {
  return ((square % OUTER_TRACK) + OUTER_TRACK) % OUTER_TRACK;
}

/**
 * isInHomeColumn — returns true if the token has entered its player's home column
 * and is still on the board (not yet finished).
 * @param {number} playerIndex
 * @param {number} outerSquare  — the token's position on the outer track (0-51)
 * @param {number} homeStep    — how many squares into the home column the token is (0-6)
 */
function isInHomeColumn(playerIndex, outerSquare, homeStep) {
  if (homeStep <= 0) return false;
  const entry = getHomeEntrySquare(playerIndex);
  return normalizeSquare(outerSquare - entry) > 0;
}

module.exports = { OUTER_TRACK, SQUARES_PER_PLAYER, HOME_COLUMN_LENGTH,
  getStartSquare, getHomeEntrySquare, normalizeSquare, isInHomeColumn };

Phase 2.2 — Dice rolling (dice.js)

The dice module produces cryptographically random rolls via Node's crypto module. Using Math.random() in a server-side game engine is a known anti-pattern — it makes the game vulnerable to seed attacks. The getDiceRoll() function below uses randomInt from Node's built-in crypto module, ensuring the server controls randomness and clients cannot predict or manipulate outcomes.

src/engine/dice.js
const { randomInt } = require('crypto');
const DICE_FACES = 6;

/**
 * rollDice — returns a random integer between 1 and DICE_FACES (inclusive).
 * Uses crypto.randomInt for server-side unpredictability.
 */
function rollDice() {
  return randomInt(1, DICE_FACES + 1);
}

/**
 * canMoveAgain — in Ludo, rolling a 6 grants an extra turn.
 * @param {number} diceValue
 * @returns {boolean}
 */
function canMoveAgain(diceValue) {
  return diceValue === 6;
}

module.exports = { rollDice, canMoveAgain, DICE_FACES };

Phase 2.3 — Token state management (tokens.js)

Each player controls four tokens. A token has three states: in_house (square -1), on_board (square 0-51 or in home column), and finished (home column complete). Representing these states as a single object per token keeps serialization simple — the same object travels over REST, Socket.IO, and into the browser's JavaScript without transformation.

src/engine/tokens.js
/**
 * createToken — factory function producing a fresh token in the starting state.
 * @param {number} playerIndex  — 0-based player index (0-3)
 * @param {number} tokenIndex   — 0-based token index within that player (0-3)
 */
function createToken(playerIndex, tokenIndex) {
  return {
    player: playerIndex,
    index: tokenIndex,
    square: -1,       // -1 means "in house" (not yet on the board)
    homeStep: 0,      // 0 = not in home column; 1-6 = position in home column
    finished: false
  };
}

/**
 * canLeaveHouse — token can only leave house if the dice roll is exactly 6.
 * @param {object} token
 * @param {number} dice
 * @returns {boolean}
 */
function canLeaveHouse(token, dice) {
  return token.square === -1 && dice === 6;
}

/**
 * isHome — token has reached the end of its home column.
 * @param {object} token
 * @returns {boolean}
 */
function isHome(token) {
  return token.homeStep >= 7;
}

module.exports = { createToken, canLeaveHouse, isHome };

Phase 2.4 — Move validation (moves.js)

Move validation is where most Ludo implementations develop bugs. This module encodes every rule: leaving house on a 6, safe squares, capturing opponents, home column entry, and finishing. The validateMove function is a pure function — given the same inputs, it always returns the same result — which makes it trivially testable. This purity is the single most important property of the game engine.

src/engine/moves.js
const { OUTER_TRACK, getStartSquare, getHomeEntrySquare, normalizeSquare, isInHomeColumn } = require('./board');
const { canLeaveHouse } = require('./tokens');

const SAFE_SQUARES = new Set([0, 8, 13, 21, 26, 34, 39, 47]);
// Each player's start square is a safe star square

/**
 * validateMove — core rule engine. Returns { valid: true } or { valid: false, reason: string }.
 *
 * Rules enforced:
 *  1. Token belongs to the player attempting to move
 *  2. Token is not already finished
 *  3. If in house: dice must be 6 to leave
 *  4. If on board: movement must not overshoot the home column
 *  5. If landing on opponent token (not on safe square): capture
 *
 * @param {object} token         — token being moved
 * @param {number} dice          — dice value (1-6)
 * @param {number} currentPlayer — index of player attempting the move
 * @param {number} playerCount   — total number of players in game
 * @param {object[]} allTokens   — all tokens in game (for capture detection)
 */
function validateMove(token, dice, currentPlayer, playerCount, allTokens) {
  // Rule 1: token belongs to mover
  if (token.player !== currentPlayer) {
    return { valid: false, reason: "Token does not belong to the current player" };
  }

  // Rule 2: token not finished
  if (token.finished) {
    return { valid: false, reason: "Token has already finished the race" };
  }

  // Rule 3: leaving house
  if (token.square === -1) {
    if (!canLeaveHouse(token, dice)) {
      return { valid: false, reason: "Must roll a 6 to move a token out of house" };
    }
    return { valid: true, action: "leave_house" };
  }

  // Rule 4: token on the board — calculate destination
  const entry = getHomeEntrySquare(token.player);
  const stepsFromEntry = normalizeSquare(token.square - entry);
  const newHomeStep = token.homeStep + dice;

  // Overshooting home column
  if (newHomeStep > 7) {   // 6 home squares + 1 for "finished"
    return { valid: false, reason: "Move overshoots home column" };
  }

  // Reaching exactly home
  if (newHomeStep === 7) {
    return { valid: true, action: "finish" };
  }

  // Still in home column
  if (newHomeStep > 0) {
    return { valid: true, action: "home_column", newHomeStep };
  }

  // Moving along outer track
  const targetSquare = normalizeSquare(token.square + dice);

  // Rule 5: capture check — can only capture on non-safe squares
  const occupant = allTokens.find(t =>
    t.player !== token.player &&
    t.square !== -1 &&
    t.homeStep === 0 &&
    normalizeSquare(t.square) === targetSquare
  );
  if (occupant && !SAFE_SQUARES.has(targetSquare)) {
    return { valid: true, action: "capture", capturedToken: occupant, targetSquare };
  }

  return { valid: true, action: "move", targetSquare };
}

module.exports = { validateMove, SAFE_SQUARES };

Phase 2.5 — Game class (game.js)

The Game class orchestrates all engine modules. It maintains the game state, enforces turn order, applies validated moves, detects captures and wins, and exposes a serializable getState() method consumed by both REST responses and Socket.IO broadcasts. This class has no knowledge of HTTP or WebSockets — it is a pure game logic class, which is the correct boundary to maintain.

src/engine/game.js
const { v4: uuidv4 } = require('uuid');
const { rollDice, canMoveAgain } = require('./dice');
const { createToken, isHome } = require('./tokens');
const { validateMove } = require('./moves');
const { getStartSquare, normalizeSquare } = require('./board');

class Game {
  constructor({ playerCount = 2, variant = 'classic' } = {}) {
    this.id = uuidv4();
    this.variant = variant;
    this.playerCount = playerCount;
    this.players = [];          // { id, name }
    this.tokens = [];
    this.currentPlayer = 0;
    this.diceValue = 0;
    this.extraTurn = false;
    this.gameOver = false;
    this.winner = null;
    this.lastEvent = null;   // { type, data } — consumed by Socket.IO

    // Initialize tokens: 4 per player
    for (let p = 0; p < playerCount; p++) {
      for (let t = 0; t < 4; t++) {
        this.tokens.push(createToken(p, t));
      }
    }
  }

  /**
   * addPlayer — register a player and return their player ID.
   */
  addPlayer(name) {
    if (this.players.length >= this.playerCount) {
      throw new Error("Game is full");
    }
    const id = uuidv4();
    this.players.push({ id, name });
    return id;
  }

  /**
   * roll — roll the dice for the current player. Returns the dice value.
   */
  roll() {
    this.diceValue = rollDice();
    this.extraTurn = canMoveAgain(this.diceValue);
    this.lastEvent = { type: 'dice_rolled', player: this.currentPlayer, value: this.diceValue };
    return this.diceValue;
  }

  /**
   * submitMove — validate and execute a move. Returns { success, reason, event }.
   */
  submitMove(playerIndex, tokenIndex) {
    if (this.gameOver) {
      return { success: false, reason: "Game is already over" };
    }
    if (playerIndex !== this.currentPlayer) {
      return { success: false, reason: "Not this player's turn" };
    }
    if (this.diceValue === 0) {
      return { success: false, reason: "Must roll dice before submitting a move" };
    }

    const token = this.tokens.find(t => t.player === playerIndex && t.index === tokenIndex);
    if (!token) {
      return { success: false, reason: "Invalid token index" };
    }

    const result = validateMove(token, this.diceValue, playerIndex, this.playerCount, this.tokens);
    if (!result.valid) {
      return { success: false, reason: result.reason };
    }

    // Apply the validated move
    this.applyMove(token, result);

    // Check for win
    this.checkWin(playerIndex);

    // Advance turn if no extra turn earned
    if (!this.extraTurn && !this.gameOver) {
      this.advanceTurn();
    } else {
      this.extraTurn = false;  // consume the flag; reset for next roll
    }

    return { success: true, event: this.lastEvent };
  }

  applyMove(token, result) {
    switch (result.action) {
      case 'leave_house':
        token.square = getStartSquare(token.player);
        this.lastEvent = { type: 'token_left_house', player: token.player, tokenIndex: token.index };
        break;
      case 'move':
        token.square = result.targetSquare;
        this.lastEvent = { type: 'token_moved', player: token.player, tokenIndex: token.index, square: result.targetSquare };
        break;
      case 'capture':
        const captured = result.capturedToken;
        captured.square = -1;   // captured token goes back to house
        token.square = result.targetSquare;
        this.lastEvent = { type: 'token_captured', player: token.player, tokenIndex: token.index,
          capturedBy: token.player, capturedTokenIndex: captured.index };
        break;
      case 'home_column':
        token.homeStep = result.newHomeStep;
        this.lastEvent = { type: 'token_home_column', player: token.player, tokenIndex: token.index, homeStep: result.newHomeStep };
        break;
      case 'finish':
        token.homeStep = 7;
        token.finished = true;
        this.lastEvent = { type: 'token_finished', player: token.player, tokenIndex: token.index };
        break;
    }
  }

  checkWin(playerIndex) {
    const playerTokens = this.tokens.filter(t => t.player === playerIndex);
    if (playerTokens.every(t => t.finished)) {
      this.gameOver = true;
      this.winner = playerIndex;
      this.lastEvent = { type: 'game_over', winner: playerIndex };
    }
  }

  advanceTurn() {
    this.currentPlayer = (this.currentPlayer + 1) % this.playerCount;
    this.diceValue = 0;
  }

  /**
   * getState — serializable snapshot for REST responses and Socket.IO broadcasts.
   */
  getState() {
    return {
      game_id: this.id,
      variant: this.variant,
      player_count: this.playerCount,
      players: this.players,
      current_player: this.currentPlayer,
      dice: this.diceValue,
      tokens: this.tokens,
      game_over: this.gameOver,
      winner: this.winner
    };
  }
}

module.exports = Game;

Phase 3 — REST API Endpoints

The REST layer wraps the Game class with HTTP endpoints. Every endpoint maps directly to a game operation: create a game, join it, roll dice, submit a move, and fetch state. This layer knows nothing about how the game works internally — it only translates HTTP requests into method calls on the Game instance and serializes the results back as JSON. For a production-grade implementation of these patterns, see the Ludo REST API reference.

Server entry point (src/index.js)

src/index.js
require('dotenv').config();
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const cors = require('cors');

const app = express();
const server = http.createServer(app);
const io = new Server(server, {
  cors: { origin: "*", methods: ["GET", "POST"] }
});

app.use(express.static('public'));
app.use(cors());
app.use(express.json());

// In-memory store — replace with Redis/Postgres for production multi-instance deployment
const games = new Map();

require('./api/routes')(app, games, io);
require('./socket/handler')(io, games);

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`Ludo server running on http://localhost:${PORT}`);
});

API routes (src/api/routes.js)

src/api/routes.js
const Game = require('../engine/game');

function authMiddleware(req, res, next) {
  const apiKey = req.headers['authorization']?.replace('Bearer ', '');
  if (!apiKey && process.env.NODE_ENV === 'production') {
    return res.status(401).json({ error: 'unauthorized', message: 'Missing API key' });
  }
  next();
}

module.exports = function(app, games, io) {

  // POST /games — create a new game room
  app.post('/games', authMiddleware, (req, res) => {
    const { player_count = 2, variant = 'classic', host_name } = req.body;
    if (player_count < 2 || player_count > 4) {
      return res.status(400).json({ error: 'invalid_player_count', message: 'Player count must be 2-4' });
    }
    const game = new Game({ playerCount: player_count, variant });
    const hostId = game.addPlayer(host_name || 'Host');
    games.set(game.id, game);
    io.emit('game_created', { game_id: game.id, host_id: hostId });
    res.status(201).json({ game_id: game.id, host_id: hostId, state: game.getState() });
  });

  // POST /games/:id/join — join an existing game
  app.post('/games/:id/join', authMiddleware, (req, res) => {
    const game = games.get(req.params.id);
    if (!game) return res.status(404).json({ error: 'not_found', message: 'Game not found' });
    try {
      const playerId = game.addPlayer(req.body.name || 'Player');
      io.to(req.params.id).emit('player_joined', { player_id: playerId, state: game.getState() });
      res.json({ player_id: playerId, state: game.getState() });
    } catch (e) {
      res.status(400).json({ error: 'join_failed', message: e.message });
    }
  });

  // GET /games/:id/state — fetch current game state (no side effects)
  app.get('/games/:id/state', authMiddleware, (req, res) => {
    const game = games.get(req.params.id);
    if (!game) return res.status(404).json({ error: 'not_found', message: 'Game not found' });
    res.json(game.getState());
  });

  // POST /games/:id/roll — roll the dice for the current player
  app.post('/games/:id/roll', authMiddleware, (req, res) => {
    const game = games.get(req.params.id);
    if (!game) return res.status(404).json({ error: 'not_found' });
    const { player_id } = req.body;
    const player = game.players.find(p => p.id === player_id);
    if (!player) return res.status(400).json({ error: 'invalid_player' });
    if (game.players[game.currentPlayer].id !== player_id) {
      return res.status(400).json({ error: 'not_your_turn' });
    }
    const diceValue = game.roll();
    io.to(req.params.id).emit('dice_rolled', { player: game.currentPlayer, value: diceValue, state: game.getState() });
    res.json({ dice: diceValue, state: game.getState() });
  });

  // POST /games/:id/move — submit a move
  app.post('/games/:id/move', authMiddleware, (req, res) => {
    const game = games.get(req.params.id);
    if (!game) return res.status(404).json({ error: 'not_found' });
    const { player_id, token_index } = req.body;
    const playerIndex = game.players.findIndex(p => p.id === player_id);
    if (playerIndex === -1) return res.status(400).json({ error: 'invalid_player' });
    const result = game.submitMove(playerIndex, token_index);
    if (!result.success) {
      return res.status(400).json({ error: 'illegal_move', message: result.reason });
    }
    io.to(req.params.id).emit('game_state', { event: result.event, state: game.getState() });
    res.json({ success: true, event: result.event, state: game.getState() });
  });

  // Global error handler
  app.use((err, req, res, _next) => {
    console.error(err);
    res.status(500).json({ error: 'internal_error', message: err.message });
  });
};

The in-memory Map works for single-instance deployments and during development. Scaling to multiple server instances (e.g., behind a load balancer) requires replacing it with a shared store — Redis is the standard choice for session and game state because it supports sub-millisecond reads and pub/sub for cross-instance event propagation. See the Node.js API implementation guide for a Redis-backed version of this exact pattern.

Phase 4 — Socket.IO Real-Time Layer

REST endpoints are request-response by design — the client sends a request, the server sends one response. Ludo is a real-time, multi-player game where every dice roll and token move must be communicated to all connected players immediately. Socket.IO solves this by maintaining persistent WebSocket connections and providing a room abstraction that maps naturally to game sessions: each game gets a room, and every Socket.IO event is broadcast to all clients in that room with a single io.to(roomId).emit() call. The complete wiring is covered in the Ludo + Socket.IO game development guide.

Socket handler (src/socket/handler.js)

src/socket/handler.js
/**
 * Socket.IO handler — manages real-time bidirectional communication.
 *
 * Events emitted by server:
 *   game_created     — new game was created
 *   player_joined    — a player successfully joined a game room
 *   dice_rolled      — a player rolled the dice (includes new dice value)
 *   token_moved      — a token changed position
 *   token_captured   — a token landed on and sent an opponent back to house
 *   token_finished   — a token reached home
 *   game_over        — game ended (includes winner index)
 *   error            — invalid action (includes reason string)
 *
 * Events received from client:
 *   join_room        — client wants to observe a game
 *   leave_room       — client disconnects from a game room
 *   ping             — heartbeat to keep connection alive
 */

module.exports = function(io, games) {

  io.on('connection', (socket) => {
    console.log(`Client connected: ${socket.id}`);

    socket.on('join_room', ({ game_id }) => {
      const game = games.get(game_id);
      if (!game) {
        socket.emit('error', { message: 'Game not found' });
        return;
      }
      socket.join(game_id);
      socket.gameId = game_id;  // track which room this socket belongs to
      socket.emit('joined_room', { game_id, state: game.getState() });
      console.log(`Socket ${socket.id} joined room ${game_id}`);
    });

    socket.on('leave_room', ({ game_id }) => {
      socket.leave(game_id);
      socket.gameId = null;
      console.log(`Socket ${socket.id} left room ${game_id}`);
    });

    socket.on('ping', () => {
      socket.emit('pong', { timestamp: Date.now() });
    });

    socket.on('disconnect', (reason) => {
      console.log(`Client disconnected: ${socket.id}${reason}`);
      if (socket.gameId) {
        socket.leave(socket.gameId);
      }
    });
  });
};

The socket.gameId assignment on line 36 is a lightweight way to track room membership without a separate session store. When the client disconnects, the handler automatically leaves the room, preventing orphaned socket objects from accumulating in Socket.IO's memory. For high-concurrency deployments (thousands of simultaneous games), consider using Socket.IO's Redis adapter to handle room state across multiple Node.js processes.

Phase 5 — Frontend Integration

The frontend is a single HTML file with embedded JavaScript that communicates with the REST API for game operations and Socket.IO for real-time state updates. This approach avoids any build step — open the HTML file in a browser and it connects directly to the server. No Webpack, no React, no bundle pipeline required for this phase. The file lives in public/index.html and is served by Express via app.use(express.static('public')).

public/index.html
<!-- Serve this file from src/index.js: app.use(express.static('public')) -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Ludo Game Client</title>
  <style>
    body { font-family: system-ui; background: #0f1117; color: #e6edf3; margin: 0; padding: 20px; }
    button { padding: 8px 16px; border-radius: 6px; border: 1px solid #30363d; background: #21262d; color: #e6edf3; cursor: pointer; margin: 4px; }
    button:hover { background: #30363d; }
    button:disabled { opacity: 0.5; cursor: not-allowed; }
    #log { background: #161b22; border-radius: 8px; padding: 12px; height: 300px; overflow-y: auto; font-family: monospace; font-size: 13px; margin-top: 16px; white-space: pre-wrap; }
    #state { background: #161b22; border-radius: 8px; padding: 12px; margin-top: 12px; font-family: monospace; }
  </style>
</head>
<body>
  <h2>Ludo Game Client</h2>
  <div>
    <button id="btnCreate">Create Game</button>
    <input id="gameIdInput" placeholder="Game ID" />
    <button id="btnJoin">Join Game</button>
    <button id="btnRoll">Roll Dice</button>
  </div>
  <div id="state">No active game</div>
  <h3>Event Log</h3>
  <div id="log"></div>

  <script src="/socket.io/socket.io.js"></script>
  <script>
    const API = 'http://localhost:3000';
    const socket = io(API);
    let currentGame = null;
    let myPlayerId = null;
    const logEl = document.getElementById('log');

    function info(msg) {
      logEl.textContent += `[${new Date().toLocaleTimeString()}] ${msg}\n`;
      logEl.scrollTop = logEl.scrollHeight;
    }

    function renderState(state) {
      if (!state) { document.getElementById('state').textContent = 'No active game'; return; }
      const cur = state.current_player;
      document.getElementById('state').innerHTML =
        `Game: ${state.game_id.slice(0,8)}... | ` +
        `Current: Player ${cur} | Dice: ${state.dice} | Over: ${state.game_over}` +
        (state.game_over ? ` | Winner: Player ${state.winner}` : '');
    }

    // REST helper
    async function api(path, options = {}) {
      const res = await fetch(`${API}${path}`, {
        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer test' },
        ...options,
        body: options.body ? JSON.stringify(options.body) : undefined
      });
      return res.json();
    }

    // Socket.IO event handlers
    socket.on('dice_rolled', ({ player, value, state }) => {
      info(`Player ${player} rolled a ${value}`);
      renderState(state);
    });
    socket.on('token_moved', ({ event, state }) => {
      info(`Token moved: player=${event.player} token=${event.tokenIndex} square=${event.square}`);
      renderState(state);
    });
    socket.on('token_captured', ({ event, state }) => {
      info(`CAPTURE: Player ${event.player} sent token=${event.capturedTokenIndex} back to house!`);
      renderState(state);
    });
    socket.on('game_over', ({ event, state }) => {
      info(`GAME OVER! Player ${event.winner} wins!`);
      renderState(state);
    });
    socket.on('joined_room', ({ state }) => {
      info(`Joined room. Players: ${state.players.length}/${state.player_count}`);
      renderState(state);
    });
    socket.on('error', ({ message }) => { info(`ERROR: ${message}`); });

    // Button handlers
    document.getElementById('btnCreate').onclick = async () => {
      const data = await api('/games', { method: 'POST', body: { player_count: 2, host_name: 'Alice' } });
      currentGame = data.game_id;
      myPlayerId = data.host_id;
      document.getElementById('gameIdInput').value = currentGame;
      socket.emit('join_room', { game_id: currentGame });
      info(`Game created: ${currentGame}`);
      renderState(data.state);
    };

    document.getElementById('btnJoin').onclick = async () => {
      const gameId = document.getElementById('gameIdInput').value;
      if (!gameId) { info('Enter a game ID first'); return; }
      const data = await api(`/games/${gameId}/join`, { method: 'POST', body: { name: 'Bob' } });
      myPlayerId = data.player_id;
      currentGame = gameId;
      socket.emit('join_room', { game_id: gameId });
      info(`Joined game: ${gameId}`);
      renderState(data.state);
    };

    document.getElementById('btnRoll').onclick = async () => {
      if (!currentGame) { info('Create or join a game first'); return; }
      const data = await api(`/games/${currentGame}/roll`, { method: 'POST', body: { player_id: myPlayerId } });
      renderState(data.state);
    };
  </script>
</body>
</html>

Run the server with node src/index.js, open http://localhost:3000 in two browser tabs, create a game in the first tab, copy the game ID into the second tab and join. Each tab represents a different player. Rolling dice in either tab triggers a Socket.IO event that updates both tabs simultaneously — demonstrating the full real-time loop without any polling. Extend this client with a visual board renderer by mapping the token positions from state.tokens to board coordinates, then drawing the board with an HTML canvas or SVG.

Phase 6 — Docker Deployment

Docker packages the entire application — Node.js runtime, dependencies, source code, and configuration — into a single image that runs identically on any machine with Docker installed. For production Ludo game servers, this eliminates the "works on my machine" class of bugs entirely. The image also makes horizontal scaling straightforward: run docker-compose up --scale ludo-server=5 and five game server instances are behind the load balancer in seconds. The full Docker deployment guide with multi-stage builds and reverse proxy configuration is at Ludo Game Docker deployment.

Dockerfile — multi-stage production build

Dockerfile
# ---- Base stage: install all dependencies ----
FROM node:20-slim AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# ---- Builder stage: run tests and linting ----
FROM base AS builder
COPY package*.json ./
RUN npm install
COPY tests/ ./tests/
RUN npm test

# ---- Production stage: minimal runtime image ----
FROM node:20-slim
WORKDIR /app
COPY --from=base /app/node_modules ./node_modules
COPY src/ ./src/
COPY public/ ./public/

# Non-root user for security (Docker best practice)
RUN groupadd -r ludo && useradd -r -g ludo ludo
USER ludo

EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000

CMD ["node", "src/index.js"]

docker-compose.yml — development and production profiles

docker-compose.yml
version: '3.9'

services:
  # Development profile: mounts source as a volume for hot reload
  ludo-dev:
    build:
      context: .
      target: builder
    ports:
      - "3000:3000"
    volumes:
      - ./src:/app/src:ro
      - ./public:/app/public:ro
    environment:
      - NODE_ENV=development
      - PORT=3000
    command: node src/index.js
    profiles:
      - dev

  # Production profile: runs the optimized production image
  ludo-server:
    build:
      context: .
      target: production
    ports:
      - "3000:3000"
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/games/Health/state"] || exit 1
      interval: 30s
      timeout: 10s
      retries: 3
    environment:
      - NODE_ENV=production
      - PORT=3000
      - LUDOKING_API_KEY=${LUDOKING_API_KEY}
    profiles:
      - prod

Build and run

bash
# Development (hot reload via volume mounts)
docker-compose --profile dev up --build

# Production (optimized production image)
LUDOKING_API_KEY=your_key_here docker-compose --profile prod up --build -d

# Verify the container is running
docker ps | grep ludo-server

The health check endpoint (/games/Health/state) does not exist yet — add it to routes.js as a simple passthrough that returns { status: 'ok' }. Docker's health check will poll this endpoint every 30 seconds, and the orchestrator (Docker Swarm, Kubernetes, ECS) will automatically restart any container that reports unhealthy. This completes the deployment contract: the container is not just running, it is verifiably serving correct responses.

Common Errors and How to Fix Them

Error: illegal_move — Must roll a 6 to move a token out of house

Cause: The client submitted a move before calling the roll endpoint. Every move requires a preceding dice roll — submitMove returns an error if diceValue === 0. This is enforced in the Game class at Phase 2.5.
Fix: Ensure your client calls POST /games/:id/roll first, receives the dice value, then calls POST /games/:id/move. The client-side flow must mirror this sequence: user taps "Roll" button → wait for Socket.IO dice_rolled event → enable token selection buttons.

Error: illegal_move — Move overshoots home column

Cause: The token is in the home column and the dice roll would push it past square 6 (the last square before finishing). Ludo rules require exact rolls to finish — a token cannot overshoot home.
Fix: In validateMove, the check newHomeStep > 7 catches this. Your client should not display the token as selectable if the roll would overshoot. Calculate the maximum safe home-column position and grey out tokens that cannot move.

Error: not_your_turn

Cause: The player_id in the request body does not match the player at state.current_player. This happens when multiple tabs or clients are submitting moves simultaneously, or when a client is out of sync with the server state.
Fix: Always fetch GET /games/:id/state (or rely on the Socket.IO game_state event) before submitting a move. Reject any user input that arrives during the network round-trip to prevent double-submission. Consider implementing an optimistic UI that rolls back if the server rejects the move.

Error: Socket.IO connection refused on port 3000

Cause: CORS is blocking the WebSocket upgrade, or the server is not running. Socket.IO requires the server to explicitly allow cross-origin connections via the CORS configuration.
Fix: Verify cors: { origin: "*" } is passed to the Socket.IO Server constructor (Phase 3, src/index.js). In production, replace "*" with an explicit list of allowed origins. If behind a reverse proxy (nginx, Caddy), ensure the proxy is configured to forward WebSocket upgrade headers (Upgrade, Connection).

Error: Container exits immediately after docker-compose up

Cause: The CMD instruction references node src/index.js but the working directory or file path inside the container is incorrect. Multi-stage Docker builds copy files to /app, not the project root.
Fix: Add WORKDIR /app before the CMD instruction in the Dockerfile (it is already present in the production stage above). Run docker logs <container_id> to see the actual error message, which will usually be a clear ENOENT indicating the missing file path.

Error: illegal_move — Token does not belong to the current player

Cause: The token_index in the move request references a token owned by a different player. In the Game class, submitMove finds the token by matching both player and index fields. If only index is sent, the wrong token may be found across player boundaries.
Fix: The move request body should include both player_id (for REST authentication) and token_index (for token identification within the player's set). The REST handler resolves player_id to playerIndex before calling submitMove — verify your routes.js implementation matches Phase 3 exactly.

Frequently Asked Questions

In Ludo, two tokens from the same player can occupy the same square — this is a "stack" and is a valid defensive strategy since opponents cannot capture a token that is stacked with another of your tokens. Two tokens from different players on the same outer-track square (outside of safe squares) results in a capture: the arriving token sends the occupant back to house. The validateMove function in src/engine/moves.js handles both cases by checking whether the target square contains an opponent token with homeStep === 0 — stacked tokens have the same square value but are identified by their unique player and index combination. If you need to support team variants where teammates protect each other, modify the capture condition to also check for allied tokens on the same square.
Yes — the game engine at Phase 2 is language-agnostic. The Ludo bot with Python guide covers implementing the same engine logic (board, dice, move validation) in Python using FastAPI for the REST layer and websockets for real-time communication. The API contract is identical: POST /games creates a game, POST /games/:id/move submits a move, and the Socket.IO room events are replaced by native WebSocket messages. The LudoKingAPI platform exposes all endpoints over both HTTP and WebSocket, so the language choice is entirely yours.
The free tier allows 100 requests per minute per API key across all endpoints. The game state endpoints (GET /games/:id/state and WebSocket events) do not count against this limit when used for game observation. Polling GET /games/:id/state in a loop is strongly discouraged — use Socket.IO subscriptions instead, both for performance and to conserve your rate limit quota. Paid tiers raise the limit to 1,000/minute and add bulk game creation endpoints, webhook support for tournament automation, and priority message queues. See the REST API reference for full rate limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset) returned on every response.
The Socket.IO handler in Phase 4 detects disconnection via the disconnect event, but the current implementation does not remove the player from the game — the game continues with the disconnected player's turn being skipped. For a production deployment, implement a grace period: store socket.id alongside player_id in a Map, start a 30-second timer on disconnect, and either restore the session on reconnect (if the same socket.id reconnects within the grace period) or forfeit the turn and remove the player. The timer approach prevents griefing where a player deliberately disconnects to stall the game. Tournament mode (configured via the Node.js API) enforces automatic forfeit after the grace period with no manual intervention required.
The tutorial builds a self-contained game server, but you can delegate the multiplayer infrastructure to LudoKingAPI. Use the REST API to create rooms and manage player sessions, subscribe to game events via WebSocket to receive real-time updates, and implement your own game logic engine for custom rule variants. LudoKingAPI's matchmaking API accepts a player queue and automatically pairs players based on skill rating (Elo), game variant preference, and latency tier. The webhook system delivers game results directly to your server so you can update player ratings and trigger tournament progressions without polling. This hybrid approach — LudoKingAPI for matchmaking and infrastructure, your server for custom game rules — is the recommended production architecture.
The game engine is the most testable part of this stack because it is a pure Node.js class with no I/O dependencies. Write unit tests for each module in tests/game.test.js using Jest. Critical test cases include: (1) canLeaveHouse returns true only on a roll of 6; (2) validateMove rejects moves when diceValue === 0; (3) a token on square 51 rolling a 1 lands on square 0 (wraparound); (4) capturing a token on a non-safe square sends it back to house; (5) a token cannot be captured on a safe square; (6) exact rolls into home work, overshooting does not; (7) rolling a 6 grants an extra turn, rolling 1-5 advances to the next player; (8) the player who finishes all four tokens first sets gameOver = true and winner = playerIndex. Run tests with npm test — the multi-stage Dockerfile's builder stage runs these tests before building the production image, so a failed test suite prevents a broken image from reaching production.

Start Building with the LudoKingAPI

Get your free API key and follow the full tutorial to connect your first game in minutes. Full support via WhatsApp for setup questions and integration help.