Ludo Multiplayer API — Room Management, Matchmaking & Game Initialization
A multiplayer Ludo experience demands far more than a shared dice roll. Behind every seamless 4-player match is a carefully orchestrated backend system: rooms that manage player lifecycles, queues that balance speed against fairness, state machines that enforce turn integrity, and protocols that keep every client synchronized in real time. This guide walks through the complete multiplayer stack with full implementation code in Node.js — from a production-grade RoomManager class to the initialization event sequence that launches each game.
The Multiplayer Architecture Overview
The Ludo multiplayer stack consists of four interacting subsystems. The Room Manager handles private room lifecycle — creation, joining via codes, leaving, and idle timeout enforcement. The Matchmaking Queue collects players seeking ranked games and assembles them into matched groups using FIFO ordering combined with skill-based rating proximity. The Game Initialization Protocol sequences the broadcast of starting state to all connected players. The Player State Machine governs what each player can do at every moment — from waiting in a lobby to actively rolling dice to spectating a completed match.
All four subsystems communicate over WebSocket connections managed by a Socket.IO server. The server is the authoritative source of truth for game state; clients are thin renderers that apply server events. This architecture eliminates the most common multiplayer pitfalls: desync, ghost moves, and race-condition exploits.
Room Manager — Full Implementation
The RoomManager class is the core building block for private multiplayer rooms. It handles room creation with auto-generated codes, player joining and leaving, game start conditions, and automatic cleanup via idle timeouts. Each room maintains its own player list, game state reference, and lifecycle timer.
// RoomManager — Full private room lifecycle management
const EventEmitter = require('events');
const { v4: uuidv4 } = require('uuid');
const ROOM_CODE_LENGTH = 6;
const MAX_PLAYERS = 4;
const IDLE_TIMEOUT_MS = 120000; // 2 min before game auto-starts or room closes
const READY_TIMEOUT_MS = 300000; // 5 min waiting for players to ready up
const ROOM_STATES = Object.freeze({
WAITING: 'waiting', // Open for players to join
READYING: 'readying', // Players clicking "Ready" before start
STARTING: 'starting', // Countdown sequence active
PLAYING: 'playing', // Game in progress
FINISHED: 'finished', // Game concluded, results shown
CLOSED: 'closed' // Room destroyed, resources freed
});
class RoomManager extends EventEmitter {
/**
* @param {Object} options
* @param {string} options.roomCode - Unique human-readable code (auto-generated if omitted)
* @param {string} options.hostId - Player ID of the room creator
* @param {string} options.gameMode - 'classic' | 'quick' | 'timed'
* @param {number} options.maxPlayers - Max players (default 4)
* @param {Object} options.settings - Room-specific settings (turnTimer, private, etc.)
*/
constructor(options = {}) {
super();
this.id = uuidv4();
this.roomCode = options.roomCode || generateRoomCode();
this.hostId = options.hostId;
this.gameMode = options.gameMode || 'classic';
this.maxPlayers = options.maxPlayers || MAX_PLAYERS;
this.settings = {
turnTimer: options.settings?.turnTimer ?? 30000,
isPrivate: options.settings?.isPrivate ?? false,
allowSpectators: options.settings?.allowSpectators ?? true,
minRating: options.settings?.minRating ?? 0,
...options.settings
};
this.state = ROOM_STATES.WAITING;
this.players = new Map(); // playerId -> { id, name, avatar, rating, color, socketId, ready, joinedAt }
this.spectators = new Map(); // spectatorId -> { id, socketId, joinedAt }
this.gameState = null;
this.createdAt = Date.now();
this.gameStartedAt = null;
this.idleTimer = null;
this.readyTimer = null;
this.isDestroyed = false;
// Bind methods to preserve `this` context in callbacks
this._resetIdleTimer = this._resetIdleTimer.bind(this);
this._onIdleTimeout = this._onIdleTimeout.bind(this);
this._onReadyTimeout = this._onReadyTimeout.bind(this);
// Start idle cleanup timer
this._startIdleTimer();
}
// ─── Public API ───────────────────────────────────────────────
/**
* Add a player to the room.
* @returns {{ success: boolean, error?: string, roomState: string }}
*/
join(player) {
if (this.isDestroyed) return { success: false, error: 'Room is closed', roomState: ROOM_STATES.CLOSED };
if (this.state === ROOM_STATES.PLAYING) {
// Allow joining as spectator during game
if (this.settings.allowSpectators) {
return this._addSpectator(player);
}
return { success: false, error: 'Game already in progress', roomState: this.state };
}
if (this.state === ROOM_STATES.FINISHED || this.state === ROOM_STATES.CLOSED) {
return { success: false, error: 'Room no longer accepting players', roomState: this.state };
}
if (this.players.size >= this.maxPlayers) {
return { success: false, error: 'Room is full', roomState: this.state };
}
if (player.rating < this.settings.minRating) {
return { success: false, error: `Minimum rating ${this.settings.minRating} required`, roomState: this.state };
}
if (this.players.has(player.id)) {
return { success: false, error: 'Already in this room', roomState: this.state };
}
// Assign a color if not provided
const usedColors = [...this.players.values()].map(p => p.color);
const availableColors = ['red', 'green', 'yellow', 'blue'].filter(c => !usedColors.includes(c));
const playerEntry = {
id: player.id,
name: player.name || 'Anonymous',
avatar: player.avatar || '',
rating: player.rating || 1000,
color: player.color || availableColors[0] || 'red',
socketId: player.socketId,
ready: false,
joinedAt: Date.now()
};
this.players.set(player.id, playerEntry);
this._resetIdleTimer();
console.log(`Room {this.roomCode}: Player {player.id} joined. Players: {this.players.size}/`{this.maxPlayers}`);
// If room just reached minimum players, transition to READYING state
if (this.players.size === this.maxPlayers && this.state === ROOM_STATES.WAITING) {
this._transitionTo(ROOM_STATES.READYING);
this._startReadyTimer();
}
this.emit('player:joined', { room: this, player: playerEntry });
return { success: true, roomState: this.state, player: playerEntry };
}
/**
* Remove a player from the room. Handles cleanup, host transfer, and game abort.
* @returns {{ success: boolean, reason?: string }}
*/
leave(playerId) {
if (!this.players.has(playerId)) return { success: false, reason: 'Not in room' };
const isHost = this.hostId === playerId;
const wasInGame = this.state === ROOM_STATES.PLAYING;
const player = this.players.get(playerId);
this.players.delete(playerId);
console.log(`Room {this.roomCode}: Player {playerId} left. Players: {this.players.size}`);
// If host left and game hasn't started, transfer host to oldest remaining player
if (isHost && this.state !== ROOM_STATES.PLAYING && this.state !== ROOM_STATES.FINISHED) {
const oldest = [...this.players.values()].sort((a, b) => a.joinedAt - b.joinedAt)[0];
if (oldest) {
this.hostId = oldest.id;
console.log(`Room {this.roomCode}: Host transferred to {oldest.id}`);
}
}
// If game was in progress and a player left, the game must abort or continue
if (wasInGame) {
if (this.players.size < 2) {
// Not enough players to continue — abort the game
this._abortGame('Not enough players');
} else {
// Game continues — remaining players get a forfeit win when game ends naturally
this.emit('player:left-during-game', { room: this, leftPlayer: player });
}
}
// If in READYING state and lost a player, check if we still have enough
if (this.state === ROOM_STATES.READYING) {
if (this.players.size < 2) {
this._transitionTo(ROOM_STATES.WAITING);
this._cancelReadyTimer();
}
}
this.emit('player:left', { room: this, player, wasHost });
// Auto-close room if empty
if (this.players.size === 0) {
this.destroy('Empty room');
}
return { success: true, isHost, remainingPlayers: this.players.size };
}
/**
* Set a player as ready (used in READYING state).
*/
setReady(playerId, ready = true) {
if (!this.players.has(playerId)) return false;
if (this.state !== ROOM_STATES.READYING) return false;
this.players.get(playerId).ready = ready;
this.emit('player:ready-changed', { room: this, playerId, ready });
// Check if all players are ready
const allReady = [...this.players.values()].every(p => p.ready);
if (allReady) {
this._startCountdown();
}
return true;
}
/**
* Begin the game. Called internally after countdown or by host.
*/
startGame(gameState) {
if (this.state === ROOM_STATES.PLAYING) return { success: false, error: 'Already playing' };
if (this.players.size < 2) return { success: false, error: 'Need at least 2 players' };
this.gameState = gameState;
this.gameStartedAt = Date.now();
this._cancelReadyTimer();
this._transitionTo(ROOM_STATES.PLAYING);
this.emit('game:started', { room: this, gameState });
return { success: true };
}
/**
* Clean up room resources. Call when tournament ends or room is abandoned.
*/
destroy(reason = 'Manual close') {
if (this.isDestroyed) return;
this.isDestroyed = true;
this._cancelAllTimers();
this._transitionTo(ROOM_STATES.CLOSED);
this.emit('room:destroyed', { room: this, reason });
console.log(`Room {this.roomCode} destroyed: {reason}`);
}
// ─── Private Methods ───────────────────────────────────────────
_startIdleTimer() {
this.idleTimer = setTimeout(this._onIdleTimeout, IDLE_TIMEOUT_MS);
}
_resetIdleTimer() {
clearTimeout(this.idleTimer);
this._startIdleTimer();
}
_onIdleTimeout() {
console.warn(`Room {this.roomCode} idle timeout`);
this.destroy('Idle timeout');
}
_startReadyTimer() {
this.readyTimer = setTimeout(this._onReadyTimeout, READY_TIMEOUT_MS);
}
_cancelReadyTimer() {
clearTimeout(this.readyTimer);
this.readyTimer = null;
}
_onReadyTimeout() {
console.warn(`Room {this.roomCode} ready timeout — forcing start with {this.players.size} players`);
this.emit('room:ready-timeout', { room: this });
// Don't auto-start — notify the host to decide
}
_startCountdown() {
this._transitionTo(ROOM_STATES.STARTING);
let countdown = 3;
const interval = setInterval(() => {
this.emit('game:countdown', { room: this, secondsRemaining: countdown });
countdown--;
if (countdown < 0) {
clearInterval(interval);
this.emit('room:countdown-complete', { room: this });
}
}, 1000);
}
_abortGame(reason) {
console.log(`Room {this.roomCode} game aborted: {reason}`);
this.gameState = null;
this.gameStartedAt = null;
this._transitionTo(ROOM_STATES.WAITING);
this.emit('game:aborted', { room: this, reason });
}
_transitionTo(newState) {
const oldState = this.state;
this.state = newState;
this.emit('room:state-changed', { room: this, from: oldState, to: newState });
}
_addSpectator(player) {
const specEntry = { id: player.id, socketId: player.socketId, joinedAt: Date.now() };
this.spectators.set(player.id, specEntry);
this.emit('spectator:joined', { room: this, spectator: specEntry });
return { success: true, roomState: this.state, isSpectator: true };
}
_cancelAllTimers() {
clearTimeout(this.idleTimer);
clearTimeout(this.readyTimer);
}
// ─── Getters ──────────────────────────────────────────────────
getInfo() {
return {
id: this.id,
roomCode: this.roomCode,
state: this.state,
gameMode: this.gameMode,
playerCount: this.players.size,
maxPlayers: this.maxPlayers,
spectatorCount: this.spectators.size,
hostId: this.hostId,
players: [...this.players.values()],
createdAt: this.createdAt,
gameStartedAt: this.gameStartedAt
};
}
}
// Helper: generate a unique 6-character alphanumeric room code
function generateRoomCode() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed confusing chars: 0/O, 1/I
return Array.from({ length: ROOM_CODE_LENGTH }, () =>
chars[Math.floor(Math.random() * chars.length)]
).join('');
}
module.exports = { RoomManager, ROOM_STATES };
Matchmaking Queue — FIFO + Skill-Based Priority
The matchmaking queue pairs players seeking ranked games. It uses a two-tier approach: players are sorted by join time within rating bands, ensuring FIFO fairness while preventing mismatches between vastly different skill levels. A background worker iterates the queue every second, attempting to assemble groups of 4 players whose ratings fall within the acceptable spread. If a player waits beyond the maximum wait threshold, the spread tolerance increases to guarantee they eventually get a match.
The queue also supports priority boosting — players who were previously matched but had their match cancelled (due to a player disconnect before game start) receive a temporary priority flag that lets them be re-matched faster.
// MatchmakingQueue — FIFO + skill-based pairing with priority support
const { EventEmitter } = require('events');
const QUEUE_STATES = Object.freeze({
IDLE: 'idle',
MATCHING: 'matching',
MATCH_FOUND: 'match_found'
});
class MatchmakingQueue extends EventEmitter {
constructor(options = {}) {
super();
this.queue = new Map();
this.state = QUEUE_STATES.IDLE;
// Configuration
this.partySize = options.partySize ?? 4; // Players needed per match
this.maxRatingSpread = options.maxRatingSpread ?? 200; // Tight spread for skill fairness
this.expandedSpread = options.expandedSpread ?? 400; // Forced-match after wait threshold
this.waitThreshold = options.waitThreshold ?? 60000; // 60s before spread widens
this.tickInterval = options.tickInterval ?? 1000; // Process every 1s
this.botInjectionDelay = options.botInjectionDelay ?? 90000;
this.botRating = options.botRating ?? 1000;
this.timer = null;
this.stats = { totalMatches: 0, totalQueued: 0, avgWaitMs: 0 };
}
start() {
this.timer = setInterval(() => this._processQueue(), this.tickInterval);
console.log('MatchmakingQueue started');
}
stop() {
clearInterval(this.timer);
this.timer = null;
console.log('MatchmakingQueue stopped');
}
/**
* Add a player to the matchmaking queue.
* @param {string} playerId
* @param {number} rating - ELO rating
* @param {Object} meta - gameMode, partyId, priority (for re-queued players)
*/
enqueue(playerId, rating, meta = {}) {
if (this.queue.has(playerId)) {
console.warn(`Player {playerId} already in queue`);
return false;
}
const entry = {
playerId,
rating: Math.round(rating),
gameMode: meta.gameMode || 'classic',
partyId: meta.partyId || null, // Group ID for party queueing
priority: meta.priority || 0, // Boost priority (re-queued players)
joinedAt: Date.now(),
lastProcessedAt: 0
};
this.queue.set(playerId, entry);
this.stats.totalQueued++;
this.emit('queue:player-added', { playerId, queueSize: this.queue.size });
return true;
}
/**
* Remove a player from the queue (manual cancel or disconnect).
*/
dequeue(playerId) {
const removed = this.queue.delete(playerId);
if (removed) {
this
Player State Machine
Each player in a Ludo multiplayer session transitions through a defined set of states. Modeling these states explicitly prevents invalid actions — for example, a player in the SPECTATING state cannot roll dice, and a player in DISCONNECTED state has a limited reconnection window before they're automatically ejected. The state machine is enforced server-side; clients receive state updates and render accordingly.
// PlayerState — Enum-like definition of all player states in a multiplayer room
// State transitions are enforced server-side only.
enum PlayerState {
// Pre-game states
IN_LOBBY = 'in_lobby', // Added to room, waiting for more players
READY = 'ready', // Clicked "Ready" in READYING state
NOT_READY = 'not_ready', // In READYING but not yet ready
// In-game states
WAITING_TURN = 'waiting_turn', // Not their turn, waiting
MY_TURN = 'my_turn', // Active player, can move pieces
ROLLED_DICE = 'rolled_dice', // Dice rolled, selecting piece
SELECTING_MOVE = 'selecting_move', // Choosing which piece to move
ANIMATING = 'animating', // Move animation in progress
TURN_TIMEOUT = 'turn_timeout', // Timer expired, turn auto-forfeited
// Post-game / end states
GAME_OVER = 'game_over', // Finished the game (placed all pieces)
ELIMINATED = 'eliminated', // Lost all pieces and cannot continue
// Connection states
DISCONNECTED = 'disconnected', // WebSocket closed, grace period active
RECONNECTING = 'reconnecting', // Socket reconnecting, session recovery
EJECTED = 'ejected', // Removed after disconnect grace period
// Spectator
SPECTATING = 'spectating' // Watching the game, cannot interact
}
/**
* Valid state transitions.
* Any transition not in this map is invalid and must be rejected.
*/
const VALID_TRANSITIONS = new Map([
[PlayerState.IN_LOBBY, [PlayerState.READY, PlayerState.NOT_READY, PlayerState.DISCONNECTED]],
[PlayerState.NOT_READY, [PlayerState.READY, PlayerState.DISCONNECTED]],
[PlayerState.READY, [PlayerState.WAITING_TURN, PlayerState.DISCONNECTED]],
[PlayerState.WAITING_TURN, [PlayerState.MY_TURN, PlayerState.DISCONNECTED]],
[PlayerState.MY_TURN, [PlayerState.ROLLED_DICE, PlayerState.TURN_TIMEOUT]],
[PlayerState.ROLLED_DICE, [PlayerState.SELECTING_MOVE, PlayerState.ANIMATING, PlayerState.MY_TURN]],
[PlayerState.SELECTING_MOVE, [PlayerState.ANIMATING]],
[PlayerState.ANIMATING, [PlayerState.WAITING_TURN, PlayerState.GAME_OVER, PlayerState.ELIMINATED]],
[PlayerState.TURN_TIMEOUT, [PlayerState.WAITING_TURN]],
[PlayerState.GAME_OVER, [PlayerState.SPECTATING]],
[PlayerState.ELIMINATED, [PlayerState.SPECTATING]],
[PlayerState.DISCONNECTED, [PlayerState.RECONNECTING, PlayerState.EJECTED]],
[PlayerState.RECONNECTING, [PlayerState.WAITING_TURN, PlayerState.EJECTED]],
[PlayerState.SPECTATING, []],
]);
/**
* TransitionGuard — validates and executes state transitions
*/
class PlayerStateMachine {
constructor(playerId) {
this.playerId = playerId;
this.state = PlayerState.IN_LOBBY;
this.history = [];
}
canTransitionTo(newState) {
const allowed = VALID_TRANSITIONS.get(this.state) || [];
return allowed.includes(newState);
}
transitionTo(newState, metadata = {}) {
if (!this.canTransitionTo(newState)) {
throw new Error(
`Invalid transition {this.state} -> {newState} for player {this.playerId}`
);
}
const prev = this.state;
this.state = newState;
this.history.push({ from: prev, to: newState, at: Date.now(), ...metadata });
return prev;
}
isActive() {
return [PlayerState.MY_TURN, PlayerState.ROLLED_DICE, PlayerState.SELECTING_MOVE].includes(this.state);
}
canAct() {
return this.state === PlayerState.MY_TURN || this.state === PlayerState.SELECTING_MOVE;
}
}
Game Initialization Protocol — Event Sequence
When a match is formed (either through room filling or matchmaking), the server executes a precise initialization sequence before the first dice roll. This sequence ensures every player receives identical starting conditions: piece positions, color assignments, turn order, and game rules. The sequence is atomic — if any step fails, the game does not start and players are returned to their respective queues.
Initialization Event Sequence
The full initialization involves seven sequential steps. First, the server generates a cryptographically random seed for dice outcomes and game events, ensuring neither client can predict rolls. Second, it assigns player colors based on rating — the highest-rated player gets red (statistically strongest starting position), with remaining colors shuffled among the others. Third, the server computes the initial turn order using a seeded random shuffle so all clients agree on who goes first. Fourth, it creates the canonical game state object containing all board positions and player states. Fifth, it broadcasts a game:init event to all players containing the full starting state. Sixth, it sends each player their private state update (hidden information). Seventh, it activates the turn timer for the first player and broadcasts game:turn-start.
// GameInitializationProtocol — sequences all setup events atomically
async function initializeGame(room, players) {
const io = room.io; // Socket.IO instance
// Step 1: Generate cryptographic seed for the game session
const gameSeed = crypto.randomBytes(32).toString('hex');
const seededRandom = new SeedRandom(gameSeed);
// Step 2: Assign colors by rating (highest-rated = red)
const sortedByRating = [...players].sort((a, b) => b.rating - a.rating);
const COLORS = ['red', 'green', 'yellow', 'blue'];
const colorAssignments = {};
sortedByRating.forEach((p, idx) => { colorAssignments[p.id] = COLORS[idx]; });
// Step 3: Determine turn order (seeded random shuffle)
const turnOrder = seededRandom.shuffle(players.map(p => p.id));
// Step 4: Create canonical game state
const gameState = {
gameId: uuidv4(),
seed: gameSeed,
roomId: room.id,
state: 'playing',
turnIndex: 0,
turnOrder,
currentPlayerId: turnOrder[0],
turnNumber: 1,
turnStartedAt: Date.now(),
turnTimerMs: room.settings.turnTimer,
board: createInitialBoard(turnOrder, colorAssignments),
diceRoll: null,
moveHistory: [],
placements: {}, // playerId -> finishPosition (null until game over)
winner: null,
settings: room.settings
};
// Step 5: Broadcast game:init to all players simultaneously
const initPayload = {
gameId: gameState.gameId,
turnOrder,
currentPlayerId: gameState.currentPlayerId,
turnTimerMs: gameState.turnTimerMs,
board: gameState.board,
gameMode: room.gameMode
};
players.forEach(p => {
io.to(p.socketId).emit('game:init', {
...initPayload,
// Include private info for each player
yourPlayerId: p.id,
yourColor: colorAssignments[p.id],
yourRating: p.rating,
opponents: players.filter(op => op.id !== p.id).map(op => ({
playerId: op.id,
name: op.name,
color: colorAssignments[op.id],
rating: op.rating
}))
});
});
// Step 6: Notify spectators (read-only board view)
if (room.settings.allowSpectators) {
room.spectators.forEach(spec => {
io.to(spec.socketId).emit('game:init', {
...initPayload,
isSpectator: true,
players: players.map(p => ({
playerId: p.id,
name: p.name,
color: colorAssignments[p.id],
rating: p.rating
}))
});
});
}
// Step 7: Start the first turn timer
const turnTimer = setTimeout(() => {
handleTurnTimeout(room, gameState);
}, gameState.turnTimerMs);
room.gameState = gameState;
room.turnTimer = turnTimer;
room.startGame(gameState);
console.log(`Game {gameState.gameId} initialized. First player: {gameState.currentPlayerId}`);
return gameState;
}
Spectator Mode
Spectator mode allows players to watch ongoing games without participating. This is critical for tournament broadcasts, replay viewing, and friend social features. Spectators receive the same board state updates as active players but cannot send any game actions. They also receive a delayed feed (2-second lag by default) to prevent stream-sniping — spectators see moves after they happen, not in real time. The server maintains a separate event stream per spectator, buffering events for the delay window before forwarding them.
Spectators can be promoted to active players if an active player disconnects during the grace period and the room settings allow. The spectator receives a spectator:promoted event with the current game state and transitions to WAITING_TURN or SPECTATING depending on game progress.
WebSocket Events Reference
The multiplayer API communicates through a well-defined WebSocket event protocol. Room events inform clients about player joins, leaves, and state changes. Game events carry all game state updates. The Real-Time API guide covers the full event taxonomy with payload schemas for every event type.
Frequently Asked Questions
The generateRoomCode() function uses a 22-character alphabet (ABCDEFGHJKLMNPQRSTUVWXYZ + 23456789), giving approximately 2.7 billion possible codes for a 6-character string. Before assigning a code, the RoomManager checks against the active room registry (an in-memory Map or Redis hash) to confirm the code isn't in use. In the extremely rare event of a collision, the generator retries up to 5 times before failing. This makes duplicate codes effectively impossible in production.
When a WebSocket disconnect is detected, the player's state transitions to DISCONNECTED and a grace period timer starts (default 30 seconds). During this window, the server holds the player's position in the game — their pieces remain on the board and other players see a "reconnecting" indicator. If they reconnect within the window, they receive a game:reconnect event with the full current state and their state transitions back to the appropriate in-game state. If the timer expires, the player transitions to EJECTED and their pieces are removed from the board. The game continues with remaining players, and the ejected player's eventual finish position is calculated based on their last known state.
Yes — the MatchmakingQueue supports party queueing. When multiple players enqueue with the same partyId, they are treated as a single unit during matching. The queue attempts to find groups of 4 where combined party size plus individual players fills exactly 4 slots. The matchmaking algorithm respects party integrity — it will not split a party across two different matches. Party ratings are averaged for the skill-based spread check, so a 2-player party of 1200 and 1400 average 1300 for matching purposes.
The 2-second delay on spectator feeds means a viewer cannot relay real-time information to an active player. In live tournament scenarios, this is critical — a spectator watching a player's screen cannot text them "roll now, safe path" because the spectator sees events 2 seconds after they happen. The delay is enforced server-side by buffering events before sending them to spectator sockets. Spectators cannot send game action events regardless of delay. For premium tournament broadcasts (e.g., a streamed final), the delay can be increased to 5 or 10 seconds with a flag set at tournament creation.
Extreme-rated players face longer queue times by design — they cannot be matched with beginners because the rating spread would exceed thresholds. To prevent these players from being abandoned, the system applies progressive spread widening over time. At 60 seconds, the spread widens from 200 to 400 ELO. At 120 seconds, bots are injected to fill the remaining slots. After 180 seconds, the player is matched regardless of spread. This ensures that even grandmaster-level players (rating 2000+) and brand new players (rating 500-) can find matches, though with longer average wait times than mid-tier players.
Node.js is single-threaded, so basic Map/Set operations within a single process are inherently safe for synchronous code paths. However, if you're running multiple Node.js processes (e.g., with a cluster) or using Redis for distributed room storage, you need atomic operations. For cluster mode, use a Redis-backed room registry instead of an in-memory Map. All room mutations (join, leave, state transitions) should use Redis transactions (MULTI/EXEC) or Lua scripts to prevent race conditions. The RoomManager emits events after each mutation — if you're using Redis pub/sub for distributed state, subscribe to these events to keep all process copies synchronized.
Build Your Multiplayer Ludo Backend Today
Get the complete implementation: RoomManager, MatchmakingQueue, state machine, initialization protocol, and real-time sync — production-ready Node.js code. Reach out on WhatsApp for the full source package, API credentials, and hosting deployment guide.
Chat on WhatsApp