Real-Time Ludo API — WebSocket Events & Game State Synchronization
Real-time gameplay is what separates a Ludo app from a static website. When a player rolls a dice, every other player in the room needs to see that result within milliseconds — not after a page refresh or a polling interval. This guide covers the complete WebSocket implementation for real-time Ludo: connection lifecycle, authentication handshake, event taxonomy, state synchronization strategies, and a production-ready JavaScript client implementation.
Why WebSocket for Ludo?
HTTP is request-response by design — a client sends a request, the server responds, and the connection closes. For turn-based Ludo, this creates a dilemma: poll the server frequently to catch updates (wasteful), or wait for the next player action to discover what happened (unresponsive). WebSocket solves this by establishing a persistent, bidirectional connection. The server can push events to clients the moment something happens — dice rolls, piece movements, turn changes — with sub-100ms latency in the same connection.
For Ludo specifically, WebSocket is ideal because: game events are discrete and infrequent (not high-frequency like an FPS), every player in a room needs to see the same events simultaneously, and the state is authoritative on the server (prevents desync and cheating).
Connection Lifecycle
A WebSocket connection for Ludo goes through four phases: establishment, authentication, room subscription, and active gameplay. The connection remains open throughout the game session and closes when the game ends or a player disconnects.
Phase 1 — Connection Establishment
The client initiates a WebSocket handshake to the server URL. This is a standard HTTP upgrade request. Most
libraries handle this automatically — you call new WebSocket(url) or io.connect(url) and the handshake happens behind the scenes.
Phase 2 — Authentication
Immediately after connecting, the client must authenticate. Pass your session token as part of the handshake auth object (Socket.IO) or in the first message after connection. The server validates the token and associates the WebSocket connection with the player identity. An unauthenticated connection should be closed after a short timeout (5–10 seconds) to prevent resource exhaustion.
Phase 3 — Room Subscription
Once authenticated, the client emits a room:join event with the room code.
The server validates the room exists and has space, adds the player to the room's subscriber list, and
responds with the full room state. The client then renders the board and UI based on this initial state.
Phase 4 — Active Gameplay
The client listens for game events and emits player actions. This is the bulk of the WebSocket's lifetime — typically 10–20 minutes for a casual Ludo game.
WebSocket Client — Complete Implementation
This is a production-ready WebSocket client for Ludo using Socket.IO v4. It handles connection, reconnection, authentication, room joining, and all game events. You can adapt this directly into your frontend codebase.
class LudoGameClient {
constructor({ serverUrl, sessionToken, roomCode }) {
this.serverUrl = serverUrl;
this.sessionToken = sessionToken;
this.roomCode = roomCode;
this.socket = null;
this.gameState = null;
this.listeners = new Map();
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
}
connect() {
this.socket = io(this.serverUrl, {
auth: { token: this.sessionToken },
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: this.maxReconnectAttempts,
timeout: 10000
});
this.socket.on('connect', () => {
console.log('Connected to game server');
this.reconnectAttempts = 0;
this.socket.emit('room:join', { roomCode: this.roomCode });
});
this.socket.on('connect_error', (err) => {
console.error('Connection error:', err.message);
if (err.message === 'Invalid token') {
alert('Session expired. Please log in again.');
this.disconnect();
}
});
this.socket.on('disconnect', (reason) => {
console.warn('Disconnected:', reason);
if (reason === 'io server disconnect') {
// Server initiated disconnect — attempt manual reconnect
this.socket.connect();
}
});
this.socket.on('reconnect_attempt', (attempt) => {
this.reconnectAttempts = attempt;
console.log(`Reconnection attempt {attempt}/{this.maxReconnectAttempts}`);
});
// Register game event handlers
this._registerGameEvents();
}
_registerGameEvents() {
this.socket.on('room:joined', (data) => {
this.gameState = data.room;
this._emit('roomJoined', data);
});
this.socket.on('room:player-joined', (data) => {
this.gameState.players.push(data.player);
this._emit('playerJoined', data);
});
this.socket.on('room:player-left', (data) => {
this.gameState.players = this.gameState.players
.filter(p => p.id !== data.playerId);
this._emit('playerLeft', data);
});
this.socket.on('game:dice-rolled', (data) => {
this.gameState.lastDiceValue = data.value;
this._emit('diceRolled', data);
});
this.socket.on('game:turn-change', (data) => {
this.gameState.currentPlayerId = data.currentPlayerId;
this.gameState.turnStartedAt = Date.now();
this._emit('turnChange', data);
});
this.socket.on('game:piece-moved', (data) => {
const piece = this.gameState.pieces.find(p => p.id === data.pieceId);
if (piece) {
piece.position = data.position;
piece.status = data.status;
}
this._emit('pieceMoved', data);
});
this.socket.on('game:ended', (data) => {
this.gameState.status = 'ended';
this.gameState.winner = data.winnerId;
this._emit('gameEnded', data);
});
this.socket.on('room:closed', () => {
this._emit('roomClosed');
this.disconnect();
});
}
// Public action methods
rollDice() {
this.socket.emit('game:roll-dice', { roomCode: this.roomCode });
}
movePiece(pieceId, targetPosition) {
this.socket.emit('game:move-piece', {
roomCode: this.roomCode,
pieceId,
targetPosition
});
}
sendChat(message) {
this.socket.emit('room:chat', {
roomCode: this.roomCode,
message
});
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event).add(callback);
}
_emit(event, data) {
const callbacks = this.listeners.get(event);
if (callbacks) callbacks.forEach(cb => cb(data));
}
disconnect() {
if (this.socket) this.socket.disconnect();
}
}
// Usage example
const client = new LudoGameClient({
serverUrl: 'wss://api.ludokingapi.site/ws',
sessionToken: 'eyJhbGci...',
roomCode: 'ABCD12'
});
client.on('turnChange', ({ currentPlayerId }) => {
const isMyTurn = currentPlayerId === client.gameState.playerId;
document.getElementById('status').textContent = isMyTurn
? 'Your turn!'
: "Opponent's turn";
});
client.on('diceRolled', ({ value, playerId }) => {
animateDiceRoll(value);
console.log(`Player {playerId} rolled {value}`);
});
client.connect();
Game State Synchronization Strategy
State sync is the hardest part of any multiplayer real-time game. The goal: every player sees the same board state, in the same order of events, even under network jitter and reconnection. There are three proven approaches:
1. Event Sourcing (Recommended for Ludo)
The server maintains an authoritative event log. Every game action (dice roll, piece move) is appended as an event with a monotonic sequence number. When a player connects mid-game, the server sends the full event log from the beginning, and the client replays it to reconstruct the current state. This is the most reliable approach for Ludo's turn-based model — events are few (a typical game has 200–400 events), so replay cost is negligible.
2. Full State Snapshots
The server periodically broadcasts the complete game state to all clients. Between snapshots, clients rely on incremental events. This reduces the reconnect reconstruction cost (just load the latest snapshot) but adds bandwidth overhead. For Ludo, a snapshot every 10–20 seconds is sufficient since the board doesn't change rapidly.
3. Client-Side Prediction (Advanced)
When it's your turn, the client predicts the result of your own actions immediately (optimistic UI) and reconciles with the server's authoritative response. If the server disagrees (e.g., the move was invalid), the client rolls back and applies the correction. This gives instant feedback but adds complexity. For casual Ludo games, event sourcing without prediction is simpler and sufficient.
room:sync event to get any missed events since disconnection.
Event Reference Table
| Event Name | Direction | Payload | Description |
|---|---|---|---|
room:join |
Client → Server | { roomCode } | Join a game room by code |
room:joined |
Server → Client | { room, player } | Confirmation with full room state |
room:player-joined |
Server → Client | { player } | Another player joined the room |
room:player-left |
Server → Client | { playerId } | A player left or disconnected |
game:roll-dice |
Client → Server | { roomCode } | Request a dice roll for current player |
game:dice-rolled |
Server → Client | { playerId, value, isValid } | Broadcast dice result to all players |
game:move-piece |
Client → Server | { roomCode, pieceId, targetPos } | Submit a piece movement |
game:piece-moved |
Server → Client | { pieceId, from, to, status } | Broadcast validated move to all players |
game:turn-change |
Server → Client | { currentPlayerId, turnNumber } | Notify whose turn it is now |
game:ended |
Server → Client | { winnerId, scores, duration } | Game completed with winner info |
Frequently Asked Questions
The client stores the last event sequence number it processed in memory (and optionally
localStorage as a backup). On reconnection, it sends room:sync
with that sequence number. The server responds with all events after that sequence. The client
replays those events to catch up to the current state. The event sourcing model makes this elegant
— there's no complex state snapshot reconciliation needed. The reconnect window should be 30–60
seconds; after that, the player's seat is forfeited per standard Ludo rules.
The server processes moves sequentially by arrival time, using the room's current player turn as
the authority. If a move arrives from a player whose turn it is not, the server rejects it with
NOT_YOUR_TURN. Since Ludo is strictly turn-based (only one player
acts at a time), true simultaneous conflicts are rare. The edge case is a move arriving during the
network latency window after a turn change — the server's turn state is authoritative, so the late
move is rejected.
Ludo is one of the most latency-tolerant real-time games. Because turns are discrete (10–30 seconds per turn) and players take actions sequentially, latency under 300ms is imperceptible. Even 500ms is acceptable — you simply see the dice roll arrive slightly after clicking. WebSocket round-trips to servers in major markets are typically 20–100ms. The only concern is players on high-latency mobile connections in rural areas, where 1–2 second delays are possible. Design your UI to show a loading state rather than freezing.
WebRTC is overkill for Ludo and adds significant complexity. WebRTC's strength is peer-to-peer data channels and media streaming — neither of which Ludo needs. WebSocket with a central server is the right choice because: you need server-authoritative game logic anyway (anti-cheat), you need to broadcast to all room members simultaneously, and Socket.IO handles reconnection, room management, and fallback automatically. Use WebRTC only if you're building a peer-to-peer architecture where players connect directly without a server.
A single Node.js process with Socket.IO handles 10,000–20,000 concurrent WebSocket connections comfortably. For Ludo specifically, each room has 4 players = 4 connections. So one process supports 2,500–5,000 concurrent game rooms. Most indie games never approach this. If you do, scale horizontally with the Socket.IO Redis adapter — the same room always reaches the same process via sticky sessions, and Redis pub/sub fans out broadcast events across all instances.
Ready to Add Real-Time Multiplayer to Your Ludo Game?
Get a pre-built WebSocket server with room management, game logic, and state sync included.
Chat on WhatsApp