What You Will Build

By the end of this tutorial, you'll have a running Node.js server that hosts multiplayer Ludo games with real-time state synchronization. The server exposes REST endpoints for game lifecycle management (create, join, get state) and a Socket.IO channel for broadcasting dice rolls, moves, and turn changes to all connected players.

The architecture follows an authoritative server pattern: your Node.js server owns game state, validates all moves server-side, and broadcasts confirmed state to clients. This prevents cheating and ensures all players see identical game boards. For move validation and persistence, the server integrates with the LudoKingAPI Node.js client.

The complete server fits in under 300 lines of JavaScript. A frontend client (React, Vue, or plain HTML) connects via Socket.IO to receive state updates and emit move events. Study the Socket.IO multiplayer setup for the client-side counterpart.

Architecture Overview

Clients (browsers) connect via Socket.IO WebSocket to the Node.js server. Node.js server maintains authoritative game state in memory and delegates validation to the LudoKingAPI. LudoKingAPI confirms move legality, computes new state, and the Node.js server broadcasts updates to all room members.

Prerequisites

Before starting, ensure you have Node.js 18+ installed. Run node --version to verify. You'll also need npm (bundled with Node.js) and a code editor. No prior Socket.IO experience is required — this tutorial builds the complete integration from scratch.

Bash — Verify Node.js
node --version   # Should be v18.x or higher
npm --version    # Should be 9.x or higher
# If not installed, use:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs

Step 1 — Project Initialization

Create a new project directory and initialize it with npm. The npm init command creates a package.json with defaults; using -y accepts all defaults without interactive prompts. Separate your server code from client code from the start — a /server and /client directory structure keeps things organized.

Bash — Initialize Project
mkdir -p ludo-server
cd ludo-server
npm init -y
# Creates package.json with defaults:
# {
#   "name": "ludo-server",
#   "version": "1.0.0",
#   "main": "index.js",
#   "scripts": {},
#   "keywords": [],
#   "author": "",
#   "license": "ISC"
# }

Create the following directory structure for a production-ready layout:

Directory Structure
ludo-server/
├── server/
│   ├── index.js              # HTTP server entry point
│   ├── routes/
│   │   ├── index.js          # Route aggregator
│   │   └── games.js          # Game REST endpoints
│   ├── socket/
│   │   ├── index.js          # Socket.IO event bindings
│   │   └── handlers/
│   │       ├── join.js       # Player joins game room
│   │       ├── move.js       # Move submission + broadcast
│   │       ├── dice.js       # Dice roll generation
│   │       └── disconnect.js # Cleanup on player disconnect
│   ├── services/
│   │   ├── gameState.js      # In-memory game state manager
│   │   ├── apiClient.js      # LudoKingAPI HTTP client
│   │   └── dice.js           # Server-side fair dice
│   └── middleware/
│       ├── auth.js           # Player token verification
│       └── validation.js     # Request payload validation
├── .env                     # Environment variables (gitignored)
├── .env.example             # Template for collaborators
├── package.json
└── README.md

Step 2 — package.json Dependencies

Install the required dependencies with a single command. Express handles the HTTP REST API, Socket.IO manages WebSocket connections and room multiplexing, Axios makes HTTP calls to the LudoKingAPI, and dotenv loads environment variables from the .env file.

Bash — Install Dependencies
npm install express socket.io axios dotenv cors

Add development dependencies for auto-reloading and testing:

Bash — Install Dev Dependencies
npm install --save-dev nodemon jest

Update your package.json scripts section to use nodemon for development:

package.json
{
  "name": "ludo-server",
  "version": "1.0.0",
  "description": "Real-time Ludo game server with Express & Socket.IO",
  "main": "server/index.js",
  "scripts": {
    "start": "node server/index.js",
    "dev": "nodemon server/index.js",
    "test": "jest"
  },
  "dependencies": {
    "axios": "^1.6.0",
    "cors": "^2.8.5",
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "socket.io": "^4.7.2"
  },
  "devDependencies": {
    "jest": "^29.7.0",
    "nodemon": "^3.0.2"
  }
}

Run npm run dev during development — nodemon watches for file changes and restarts the server automatically, so you never manually restart during iteration.

Step 3 — Environment Configuration

Store sensitive configuration in a .env file and never commit it. Create .env.example as a template for collaborators that contains all keys with placeholder values. The dotenv package loads .env at server startup and makes variables available via process.env.

.env.example
# .env — Local development (gitignored)
# Copy this file to .env and fill in real values

# Server configuration
PORT=3000
NODE_ENV=development
HOST=http://localhost:3000

# LudoKingAPI credentials
# Get your API key at https://ludokingapi.site
LUDO_API_KEY=your_api_key_here
LUDO_API_BASE_URL=https://api.ludokingapi.site/v1

# CORS — comma-separated list of allowed origins
CORS_ORIGINS=http://localhost:5173,http://localhost:3001

# Redis (optional — for horizontal scaling across multiple processes)
# REDIS_URL=redis://localhost:6379

# Game configuration
MAX_PLAYERS_PER_GAME=4
GAME_INACTIVITY_TIMEOUT_MS=300000
RECONNECTION_WINDOW_MS=60000

In your server entry point, load dotenv at the very top — before any other imports:

JavaScript — Load dotenv first
// server/index.js — Line 1
require('dotenv').config();

// Now all process.env.* values are available
const PORT = process.env.PORT || 3000;
const IS_PROD = process.env.NODE_ENV === 'production';

Step 4 — Express Server Setup

The Express server handles REST API requests (game creation, state retrieval, move submission via HTTP) while Socket.IO runs on the same HTTP server to handle WebSocket connections for real-time events. This dual-use of a single port is a key architectural pattern for game servers.

JavaScript — server/index.js
// server/index.js — Main HTTP + WebSocket server entry point
require('dotenv').config();

const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const cors = require('cors');

const gameRoutes = require('./routes/games');
const { initSocketHandlers } = require('./socket');

// Create Express app and wrap it in an HTTP server
const app = express();
const httpServer = http.createServer(app);

// Attach Socket.IO to the same HTTP server
const io = new Server(httpServer, {
  cors: {
    origin: process.env.CORS_ORIGINS
      ? process.env.CORS_ORIGINS.split(',')
      : '*',
    methods: ['GET', 'POST'],
    credentials: true,
  },
  pingTimeout: 60000,   // Wait 60s for pong before disconnecting
  pingInterval: 25000,  // Send ping every 25s to keep connection alive
});

// Middleware stack — order matters
app.use(cors({ origin: process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',') : '*' }));
app.use(express.json());       // Parse JSON request bodies
app.use(express.urlencoded({ extended: true }));

// Health check endpoint
app.get('/health', (req, res) => {
  res.json({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() });
});

// Mount REST routes
app.use('/api/games', gameRoutes);

// Initialize Socket.IO event handlers
initSocketHandlers(io);

// Start listening
httpServer.listen(PORT, () => {
  console.log(`🎲 Ludo server running on port ${PORT}`);
  console.log(`   REST API: http://localhost:${PORT}/api/games`);
  console.log(`   WebSocket: ws://localhost:${PORT}`);
});

// Graceful shutdown on SIGTERM/SIGINT
process.on('SIGTERM', () => {
  console.log('SIGTERM received — closing server...');
  httpServer.close(() => process.exit(0));
});

Step 5 — Game Route Handlers (Express Router)

Express Router creates a modular route system. All game-related REST endpoints live under /api/games. The router handles game creation, state retrieval, and move submission — these are the same operations that Socket.IO events trigger internally, giving you the flexibility to interact via HTTP REST calls or WebSocket messages.

JavaScript — server/routes/games.js
// server/routes/games.js
const express = require('express');
const router = express.Router();
const gameState = require('../services/gameState');
const { createGame: apiCreate, getState: apiGetState, submitMove: apiSubmitMove } =
  require('../services/apiClient');

/**
 * POST /api/games
 * Create a new game session.
 * Body: { player_count?: number, variant?: 'classic' | 'quick' }
 */
router.post('/', async (req, res) => {
  try {
    const { player_count = 4, variant = 'classic' } = req.body;

    if (player_count < 2 || player_count > 4) {
      return res.status(400).json({
        error: 'player_count must be between 2 and 4'
      });
    }

    // Delegate to LudoKingAPI for authoritative game creation
    const apiResult = await apiCreate({ player_count, variant });

    // Cache locally for fast WebSocket access
    gameState.createGame(apiResult.game_id, apiResult.state, {
      variant,
      created_at: new Date().toISOString(),
    });

    res.status(201).json({
      game_id: apiResult.game_id,
      state: apiResult.state,
      variant,
      api_url: `${process.env.LUDO_API_BASE_URL}/games/${apiResult.game_id}`,
    });
  } catch (err) {
    console.error('Failed to create game:', err.message);
    res.status(500).json({ error: err.message });
  }
});

/**
 * GET /api/games/:gameId
 * Retrieve current game state.
 */
router.get('/:gameId', async (req, res) => {
  const { gameId } = req.params;

  // Check local cache first
  const cached = gameState.getGame(gameId);
  if (cached) {
    return res.json({ source: 'cache', state: cached.state });
  }

  // Fall back to API
  try {
    const state = await apiGetState(gameId);
    res.json({ source: 'api', state });
  } catch (err) {
    res.status(404).json({ error: 'Game not found' });
  }
});

/**
 * POST /api/games/:gameId/move
 * Submit a move for server-side validation.
 * Body: { player_id, token_index, dice }
 */
router.post('/:gameId/move', async (req, res) => {
  const { gameId } = req.params;
  const { player_id, token_index, dice } = req.body;

  // Validate payload
  if (!player_id || token_index === undefined || !dice) {
    return res.status(400).json({ error: 'Missing player_id, token_index, or dice' });
  }

  try {
    const result = await apiSubmitMove(gameId, { player_id, token_index, dice });

    // Update local cache
    const newState = await apiGetState(gameId);
    gameState.updateState(gameId, newState);

    res.json({ result, state: newState });
  } catch (err) {
    const statusCode = err.response?.status || 500;
    res.status(statusCode).json({
      error: err.response?.data?.error || err.message,
    });
  }
});

module.exports = router;

Step 6 — Socket.IO Integration

Socket.IO runs on top of the same HTTP server as Express, using WebSocket as its primary transport with automatic fallback to HTTP long-polling. The key abstraction is rooms — each game gets its own Socket.IO room, and messages sent to a room are broadcast only to sockets that have joined that room. This is the foundation of multiplayer game state broadcasting.

JavaScript — server/socket/index.js
// server/socket/index.js — Socket.IO event bindings
const gameState = require('../services/gameState');
const { rollDice } = require('../services/dice');
const { submitMove: apiSubmitMove } = require('../services/apiClient');

function initSocketHandlers(io) {

  io.on('connection', (socket) => {
    console.log(`[WS] Client connected: ${socket.id} from ${socket.handshake.address}`);

    // --- Event: player joins a game room ---
    socket.on('game:join', ({ game_id, player_id }) => {
      if (!game_id || !player_id) {
        socket.emit('error', { code: 'INVALID_PAYLOAD', message: 'game_id and player_id required' });
        return;
      }

      const game = gameState.joinGame(game_id, player_id, socket.id);
      if (!game) {
        socket.emit('error', { code: 'GAME_NOT_FOUND', message: 'Game is full or does not exist' });
        return;
      }

      // Join the Socket.IO room for this game
      socket.join(game_id);
      socket.gameId = game_id;
      socket.playerId = player_id;

      // Send current game state to the joining player
      socket.emit('game:state', game.state);

      // Broadcast player_joined to everyone else in the room
      socket.to(game_id).emit('game:playerJoined', {
        player_id,
        player_count: game.players.length,
      });

      console.log(`[WS] Player ${player_id} joined game ${game_id}${game.players.length}/4 players`);
    });

    // --- Event: player requests a dice roll ---
    socket.on('game:rollDice', () => {
      const { gameId, playerId } = socket;

      if (!gameId || !playerId) {
        socket.emit('error', { code: 'NOT_IN_GAME', message: 'Join a game first' });
        return;
      }

      const game = gameState.getGame(gameId);
      if (game?.state.current_player !== playerId) {
        socket.emit('error', { code: 'NOT_YOUR_TURN', message: 'Wait for your turn' });
        return;
      }

      // Generate fair dice value server-side (never trust client input)
      const diceValue = rollDice();

      // Broadcast dice roll to all players in the room (including sender)
      io.to(gameId).emit('game:diceRolled', {
        player_id: playerId,
        dice: diceValue,
        timestamp: new Date().toISOString(),
      });

      // Store the roll in game state for the subsequent move event
      gameState.setPendingDice(gameId, diceValue);
    });

    // --- Event: player submits a move ---
    socket.on('game:move', async ({ token_index }) => {
      const { gameId, playerId } = socket;

      if (!gameId || !playerId) {
        socket.emit('error', { code: 'NOT_IN_GAME', message: 'Join a game first' });
        return;
      }

      const game = gameState.getGame(gameId);
      const pendingDice = gameState.getPendingDice(gameId);

      if (!game) {
        socket.emit('error', { code: 'GAME_NOT_FOUND' });
        return;
      }

      if (game.state.current_player !== playerId) {
        socket.emit('error', { code: 'NOT_YOUR_TURN', message: 'Not your turn to move' });
        return;
      }

      if (pendingDice === null) {
        socket.emit('error', { code: 'ROLL_DICE_FIRST', message: 'Roll dice before making a move' });
        return;
      }

      try {
        // Submit move to LudoKingAPI for validation and state update
        const result = await apiSubmitMove(gameId, {
          player_id: playerId,
          token_index,
          dice: pendingDice,
        });

        // Refresh state from API after the move
        const newState = result.state;

        // Update local cache
        gameState.updateState(gameId, newState);
        gameState.clearPendingDice(gameId);

        // Broadcast new state to ALL players in the room
        io.to(gameId).emit('game:stateUpdate', newState);

        // If the game is over, emit game_over to trigger win screen
        if (newState.game_over) {
          io.to(gameId).emit('game:over', {
            winner: newState.winner,
            rankings: newState.rankings || [],
          });
          gameState.closeGame(gameId);
        }
      } catch (err) {
        socket.emit('game:moveRejected', {
          reason: err.response?.data?.error || err.message,
        });
      }
    });

    // --- Event: player disconnects ---
    socket.on('disconnect', (reason) => {
      console.log(`[WS] Client disconnected: ${socket.id} — reason: ${reason}`);

      const { gameId, playerId } = socket;
      if (gameId && playerId) {
        gameState.markDisconnected(gameId, playerId);
        socket.to(gameId).emit('game:playerLeft', {
          player_id: playerId,
          reason,
        });
      }
    });
  });
}

module.exports = { initSocketHandlers };

Step 7 — Game State Management & API Client

The in-memory game state manager caches active games for fast WebSocket access. It maps game IDs to game objects containing the current state, connected players, and socket IDs. The API client wraps Axios calls to the LudoKingAPI for authoritative operations.

JavaScript — server/services/gameState.js
// server/services/gameState.js — In-memory game state store
class GameStateManager {
  constructor() {
    this.games = new Map();    // gameId → game object
    this.pendingDice = new Map();  // gameId → dice value (null if none)
  }

  createGame(gameId, state, metadata = {}) {
    this.games.set(gameId, {
      id: gameId,
      state,
      players: [],
      socketIds: new Map(),
      disconnected: new Set(),
      ...metadata,
    });
    this.pendingDice.set(gameId, null);
    return this.games.get(gameId);
  }

  getGame(gameId) {
    return this.games.get(gameId) || null;
  }

  joinGame(gameId, playerId, socketId) {
    const game = this.games.get(gameId);
    if (!game) return null;

    const maxPlayers = parseInt(process.env.MAX_PLAYERS_PER_GAME || '4');
    if (game.players.length >= maxPlayers) return null;
    if (game.players.includes(playerId)) {
      // Reconnection — update socket ID
      game.socketIds.set(socketId, playerId);
      game.disconnected.delete(playerId);
      return game;
    }

    game.players.push(playerId);
    game.socketIds.set(socketId, playerId);
    return game;
  }

  updateState(gameId, newState) {
    const game = this.games.get(gameId);
    if (game) game.state = newState;
  }

  setPendingDice(gameId, value) {
    this.pendingDice.set(gameId, value);
  }

  getPendingDice(gameId) {
    return this.pendingDice.get(gameId) ?? null;
  }

  clearPendingDice(gameId) {
    this.pendingDice.set(gameId, null);
  }

  markDisconnected(gameId, playerId) {
    const game = this.games.get(gameId);
    if (game) game.disconnected.add(playerId);
  }

  closeGame(gameId) {
    this.games.delete(gameId);
    this.pendingDice.delete(gameId);
  }

  getActiveGameCount() {
    return this.games.size;
  }
}

module.exports = new GameStateManager();
JavaScript — server/services/apiClient.js
// server/services/apiClient.js — LudoKingAPI HTTP wrapper
const axios = require('axios');

const BASE_URL = process.env.LUDO_API_BASE_URL || 'https://api.ludokingapi.site/v1';

const client = axios.create({
  baseURL: BASE_URL,
  timeout: 10000,
  headers: {
    'Authorization': `Bearer ${process.env.LUDO_API_KEY}`,
    'Content-Type': 'application/json',
  },
});

async function createGame({ player_count, variant }) {
  const res = await client.post('/games', {
    player_count,
    variant,
  });
  return res.data;
}

async function getState(gameId) {
  const res = await client.get(`/games/${gameId}/state`);
  return res.data;
}

async function submitMove(gameId, { player_id, token_index, dice }) {
  const res = await client.post(`/games/${gameId}/move`, {
    player_id,
    token_index,
    dice,
  });
  return res.data;
}

module.exports = {
  createGame,
  getState,
  submitMove,
};
JavaScript — server/services/dice.js
// server/services/dice.js — Server-side fair dice generation
const { randomInt } = require('crypto');

function rollDice() {
  // randomInt(1, 7) generates integers from 1 to 6 inclusive
  return randomInt(1, 7);
}

module.exports = { rollDice };

Step 8 — Testing with curl

Start the server with npm run dev and use curl to verify every REST endpoint before connecting a frontend client. These commands test the full game lifecycle: create a game, retrieve its state, submit a move, and check health.

Bash — Start Server
npm run dev
# Output: 🎲 Ludo server running on port 3000
#        REST API: http://localhost:3000/api/games
#        WebSocket: ws://localhost:3000
curl — Test REST Endpoints
# Health check
curl -s http://localhost:3000/health | jq
# {"status":"ok","uptime":12.34,"timestamp":"2025-03-21T..."}

# Create a new 4-player classic game
curl -s -X POST http://localhost:3000/api/games \
  -H "Content-Type: application/json" \
  -d '{"player_count": 4, "variant": "classic"}' | jq
# {"game_id":"game_abc123","state":{...},"variant":"classic",...}

# Extract the game ID
GAME_ID=$(curl -s -X POST http://localhost:3000/api/games \
  -H "Content-Type: application/json" \
  -d '{"player_count": 2}' | jq -r '.game_id')

# Get current game state
curl -s http://localhost:3000/api/games/$GAME_ID | jq '.state'
# {"current_player":"player_0","tokens":[...],"game_over":false,...}

# Submit a move
curl -s -X POST http://localhost:3000/api/games/$GAME_ID/move \
  -H "Content-Type: application/json" \
  -d '{
    "player_id": "player_0",
    "token_index": 0,
    "dice": 6
  }' | jq '.result'

For WebSocket testing, use a browser's developer console with the Socket.IO client library, or use the wscat command-line tool:

Bash — WebSocket Testing
# Install wscat
npm install -g wscat

# Connect to the WebSocket server
wscat -c http://localhost:3000

# Join a game
> {"event":"game:join","data":{"game_id":"game_abc123","player_id":"player_0"}}
< {"event":"game:state","data":{...}}

# Roll dice
> {"event":"game:rollDice","data":{}}
< {"event":"game:diceRolled","data":{"player_id":"player_0","dice":4,...}}

# Make a move
> {"event":"game:move","data":{"token_index":0}}
< {"event":"game:stateUpdate","data":{...}}

Step 9 — Production Deployment

For production, replace the development server with a process manager. PM2 is the standard choice for Node.js production deployments — it handles process restarts, zero-downtime reloads, log management, and cluster mode for utilizing multiple CPU cores.

Bash — Production with PM2
# Install PM2 globally
npm install -g pm2

# Start the server with PM2
pm2 start server/index.js --name ludo-server

# Enable cluster mode for multi-core utilization
pm2 start server/index.js -i max --name ludo-server

# If using cluster mode with Socket.IO across multiple processes,
# install the Redis adapter for cross-process pub/sub:
npm install @socket.io/redis-adapter ioredis

# Generate a startup script (run once)
pm2 startup

# Save the process list (run after configuring)
pm2 save

# View logs
pm2 logs ludo-server --lines 50

# View status
pm2 status

For horizontal scaling across multiple servers, Socket.IO's Redis adapter enables pub/sub between processes — all server instances share the same game state notifications. See the REST API reference for production API keys and rate limiting configuration.

Frequently Asked Questions

In a multiplayer game, trusting client-generated dice values is equivalent to having no anti-cheat system. A malicious client could submit only 6s until it reaches the finish, or skip moves that would put it in danger. The server must be the single source of truth for all random events. When a client emits game:rollDice, the server generates the value with crypto.randomInt(1, 7), broadcasts it to all clients simultaneously, then waits for the client's game:move event with the confirmed dice value.
Yes — Render and Railway both offer free tiers suitable for development and small-scale production. However, free tiers typically put the server to sleep after 15–30 minutes of inactivity, which terminates active Socket.IO connections. For a live multiplayer game, you need a server that stays awake: a $5/month VPS (DigitalOcean, Linode, Hetzner), or platform tiers on Render/Railway that keep the server running. WebSocket connections require a persistent server — serverless functions like AWS Lambda don't maintain long-lived WebSocket connections without additional infrastructure.
Socket.IO rooms are virtual channels that sockets can join and leave dynamically. When a player calls socket.join(game_id), the socket is subscribed to messages sent to that room. Using io.to(game_id).emit('event', data) broadcasts to every socket in the room. Using socket.to(game_id).emit('event', data) broadcasts to every socket except the sender. This is the core primitive for multiplayer game state sync — every player in a game room sees the same state updates at the same time. Study the Socket.IO multiplayer guide for detailed room lifecycle management.
When a socket disconnects, mark the player as temporarily disconnected in game state (game.disconnected.add(playerId)) but don't remove them from the players array. Give them a 60-second reconnection window. When they reconnect with the same player_id, the joinGame method detects the duplicate and reconnects them to the existing game room, sending the current state immediately. If the window expires without reconnection, either AI takes over their turn (for single-player continuity) or the game is marked abandoned.
The REST API (POST /api/games/:id/move) is for operations that don't need real-time broadcasting: creating a game, fetching historical state, or integrating with external systems. Socket.IO events are for everything that needs to reach multiple clients simultaneously: dice rolls, move confirmations, player joins/leaves, and game-over notifications. In practice, your frontend emits game:move over WebSocket and listens for game:stateUpdate — the REST endpoint is a fallback for clients behind restrictive proxies or for programmatic API access.
For active games, the in-memory gameState map handles everything — reads are O(1) and state is transient. For persistence (game history, player stats, leaderboards), you need a database, but this is optional for the core multiplayer experience. The LudoKingAPI handles move validation and state persistence server-side, so your Node.js server only needs a database if you're storing custom data like player accounts, game replays, or tournament brackets. PostgreSQL via a connection pool or Redis for hot data are both solid choices.

Build Your Ludo Game Server with Node.js

Get expert guidance on Express setup, Socket.IO room management, and API integration for your Ludo game server.