How to Build a Multiplayer Game with WebSockets
Build a real-time multiplayer game using WebSockets — from game state synchronization to player presence and input broadcasting, with a working example.
Multiplayer games live and die by latency. A 200ms delay between pressing a key and seeing the result on screen is the difference between a playable game and a frustrating one. HTTP request-response cycles, with their connection setup overhead and one-directional flow, are simply the wrong tool. WebSockets give you a persistent, bidirectional channel between every client and your server — and that changes everything.
This post walks through building a simple real-time multiplayer game: a shared canvas where each player appears as a colored dot, and everyone sees everyone else moving in real time. It is a minimal example, but it covers the core patterns that apply to almost any multiplayer game: presence tracking, position broadcasting, client-side interpolation, and knowing when to trust the client versus enforcing state on the server.
Why WebSockets for Games
Traditional HTTP is pull-based. The client asks, the server answers, the connection closes. To simulate real-time updates you end up polling — hitting the server every few hundred milliseconds — which wastes bandwidth and still feels sluggish.
WebSockets flip this model. After a single HTTP handshake, the connection stays open and either side can send data at any time. For a game this means:
- Low latency: position updates arrive in single-digit milliseconds rather than waiting for a poll interval.
- Bidirectional: the server can push state changes to all clients simultaneously without any client asking first.
- Efficient: one persistent connection per client instead of repeated TCP handshakes and HTTP headers.
For a cursor game, polling at even 60 requests per second would generate enormous overhead. With WebSockets you push a small JSON payload every frame and every connected player receives it almost instantly.
Game Architecture: Rooms and Presence Channels
The natural structure for a multiplayer game is a room. Players join a named room and interact only with others in the same room. Apinator's presence channels map directly onto this concept.
A presence channel (presence-game-{roomId}) does three things automatically: it tracks who is currently subscribed, it broadcasts join and leave events, and it makes the full member list available to any client in the channel. You get a real-time player list with no extra server logic.
When a player opens the game, your server authenticates them for the room's presence channel and returns a signed token that includes their display name and color. Apinator uses this data to build the member list that every client sees.
// server.ts (Node.js / Express)
import { ApinatorServer } from '@apinator/server';
const apinator = new ApinatorServer({
appId: process.env.APINATOR_APP_ID!,
apiKey: process.env.APINATOR_API_KEY!,
apiSecret: process.env.APINATOR_API_SECRET!,
});
app.post('/auth/channel', (req, res) => {
const { socket_id, channel_name } = req.body;
const userId = req.session.userId;
const user = getUserFromSession(req);
// Only presence channels need user data
const presenceData = channel_name.startsWith('presence-')
? { id: userId, info: { name: user.name, color: user.color } }
: undefined;
const auth = apinator.authorizeChannel(socket_id, channel_name, presenceData);
res.json(auth);
});
Joining a Room on the Client
On the client side, you connect to Apinator, subscribe to the presence channel for the current room, and start listening for members joining and leaving.
import { RealtimeClient } from '@apinator/sdk';
const client = new RealtimeClient({
apiKey: 'YOUR_PUBLIC_API_KEY',
authEndpoint: '/auth/channel',
});
const roomId = 'room-abc123';
const channel = client.subscribe(`presence-game-${roomId}`);
// The full member list is available once subscribed
channel.bind('realtime:subscription_succeeded', (data: { members: Record<string, Member> }) => {
for (const [id, member] of Object.entries(data.members)) {
addPlayer(id, member.info.name, member.info.color);
}
});
channel.bind('realtime:member_added', (member: { id: string; info: MemberInfo }) => {
addPlayer(member.id, member.info.name, member.info.color);
});
channel.bind('realtime:member_removed', (member: { id: string }) => {
removePlayer(member.id);
});
Player join and leave are handled entirely through presence events. You never need to write a polling loop to check who is still connected.
Broadcasting Position Without a Server Roundtrip
Position updates need to be fast. If every mouse move had to travel to your server, get validated, and then fan out to other clients, you would add a full round-trip of latency on every frame. For cursor position this is unnecessary — there is nothing to validate server-side. This is exactly what client events are for.
Client events (prefixed with client-) are sent directly through Apinator and broadcast to other channel members without ever hitting your application server. The latency is as low as the network path between clients allows.
// Broadcasting the local player's position
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width; // normalize 0–1
const y = (e.clientY - rect.top) / rect.height;
channel.trigger('client-move', { x, y });
});
// Receiving other players' positions
channel.bind('client-move', (data: { x: number; y: number }, metadata: { sender_id: string }) => {
updateRemotePlayer(metadata.sender_id, data.x, data.y);
});
Positions are normalized to 0–1 so the game works correctly regardless of each player's screen size.
Client-Side Interpolation with requestAnimationFrame
Network updates arrive at irregular intervals, not locked to your frame rate. If you render immediately at each received position, movement will appear jerky. Instead, store the last known position and target position for each remote player, then interpolate toward the target on every animation frame.
interface RemotePlayer {
name: string;
color: string;
x: number;
y: number;
targetX: number;
targetY: number;
}
const players = new Map<string, RemotePlayer>();
function updateRemotePlayer(id: string, targetX: number, targetY: number) {
const player = players.get(id);
if (player) {
player.targetX = targetX;
player.targetY = targetY;
}
}
function gameLoop() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const player of players.values()) {
// Lerp toward the target position — adjust 0.2 to control smoothness
player.x += (player.targetX - player.x) * 0.2;
player.y += (player.targetY - player.y) * 0.2;
const px = player.x * canvas.width;
const py = player.y * canvas.height;
ctx.beginPath();
ctx.arc(px, py, 12, 0, Math.PI * 2);
ctx.fillStyle = player.color;
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(player.name, px, py - 18);
}
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
The lerp factor of 0.2 means the rendered position moves 20% of the remaining distance each frame. Higher values feel snappier; lower values feel smoother. This simple technique makes network jitter nearly invisible.
Server-Authoritative State: When to Validate Server-Side
Client events are great for low-stakes, high-frequency updates like cursor position. But not everything belongs on the client. Any game state that affects fairness — scores, inventory, collision results, win conditions — should flow through your server.
The rule of thumb is: if a malicious client could gain an advantage by sending a fake value, validate it server-side.
For our cursor game, scores (say, for collecting a token that appears on the canvas) should be handled by a regular server event. The client sends a claim (POST /game/collect), the server checks whether the player was actually close enough to the token based on the last received position, and then publishes the updated score to all players via the Apinator server SDK:
// server.ts
app.post('/game/collect', async (req, res) => {
const { roomId, tokenId } = req.body;
const playerId = req.session.userId;
const valid = validateCollection(roomId, playerId, tokenId);
if (!valid) return res.status(400).json({ error: 'Invalid collection' });
const newScore = incrementScore(roomId, playerId);
// Push the authoritative score update to all players in the room
await apinator.trigger(`presence-game-${roomId}`, 'score-update', {
playerId,
score: newScore,
});
res.json({ ok: true });
});
Position: trust the client, interpolate smoothly. Scores, collisions, game outcomes: validate on the server, push the result.
Handling Disconnections Gracefully
Presence channels handle the hard part of disconnect detection. When a player's connection drops — whether they closed the tab, lost network, or hit a timeout — Apinator fires realtime:member_removed on every other client in the channel. Your removePlayer call cleans up the canvas. No heartbeat polling required.
For reconnection, the Apinator client SDK handles exponential backoff automatically. When a player reconnects, realtime:subscription_succeeded fires again with the current member list and your game rehydrates their view from scratch.
Connection Infrastructure
Running WebSocket servers at scale involves managing connection state across server instances, coordinating fan-out across nodes, and handling reconnects gracefully. Apinator handles all of this — the pub/sub fanout, presence state, TLS termination, and global distribution. You write game logic; the connection infrastructure is managed for you.
For more on how Apinator routes messages across regions and keeps presence state consistent, see the documentation.
What to Build Next
The cursor game demonstrates the core patterns. From here you can extend them to any genre:
- Turn-based games (chess, card games): use private channels and server-published state after each move.
- Action games: use client events for position and inputs; use server events for health, kills, and respawns.
- Collaborative tools: the same presence + client-event pattern works for shared whiteboards, document cursors, and live coding environments.
The WebSocket connection model does not change — only the data you send through it.