EDUCATIONOverviewStudentsTeachersSchoolsCurriculumProject Library
GUIDED PROJECT·Advanced·~4 weeks·15 min read

Build a Multiplayer Game

Build a real-time multiplayer game — players connect, move, and see each other live over WebSockets. You'll implement the game loop, make the server the authoritative owner of the shared world, render it on a canvas, and solve the core problem of keeping many browsers agreeing on one state.

WHAT YOU'LL WALK AWAY WITH
  • A playable real-time game where multiple people share one live world
  • A real grasp of WebSockets — persistent, two-way connections, not request/response
  • An authoritative server game loop, and why the server (not the client) must own the truth
  • The core real-time challenge handled: keeping many clients in sync as players join and leave
TIER
Advanced
TIME
~4 weeks
SKILLS
WebSockets, real-time state, game loops
STACK
TypeScript, WebSockets, Canvas
PORTFOLIO
Very high — real-time systems impress
REQUIRES
Two intermediate projects

"I built a real-time multiplayer game" is a portfolio line that stops people, because real-time systems are genuinely hard and everyone knows it. Two browsers, one shared world that has to stay consistent as players move at the same instant — that's a distributed-systems problem wearing a fun costume. A small version teaches the whole idea, and it transfers straight to chat apps, collaborative editors, live dashboards, and anything else where many clients share one state.

This is an advanced build. It assumes you've finished two intermediate projects from the project library — something like the AI Chatbot and the Expense Tracker, so you're comfortable with TypeScript, a server you wrote yourself, and async data flow. If a client/server boundary already feels natural, you're ready. If not, build those first — this project stacks on them.

Info

Estimated time: ~4 weeks, part-time. Not because there's a lot of code — the game is small on purpose — but because the mental model is new. You'll spend real time sitting with why the server owns the state and the client only renders. Rush that and you'll build something that works with one player and falls apart with two. Go slow and you'll understand a pattern the rest of the industry pays for.

What you're building

A tiny arena game. Open the page and you're a colored dot in a shared 2D world. Press the arrow keys and your dot moves. Anyone else on the same URL sees you move, and you see them — smoothly, in real time. That's it: dots in a box. No scoring, no combat, no accounts. The simplicity is deliberate, because the interesting part isn't the game, it's the sync.

Stack: TypeScript end to end (one language, shared types across client and server), WebSockets for the persistent two-way connection request/response can't give you, and the HTML Canvas for drawing the world every frame. WHY these three: a game needs to push updates to clients without being asked (WebSockets, not fetch), it needs a shared world that outlives any single request (a long-running server process holding state in memory), and it needs to redraw fast (Canvas, not DOM nodes per player).

Tip

The whole architecture lives in one sentence: clients send intent, the server owns state, the server broadcasts snapshots, clients render. Write that on a sticky note. Every milestone below is just one more piece of that loop, and every bug you hit will trace back to breaking it.

Milestone 0 — Design: the authoritative server and the game loop

Before any code, get the model straight, because it's the whole project. There are two ways to build a multiplayer game, and only one of them survives contact with real players:

  • Trust the clients. Each browser tracks its own position and tells the server "I'm at (120, 340)." The server relays that to everyone. Simple — and broken. A player can send any position, so they teleport, walk through walls, and cheat. Two clients can also disagree about where someone is, with no one to settle it.
  • Authoritative server. Clients never report position. They report intent — "the right key is down." The server holds the one true world, advances it on a fixed tick (say 20 times a second), applies everyone's intent, and broadcasts the resulting snapshot to all clients. Every browser draws the same snapshot, so they can't disagree, and no client can move itself anywhere the server didn't put it.

Draw the loop on paper:

client: key down  ─────▶  server: record "player X wants to go right"
                          server tick (every 50ms): apply intent, build a snapshot
                          server ─── snapshot ──▶ all clients
client: draw the snapshot on the canvas

Why this is Milestone 0 and not a detail: the authoritative-server choice decides your data flow, your message types, and your security model all at once. Get it clear now and the next six milestones are filling in a diagram you already understand. Skip it and you'll write the trust-the-client version by accident, because it's the one that feels obvious.

Milestone 1 — A WebSocket connection (client to server)

Start with the pipe. A WebSocket is a connection that stays open — either side can send a message at any time, unlike fetch, where the client asks and the server answers once. Scaffold a Node server with the ws library:

mkdir arena && cd arena
npm init -y
npm install ws
npm install -D typescript tsx @types/ws

A minimal server that accepts connections and echoes what it hears:

// server.ts
import { WebSocketServer } from "ws";

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

wss.on("connection", (socket) => {
  console.log("a client connected");

  socket.on("message", (raw) => {
    const msg = JSON.parse(raw.toString());
    console.log("got", msg);
  });

  socket.on("close", () => console.log("a client left"));
});

And a client that connects and says hello:

// client: main.ts (bundled into the page)
const socket = new WebSocket("ws://localhost:8080");

socket.addEventListener("open", () => {
  socket.send(JSON.stringify({ type: "hello" }));
});

socket.addEventListener("message", (event) => {
  const msg = JSON.parse(event.data);
  console.log("server said", msg);
});

What to understand before moving on:

  • The connection is persistent and bidirectional. After open, the server can push to the client whenever it wants — that push is the thing plain HTTP can't do, and it's the entire reason a game uses WebSockets.
  • You send strings, so you need a message protocol. Everything crossing the wire is JSON with a type field (hello, input, snapshot). Design that vocabulary deliberately; it's the contract between the two halves of your app, exactly like an API is — and a WebSocket carries text, not objects, so it's JSON.stringify out and JSON.parse in, every time.

Milestone 2 — The server game loop and the shared world state

Now the heart of it. The server holds the world in memory and advances it on a fixed timer — the tick. This is what makes the server authoritative: it, and only it, decides where everyone is.

// world state lives on the server, in memory
interface Player {
  id: string;
  x: number;
  y: number;
  input: { up: boolean; down: boolean; left: boolean; right: boolean };
}

const players = new Map<string, Player>();

const TICK_MS = 50; // 20 ticks per second
const SPEED = 4;    // pixels per tick

setInterval(() => {
  // 1. advance the world: apply each player's current intent
  for (const p of players.values()) {
    if (p.input.left)  p.x -= SPEED;
    if (p.input.right) p.x += SPEED;
    if (p.input.up)    p.y -= SPEED;
    if (p.input.down)  p.y += SPEED;
    p.x = Math.max(0, Math.min(800, p.x)); // clamp to the arena
    p.y = Math.max(0, Math.min(600, p.y));
  }

  // 2. broadcast a snapshot of the whole world to everyone
  const snapshot = {
    type: "snapshot",
    players: [...players.values()].map(({ id, x, y }) => ({ id, x, y })),
  };
  const payload = JSON.stringify(snapshot);
  for (const client of wss.clients) client.send(payload);
}, TICK_MS);

What to understand, because this is the milestone people copy without grasping:

  • The tick is a fixed-rate heartbeat. Movement isn't "when a key is pressed" — it's "every tick, move everyone whose key is currently held." Decoupling simulation from input keeps every player moving at the same speed regardless of how fast their keyboard fires events.
  • The snapshot is the whole truth, sent on a schedule. You don't send diffs yet — every tick sends the full list of positions. Wasteful, and completely fine for a small game; correctness first, optimization much later.
  • input is intent, not position. The server stores which keys a player is holding and computes position itself — the client never gets to write x or y. That single decision is the authoritative model made concrete.
Heads up

This is the fork in the road, so be deliberate: never let a client tell you where it is. If your input message contained x and y and you copied them into the world, you'd have rebuilt the trust-the-client design — and with it, teleporting cheaters and clients that disagree. The server computes position from intent, always. Equally important: the world lives on the server, not in the browser's memory. When a player refreshes, their tab starts blank and must re-sync from the next snapshot — the truth was never in their browser to lose.

Milestone 3 — Rendering the world on a canvas

The client's whole job now is: receive a snapshot, draw it. Nothing more. Add a canvas and paint every player each time a snapshot arrives:

const canvas = document.getElementById("game") as HTMLCanvasElement;
const ctx = canvas.getContext("2d")!;

let latest: { players: { id: string; x: number; y: number }[] } = { players: [] };

socket.addEventListener("message", (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === "snapshot") latest = msg;
});

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for (const p of latest.players) {
    ctx.fillStyle = "#4f46e5";
    ctx.beginPath();
    ctx.arc(p.x, p.y, 12, 0, Math.PI * 2);
    ctx.fill();
  }
  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

What to understand:

  • Drawing and networking run on separate clocks. The server ticks at 20 Hz; the browser redraws at 60 Hz via requestAnimationFrame, painting whatever the latest snapshot said. They're intentionally decoupled — the render loop never waits on the network.
  • The client holds no authoritative state. latest is a cache of what the server last told you, not a world the client owns. Cut off the snapshots and the screen freezes where it is — the correct behavior, because the client genuinely doesn't know what happens next.
  • clearRect every frame. Canvas is immediate-mode: you repaint the whole scene from scratch each frame. Forget to clear and every dot leaves a trail.

Milestone 4 — Input and movement (client sends intent, server decides)

Now close the loop. The client listens for arrow keys and sends intent — never position. Send a message when a key goes down or up:

const keys: Record<string, "up" | "down" | "left" | "right"> = {
  ArrowUp: "up",
  ArrowDown: "down",
  ArrowLeft: "left",
  ArrowRight: "right",
};

function sendInput(dir: string, pressed: boolean) {
  socket.send(JSON.stringify({ type: "input", dir, pressed }));
}

window.addEventListener("keydown", (e) => keys[e.key] && sendInput(keys[e.key], true));
window.addEventListener("keyup", (e) => keys[e.key] && sendInput(keys[e.key], false));

On the server, record that intent against the right player — and that's all you do with it. The tick loop from Milestone 2 turns it into movement:

socket.on("message", (raw) => {
  const msg = JSON.parse(raw.toString());
  const player = players.get(socket.id); // see Milestone 5 for socket.id
  if (msg.type === "input" && player) {
    player.input[msg.dir as "up" | "down" | "left" | "right"] = msg.pressed;
  }
});

What to understand:

  • Press and release are two events. You track keys as held-down state (set true on keydown, false on keyup) so the tick loop keeps moving a player while a key stays down. That's why intent is a set of booleans, not a one-shot "move" command.
  • The round trip is real and visible. Your key press travels to the server, waits for the next tick, and comes back in a snapshot before you move on screen. That lag is the honest cost of an authoritative server — and smoothing it (client-side prediction) is a famous, optional rabbit hole for after the basic version works.
  • The server validates by construction. Because it only ever reads dir and pressed, a malicious client can't inject a position — the worst it can do is claim to hold a key, which just moves its own dot at the normal speed.

Milestone 5 — Players joining and leaving, and staying in sync

A game with a fixed world isn't multiplayer. Wire up connect and disconnect so the roster grows and shrinks. On connection, mint a player and add it to the world; on close, remove it:

import { randomUUID } from "crypto";

wss.on("connection", (socket) => {
  const id = randomUUID();
  (socket as any).id = id;

  players.set(id, {
    id,
    x: Math.random() * 800,
    y: Math.random() * 600,
    input: { up: false, down: false, left: false, right: false },
  });

  socket.send(JSON.stringify({ type: "welcome", id })); // tell the client which dot is theirs

  socket.on("close", () => {
    players.delete(id); // remove them from the world
  });
});

What to understand:

  • Join and leave are just edits to the shared Map. Add a player and they appear in the next snapshot everyone receives; delete them on close and they vanish the same way. No special "a player joined" broadcast is needed — the snapshot already carries the full roster, so the tick loop does the announcing for free.
  • The client needs to know which dot is it. The welcome message hands the client its own id so it can draw itself in a different color — but notice it still doesn't own its position, it just knows which of the server's dots to recognize.
  • Disconnect is normal, not exceptional. Tabs close, laptops sleep, WiFi drops. Handling close by cleanly removing the player is core gameplay logic, not error handling — a real-time system is defined as much by how it handles leaving as joining.
Heads up

Re-confirm the refresh behavior here, because it's the clearest test of whether you built the model right: when a player hits reload, their browser throws away everything and reconnects as a brand-new player getting a fresh id and a fresh snapshot. If a refresh ever preserved their old position from browser memory, that state was living in the wrong place. The server is the only thing that remembers — that's the entire point.

Milestone 6 — Ship it

Here's the deployment twist that makes this project different from the beginner builds. The Weather App is static files you can drop on GitHub Pages. This game cannot. It needs a persistent server process — one that stays running, holds the world in memory, and ticks 20 times a second. GitHub Pages and other static hosts run no server; there's nowhere for your world to live.

So deploy the server to a host that runs a long-lived Node process — Render, Railway, or Fly.io all have a free-ish tier and support WebSockets:

  1. Push the repo to GitHub.
  2. Create a service on your chosen host pointing at the repo; set the start command to run your server.
  3. Use the secure WebSocket URL (wss://your-app.onrender.com) in the client instead of ws://localhost:8080 — production is TLS, so it's wss, not ws.
  4. Serve the small static client (the HTML + bundled JS) from the same host or any static host, pointed at that wss URL.

Open the live link in two browser windows side by side and drive both dots. That's the moment it becomes real: not "runs on my laptop," but a shared world two strangers can stand in at once.

Stuck? Study a production build

When the sync model won't click, read how the grown-up versions solve the same problem — after you've built your own, so you're comparing against something you understand instead of copying an answer you don't:

  • socketio/socket.io — the most-used real-time library for JavaScript. Even if you stayed on raw WebSockets, its docs frame every problem you just hit: rooms, broadcasts, reconnection.
  • colyseus/colyseus — a multiplayer game server built entirely around authoritative state and rooms. It's the exact pattern from this walkthrough, at production scale — you'll recognize your tick loop and your snapshots in it.

Build first, read second. Reading Colyseus before you've wrestled with an authoritative loop yourself teaches you what the framework is; reading it after teaches you why it's shaped that way — and which of your hand-rolled pieces it's abstracting.

Build it with Cartara

Here's the honest part. Real-time code is some of the easiest to get running and hardest to actually understand — and it's precisely the kind an AI assistant will generate for you in one shot. You could paste a tick loop and a snapshot broadcaster, watch two dots move, and never grasp why the server owns position, why refresh re-syncs, or why intent-not-position is the whole ballgame. It would run. And the moment someone asked "how do you stop a client from cheating?" — or you tried to add collisions — the gap between shipping it and understanding it would swallow you.

That gap is exactly what Cartara exists to close. As you build:

  • Point Cartara at your repo. It watches the code that lands — including anything an AI assistant generated — and turns each meaningful change into a checkpoint for understanding, not just a commit.
  • Explain your changes back. When you add the authoritative tick loop or the join/leave handling, Cartara asks you the why and the trade-offs — the same questions a reviewer or interviewer would about a real-time system.
  • See your comprehension, not just your output. You get an honest read on which parts of your own game you actually understand — the sync model, the tick, the trust boundary — so you can shore up the weak spots before they surface in a review.

That's the loop this whole library is built around: build something real, then prove to yourself you understand it. The game is the project. The understanding is the point.

Turn a real-time build into understanding you can prove

Point Cartara at your multiplayer-game repo and it captures what you genuinely learned about authoritative state and sync — the honest way to make an ambitious, AI-assisted project truly yours.

Join the waitlist

Reference implementations

Production code worth studying — afteryou've built your own version.

socketio/socket.io
The most-used real-time library for JS. Even if you use raw WebSockets, its docs frame the problems you'll hit.
colyseus/colyseus
A multiplayer game server framework built around authoritative state and rooms — the exact patterns this build teaches, at production scale.
← All projects

Build it with Cartara

Point Cartara at your repo as you build this project. It captures what you actually learned from every change — including the code an AI assistant wrote — so an AI-assisted build becomes understanding you can prove.

Join the waitlist