How to Add Multiplayer to a Browser Game (WebSockets)

high rise buildings during night time

Single-player browser games top out at “fun but lonely.” Add a second player and the same game becomes social. This guide walks through how to add multiplayer to a browser game using WebSockets — the standard low-latency two-way protocol for browser-to-server communication. We cover the vanilla Node.js path with the ws library, and then the managed services that abstract most of the server work away.

Key takeaways

  • WebSockets give you a persistent two-way channel between browser and server, fast enough for real-time games.
  • The minimum viable setup is a Node.js server using the ws library plus a browser-side WebSocket client.
  • The server holds authoritative game state and broadcasts updates; clients render and send input.
  • Managed services like PartyKit, Pusher, and Socket.io reduce the boilerplate at the cost of dependency.
  • Lag compensation, reconnection, and state authority are the hard parts; the WebSocket layer itself is easy.

Why WebSockets

HTTP is request-response. The client asks, the server answers, the connection closes. That model is fine for fetching a webpage but disastrous for a real-time game, where you need updates pushed from server to client without polling. WebSockets solve this by upgrading an initial HTTP request into a persistent bidirectional connection. Once connected, both sides can send messages at any time.

Latency is typically a few milliseconds plus network round-trip. Messages are framed at the protocol level — you send and receive whole strings or binary blobs, not raw bytes. The MDN WebSocket documentation is the reference.

The client side

The browser-side WebSocket API is built in. No library required.

const ws = new WebSocket('wss://your-server.com/game');

ws.addEventListener('open', () => {
  console.log('connected');
  ws.send(JSON.stringify({ type: 'join', name: 'player1' }));
});

ws.addEventListener('message', event => {
  const data = JSON.parse(event.data);
  handleServerMessage(data);
});

ws.addEventListener('close', () => {
  console.log('disconnected');
  // reconnect logic here
});

ws.addEventListener('error', err => {
  console.error('socket error', err);
});

Use wss:// for production (TLS-encrypted) and ws:// only for local development. Sending and receiving JSON is the standard pattern — easy to debug, easy to parse, slightly larger than binary.

The server side with ws

The Node.js library ws is the leanest WebSocket server implementation. Install it with npm install ws.

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });
const players = new Map();

wss.on('connection', (socket) => {
  const id = crypto.randomUUID();
  players.set(id, { socket, x: 0, y: 0, name: 'anon' });

  socket.on('message', (raw) => {
    const msg = JSON.parse(raw.toString());
    handleClientMessage(id, msg);
  });

  socket.on('close', () => {
    players.delete(id);
    broadcast({ type: 'leave', id });
  });
});

function broadcast(msg) {
  const str = JSON.stringify(msg);
  for (const { socket } of players.values()) {
    if (socket.readyState === 1) socket.send(str);
  }
}

That is the entire server skeleton. The players map holds connected clients keyed by ID. broadcast sends a message to everyone. readyState === 1 means the socket is currently open and able to receive.

Message protocol

Define a small set of message types for both directions. Keep them flat and predictable.

Client to server:

  • join: Player connecting; payload is name.
  • input: Player input; payload is direction or action.
  • chat: Optional text message.

Server to client:

  • state: Full or partial game state update; payload is positions, scores, etc.
  • join: Another player connected; payload is their ID and name.
  • leave: Another player disconnected.
  • chat: Forwarded chat message.

Stick to JSON for the first version. Once the protocol is stable, you can switch to a binary format like MessagePack or Protocol Buffers if bandwidth becomes a concern.

Authoritative server design

For any competitive multiplayer game, the server must be authoritative. The client sends inputs (“I pressed up”); the server decides what happens (“your position is now Y”). Letting the client send its own position is an invitation to cheaters. Even for casual games, server authority simplifies state synchronization.

function handleClientMessage(id, msg) {
  const player = players.get(id);
  if (!player) return;

  switch (msg.type) {
    case 'input':
      if (msg.direction === 'up')    player.y -= 5;
      if (msg.direction === 'down')  player.y += 5;
      if (msg.direction === 'left')  player.x -= 5;
      if (msg.direction === 'right') player.x += 5;
      break;
  }
}

// Game loop on server, runs ~30 times per second
setInterval(() => {
  const state = {
    type: 'state',
    players: [...players.entries()].map(([id, p]) => ({
      id, x: p.x, y: p.y, name: p.name
    }))
  };
  broadcast(state);
}, 33);

The server runs its own game loop at a fixed rate (30Hz here, so every 33ms) and broadcasts state to all clients on each tick. The clients render whatever the server sends them.

Client-side prediction

At 30Hz update rate, the client renders the server state with up to 33ms of staleness. For fast-paced games, that lag is noticeable. The fix is client-side prediction: the client applies its own input immediately, then reconciles with the server when an update arrives.

let predictedX = 0, predictedY = 0;

window.addEventListener('keydown', e => {
  if (e.key === 'ArrowUp') {
    predictedY -= 5;
    ws.send(JSON.stringify({ type: 'input', direction: 'up' }));
  }
});

ws.addEventListener('message', event => {
  const data = JSON.parse(event.data);
  if (data.type === 'state') {
    const me = data.players.find(p => p.id === myId);
    if (me) {
      predictedX = me.x;
      predictedY = me.y;
    }
  }
});

function draw() {
  ctx.fillRect(predictedX, predictedY, 20, 20);
}

This sketch is simplified — full prediction-and-reconciliation involves replaying recent inputs against the server snapshot. But the principle is in the snippet: render the predicted position immediately, then snap to the server’s position when an update arrives.

Reconnection logic

WebSockets disconnect. Mobile networks, Wi-Fi drops, server restarts — players lose their connection more often than you would like. Build reconnection into the client from the start.

let ws;
function connect() {
  ws = new WebSocket('wss://your-server.com/game');

  ws.addEventListener('open', () => {
    ws.send(JSON.stringify({ type: 'rejoin', id: localStorage.playerId }));
  });

  ws.addEventListener('close', () => {
    setTimeout(connect, 1000);
  });
}
connect();

Store a persistent player ID in localStorage so the server can restore the player’s session on reconnect. Use exponential backoff in production to avoid hammering a downed server.

Hosting the server

For a WebSocket server you need a long-running Node.js process. Common options:

  • Fly.io: Cheap, easy WebSocket support, geographic distribution.
  • Railway: Simple deployments, automatic builds from GitHub.
  • Render: Managed Node hosting with WebSocket support.
  • Bare VPS (Hetzner, DigitalOcean): Cheapest at scale; more setup required.

Serverless platforms (Vercel, Netlify Functions) generally do not support WebSockets natively because functions are not long-lived. Some have specific WebSocket products, but those are often pay-per-message rather than a flat hosting cost.

Managed alternatives

If you do not want to run your own server, several managed services handle the WebSocket layer for you.

PartyKit

PartyKit runs your server code on Cloudflare’s edge network. You write a single class that handles connections and messages; PartyKit deploys it globally. The tradeoff is vendor lock-in for the convenience of zero infrastructure work.

Socket.io

Socket.io is a server library plus client library that adds reconnection, fallback transports (long polling if WebSockets fail), and room-based message routing on top of vanilla WebSockets. The runtime overhead is higher, but the developer experience is friendlier. Run a Socket.io server on any Node.js host.

Pusher

Pusher is a fully managed WebSocket service. You send events to Pusher, Pusher forwards them to subscribed clients. No server code needed for simple pub/sub patterns. Pricing scales with messages, which makes it expensive for high-frequency game traffic.

Liveblocks

Liveblocks targets collaborative apps (cursors, presence, real-time documents) more than action games, but the underlying primitives — rooms, presence, broadcast — fit casual multiplayer games too. Particularly strong for turn-based or low-traffic real-time use cases.

Latency considerations

WebSocket round-trip is usually 20–80ms within a continent and 100–250ms across continents. For turn-based games, this is invisible. For real-time games, it shapes the design.

  • 0–50ms: Feels instantaneous. Suitable for any game type.
  • 50–150ms: Noticeable but playable for most genres with prediction.
  • 150–300ms: Tolerable for slower games (puzzles, RTS). Painful for fighting games or shooters.
  • 300ms+: Multiplayer is broken for action games. Consider asynchronous modes.

If your game targets a global audience, geographic server distribution matters. PartyKit, Cloudflare Durable Objects, and similar edge services place your code in many regions automatically.

Security

Server-authoritative design solves most cheating concerns, but a few more things matter.

  • Rate-limit inputs. A malicious client can flood the server with messages. Cap inputs per second per player.
  • Validate every payload. Never trust client-provided data. Parse, check types, reject malformed messages.
  • Use TLS (wss://). Required for any real deployment; many hosts default to this.
  • Authenticate connections. A token in the WebSocket query string is the simplest pattern.

Testing multiplayer

Test with multiple browser windows on the same machine. Open two or three tabs against your local server, each with a different player name, and verify that actions in one appear in the others. Network latency is artificially zero in this setup, so always test against a remote staging server before declaring the game shippable.

Browser dev tools include network throttling. Set it to “Slow 3G” or “Fast 3G” to simulate real-world latency without leaving your machine.

Putting it together

The minimum complete multiplayer game on this stack is roughly:

  1. A Node.js server using ws, running a 30Hz game loop and broadcasting state.
  2. An HTML page with a Canvas and a WebSocket client that sends inputs and renders state.
  3. A simple JSON message protocol with a few types.
  4. Reconnection logic on the client.

That stack runs comfortably on a $5 VPS for several hundred concurrent players. Beyond that, you add horizontal scaling (multiple server instances with sticky sessions or a shared Redis state store).

What’s next

Once the basic multiplayer is working, the design problems get interesting. How do you handle player counts above your server’s limit? How do you matchmake? How do you handle drift in clients with different frame rates? These problems do not have one-size answers; they depend on your game’s design.

For inspiration on what shipped multiplayer browser games look like, our roundup of the best real-time multiplayer browser games covers a wide range of approaches. And for a single-player browser game to study as a baseline, the Chrome Dino game shows what a tight loop can do without any networking at all.

Frequently asked questions

Do I need a backend for multiplayer?

For any real-time multiplayer game, yes — you need a server to hold authoritative state and relay messages between clients. Pure peer-to-peer WebRTC is possible but complex; for most games, a small WebSocket server is easier and more reliable.

What’s the difference between WebSockets and WebRTC?

WebSockets are client-to-server, TCP-based, easy to deploy. WebRTC is peer-to-peer, UDP-based, lower latency but harder to set up. Most browser games use WebSockets; WebRTC is more common in voice and video chat, and in very latency-sensitive games.

Can I run a multiplayer game without writing a server?

Yes — managed services like Pusher, PartyKit, and Liveblocks handle the server side. You write only the client code and the message handlers. The tradeoff is recurring cost and vendor dependency.

How many players can a single WebSocket server handle?

A modern Node.js server with ws can comfortably handle 1,000–10,000 concurrent connections, depending on message frequency and payload size. Beyond that, you scale horizontally with multiple instances and a shared state store.

Should I use Socket.io or vanilla WebSockets?

Vanilla WebSockets are simpler, more standard, and have less overhead. Socket.io adds useful features (reconnection, rooms, transport fallbacks) at the cost of weight. For most modern browser games, vanilla WebSockets are fine; Socket.io is worth it if you specifically want its room and namespace abstractions.

The takeaway

Adding multiplayer to a browser game is a server problem more than a browser problem. WebSockets give you the channel; the design work is server authority, state synchronization, and lag compensation. Start with the simplest possible stack — Node.js, ws, JSON messages — and reach for managed services or binary protocols only when your scale demands it. For a single-player browser game as a contrast study, the T-Rex Runner shows how much fun is possible with zero network code at all.

🔌 Connect any AI assistant to dinogame.gg — we run an MCP server: https://dinogame.gg/mcp