Build a Real-Time Multiplayer Game in 150 Lines of TypeScript
Forget bloated game engines. We'll build a real-time multiplayer dot game using nothing but TypeScript, WebSockets, and HTML Canvas — deployable in minutes.
Ever wondered how multiplayer games sync player positions in real time? It's simpler than you think. In this tutorial, we'll build a fully playable multiplayer dot game from scratch — no Unity, no Godot, no game engine at all. Just TypeScript, a WebSocket server, and an HTML Canvas.
By the end, you'll have a game where players connect, move colored dots around a shared arena, and see each other in real time. The entire thing fits in ~150 lines of code.
What We're Building
A browser-based arena where:
- Each player is a colored circle that moves with WASD or arrow keys
- All connected players see each other move in real time
- Players get a random color on join and disappear when they disconnect
- The server is the single source of truth for all game state
Here's the architecture:
Browser (Canvas + Input) ←→ WebSocket ←→ Node.js Server (Game State)
Simple. No database, no auth, no frameworks. Let's go.
Part 1: The Server (60 Lines)
Create a new project and install the only dependency we need:
mkdir dot-arena && cd dot-arena
npm init -y
npm install ws
npm install -D typescript @types/ws @types/node tsx
Add this to tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"outDir": "dist"
}
}
Now the server — server.ts:
import { WebSocketServer, WebSocket } from "ws";
type Player = {
id: string;
x: number;
y: number;
color: string;
name: string;
};
const PORT = 8080;
const COLORS = ["#EC1010", "#3B82F6", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899"];
const players = new Map<WebSocket, Player>();
let nextId = 0;
function broadcast(data: object) {
const msg = JSON.stringify(data);
for (const ws of players.keys()) {
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
}
}
function gameState() {
return { type: "state", players: [...players.values()] };
}
const wss = new WebSocketServer({ port: PORT });
wss.on("connection", (ws) => {
const player: Player = {
id: `p${nextId++}`,
x: 200 + Math.random() * 400,
y: 200 + Math.random() * 200,
color: COLORS[nextId % COLORS.length],
name: `Player ${nextId}`,
};
players.set(ws, player);
ws.send(JSON.stringify({ type: "welcome", id: player.id }));
broadcast(gameState());
ws.on("message", (raw) => {
try {
const msg = JSON.parse(raw.toString());
if (msg.type === "move") {
player.x = Math.max(0, Math.min(800, player.x + (msg.dx ?? 0)));
player.y = Math.max(0, Math.min(600, player.y + (msg.dy ?? 0)));
broadcast(gameState());
}
} catch {}
});
ws.on("close", () => {
players.delete(ws);
broadcast(gameState());
});
});
console.log(`Dot Arena server running on ws://localhost:${PORT}`);
That's the entire backend. 55 lines. It handles connections, movement, and broadcasts the full game state to all players on every change.
Run it with:
npx tsx server.ts
Part 2: The Client (90 Lines)
Create index.html:
<!DOCTYPE html>
<html>
<head>
<title>Dot Arena</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0f0f0f;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-family: system-ui;
}
canvas {
border: 2px solid #333;
border-radius: 12px;
}
#status {
position: fixed;
top: 16px;
left: 16px;
color: #666;
font-size: 14px;
}
</style>
</head>
<body>
<div id="status">Connecting...</div>
<canvas id="arena" width="800" height="600"></canvas>
<script>
const canvas = document.getElementById("arena");
const ctx = canvas.getContext("2d");
const status = document.getElementById("status");
let players = [];
let myId = null;
const keys = {};
const SPEED = 5;
const ws = new WebSocket("ws://localhost:8080");
ws.onopen = () => {
status.textContent = "Connected! Use WASD or Arrow Keys to move.";
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "welcome") myId = msg.id;
if (msg.type === "state") players = msg.players;
};
ws.onclose = () => {
status.textContent = "Disconnected. Refresh to rejoin.";
};
document.addEventListener("keydown", (e) => (keys[e.key] = true));
document.addEventListener("keyup", (e) => (keys[e.key] = false));
function tick() {
let dx = 0, dy = 0;
if (keys["w"] || keys["ArrowUp"]) dy -= SPEED;
if (keys["s"] || keys["ArrowDown"]) dy += SPEED;
if (keys["a"] || keys["ArrowLeft"]) dx -= SPEED;
if (keys["d"] || keys["ArrowRight"]) dx += SPEED;
if ((dx || dy) && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "move", dx, dy }));
}
ctx.fillStyle = "#0f0f0f";
ctx.fillRect(0, 0, 800, 600);
ctx.strokeStyle = "#1a1a1a";
ctx.lineWidth = 1;
for (let x = 0; x < 800; x += 40) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, 600); ctx.stroke();
}
for (let y = 0; y < 600; y += 40) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(800, y); ctx.stroke();
}
for (const p of players) {
ctx.shadowColor = p.color;
ctx.shadowBlur = p.id === myId ? 20 : 10;
ctx.beginPath();
ctx.arc(p.x, p.y, p.id === myId ? 18 : 14, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = "#fff";
ctx.font = "12px system-ui";
ctx.textAlign = "center";
ctx.fillText(p.name, p.x, p.y - 24);
if (p.id === myId) {
ctx.fillStyle = "#666";
ctx.font = "10px system-ui";
ctx.fillText("(you)", p.x, p.y - 36);
}
}
ctx.shadowBlur = 0;
requestAnimationFrame(tick);
}
tick();
</script>
</body>
</html>
Try It Right Now
Open two terminal tabs:
# Tab 1: Start the server
npx tsx server.ts
# Tab 2: Serve the client
npx serve .
Open http://localhost:3000 in two browser tabs. You'll see two dots — move one with WASD and watch it move in the other tab in real time.
How It Works Under the Hood
Let's break down the key architectural decisions:
1. Server-Authoritative State
The server owns all player positions. When a client presses a key, it sends a movement delta (dx, dy), not an absolute position. The server applies it, clamps it to bounds, and broadcasts the new state. This prevents cheating — a client can't teleport by sending fake coordinates.
// Client sends intent, not state
ws.send(JSON.stringify({ type: "move", dx: -5, dy: 0 }));
// Server validates and applies
player.x = Math.max(0, Math.min(800, player.x + msg.dx));
2. Full State Broadcast
On every change, we broadcast the entire game state to all players. This is fine for our scale (< 50 players), but for hundreds of players you'd want:
- Delta compression: only send what changed
- Spatial partitioning: only send nearby player data
- Tick rate limiting: broadcast at fixed intervals (e.g., 20Hz) instead of on every input
3. The Game Loop Pattern
The client runs a requestAnimationFrame loop at ~60fps that:
- Reads keyboard state
- Sends movement to server if any keys are pressed
- Renders all players from the latest server state
This is the same pattern every real-time game uses — input → network → render.
Level Up: 5 Exercises to Try
Here are practical extensions you can build right now:
Exercise 1: Add Collectibles
Add random coins that players can collect for points. The server spawns them and checks collision:
type Coin = { x: number; y: number; id: number };
const coins: Coin[] = [];
let coinId = 0;
const scores = new Map<string, number>();
function spawnCoin() {
coins.push({
id: coinId++,
x: 50 + Math.random() * 700,
y: 50 + Math.random() * 500,
});
}
// In the message handler, after updating position:
for (let i = coins.length - 1; i >= 0; i--) {
const c = coins[i];
const dist = Math.hypot(player.x - c.x, player.y - c.y);
if (dist < 20) {
coins.splice(i, 1);
scores.set(player.id, (scores.get(player.id) ?? 0) + 1);
spawnCoin(); // replace it
}
}
Exercise 2: Add a Chat System
Extend the WebSocket protocol to handle chat messages:
// Client
function sendChat(text) {
ws.send(JSON.stringify({ type: "chat", text }));
}
// Server
if (msg.type === "chat") {
broadcast({
type: "chat",
from: player.name,
color: player.color,
text: msg.text.slice(0, 200), // limit length
});
}
Exercise 3: Add Dash Ability
Give players a dash on spacebar with a 2-second cooldown:
// Client-side
let canDash = true;
document.addEventListener("keydown", (e) => {
if (e.code === "Space" && canDash) {
canDash = false;
ws.send(JSON.stringify({ type: "dash" }));
setTimeout(() => (canDash = true), 2000);
}
});
// Server-side
if (msg.type === "dash") {
const dashDist = 80;
player.x = Math.max(0, Math.min(800, player.x + lastDx * dashDist));
player.y = Math.max(0, Math.min(600, player.y + lastDy * dashDist));
}
Exercise 4: Add Player Trail
Render a fading trail behind each player:
const trails = new Map();
for (const p of players) {
if (!trails.has(p.id)) trails.set(p.id, []);
const trail = trails.get(p.id);
trail.push({ x: p.x, y: p.y, age: 0 });
for (let i = trail.length - 1; i >= 0; i--) {
trail[i].age++;
if (trail[i].age > 30) { trail.splice(i, 1); continue; }
const alpha = 1 - trail[i].age / 30;
ctx.globalAlpha = alpha * 0.3;
ctx.beginPath();
ctx.arc(trail[i].x, trail[i].y, 8, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.fill();
}
ctx.globalAlpha = 1;
}
Exercise 5: Deploy It
Make it accessible to the world. Add this to server.ts:
import { createServer } from "http";
import { readFileSync } from "fs";
const server = createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(readFileSync("index.html"));
});
const wss = new WebSocketServer({ server });
server.listen(8080);
Then deploy to any VPS with:
ssh your-server "mkdir -p ~/dot-arena"
scp server.ts index.html tsconfig.json package.json your-server:~/dot-arena/
ssh your-server "cd ~/dot-arena && npm install && npx tsx server.ts &"
Now share the link with friends and watch them join.
Why This Matters
This tiny project teaches patterns used by every real-time application:
- WebSocket bidirectional communication — the foundation of chat apps, live dashboards, and collaborative editors
- Server-authoritative state — how production games prevent cheating
- Client-side prediction and rendering — the game loop pattern used from indie games to AAA titles
- Broadcast pub/sub — the same pattern behind Figma, Google Docs, and Slack
The 150 lines you wrote here contain the same DNA as a production multiplayer system. The difference is just scale — not architecture.
Full Source Code
The complete project (server + client + all exercises) is available at the code blocks above. Copy them, run them, break them, extend them. That's the whole point.
What will you build? Drop us a message at support@primemeshmatrix.com — we love seeing what people make.
PMML Engineering builds real-time systems, web platforms, and AI products for teams that ship. Based in Accra, Ghana.