Back to journal
Engineering

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.

PMML Engineering 26 April 2026 12 min read 8 views
Build a Real-Time Multiplayer Game in 150 Lines of TypeScript

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)

Server infrastructure powering real-time game connections

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)

Game controller representing player input handling

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

Under the hood — how the game engine processes state

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:

  1. Reads keyboard state
  2. Sends movement to server if any keys are pressed
  3. 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.

#typescript#websockets#gamedev#tutorial#canvas#real-time

Keep reading

You might also like