Adding multiplayer to a browser game means solving one of the hardest problems in game development: keeping multiple clients synchronized over an unreliable network with variable latency. The good news is that browser games have well-established patterns that work, and WebSockets make the transport layer straightforward.
The golden rule of multiplayer game networking: the server is always right. Clients predict, display, and hope — but the server's state is the only truth. Every multiplayer architecture is a variation on this theme.
For any game where fairness matters (competitive, economy-based, or persistent worlds), use an authoritative server. The server runs the game simulation, processes inputs, and broadcasts the resulting state. Clients send inputs and render the state they receive.
Peer-to-peer seems simpler — no server to host or pay for. But it has fatal flaws for most games: any client can cheat by modifying their local state, latency between peers varies wildly (especially on mobile), and NAT traversal is unreliable in browsers. Use P2P only for cooperative games where cheating does not matter.
Design your message protocol to minimize bandwidth. Every byte matters when you are sending 20-60 messages per second to each connected client.
// Message types as single-byte IDs, not strings
const MSG = {
PLAYER_INPUT: 1, // client -> server
GAME_STATE: 2, // server -> client
PLAYER_JOIN: 3,
PLAYER_LEAVE: 4,
CHAT: 5,
};
// Binary encoding for position updates
function encodePosition(x, y) {
const buf = new ArrayBuffer(8);
const view = new DataView(buf);
view.setFloat32(0, x);
view.setFloat32(4, y);
return buf;
}
Use binary messages (ArrayBuffer) instead of JSON for high-frequency updates like position. JSON serialization and parsing adds 1-3ms per message — insignificant for chat, fatal for 60 FPS state sync.
Without prediction, every player action has a round-trip delay before the player sees the result. At 100ms latency, the game feels like moving through molasses. Client-side prediction fixes this by immediately applying the player's input locally while simultaneously sending it to the server.
class PredictedPlayer {
constructor() {
this.pendingInputs = [];
this.inputSeq = 0;
}
applyInput(input) {
input.seq = this.inputSeq++;
this.pendingInputs.push(input);
// Apply locally (prediction)
this.x += input.dx;
this.y += input.dy;
// Send to server
socket.send(encode(MSG.PLAYER_INPUT, input));
}
reconcile(serverState) {
// Remove acknowledged inputs
this.pendingInputs = this.pendingInputs.filter(
i => i.seq > serverState.lastProcessedInput
);
// Reset to server position
this.x = serverState.x;
this.y = serverState.y;
// Re-apply unacknowledged inputs
for (const input of this.pendingInputs) {
this.x += input.dx;
this.y += input.dy;
}
}
}
The key insight: when the server confirms your position, discard all inputs the server has already processed, snap to the server's position, then re-apply any inputs the server has not seen yet. This corrects drift without visible snapping.
Other players' positions arrive in discrete updates (typically 10-20 per second, not 60). Without interpolation, remote players appear to teleport between positions. Linear interpolation smooths the movement:
function interpolate(prev, next, t) {
return {
x: prev.x + (next.x - prev.x) * t,
y: prev.y + (next.y - prev.y) * t,
};
}
// Render other players between the two most recent
// server states, with a fixed delay (e.g., 100ms)
function renderRemotePlayer(player) {
const renderTime = Date.now() - 100; // 100ms behind
const [prev, next] = findSurroundingStates(
player.stateBuffer, renderTime
);
const t = (renderTime - prev.time) / (next.time - prev.time);
return interpolate(prev, next, t);
}
This introduces 100ms of visual delay for remote players, but the movement looks smooth. Players will not notice the delay in most game types.
The server does not need to run at 60 FPS. A tick rate of 20-30 Hz is sufficient for most games and dramatically reduces server CPU and bandwidth:
Players disconnect constantly — bad WiFi, tab switches, phone calls. Your server needs to handle this gracefully: