Ludo Node.js Tutorial — Real-Time Game Server with Express & Socket.IO
Build a complete Node.js Ludo game server from scratch using Express for REST endpoints and Socket.IO for real-time game events. This tutorial walks through every file: package.json setup, server initialization, Express router configuration, Socket.IO room management, game state handling, and curl-based API testing.
📋 Jump to Section
- What You Will Build
- Prerequisites
- Step 1 — Project Initialization
- Step 2 — package.json Dependencies
- Step 3 — Environment Configuration
- Step 4 — Express Server Setup
- Step 5 — Game Route Handlers
- Step 6 — Socket.IO Integration
- Step 7 — Game State Management
- Step 8 — Testing with curl
- Step 9 — Production Deployment
- Frequently Asked Questions
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.
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.
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:
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.
npm install express socket.io axios dotenv cors
Add development dependencies for auto-reloading and testing:
npm install --save-dev nodemon jest
Update your package.json scripts section to use nodemon for development:
{
"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 — 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:
// 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.
// 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.
// 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.
// 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.
// 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();
// 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,
};
// 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.
npm run dev
# Output: 🎲 Ludo server running on port 3000
# REST API: http://localhost:3000/api/games
# WebSocket: ws://localhost:3000
# 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:
# 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.
# 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
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.
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.
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.
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.
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.