Build Multiplayer Netcode from Scratch
Build a real-time multiplayer game with authoritative server and client-side prediction.
Introduction
30 Second Summary
Every time you press a key in a multiplayer game, your input travels across the internet to a server and back before anything moves on screen. In a fast-paced game, that delay would make everything feel unplayable.
In this project, you will build the networking layer that professional multiplayer games use to hide that delay. You will create a 2-player arena game from scratch, implementing the same prediction and reconciliation techniques used in games like Quake and Overwatch.
What You'll Build
A tiny multiplayer arena game where two colored squares move around a canvas, connected through WebSockets to an authoritative Node.js server that acts as the single source of truth for all game state.
By the end of this project, you'll have:
- A Node.js WebSocket server running a fixed-timestep authoritative game loop at 20 ticks per second.
- A browser client with client-side prediction that makes your own movement feel instant, even though the server is the final authority.
- A server reconciliation system using input sequence numbers to correct mispredictions without visible rubber-banding.
- Secret Mission: an artificial lag simulator that proves your netcode handles 200ms+ round-trip delay gracefully.
Do I need JavaScript experience?
No. This project assumes basic programming experience (loops, functions, variables) but does not require prior JavaScript or WebSocket knowledge. If you have a C++ or similar background, you will pick up the syntax as you go.
Set Up the Project
Every multiplayer game needs a clear separation between server code and client code. Your server will run on Node.js and manage all game state, while your client will be a plain HTML file served to the browser.
In this step, you'll create the project folder, install the WebSocket library, and configure Node.js to use modern ES modules.
In this step, get ready to:
- Create the project directory and initialize npm.
- Install the ws WebSocket library.
- Configure package.json for ES modules.
Create the project directory and install dependencies
You need a dedicated folder for your multiplayer game project. All server code, client code, and dependencies will live here.
- In VS Code, open the integrated terminal by pressing Ctrl+` (backtick key).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir multiplayer-netcode
cd multiplayer-netcode
- Initialize the project with npm by running this command:
npm init -y
What does this command do?
The -y flag tells npm to accept all default values and skip the interactive prompts. This creates a package.json file that tracks your project's name, version, and dependencies.
- Install the ws WebSocket library by running this command:
npm install ws@8.21.0
What is ws?
The ws library is a lightweight WebSocket implementation for Node.js. It has zero dependencies and gives you low-level control over WebSocket connections.
Pinning the version with @8.21.0 ensures everyone working on this project uses the same version.
- Create the public directory for your client file by running this command:
mkdir public
- Confirm your project structure by running:
ls
You should see node_modules, package.json, package-lock.json, and public in the output.
Seeing permission errors or command not found?
If npm is not recognized, confirm Node.js is installed by running node --version. You should see a version number starting with v24.
If you see permission errors on Linux, avoid using sudo with npm. Instead, check that your npm global directory has the correct ownership.
help me fix npm permission errors or command not found issues
- Open your new project folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to Desktop, select the multiplayer-netcode folder, and click Open.
VS Code reloads with your project as the workspace. You should see the public folder and package.json in the Explorer sidebar on the left.
Configure package.json for ES modules
Node.js supports two module systems. The older CommonJS uses require(), while modern ES modules use import/export. You need to tell Node.js which system your project uses.
- In the Explorer sidebar, click package.json to open it in the editor.
- Select all the existing content and replace it with the following code:
{
"name": "multiplayer-netcode",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"ws": "8.21.0"
}
}
What does this configuration do?
The "type": "module" field tells Node.js to treat all .js files in this project as ES modules. This lets you use import statements instead of require().
The "start": "node server.js" script means you can launch your game server by simply running npm start in the terminal.
- Save package.json by pressing Ctrl+S.
Seeing a JSON parse error later?
JSON is strict about syntax. Make sure every string is wrapped in double quotes, not single quotes. Check for trailing commas after the last item in an object or array.
help me fix JSON syntax errors in my package.json
✔️ Awesome, I've got everything!
Great. Double check you've saved package.json and you're ready to build the server.
ⓧ I'd like to double check the full code
Here is the complete package.json file. Make sure yours matches exactly:
{
"name": "multiplayer-netcode",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"ws": "8.21.0"
}
}
Your project structure is ready. Next up, you'll build the authoritative server that manages all game state and broadcasts it to connected players.
Build the Authoritative Server
Your project is set up and ready to go. Now you'll build the heart of the multiplayer system: the authoritative server.
In multiplayer games, every player's computer could disagree about where everyone is standing. The authoritative server solves this by being the single source of truth. It receives player inputs, simulates the game world, and tells everyone the official state.
In this step, get ready to:
- Create a Node.js server that serves files and accepts WebSocket connections.
- Build a fixed-timestep game loop that processes inputs and broadcasts state 20 times per second.
- Handle incoming player inputs by queuing them for the next tick.
Create the server foundation
The server needs to do two things: serve the HTML page to browsers, and establish WebSocket connections for real-time communication. You'll set all of this up in a single server.js file.
- Create a new file called server.js in the root of your multiplayer-netcode folder, then add the following imports and game constants:
import { WebSocketServer } from 'ws';
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Game constants
const TICK_RATE = 20;
const TICK_INTERVAL = 1000 / TICK_RATE;
const PLAYER_SPEED = 200;
const ARENA_WIDTH = 800;
const ARENA_HEIGHT = 600;
const PLAYER_SIZE = 30;
// Track all connected players
const players = new Map();
const COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12'];
let nextId = 1;
What does this code do?
The imports pull in the ws library for WebSocket support, plus Node.js built-in modules for HTTP, file reading, and path resolution.
The __dirname line recreates the classic directory path variable that ES modules don't provide by default. The game constants define the tick rate (20 ticks per second = 50ms per tick), movement speed, and arena dimensions.
The players Map stores every connected player's state. Each new player gets assigned a color from the COLORS array.
- Below the constants, add the HTTP server and WebSocketServer setup:
const server = createServer((req, res) => {
// Serve the client HTML file
if (req.url === '/' || req.url === '/index.html') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(readFileSync(join(__dirname, 'public', 'index.html')));
} else {
res.writeHead(404);
res.end();
}
});
// Attach WebSocket server to the HTTP server
const wss = new WebSocketServer({ server });
What does this code do?
The HTTP server handles browser requests. When someone visits http://localhost:3000, it serves public/index.html (you'll create that file in the next step).
The WebSocketServer shares the same port as the HTTP server by passing { server } to its constructor. This means both regular page requests and WebSocket connections go through port 3000.
- Now add the connection handler that manages player lifecycle. Paste this below the WebSocketServer line:
wss.on('connection', (ws) => {
// Assign a unique ID and color to the new player
const id = `player-${nextId}`;
const color = COLORS[(nextId - 1) % COLORS.length];
nextId++;
// Create the player object with a random spawn position
const player = {
id,
x: Math.random() * (ARENA_WIDTH - PLAYER_SIZE),
y: Math.random() * (ARENA_HEIGHT - PLAYER_SIZE),
color,
inputQueue: [],
lastProcessedSeq: 0,
};
players.set(id, player);
// Send the new player their ID and the current game state
ws.send(JSON.stringify({
type: 'welcome',
id,
players: Array.from(players.values()).map(p => ({
id: p.id, x: p.x, y: p.y, color: p.color,
})),
}));
// Notify all other players that someone joined
wss.clients.forEach((client) => {
if (client !== ws && client.readyState === 1) {
client.send(JSON.stringify({
type: 'playerJoined',
id,
x: player.x,
y: player.y,
color: player.color,
}));
}
});
// Handle disconnection
ws.on('close', () => {
players.delete(id);
wss.clients.forEach((client) => {
if (client.readyState === 1) {
client.send(JSON.stringify({ type: 'playerLeft', id }));
}
});
});
});
What does this code do?
When a player connects, the server creates a player object with a unique ID, a random position within the arena bounds, and an empty inputQueue (for buffering their keyboard inputs until the next tick).
The welcome message tells the new player who they are and where everyone else is. The playerJoined broadcast tells existing players that someone new has arrived.
On disconnect, the server removes the player from the players Map and broadcasts a playerLeft message so other clients can clean up.
- Save server.js.
Seeing a red underline on the import?
Make sure your package.json has "type": "module" at the top level. Without it, Node.js treats the file as CommonJS and won't recognize import statements.
help me fix an import error in my server.js file
Implement the fixed-timestep game loop
The game loop is what makes this an authoritative server. Every 50ms (20 times per second), the server processes all queued inputs, updates positions, and broadcasts the official game state to every connected client.
Why a fixed timestep?
A fixed timestep means the game advances by the same amount of simulated time each tick, regardless of how long the tick actually took to compute. This makes the game deterministic. If the server and client both use the same timestep and movement logic, they'll always agree on where a player should be after a given sequence of inputs.
20 ticks per second is a common baseline for multiplayer games. It balances bandwidth usage with responsiveness.
- Below the connection handler's closing });, add the applyInput function that moves a player based on which keys are pressed:
function applyInput(player, keys, dt) {
// Move the player based on WASD keys
if (keys.w) player.y -= PLAYER_SPEED * dt;
if (keys.s) player.y += PLAYER_SPEED * dt;
if (keys.a) player.x -= PLAYER_SPEED * dt;
if (keys.d) player.x += PLAYER_SPEED * dt;
// Clamp position to arena bounds
player.x = Math.max(0, Math.min(ARENA_WIDTH - PLAYER_SIZE, player.x));
player.y = Math.max(0, Math.min(ARENA_HEIGHT - PLAYER_SIZE, player.y));
}
What does this code do?
This function takes a player, their pressed keys, and a time delta (dt), then moves the player at 200 pixels per second in the pressed direction. The clamping ensures players can't move outside the 800x600 arena.
This exact same function will be duplicated on the client later for client-side prediction. Both sides must produce identical results for reconciliation to work correctly.
- Below applyInput, add the tick function, the setInterval call, and the server listen:
function tick() {
const dt = TICK_INTERVAL / 1000;
// Process all queued inputs for each player
for (const player of players.values()) {
while (player.inputQueue.length > 0) {
const input = player.inputQueue.shift();
applyInput(player, input.keys, dt);
player.lastProcessedSeq = input.seq;
}
}
// Broadcast authoritative state to all clients
const state = JSON.stringify({
type: 'state',
players: Array.from(players.values()).map(p => ({
id: p.id,
x: p.x,
y: p.y,
lastProcessedSeq: p.lastProcessedSeq,
})),
});
wss.clients.forEach((client) => {
if (client.readyState === 1) {
client.send(state);
}
});
}
// Run the game loop at 20 ticks per second
setInterval(tick, TICK_INTERVAL);
server.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
What does this code do?
The tick function runs every 50ms. It drains each player's inputQueue, applying movement for each buffered input. After processing, it records the highest sequence number it handled in lastProcessedSeq.
The state broadcast includes each player's position and their lastProcessedSeq. The client will use this sequence number later to know which of its predicted inputs the server has already confirmed.
Finally, server.listen(3000) starts the HTTP server on port 3000.
- Save server.js.
Seeing an error about port 3000 already in use?
Another process is already using port 3000. Stop it with Ctrl+C in the terminal where it's running, or close any other local dev servers.
help me find and stop the process using port 3000
Handle incoming player inputs
The server needs to receive input messages from clients and queue them for the next tick. Inputs are never processed immediately. They wait in the queue until the tick function drains them, keeping the game simulation consistent.
- In server.js, find the ws.on('close' line inside the connection handler. Add this message handler directly above it:
ws.on('message', (data) => {
const msg = JSON.parse(data);
if (msg.type === 'input') {
player.inputQueue.push({ seq: msg.seq, keys: msg.keys });
}
});
What does this code do?
When the server receives a message, it parses the JSON and checks if it's an input type. If so, it pushes the input (containing a sequence number and the pressed keys) onto that player's inputQueue.
The sequence number (seq) is critical for reconciliation later. It lets the client know exactly which inputs the server has already processed.
- Save server.js.
- Verify the file is saved by checking that no dot or unsaved indicator appears on the server.js tab in VS Code.
Not sure where to paste the message handler?
The message handler goes inside the wss.on('connection', (ws) => { ... }) block, between the playerJoined broadcast and the ws.on('close' handler. It needs access to the player variable defined at the top of the connection callback.
help me place the message handler in the right location in server.js
✔️ Awesome, I've got everything!
Great. Double check you've saved server.js and move on to building the client.
ⓧ I'd like to double check the full code
Here's the complete server.js file. Make sure yours matches:
import { WebSocketServer } from 'ws';
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const TICK_RATE = 20;
const TICK_INTERVAL = 1000 / TICK_RATE;
const PLAYER_SPEED = 200;
const ARENA_WIDTH = 800;
const ARENA_HEIGHT = 600;
const PLAYER_SIZE = 30;
const players = new Map();
const COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12'];
let nextId = 1;
const server = createServer((req, res) => {
if (req.url === '/' || req.url === '/index.html') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(readFileSync(join(__dirname, 'public', 'index.html')));
} else {
res.writeHead(404);
res.end();
}
});
const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => {
const id = `player-${nextId}`;
const color = COLORS[(nextId - 1) % COLORS.length];
nextId++;
const player = {
id,
x: Math.random() * (ARENA_WIDTH - PLAYER_SIZE),
y: Math.random() * (ARENA_HEIGHT - PLAYER_SIZE),
color,
inputQueue: [],
lastProcessedSeq: 0,
};
players.set(id, player);
ws.send(JSON.stringify({
type: 'welcome',
id,
players: Array.from(players.values()).map(p => ({
id: p.id, x: p.x, y: p.y, color: p.color,
})),
}));
wss.clients.forEach((client) => {
if (client !== ws && client.readyState === 1) {
client.send(JSON.stringify({
type: 'playerJoined',
id,
x: player.x,
y: player.y,
color: player.color,
}));
}
});
ws.on('message', (data) => {
const msg = JSON.parse(data);
if (msg.type === 'input') {
player.inputQueue.push({ seq: msg.seq, keys: msg.keys });
}
});
ws.on('close', () => {
players.delete(id);
wss.clients.forEach((client) => {
if (client.readyState === 1) {
client.send(JSON.stringify({ type: 'playerLeft', id }));
}
});
});
});
function applyInput(player, keys, dt) {
if (keys.w) player.y -= PLAYER_SPEED * dt;
if (keys.s) player.y += PLAYER_SPEED * dt;
if (keys.a) player.x -= PLAYER_SPEED * dt;
if (keys.d) player.x += PLAYER_SPEED * dt;
player.x = Math.max(0, Math.min(ARENA_WIDTH - PLAYER_SIZE, player.x));
player.y = Math.max(0, Math.min(ARENA_HEIGHT - PLAYER_SIZE, player.y));
}
function tick() {
const dt = TICK_INTERVAL / 1000;
for (const player of players.values()) {
while (player.inputQueue.length > 0) {
const input = player.inputQueue.shift();
applyInput(player, input.keys, dt);
player.lastProcessedSeq = input.seq;
}
}
const state = JSON.stringify({
type: 'state',
players: Array.from(players.values()).map(p => ({
id: p.id,
x: p.x,
y: p.y,
lastProcessedSeq: p.lastProcessedSeq,
})),
});
wss.clients.forEach((client) => {
if (client.readyState === 1) {
client.send(state);
}
});
}
setInterval(tick, TICK_INTERVAL);
server.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
Your authoritative server is complete. It accepts connections, queues inputs, and broadcasts the official game state 20 times per second. Next up, you'll build the browser client that connects to this server and renders the game on an HTML5 Canvas.
Build the Client (Naive Version)
Your server is running a tight authoritative game loop, processing inputs and broadcasting state 20 times per second. Now you need something for players to actually see and interact with.
In this step, you'll build a browser client that connects to the server, renders the game arena, and sends keyboard inputs. The first version uses a deliberately naive approach: your player only moves when the server says so. This will feel sluggish, and that's intentional. Experiencing the problem firsthand makes the prediction solution in the next step click.
In this step, get ready to:
- Create an HTML5 Canvas client with a render loop that draws players as colored rectangles.
- Connect to the server over WebSockets and handle game state messages.
- Send keyboard inputs to the server and observe the naive round-trip delay.
Create the canvas and render loop
The client is a single HTML file that uses an HTML5 Canvas for rendering. The canvas gives you pixel-level control over drawing, which is exactly what a game needs.
- Create a new file called index.html inside the public folder by running this command:
touch public/index.html
- Confirm the file exists by running:
ls public
You should see index.html in the output.
- Open public/index.html in VS Code's editor by clicking on it in the **Explorer** sidebar.
- Paste the following HTML structure, styles, and game constants into the file:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Multiplayer Netcode</title>
<style>
body {
margin: 0;
background: #1a1a2e;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
canvas {
border: 2px solid #444;
}
#info {
position: absolute;
top: 10px;
left: 10px;
color: #fff;
font-family: monospace;
font-size: 14px;
}
</style>
</head>
<body>
<div id="info">Connecting...</div>
<canvas id="arena" width="800" height="600"></canvas>
<script>
// Grab references to the canvas and info display
const canvas = document.getElementById('arena');
const ctx = canvas.getContext('2d');
const info = document.getElementById('info');
// Game constants (must match the server)
const PLAYER_SPEED = 200;
const PLAYER_SIZE = 30;
const ARENA_WIDTH = 800;
const ARENA_HEIGHT = 600;
const TICK_RATE = 20;
const TICK_INTERVAL = 1000 / TICK_RATE;
// Client-side game state
let myId = null;
let players = {};
let pendingInputs = [];
let inputSeq = 0;
let keys = { w: false, a: false, s: false, d: false };
What does this code do?
The HTML sets up a dark page with an 800x600 canvas element and a status display in the top-left corner.
The JavaScript constants (PLAYER_SPEED, ARENA_WIDTH, etc.) intentionally match the server's values. Both sides must agree on the physics for the game to work correctly.
The state variables track your player ID, all connected players, a buffer for pending inputs (used later for prediction), a sequence counter, and which keys are currently pressed.
- Add the render function and closing tags directly below the state variables:
// Draw the game arena every frame
function render() {
// Dark background
ctx.fillStyle = '#16213e';
ctx.fillRect(0, 0, ARENA_WIDTH, ARENA_HEIGHT);
// Subtle grid lines
ctx.strokeStyle = '#1a1a3e';
ctx.lineWidth = 1;
for (let x = 0; x < ARENA_WIDTH; x += 40) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, ARENA_HEIGHT);
ctx.stroke();
}
for (let y = 0; y < ARENA_HEIGHT; y += 40) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(ARENA_WIDTH, y);
ctx.stroke();
}
// Draw each player as a colored rectangle
for (const id in players) {
const p = players[id];
ctx.fillStyle = p.color;
ctx.fillRect(p.x, p.y, PLAYER_SIZE, PLAYER_SIZE);
// Label: "YOU" for local player, ID for others
ctx.fillStyle = '#fff';
ctx.font = '10px monospace';
ctx.fillText(id === myId ? 'YOU' : id, p.x, p.y - 5);
}
// Display pending inputs counter
ctx.fillStyle = '#fff';
ctx.font = '12px monospace';
ctx.fillText(`Pending inputs: ${pendingInputs.length}`, 10, ARENA_HEIGHT - 10);
requestAnimationFrame(render);
}
render();
</script>
</body>
</html>
What does the render function do?
The render() function runs every animation frame (typically 60fps). It clears the canvas, draws a subtle grid, then loops through all players to draw them as colored squares with labels.
The requestAnimationFrame(render) call at the end schedules the next frame, creating a smooth continuous loop. The "Pending inputs" counter at the bottom-left will become meaningful once you add prediction later.
- Save public/index.html.
You won't see a change yet. The canvas needs the WebSocket connection (next chunk) to receive player data before anything draws.
Connect to the server over WebSockets
The browser has a built-in WebSocket API that handles the connection to your server. You'll use it to receive game state and send player inputs.
- In public/index.html, find the line that says let keys = { w: false, a: false, s: false, d: false };.
- Add the following WebSocket connection code between that line and the render() function:
// Connect to the WebSocket server
const ws = new WebSocket(`ws://${window.location.host}`);
ws.onopen = () => {
info.textContent = 'Connected. Waiting for game state...';
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'welcome') {
// Server assigned us an ID and sent existing players
myId = msg.id;
msg.players.forEach(p => {
players[p.id] = { x: p.x, y: p.y, color: p.color };
});
info.textContent = `You are ${myId}`;
}
if (msg.type === 'playerJoined') {
// Another player connected
players[msg.id] = { x: msg.x, y: msg.y, color: msg.color };
}
if (msg.type === 'playerLeft') {
// A player disconnected
delete players[msg.id];
}
if (msg.type === 'state') {
// Naive approach: directly apply server positions
msg.players.forEach(serverPlayer => {
if (players[serverPlayer.id]) {
players[serverPlayer.id].x = serverPlayer.x;
players[serverPlayer.id].y = serverPlayer.y;
}
});
}
};
ws.onclose = () => {
info.textContent = 'Disconnected from server';
};
What does this code do?
- new WebSocket(`ws://${window.location.host}`) opens a persistent connection to the same host that served the page.
- The welcome handler stores your player ID and populates the initial player list.
- The state handler is the key piece. It directly overwrites every player's position with whatever the server says. This is the "naive" approach. Your character won't move until the server echoes your input back.
Why is the naive approach a problem?
When you press a key, the input travels to the server, waits for the next tick (up to 50ms), gets processed, then the result travels back. Even on localhost that's a noticeable delay. On a real network with 50-100ms ping, movement feels unresponsive and laggy.
You'll fix this with client-side prediction in Step 4. For now, experiencing the lag firsthand helps you understand why prediction matters.
- Save public/index.html.
You won't see a change yet. The next chunk adds keyboard handling so you can actually move.
Add keyboard input and test the game
The server expects input messages containing which WASD keys are pressed. You'll send these at the server's tick rate (every 50ms) so inputs arrive in sync with the game loop.
- In public/index.html, find the line that says info.textContent = 'Disconnected from server'; (inside the ws.onclose handler).
- Add the following keyboard handling and input sending code between that closing }; and the render() function:
// Track which keys are currently pressed
document.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
if (key in keys) keys[key] = true;
});
document.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
if (key in keys) keys[key] = false;
});
// Send inputs to the server at the tick rate
setInterval(() => {
if (!myId || ws.readyState !== WebSocket.OPEN) return;
if (!keys.w && !keys.a && !keys.s && !keys.d) return;
inputSeq++;
ws.send(JSON.stringify({ type: 'input', seq: inputSeq, keys: { ...keys } }));
}, TICK_INTERVAL);
// Movement logic (identical to the server's applyInput)
function applyInput(player, inputKeys, dt) {
if (inputKeys.w) player.y -= PLAYER_SPEED * dt;
if (inputKeys.s) player.y += PLAYER_SPEED * dt;
if (inputKeys.a) player.x -= PLAYER_SPEED * dt;
if (inputKeys.d) player.x += PLAYER_SPEED * dt;
player.x = Math.max(0, Math.min(ARENA_WIDTH - PLAYER_SIZE, player.x));
player.y = Math.max(0, Math.min(ARENA_HEIGHT - PLAYER_SIZE, player.y));
}
What does this code do?
- The keydown/keyup listeners track which WASD keys are currently held down.
- The setInterval fires every 50ms (matching the server tick rate). It sends the current key state with an incrementing sequence number. If no keys are pressed, it skips sending to save bandwidth.
- The applyInput function uses the exact same movement logic as the server. It's not called yet in this naive version, but you'll use it for client-side prediction in the next step.
Why must applyInput be identical on client and server?
Client-side prediction works by running the same physics locally that the server runs remotely. If the movement logic differs even slightly (different speed, different clamping), the client's prediction will never match the server's authoritative result, causing constant visible corrections.
- Save public/index.html.
- Start the server by running this command in your terminal:
npm start
You should see Server running on http://localhost:3000 in the terminal.
- Open your browser and navigate to http://localhost:3000.
- Open a second browser tab and navigate to the same URL: http://localhost:3000.
- Press W, A, S, or D in one of the tabs to move your player.
You should see a dark canvas with a subtle grid pattern. Two colored squares appear (one per tab), each labeled with their player ID. The info text in the top-left shows "You are player-1" (or similar).
Movement works, but notice the sluggishness. When you press a key, there's a visible delay before your square starts moving. This is the round-trip delay in action.
Don't see any players moving?
- Check your browser console (press F12) for any JavaScript errors. A typo in the code will prevent the script from running.
- Make sure the WebSocket code is placed between the state variables and the render() function. If it's inside or after render, the connection won't be established before the first frame draws.
- Confirm the terminal still shows the server running. If you see an error, stop it with Ctrl+C and check server.js for any issues from the previous step.
Why aren't my players appearing on the canvas?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
Here's the complete public/index.html file at this point:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Multiplayer Netcode</title>
<style>
body {
margin: 0;
background: #1a1a2e;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
canvas {
border: 2px solid #444;
}
#info {
position: absolute;
top: 10px;
left: 10px;
color: #fff;
font-family: monospace;
font-size: 14px;
}
</style>
</head>
<body>
<div id="info">Connecting...</div>
<canvas id="arena" width="800" height="600"></canvas>
<script>
const canvas = document.getElementById('arena');
const ctx = canvas.getContext('2d');
const info = document.getElementById('info');
const PLAYER_SPEED = 200;
const PLAYER_SIZE = 30;
const ARENA_WIDTH = 800;
const ARENA_HEIGHT = 600;
const TICK_RATE = 20;
const TICK_INTERVAL = 1000 / TICK_RATE;
let myId = null;
let players = {};
let pendingInputs = [];
let inputSeq = 0;
let keys = { w: false, a: false, s: false, d: false };
const ws = new WebSocket(`ws://${window.location.host}`);
ws.onopen = () => {
info.textContent = 'Connected. Waiting for game state...';
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'welcome') {
myId = msg.id;
msg.players.forEach(p => {
players[p.id] = { x: p.x, y: p.y, color: p.color };
});
info.textContent = `You are ${myId}`;
}
if (msg.type === 'playerJoined') {
players[msg.id] = { x: msg.x, y: msg.y, color: msg.color };
}
if (msg.type === 'playerLeft') {
delete players[msg.id];
}
if (msg.type === 'state') {
msg.players.forEach(serverPlayer => {
if (players[serverPlayer.id]) {
players[serverPlayer.id].x = serverPlayer.x;
players[serverPlayer.id].y = serverPlayer.y;
}
});
}
};
ws.onclose = () => {
info.textContent = 'Disconnected from server';
};
document.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
if (key in keys) keys[key] = true;
});
document.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
if (key in keys) keys[key] = false;
});
setInterval(() => {
if (!myId || ws.readyState !== WebSocket.OPEN) return;
if (!keys.w && !keys.a && !keys.s && !keys.d) return;
inputSeq++;
ws.send(JSON.stringify({ type: 'input', seq: inputSeq, keys: { ...keys } }));
}, TICK_INTERVAL);
function applyInput(player, inputKeys, dt) {
if (inputKeys.w) player.y -= PLAYER_SPEED * dt;
if (inputKeys.s) player.y += PLAYER_SPEED * dt;
if (inputKeys.a) player.x -= PLAYER_SPEED * dt;
if (inputKeys.d) player.x += PLAYER_SPEED * dt;
player.x = Math.max(0, Math.min(ARENA_WIDTH - PLAYER_SIZE, player.x));
player.y = Math.max(0, Math.min(ARENA_HEIGHT - PLAYER_SIZE, player.y));
}
function render() {
ctx.fillStyle = '#16213e';
ctx.fillRect(0, 0, ARENA_WIDTH, ARENA_HEIGHT);
ctx.strokeStyle = '#1a1a3e';
ctx.lineWidth = 1;
for (let x = 0; x < ARENA_WIDTH; x += 40) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, ARENA_HEIGHT);
ctx.stroke();
}
for (let y = 0; y < ARENA_HEIGHT; y += 40) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(ARENA_WIDTH, y);
ctx.stroke();
}
for (const id in players) {
const p = players[id];
ctx.fillStyle = p.color;
ctx.fillRect(p.x, p.y, PLAYER_SIZE, PLAYER_SIZE);
ctx.fillStyle = '#fff';
ctx.font = '10px monospace';
ctx.fillText(id === myId ? 'YOU' : id, p.x, p.y - 5);
}
ctx.fillStyle = '#fff';
ctx.font = '12px monospace';
ctx.fillText(`Pending inputs: ${pendingInputs.length}`, 10, ARENA_HEIGHT - 10);
requestAnimationFrame(render);
}
render();
</script>
</body>
</html>
You've now experienced the fundamental problem with naive networked movement. Your inputs travel to the server, wait for the next tick, get processed, and the result travels back before you see any movement. In the next step, you'll make this feel instant by predicting movement locally.
Add Client-Side Prediction
In the previous step, you experienced a fundamental problem with networked games. Your player only moves after the server processes your input and sends the updated state back. Even on localhost, this round-trip introduces noticeable delay.
The fix is client-side prediction. The idea is simple: since the client has the exact same movement logic as the server, it can apply inputs locally the instant you press a key. The server remains the authority, but the client no longer waits for permission to move.
In this step, get ready to:
- Apply inputs locally for instant movement feedback.
- Buffer sent inputs in a pending array for future reconciliation.
- Test prediction across two browser tabs.
Predict movement locally after each input
Right now, your setInterval callback sends the input to the server and does nothing else. The player's position only changes when a state message arrives back from the server. You need to add two things: apply the movement immediately using the same applyInput function the server uses, and store the input so you can reconcile later.
- In public/index.html, find the setInterval callback near the bottom of the <script> tag.
- Find this line inside the callback:
ws.send(JSON.stringify({ type: 'input', seq: inputSeq, keys: { ...keys } }));
- Replace the entire setInterval callback body (everything between the opening () => { and the closing }, TICK_INTERVAL);) with this updated version:
setInterval(() => {
if (!myId || ws.readyState !== WebSocket.OPEN) return;
if (!keys.w && !keys.a && !keys.s && !keys.d) return;
inputSeq++;
const input = { seq: inputSeq, keys: { ...keys } };
ws.send(JSON.stringify({ type: 'input', seq: inputSeq, keys: { ...keys } }));
// Client-side prediction
const dt = TICK_INTERVAL / 1000;
applyInput(players[myId], input.keys, dt);
// Store for reconciliation
pendingInputs.push(input);
}, TICK_INTERVAL);
What does this code do?
Three things happen every tick when keys are pressed:
- const input = { seq: inputSeq, keys: { ...keys } } saves the input into a local variable so you can reuse it for both prediction and storage.
- applyInput(players[myId], input.keys, dt) runs the exact same movement function the server uses. Because the logic is identical, your local position will match what the server calculates.
- pendingInputs.push(input) buffers the input. In the next step, you will use this buffer to reconcile your predicted position with the server's authoritative state.
Why must applyInput be identical on client and server?
Prediction only works if both sides compute the same result from the same input. If the client moved at a different speed or used different clamping bounds, the predicted position would drift from the server's truth. You would see constant rubber-banding as the server corrects you.
This is why the PLAYER_SPEED, ARENA_WIDTH, ARENA_HEIGHT, and PLAYER_SIZE constants are defined in both files with identical values.
- Save public/index.html.
Player not moving at all?
- Make sure your applyInput function definition is still below the setInterval block. JavaScript hoists function declarations, so order does not matter for function applyInput(...) syntax. But if you accidentally deleted it, the call will fail.
- Check that players[myId] exists before applyInput runs. The early return if (!myId || ws.readyState !== WebSocket.OPEN) return; guards this, so make sure it is still the first line inside the callback.
- Open your browser's DevTools console and look for any red errors. A typo in a variable name will stop the entire callback from running.
Why is my player not moving after adding prediction?
Test the prediction
With prediction in place, your own player should now respond the instant you press a key. The server still controls the other player's position, so you will see an asymmetry between how your player and the remote player feel.
- Stop the server if it is still running by pressing Ctrl+C in your terminal.
- Restart the server by running:
npm start
- Open two browser tabs to http://localhost:3000.
- In one tab, press the W, A, S, or D keys to move your player.
Your own player now moves instantly when you press keys. Look at the second tab to see the other player. That remote player still updates at the server tick rate (~20 updates per second). This asymmetry is expected and correct.
Also notice the Pending inputs counter in the bottom-left corner. It climbs while you hold a key because inputs are being buffered. Right now nothing clears them. You will fix that in the next step with server reconciliation.
Why does the pending count keep growing?
Every tick you hold a key, one input gets pushed to pendingInputs. Nothing removes entries yet because you have not implemented reconciliation. In the next step, the client will discard inputs the server has already confirmed, keeping this buffer small and stable.
Movement still feels sluggish?
- Make sure you saved public/index.html and did a hard refresh in the browser (Ctrl+Shift+R or Cmd+Shift+R). The browser may be serving a cached version of the old file.
- Confirm the applyInput call is placed after ws.send but still inside the setInterval callback. If it ended up outside the callback, it will never run.
My player still waits for the server before moving.
✔️ Awesome, I've got everything!
Great. Make sure you have saved public/index.html.
ⓧ I'd like to double check the full code
Here is the complete public/index.html with prediction added:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Multiplayer Netcode</title>
<style>
body {
margin: 0;
background: #1a1a2e;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
canvas {
border: 2px solid #444;
}
#info {
position: absolute;
top: 10px;
left: 10px;
color: #fff;
font-family: monospace;
font-size: 14px;
}
</style>
</head>
<body>
<div id="info">Connecting...</div>
<canvas id="arena" width="800" height="600"></canvas>
<script>
const canvas = document.getElementById('arena');
const ctx = canvas.getContext('2d');
const info = document.getElementById('info');
const PLAYER_SPEED = 200;
const PLAYER_SIZE = 30;
const ARENA_WIDTH = 800;
const ARENA_HEIGHT = 600;
const TICK_RATE = 20;
const TICK_INTERVAL = 1000 / TICK_RATE;
let myId = null;
let players = {};
let pendingInputs = [];
let inputSeq = 0;
let keys = { w: false, a: false, s: false, d: false };
const ws = new WebSocket(`ws://${window.location.host}`);
ws.onopen = () => {
info.textContent = 'Connected. Waiting for game state...';
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'welcome') {
myId = msg.id;
msg.players.forEach(p => {
players[p.id] = { x: p.x, y: p.y, color: p.color };
});
info.textContent = `You are ${myId}`;
}
if (msg.type === 'playerJoined') {
players[msg.id] = { x: msg.x, y: msg.y, color: msg.color };
}
if (msg.type === 'playerLeft') {
delete players[msg.id];
}
if (msg.type === 'state') {
msg.players.forEach(serverPlayer => {
if (players[serverPlayer.id]) {
players[serverPlayer.id].x = serverPlayer.x;
players[serverPlayer.id].y = serverPlayer.y;
}
});
}
};
ws.onclose = () => {
info.textContent = 'Disconnected from server';
};
document.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
if (key in keys) keys[key] = true;
});
document.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
if (key in keys) keys[key] = false;
});
setInterval(() => {
if (!myId || ws.readyState !== WebSocket.OPEN) return;
if (!keys.w && !keys.a && !keys.s && !keys.d) return;
inputSeq++;
const input = { seq: inputSeq, keys: { ...keys } };
ws.send(JSON.stringify({ type: 'input', seq: inputSeq, keys: { ...keys } }));
// Client-side prediction
const dt = TICK_INTERVAL / 1000;
applyInput(players[myId], input.keys, dt);
// Store for reconciliation
pendingInputs.push(input);
}, TICK_INTERVAL);
function applyInput(player, inputKeys, dt) {
if (inputKeys.w) player.y -= PLAYER_SPEED * dt;
if (inputKeys.s) player.y += PLAYER_SPEED * dt;
if (inputKeys.a) player.x -= PLAYER_SPEED * dt;
if (inputKeys.d) player.x += PLAYER_SPEED * dt;
player.x = Math.max(0, Math.min(ARENA_WIDTH - PLAYER_SIZE, player.x));
player.y = Math.max(0, Math.min(ARENA_HEIGHT - PLAYER_SIZE, player.y));
}
function render() {
ctx.fillStyle = '#16213e';
ctx.fillRect(0, 0, ARENA_WIDTH, ARENA_HEIGHT);
ctx.strokeStyle = '#1a1a3e';
ctx.lineWidth = 1;
for (let x = 0; x < ARENA_WIDTH; x += 40) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, ARENA_HEIGHT);
ctx.stroke();
}
for (let y = 0; y < ARENA_HEIGHT; y += 40) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(ARENA_WIDTH, y);
ctx.stroke();
}
for (const id in players) {
const p = players[id];
ctx.fillStyle = p.color;
ctx.fillRect(p.x, p.y, PLAYER_SIZE, PLAYER_SIZE);
ctx.fillStyle = '#fff';
ctx.font = '10px monospace';
ctx.fillText(id === myId ? 'YOU' : id, p.x, p.y - 5);
}
ctx.fillStyle = '#fff';
ctx.font = '12px monospace';
ctx.fillText(`Pending inputs: ${pendingInputs.length}`, 10, ARENA_HEIGHT - 10);
requestAnimationFrame(render);
}
render();
</script>
</body>
</html>
Your player now moves instantly, but there is a catch. The server is still broadcasting its own calculated positions. Every time a state message arrives, your client snaps your player to the server's position, overwriting your prediction. Next up, you will add server reconciliation to fix this by replaying unacknowledged inputs on top of each server update.
Add Server Reconciliation
Your client-side prediction makes movement feel instant. But there's a gap: the client never checks whether its predicted position actually matches what the server calculated.
Server reconciliation closes that gap. Every time the server sends an authoritative state update, the client snaps to the server's position, throws away inputs the server has already processed, and replays any inputs the server hasn't seen yet. This keeps your local prediction accurate without rubber-banding.
In this step, get ready to:
- Replace the naive state handler with reconciliation logic.
- Test the full prediction + reconciliation pipeline across two browser tabs.
Implement server reconciliation in the state handler
Right now, your state message handler overwrites every player's position with the server data. That works for other players, but for YOUR player it wipes out your local prediction every tick.
- In public/index.html, scroll to the ws.onmessage handler.
- Find the if (msg.type === 'state') block. It currently looks like this:
if (msg.type === 'state') {
msg.players.forEach(serverPlayer => {
if (players[serverPlayer.id]) {
players[serverPlayer.id].x = serverPlayer.x;
players[serverPlayer.id].y = serverPlayer.y;
}
});
}
- Replace that entire if (msg.type === 'state') block with the following reconciliation logic:
if (msg.type === 'state') {
msg.players.forEach(serverPlayer => {
if (serverPlayer.id === myId) {
// Server reconciliation
players[myId].x = serverPlayer.x;
players[myId].y = serverPlayer.y;
// Remove acknowledged inputs
pendingInputs = pendingInputs.filter(
input => input.seq > serverPlayer.lastProcessedSeq
);
// Re-apply unacknowledged inputs
const dt = TICK_INTERVAL / 1000;
pendingInputs.forEach(input => {
applyInput(players[myId], input.keys, dt);
});
} else {
if (players[serverPlayer.id]) {
players[serverPlayer.id].x = serverPlayer.x;
players[serverPlayer.id].y = serverPlayer.y;
}
}
});
}
What does this code do?
The handler now splits behavior based on whether the state update is for YOUR player or someone else:
- For your player: snap to the server's authoritative position first. This is the "ground truth" reset.
- Then filter pendingInputs to remove any inputs where seq <= lastProcessedSeq. The server has already applied those, so they're no longer "pending."
- Finally, re-apply every remaining pending input on top of the server position. These are inputs you sent but the server hasn't confirmed yet.
- For other players: apply the server position directly (no prediction needed for them).
Why does this prevent rubber-banding?
Without reconciliation, snapping to the server position would pull your player backwards every tick (because the server is always slightly behind your prediction). By replaying unacknowledged inputs after the snap, you end up at the same position your prediction already calculated.
The result: no visible correction as long as your applyInput function is identical on client and server. Since both use the same speed, dt, and clamping logic, the positions always agree.
- Save public/index.html.
Seeing a syntax error or unexpected behavior?
- Make sure you replaced the entire if (msg.type === 'state') block, including both the opening and closing braces.
- Check that the else branch is inside the forEach callback, not outside it. The closing braces can be tricky to match.
- Verify that serverPlayer.lastProcessedSeq is spelled exactly as shown. A typo here means inputs never get removed from the pending buffer.
Still stuck? Help me debug my server reconciliation code in public/index.html
Test the full netcode
With reconciliation in place, you now have the complete prediction + reconciliation pipeline. Time to see it in action.
- If the server is still running from the previous step, stop it by pressing Ctrl+C in the terminal.
- Start the server by running:
npm start
- Open two browser tabs to http://localhost:3000.
- Move your player with W, A, S, D in both tabs.
Both players move smoothly. Your own movement is instant (prediction). The other player in each tab updates at the server tick rate.
- Look at the Pending inputs counter in the bottom-left corner of the canvas.
On localhost, the counter stays at 1-2. This means your prediction buffer is tiny because the server confirms inputs almost immediately.
Why is there no visible rubber-banding?
Because the movement logic is deterministic and identical on both client and server, the server's authoritative position always matches your predicted position. Reconciliation still runs every tick (snap, filter, replay), but the replayed result lands on the same pixel you already see.
If you added non-deterministic elements (like random knockback from another player), you would occasionally see small corrections. The reconciliation handles those gracefully by snapping and replaying.
Player snapping or teleporting?
- If your player teleports backward every tick, the reconciliation re-apply step is likely missing. Check that the pendingInputs.forEach loop is present after the filter.
- If Pending inputs grows indefinitely, the filter condition may be inverted. It should keep inputs where seq > serverPlayer.lastProcessedSeq (greater than, not less than).
- If neither player moves, open DevTools and check the console for JavaScript errors.
Need more help? My player is rubber-banding or the pending inputs counter keeps growing
✔️ Awesome, I've got everything!
Great. Make sure you've saved public/index.html and confirmed smooth movement across both tabs.
ⓧ I'd like to double check the full code
Here is the complete public/index.html with server reconciliation:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Multiplayer Netcode</title>
<style>
body {
margin: 0;
background: #1a1a2e;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
canvas {
border: 2px solid #444;
}
#info {
position: absolute;
top: 10px;
left: 10px;
color: #fff;
font-family: monospace;
font-size: 14px;
}
</style>
</head>
<body>
<div id="info">Connecting...</div>
<canvas id="arena" width="800" height="600"></canvas>
<script>
const canvas = document.getElementById('arena');
const ctx = canvas.getContext('2d');
const info = document.getElementById('info');
const PLAYER_SPEED = 200;
const PLAYER_SIZE = 30;
const ARENA_WIDTH = 800;
const ARENA_HEIGHT = 600;
const TICK_RATE = 20;
const TICK_INTERVAL = 1000 / TICK_RATE;
let myId = null;
let players = {};
let pendingInputs = [];
let inputSeq = 0;
let keys = { w: false, a: false, s: false, d: false };
const ws = new WebSocket(`ws://${window.location.host}`);
ws.onopen = () => {
info.textContent = 'Connected. Waiting for game state...';
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'welcome') {
myId = msg.id;
msg.players.forEach(p => {
players[p.id] = { x: p.x, y: p.y, color: p.color };
});
info.textContent = `You are ${myId}`;
}
if (msg.type === 'playerJoined') {
players[msg.id] = { x: msg.x, y: msg.y, color: msg.color };
}
if (msg.type === 'playerLeft') {
delete players[msg.id];
}
if (msg.type === 'state') {
msg.players.forEach(serverPlayer => {
if (serverPlayer.id === myId) {
// Server reconciliation
players[myId].x = serverPlayer.x;
players[myId].y = serverPlayer.y;
// Remove acknowledged inputs
pendingInputs = pendingInputs.filter(
input => input.seq > serverPlayer.lastProcessedSeq
);
// Re-apply unacknowledged inputs
const dt = TICK_INTERVAL / 1000;
pendingInputs.forEach(input => {
applyInput(players[myId], input.keys, dt);
});
} else {
if (players[serverPlayer.id]) {
players[serverPlayer.id].x = serverPlayer.x;
players[serverPlayer.id].y = serverPlayer.y;
}
}
});
}
};
ws.onclose = () => {
info.textContent = 'Disconnected from server';
};
document.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
if (key in keys) keys[key] = true;
});
document.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
if (key in keys) keys[key] = false;
});
setInterval(() => {
if (!myId || ws.readyState !== WebSocket.OPEN) return;
if (!keys.w && !keys.a && !keys.s && !keys.d) return;
inputSeq++;
const input = { seq: inputSeq, keys: { ...keys } };
ws.send(JSON.stringify({ type: 'input', seq: inputSeq, keys: { ...keys } }));
// Client-side prediction
const dt = TICK_INTERVAL / 1000;
applyInput(players[myId], input.keys, dt);
// Store for reconciliation
pendingInputs.push(input);
}, TICK_INTERVAL);
function applyInput(player, inputKeys, dt) {
if (inputKeys.w) player.y -= PLAYER_SPEED * dt;
if (inputKeys.s) player.y += PLAYER_SPEED * dt;
if (inputKeys.a) player.x -= PLAYER_SPEED * dt;
if (inputKeys.d) player.x += PLAYER_SPEED * dt;
player.x = Math.max(0, Math.min(ARENA_WIDTH - PLAYER_SIZE, player.x));
player.y = Math.max(0, Math.min(ARENA_HEIGHT - PLAYER_SIZE, player.y));
}
function render() {
ctx.fillStyle = '#16213e';
ctx.fillRect(0, 0, ARENA_WIDTH, ARENA_HEIGHT);
ctx.strokeStyle = '#1a1a3e';
ctx.lineWidth = 1;
for (let x = 0; x < ARENA_WIDTH; x += 40) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, ARENA_HEIGHT);
ctx.stroke();
}
for (let y = 0; y < ARENA_HEIGHT; y += 40) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(ARENA_WIDTH, y);
ctx.stroke();
}
for (const id in players) {
const p = players[id];
ctx.fillStyle = p.color;
ctx.fillRect(p.x, p.y, PLAYER_SIZE, PLAYER_SIZE);
ctx.fillStyle = '#fff';
ctx.font = '10px monospace';
ctx.fillText(id === myId ? 'YOU' : id, p.x, p.y - 5);
}
ctx.fillStyle = '#fff';
ctx.font = '12px monospace';
ctx.fillText(`Pending inputs: ${pendingInputs.length}`, 10, ARENA_HEIGHT - 10);
requestAnimationFrame(render);
}
render();
</script>
</body>
</html>
Secret mission
Add an Artificial Lag Simulator
How does your netcode perform under real-world latency? Add a configurable lag simulator to your client that artificially delays both outgoing and incoming WebSocket messages, letting you stress-test prediction and reconciliation under 200ms+ round-trip conditions.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The Node.js server process running on http://localhost:3000.
- The multiplayer-netcode project folder on your Desktop (contains server.js, public/index.html, and node_modules).
Keep everything running
No action needed. Choose this if you're still actively experimenting with the netcode or want to keep tweaking the lag simulator.
- Your server stays running at http://localhost:3000 until you close the terminal or stop it manually.
- Your project files remain in the multiplayer-netcode folder on your Desktop.
Pause - I'll come back to this later
Stop the server process to free up the port, but keep all your files so you can restart later.
- Switch to the terminal where your server is running.
- Press Ctrl+C to stop the server.
- Your project files stay on your Desktop. To restart later, open a terminal in the multiplayer-netcode folder and run npm start.
Delete - I don't want to use this again
Remove all project files and stop the server completely.
- Switch to the terminal where your server is running.
- Press Ctrl+C to stop the server.
- Navigate to your Desktop and delete the project folder. On macOS/Linux, run:
cd ~/Desktop
rm -rf multiplayer-netcode
On Windows, run:
cd ~\Desktop
rmdir /s /q multiplayer-netcode
- Confirm the folder is gone by running:
ls
You should no longer see multiplayer-netcode in the output.
That's a Wrap!
That's a Wrap!
Nice work! You just built the same networking architecture that powers professional multiplayer games like Quake, Overwatch, and Rocket League.
You've learned how to:
- Build a Node.js WebSocket server with a fixed-timestep authoritative game loop running at 20 ticks/sec as the single source of truth for all game state.
- Implement client-side prediction so local movement feels instant despite network round-trips between the browser and server.
- Implement server reconciliation using sequence numbers and input replay to correct mispredictions without rubber-banding.
- Secret Mission: Added an artificial lag simulator proving your netcode handles 200ms+ latency gracefully.
Ready to quiz yourself?