"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.
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).
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
typefield (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'sJSON.stringifyout andJSON.parsein, 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.
inputis intent, not position. The server stores which keys a player is holding and computes position itself — the client never gets to writexory. That single decision is the authoritative model made concrete.
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.
latestis 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. clearRectevery 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
trueon keydown,falseon 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
dirandpressed, 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 oncloseand 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
welcomemessage hands the client its ownidso 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
closeby 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.
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:
- Push the repo to GitHub.
- Create a service on your chosen host pointing at the repo; set the start command to run your server.
- Use the secure WebSocket URL (
wss://your-app.onrender.com) in the client instead ofws://localhost:8080— production is TLS, so it'swss, notws. - Serve the small static client (the HTML + bundled JS) from the same host or any static host, pointed at that
wssURL.
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.