Building a Real-Time Multiplayer Game Server: Client-Side Prediction and Reconciliation in Node.js
A practical, code-heavy architectural guide to building a low-latency real-time multiplayer game server in Node.js, covering authoritative server design, client-side prediction, input reconciliation, and entity interpolation.
Building a Real-Time Multiplayer Game Server: Client-Side Prediction and Reconciliation in Node.js
Writing a real-time multiplayer game server is one of the most rewarding challenges in backend engineering. Unlike standard CRUD applications where a 200ms database query is barely noticeable, a game server must process inputs, run simulation steps, and broadcast state updates at a blistering 30 to 60 frames per second.
In this comprehensive, code-heavy architectural guide, we will build a real-time 2D multiplayer game server from scratch using Node.js and WebSockets (via ws). We’ll tackle the fundamental hurdles of netcode: Authoritative Server Architecture, Client-Side Prediction, Server Reconciliation, and Entity Interpolation.
The Core Architecture
When multiple players interact in a shared virtual space, who decides what actually happened? If clients are trusted to report their own positions, hackers can easily teleport or move at impossible speeds.
To prevent this, we use an Authoritative Server. The server runs the definitive simulation of the game world. Clients do not tell the server where they are; instead, they send inputs (e.g., “moving right”). The server processes these inputs, updates the game state, and broadcasts the canonical state back to all clients.
The Latency Problem
If a player has a 100ms round-trip time (RTT), waiting for the server to confirm movement before rendering it on screen makes the game feel sluggish and unresponsive.
To solve this, modern multiplayer games rely on three pillars:
- Client-Side Prediction (CSP): The client immediately updates its own local position upon receiving player input, rather than waiting for server confirmation.
- Server Reconciliation: When the client receives a state update from the server, it compares the server’s authoritative position with its predicted position. If there is a discrepancy, the client snaps or smoothly corrects to the server state and replays unacknowledged inputs.
- Entity Interpolation: To prevent other players from jittering across the screen due to network variance, clients render other entities slightly in the past, smoothly interpolating between known server snapshots.
Setting Up the Node.js Server
Let’s initialize our project. We’ll use modern Node.js and the ws library for low-overhead WebSocket communication.
mkdir multiplayer-game-server
cd multiplayer-game-server
npm init -y
npm install ws
Server Game Loop and State Management
Our server needs a fixed-timestep game loop. A fixed loop (e.g., 20 ticks per second) ensures deterministic physics calculations regardless of CPU load or network packet arrival rates.
Create server.js:
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
const TICK_RATE = 20; // 20 ticks per second
const MS_PER_TICK = 1000 / TICK_RATE;
// Game World State
const players = new Map();
class Player {
constructor(id, x, y) {
this.id = id;
this.x = x;
this.y = y;
this.speed = 200; // pixels per second
this.lastProcessedInput = 0;
this.inputs = []; // Queue of inputs to process
}
}
wss.on('connection', (ws) => {
const playerId = Math.random().toString(36).substring(2, 9);
const player = new Player(playerId, 400, 300);
players.set(playerId, player);
console.log(`Player connected: ${playerId}`);
// Send initial handshake with assigned ID
ws.send(JSON.stringify({
type: 'HANDSHAKE',
id: playerId
}));
ws.on('message', (message) => {
const data = JSON.parse(message);
if (data.type === 'INPUT') {
const p = players.get(playerId);
if (p) {
// Push input into the player's input queue with a sequence number
p.inputs.push({
seq: data.seq,
x: data.x,
y: data.y,
dx: data.dx,
dy: data.dy,
deltaTime: data.deltaTime
});
}
}
});
ws.on('close', () => {
players.delete(playerId);
console.log(`Player disconnected: ${playerId}`);
});
});
// Authoritative Game Loop
let lastTime = Date.now();
function gameLoop() {
const now = Date.now();
const deltaTime = (now - lastTime) / 1000;
lastTime = now;
// 1. Process all queued inputs for every player
for (let [id, player] of players) {
while (player.inputs.length > 0) {
const input = player.inputs.shift();
// Apply movement logic server-side
player.x += input.dx * player.speed * input.deltaTime;
player.y += input.dy * player.speed * input.deltaTime;
player.lastProcessedInput = input.seq;
}
}
// 2. Broadcast authoritative state to all connected clients
const stateSnapshot = {
type: 'SNAPSHOT',
timestamp: Date.now(),
players: Array.from(players.values()).map(p => ({
id: p.id,
x: p.x,
y: p.y,
lastProcessedInput: p.lastProcessedInput
}))
};
const serializedState = JSON.stringify(stateSnapshot);
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(serializedState);
}
});
}
setInterval(gameLoop, MS_PER_TICK);
console.log('Game server running on ws://localhost:8080');
Implementing Client-Side Prediction and Reconciliation
Now, let’s write the client-side implementation (imagine this running in a browser environment via HTML5 Canvas). The client must predict its own movement immediately while tracking input sequence numbers to reconcile discrepancies when server snapshots arrive.
Create client.js:
const ws = new WebSocket('ws://localhost:8080');
let myId = null;
let serverX = 400, serverY = 300;
let clientX = 400, clientY = 300;
let inputSequenceNumber = 0;
const pendingInputs = [];
const playerSpeed = 200;
const keys = { w: false, a: false, s: false, d: false };
window.addEventListener('keydown', (e) => { if (e.key in keys) keys[e.key] = true; });
window.addEventListener('keyup', (e) => { if (e.key in keys) keys[e.key] = false; });
wssOnMessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'HANDSHAKE') {
myId = data.id;
console.log(`Assigned ID: ${myId}`);
}
if (data.type === 'SNAPSHOT') {
// Find our player in the server snapshot
const myPlayerState = data.players.find(p => p.id === myId);
if (!myPlayerState) return;
serverX = myPlayerState.x;
serverY = myPlayerState.y;
// SERVER RECONCILIATION:
// 1. Remove all inputs that the server has already processed
let i = 0;
while (i < pendingInputs.length) {
const input = pendingInputs[i];
if (input.seq <= myPlayerState.lastProcessedInput) {
pendingInputs.splice(i, 1);
} else {
i++;
}
}
// 2. Reset client position to the authoritative server position
clientX = serverX;
clientY = serverY;
// 3. Replay all remaining unacknowledged inputs
for (let input of pendingInputs) {
clientX += input.dx * playerSpeed * input.deltaTime;
clientY += input.dy * playerSpeed * input.deltaTime;
}
}
};
// Client Update Loop (e.g., run via requestAnimationFrame)
let lastFrameTime = performance.now();
function clientLoop() {
const now = performance.now();
const deltaTime = (now - lastFrameTime) / 1000;
lastFrameTime = now;
let dx = 0;
let dy = 0;
if (keys.w) dy -= 1;
if (keys.s) dy += 1;
if (keys.a) dx -= 1;
if (keys.d) dx += 1;
// Normalize diagonal movement
if (dx !== 0 && dy !== 0) {
dx *= Math.SQRT1_2;
dy *= Math.SQRT1_2;
}
if (dx !== 0 || dy !== 0) {
inputSequenceNumber++;
const input = {
seq: inputSequenceNumber,
dx,
dy,
deltaTime
};
// CLIENT-SIDE PREDICTION: Update local position immediately
clientX += dx * playerSpeed * deltaTime;
clientY += dy * playerSpeed * deltaTime;
// Save input for future reconciliation
pendingInputs.push(input);
// Transmit input to server
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'INPUT',
...input
}));
}
}
requestAnimationFrame(clientLoop);
}
requestAnimationFrame(clientLoop);
How Reconciliation Works in Practice
- Instant Feedback: When the user presses
D, the client incrementsclientXimmediately. The player feels zero latency. - Buffer Storage: The client pushes the input packet into
pendingInputstagged withseq: 42. - Server Processing: The server processes input
42, updates its internal state, and broadcasts a snapshot indicatinglastProcessedInput: 42. - Correction: The client receives the snapshot. It wipes out all inputs up to sequence
42frompendingInputs. It sets its base position to the server’s authoritative coordinates and instantly fast-forwards through any remaining inputs inpendingInputs(e.g., sequences43and44). If prediction was accurate, this transition is completely invisible to the user.
Smoothing Out Other Entities with Entity Interpolation
While Client-Side Prediction solves responsiveness for the local player, remote players will still appear jittery if we render them strictly at the latest position received from the server. Network jitter causes packets to arrive irregularly.
To solve this, we implement Entity Interpolation on remote entities. Instead of rendering remote players at their newest position, we maintain a history buffer of past snapshots and render them slightly in the past (e.g., 100ms behind real-time), smoothly lerping (linear interpolating) between two historical states.
// Conceptual Entity Interpolation Buffer
const renderDelay = 100; // ms
function getRemotePlayerPosition(remotePlayerHistory, renderTime) {
const targetTime = renderTime - renderDelay;
// Find two snapshots surrounding targetTime
let younger = null;
let older = null;
for (let i = 0; i < remotePlayerHistory.length; i++) {
if (remotePlayerHistory[i].timestamp <= targetTime) {
older = remotePlayerHistory[i];
younger = remotePlayerHistory[i - 1];
break;
}
}
if (!older || !younger) return remotePlayerHistory[0]; // Fallback
// Interpolate between older and younger snapshots
const duration = younger.timestamp - older.timestamp;
const progress = (targetTime - older.timestamp) / duration;
return {
x: older.x + (younger.x - older.x) * progress,
y: older.y + (younger.y - older.y) * progress
};
}
Production Considerations and Optimizations
Building a robust multiplayer game server in Node.js requires going beyond basic prototypes:
- Binary Protocols: JSON serialization adds unnecessary payload overhead and CPU parsing costs. Transition to binary serialization formats like Protocol Buffers, FlatBuffers, or custom
ArrayBufferpacking usingDataView. - Network Transport: WebSockets run over TCP. TCP guarantees packet delivery, but if a packet is dropped, TCP blocks subsequent packets until it is retransmitted, causing “buffer bloat” and input lag spikes. For fast-paced action games, consider implementing your game server over UDP (or WebRTC Data Channels / QUQ) with unreliable, unordered message delivery.
- Spatial Partitioning: Broadcasting every player’s position to every other player results in $O(N^2)$ network traffic. Implement spatial partitioning grids, quadtrees, or interest management zones so clients only receive state updates for entities within their proximity.
Conclusion
Writing a real-time multiplayer server requires flipping your mindset from passive request-handling to active simulation management. By combining an authoritative Node.js game loop with Client-Side Prediction, Server Reconciliation, and Entity Interpolation, you can deliver a buttery-smooth, responsive multiplayer experience even across high-latency networks.