DEV Community

Nic Raboy for MongoDB

Posted on

Multiplayer Game Development with TypeScript, Phaser, Socket.io, and MongoDB

Game data rarely looks the same for very long. A player document might start out with just a username and a score, and a few sprints later, it needs an inventory, a set of unlocked achievements, and a friends list, each shaped differently from the last. MongoDB's document model is a natural fit for that kind of growth, since a new field is just a new field, with no migration to write and no join to add. A match record can embed both players directly, scores and all, so reading back a finished match is a single document lookup rather than a query spread across multiple tables. Atomic operators like $inc and $max also mean stats and high scores can be updated safely by many concurrent players at once, without the application code having to coordinate a read-then-write itself.

In this tutorial, we're going to build a simple multiplayer game. Each player controls a white square on a canvas, and for thirty seconds, yellow coin squares spawn at random positions. Whoever collects the most coins when the timer runs out wins. Movement, coin spawning, and scoring all happen over WebSockets through Socket.io, with the server acting as the single source of truth for everything that happens during a match. MongoDB sits underneath that, storing player accounts, match records, and the leaderboard.

We'll use TypeScript, Zod, Socket.io, and Express on the backend, and Phaser, TypeScript, and Socket.io on the frontend. The MongoDB Node.js driver is used directly, without an ODM like Mongoose, so every query in this tutorial is plain driver code. If you'd rather skip ahead or just want to run the finished project, the full source is available on GitHub.

The Prerequisites

Prior to starting this tutorial, you'll need a few things in place:

  • A MongoDB instance, either a local server or an Atlas cluster on the free tier
  • Node.js 18+
  • Two browser tabs or windows, since a match needs two players to start

The expectation is that you can already connect to MongoDB with a connection string. If you need help getting an Atlas cluster running, check out the MongoDB documentation for getting started.

Understanding the Shape of the Project

Because the backend and the frontend are two independent npm projects with no shared package, we'll set each one up on its own. The backend is an Express application with a Socket.io server attached to the same HTTP server. The frontend is a Phaser game bootstrapped with Vite. Neither project depends on the other at build time. They only talk to each other over HTTP and WebSockets at runtime.

By the end of this tutorial, the backend will have the following shape:

backend/
├── .env
├── config.ts
├── main.ts
├── routes.ts
├── libs/
│   └── mongodb.ts
├── game/
│   ├── state.ts
│   ├── coins.ts
│   └── socket.ts
└── types/
    └── index.ts
Enter fullscreen mode Exit fullscreen mode

And the frontend will look like this:

frontend/
├── .env
├── index.html
└── src/
    ├── main.ts
    ├── style.css
    ├── api.ts
    ├── socket.ts
    ├── types.ts
    ├── screens/
    │   ├── lobby.ts
    │   └── results.ts
    └── game/
        └── game-scene.ts
Enter fullscreen mode Exit fullscreen mode

Go ahead and create the two project directories, backend and frontend, side by side, and we'll fill each one in as we go.

Setting Up the Backend Project

Inside backend, initialize a Node.js project and install the runtime dependencies:

npm init -y
npm install express cors socket.io mongodb zod dotenv
Enter fullscreen mode Exit fullscreen mode

Express is the HTTP layer, cors lets the frontend's origin talk to it, socket.io handles the real-time game traffic, the official mongodb package is our database driver, zod validates the two request bodies our REST layer accepts, and dotenv loads configuration from a .env file.

For TypeScript itself, install the following as dev dependencies:

npm install --save-dev typescript ts-node nodemon @types/express @types/node @types/cors
Enter fullscreen mode Exit fullscreen mode

ts-node runs TypeScript directly without a separate compile step, and nodemon restarts the process whenever a file changes, which keeps the feedback loop tight while you build out the socket handlers.

Add a tsconfig.json at the root of backend:

{
    "compilerOptions": {
        "target": "ES2020",
        "module": "commonjs",
        "lib": ["ES2020"],
        "outDir": "./dist",
        "rootDir": "./",
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
        "resolveJsonModule": true
    },
    "include": ["./**/*.ts"],
    "exclude": ["node_modules", "dist"]
}
Enter fullscreen mode Exit fullscreen mode

Then update the scripts block of backend/package.json so there's a development command and a production command:

"scripts": {
    "dev": "nodemon --exec ts-node main.ts",
    "build": "tsc",
    "start": "node dist/main.js"
}
Enter fullscreen mode Exit fullscreen mode

Finally, create a backend/.env file. This is where every tunable in the game lives, from canvas dimensions to how often coins spawn:

PORT=3000
MONGO_URI=ADD_YOUR_CONNECTION_STRING_HERE
MONGO_DB_NAME=coin_race
FRONTEND_URL=http://localhost:5173

CANVAS_WIDTH=1920
CANVAS_HEIGHT=1080

PLAYER_SIZE=40
PLAYER_SPEED=10

COIN_SIZE=20
COIN_MAX_ON_SCREEN=10
COIN_SPAWN_INTERVAL_MS=2000

MATCH_DURATION_MS=30000
LEADERBOARD_LIMIT=10
Enter fullscreen mode Exit fullscreen mode

Replace MONGO_URI with your own connection string. The coin_race database does not need to exist ahead of time. MongoDB creates it the first time we write to it. If you're using version control, make sure .env is in your .gitignore.

Loading Configuration Once at Startup

Nothing in this game should read process.env directly while a match is running. Player movement happens dozens of times per second, and re-parsing environment variables on every one of those calls would be wasteful for no benefit, since the values never change after the process boots. Instead, we'll read everything into a typed config object once.

Create backend/config.ts:

import "dotenv/config";

function intFromEnv(name: string, fallback: number): number {
    const raw = process.env[name];
    if (raw === undefined) return fallback;

    const parsed = parseInt(raw, 10);
    if (Number.isNaN(parsed)) {
        throw new Error(`Environment variable ${name} must be a number, got "${raw}".`);
    }

    return parsed;
}
Enter fullscreen mode Exit fullscreen mode

The intFromEnv helper does two things worth calling out. If the variable is not set, it falls back to a sane default rather than blowing up, which keeps the .env file optional for anything with a reasonable default. But if the variable is set to something that is not a number, it throws immediately at startup instead of quietly producing NaN the first time a coin tries to spawn at an NaN coordinate. A typo in .env should fail loudly on day one, not three requests into a match.

With that helper in place, add the actual config object to the bottom of the same backend/config.ts file:

export const config = {
    PORT: intFromEnv("PORT", 3000),
    FRONTEND_URL: process.env.FRONTEND_URL ?? "http://localhost:5173",

    CANVAS_WIDTH: intFromEnv("CANVAS_WIDTH", 1920),
    CANVAS_HEIGHT: intFromEnv("CANVAS_HEIGHT", 1080),

    PLAYER_SIZE: intFromEnv("PLAYER_SIZE", 40),
    PLAYER_SPEED: intFromEnv("PLAYER_SPEED", 10),

    COIN_SIZE: intFromEnv("COIN_SIZE", 20),
    COIN_MAX_ON_SCREEN: intFromEnv("COIN_MAX_ON_SCREEN", 10),
    COIN_SPAWN_INTERVAL_MS: intFromEnv("COIN_SPAWN_INTERVAL_MS", 2000),

    MATCH_DURATION_MS: intFromEnv("MATCH_DURATION_MS", 30000),
    LEADERBOARD_LIMIT: intFromEnv("LEADERBOARD_LIMIT", 10),
} as const;
Enter fullscreen mode Exit fullscreen mode

Every module in the backend that needs one of these values imports config from this file rather than touching process.env itself. MONGO_URI and MONGO_DB_NAME are the one exception. They stay in the MongoDB connection module we're about to write, since they're only ever read once, at connection time.

Connecting to MongoDB with a Cached Client

Opening a new MongoClient for every request is a common mistake. It adds connection latency to every single call and makes a clean shutdown much harder than it needs to be. A long-running server like this one should connect once when the process starts and reuse that same client for the lifetime of the process.

Create backend/libs/mongodb.ts:

import { MongoClient, Db } from "mongodb";

let client: MongoClient | null = null;
let db: Db | null = null;

export async function connectToMongoDB(): Promise<void> {
    if (client) return;

    const uri = process.env.MONGO_URI;
    const dbName = process.env.MONGO_DB_NAME;

    if (!uri || !dbName) {
        throw new Error("MONGO_URI and MONGO_DB_NAME must be set in environment");
    }

    client = new MongoClient(uri, { appName: "devrel-tutorial-typescript-multiplayergame" });

    await client.connect();
    db = client.db(dbName);

    console.log(`Connected to MongoDB — database: "${dbName}"`);
}
Enter fullscreen mode Exit fullscreen mode

The module-level client and db variables start out null. connectToMongoDB returns immediately if a client already exists, so calling it more than once is harmless. Otherwise, it reads the two required environment variables, throws if either is missing, and connects.

The rest of the codebase never calls connectToMongoDB more than once, but it does need a way to get at the cached Db handle and a way to close the connection on shutdown:

export function getDb(): Db {
    if (!db) {
        throw new Error("MongoDB not connected. Call connectToMongoDB() first.");
    }
    return db;
}

export async function closeMongoDB(): Promise<void> {
    if (client) {
        await client.close();
        client = null;
        db = null;
    }
}
Enter fullscreen mode Exit fullscreen mode

getDb is the function that every route and every socket handler calls to access the database. It throws if it's called before the connection is established, which should never happen in practice since we connect before the server starts listening. closeMongoDB exists for graceful shutdown, though this demo doesn't wire up signal handlers for it. Feel free to add SIGTERM/SIGINT handlers that call it if you want the server to shut down cleanly.

Defining the Player and Match Data Models

Before writing any routes, it's worth deciding what a player document and a match document actually look like, since TypeScript won't infer that for us. Both the database models and the socket payload shapes live in one file because the backend and frontend are separate npm projects with no shared package. The frontend keeps its own copy of these same types in frontend/src/types.ts, and the two have to be kept in sync by hand whenever a payload shape changes.

Create backend/types/index.ts, starting with the two collections that persist to MongoDB:

import { ObjectId } from "mongodb";

export interface Player {
    _id?: ObjectId;
    username: string;
    high_score: number;
    total_matches_played: number;
    total_wins: number;
    created_at: Date;
}

export type MatchStatus = "waiting" | "in_progress" | "finished";

export interface MatchPlayer {
    username: string;
    score: number;
}

export interface Match {
    _id?: ObjectId;
    status: MatchStatus;
    player_1: MatchPlayer;
    player_2: MatchPlayer | null;
    winner: string | null;
    created_at: Date;
    ended_at: Date | null;
}
Enter fullscreen mode Exit fullscreen mode

A Player document tracks a running high_score alongside lifetime stats. A Match document embeds both players directly, rather than referencing them by ID, since a match only ever needs a username and a score for each side, and there is no reason to $lookup back into the players collection to render a scoreboard. player_2 starts out null because a match is created by whoever becomes player_1, and the second player joins later over a socket.

The rest of the file describes objects that never touch MongoDB. They only exist in memory while a match is being played, and as payloads on the wire between client and server:

export interface Coin {
    id: string;
    x: number;
    y: number;
}

export interface PlayerGameState {
    username: string;
    socketId: string;
    x: number;
    y: number;
    score: number;
}

export type GameStatus = "waiting" | "in_progress" | "finished";

export interface GameState {
    roomId: string;
    player1: PlayerGameState | null;
    player2: PlayerGameState | null;
    coins: Map<string, Coin>;
    status: GameStatus;
    matchTimer: ReturnType<typeof setTimeout> | null;
    coinSpawnInterval: ReturnType<typeof setInterval> | null;
}
Enter fullscreen mode Exit fullscreen mode

GameState is the live, in-memory representation of a single match while it's being played. It's keyed by room ID and lives entirely in the Node.js process's memory, not in MongoDB. We'll build the module that manages these next.

Finally, the socket event payload types. Client-to-server payloads describe what a player's browser can ask the server to do:

export interface RoomJoinPayload {
    roomId: string;
    username: string;
}

export type MoveDirection = "up" | "down" | "left" | "right";

export interface PlayerMovePayload {
    roomId: string;
    direction: MoveDirection;
}

export interface CoinCollectPayload {
    roomId: string;
    coinId: string;
    playerX: number;
    playerY: number;
}
Enter fullscreen mode Exit fullscreen mode

And server-to-client payloads describe what the server broadcasts back:

export interface GameStartPayload {
    players: {
        player1: string;
        player2: string;
    };
    coins: Coin[];
    durationMs: number;
    canvasWidth: number;
    canvasHeight: number;
    playerSize: number;
    coinSize: number;
}

export interface Scores {
    player1: { username: string; score: number };
    player2: { username: string; score: number };
}

export interface GameEndPayload {
    scores: Scores;
    winner: string | null;
}
Enter fullscreen mode Exit fullscreen mode

Notice that GameStartPayload hands the client the canvas dimensions, player size, and coin size straight from the server's config. The frontend never hard-codes these values. It sizes its Phaser canvas and its square textures based entirely on what the server tells it, which means changing PLAYER_SIZE in the backend's .env file is the only change needed to resize every player square in the game.

Building the REST Layer

The REST surface for this game is deliberately small. Everything about playing a match, from joining a room to moving to collecting coins, happens over sockets. REST only handles the things that need to survive outside of any one match: registering a player, creating a match, and reading the leaderboard.

Create backend/routes.ts, starting with the username validation schema shared by two of the three endpoints:

import { Router, Request, Response } from "express";
import { z } from "zod";
import { config } from "./config";
import { getDb } from "./libs/mongodb";
import { Match, Player } from "./types/index";

const router = Router();

const UsernameSchema = z.object({
    username: z.string().min(1).max(20),
});
Enter fullscreen mode Exit fullscreen mode

The schema caps a username at 20 characters, which matches the maxlength attribute on the frontend's username input. Keeping those two limits in sync means a player never sees a client-side character limit that's more permissive than what the server will actually accept.

The first endpoint registers a new player:

router.post("/players", async (req: Request, res: Response) => {
    const parsed = UsernameSchema.safeParse(req.body);
    if (!parsed.success) {
        res.status(400).json({ error: parsed.error.flatten() });
        return;
    }

    const { username } = parsed.data;

    try {
        const db = getDb();

        const existing = await db.collection<Player>("players").findOne({ username });
        if (existing) {
            res.status(409).json({ error: "Username already exists." });
            return;
        }

        const player: Player = {
            username,
            high_score: 0,
            total_matches_played: 0,
            total_wins: 0,
            created_at: new Date(),
        };

        const result = await db.collection<Player>("players").insertOne(player);
        res.status(201).json({ _id: result.insertedId, ...player });
    } catch (err) {
        console.error("POST /players", err);
        res.status(500).json({ error: "Internal server error" });
    }
});
Enter fullscreen mode Exit fullscreen mode

There is no authentication in this demo. A username is the entire identity model, so the endpoint runs a findOne before inserting and responds with a 409 if that username is already taken. This is a small enough project that the race condition between the check and the insert is not worth guarding against with a unique index, though you'd want one in anything beyond a tutorial.

Next is the leaderboard, a read-only endpoint that sorts by high_score:

router.get("/players/leaderboard", async (_req: Request, res: Response) => {
    try {
        const db = getDb();

        const players = await db
            .collection<Player>("players")
            .find({})
            .sort({ high_score: -1 })
            .limit(config.LEADERBOARD_LIMIT)
            .project({ username: 1, high_score: 1, total_wins: 1, total_matches_played: 1 })
            .toArray();

        res.json(players);
    } catch (err) {
        console.error("GET /players/leaderboard", err);
        res.status(500).json({ error: "Internal server error" });
    }
});
Enter fullscreen mode Exit fullscreen mode

Sorting descending by high_score and limiting to config.LEADERBOARD_LIMIT gives us the top N players. The .project() call trims the response down to only the fields the lobby actually renders, which keeps the payload small and avoids exposing anything we don't need to.

The last REST endpoint creates a match:

router.post("/rooms", async (req: Request, res: Response) => {
    const parsed = UsernameSchema.safeParse(req.body);
    if (!parsed.success) {
        res.status(400).json({ error: parsed.error.flatten() });
        return;
    }

    const { username } = parsed.data;

    try {
        const db = getDb();

        const player = await db.collection<Player>("players").findOne({ username });
        if (!player) {
            res.status(404).json({ error: "Player not found. Create a player first." });
            return;
        }

        const match: Match = {
            status: "waiting",
            player_1: { username, score: 0 },
            player_2: null,
            winner: null,
            created_at: new Date(),
            ended_at: null,
        };

        const result = await db.collection<Match>("matches").insertOne(match);

        res.status(201).json({ _id: result.insertedId, ...match });
    } catch (err) {
        console.error("POST /rooms", err);
        res.status(500).json({ error: "Internal server error" });
    }
});

export default router;
Enter fullscreen mode Exit fullscreen mode

Whoever calls this endpoint becomes player_1, and the match starts in the waiting state with player_2 set to null. The response's _id is the room ID. That value, and nothing else, is what a second player needs to join the match. There is no lobby list and no way to browse open matches. You either have the ID, or you don't.

Wiring Express and Socket.io Together in main.ts

With the routes and the database module ready, we can assemble the actual server. The important detail here is that Socket.io does not run alongside Express. It attaches to the same underlying HTTP server, so both HTTP requests and WebSocket upgrades are served from one listening port.

Create backend/main.ts:

import express from "express";
import http from "http";
import cors from "cors";
import { Server } from "socket.io";
import { config } from "./config";
import { connectToMongoDB, getDb } from "./libs/mongodb";
import { registerGameSockets } from "./game/socket";
import apiRouter from "./routes";

const { PORT, FRONTEND_URL } = config;

async function bootstrap(): Promise<void> {
    await connectToMongoDB();

    const app = express();

    app.use(cors({ origin: FRONTEND_URL }));
    app.use(express.json());

    app.use(apiRouter);

    const httpServer = http.createServer(app);

    const io = new Server(httpServer, {
        cors: {
            origin: FRONTEND_URL,
            methods: ["GET", "POST"],
        },
    });

    registerGameSockets(io, getDb());

    httpServer.listen(PORT, () => {
        console.log(`Server listening on http://localhost:${PORT}`);
    });
}

bootstrap().catch((err) => {
    console.error("Failed to start server:", err);
    process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

bootstrap connects to MongoDB before anything else, which means if the database is unreachable, the process fails fast instead of accepting HTTP requests it can't actually serve. From there, it builds the Express app, wraps it in a plain http.createServer, and hands that same server instance to new Server(httpServer, ...) from Socket.io. Both CORS configurations, one for Express and one for Socket.io, point at FRONTEND_URL from the config, since the frontend runs on a different origin during development.

The one function we haven't written yet is registerGameSockets. That's where all the actual game logic lives, and it's big enough to deserve its own module, along with a couple of helpers.

Managing In-Memory Game State

A match's live state, who's playing, where they are, what coins exist, changes dozens of times a second while a match is in progress. You could batch those writes to MongoDB every few hundred milliseconds, or only persist them at meaningful checkpoints, and either approach is worth reaching for once a room's state needs to survive a server restart. For this tutorial, we'll take the simpler route: keep one GameState object per active room in a plain in-memory Map, and commit to MongoDB at exactly the two moments that matter, when a match starts and when it ends.

Create backend/game/state.ts:

import { config } from "../config";
import { GameState, MoveDirection, PlayerGameState } from "../types/index";

const gameRooms = new Map<string, GameState>();

export function createGameState(roomId: string): GameState {
    const state: GameState = {
        roomId,
        player1: null,
        player2: null,
        coins: new Map(),
        status: "waiting",
        matchTimer: null,
        coinSpawnInterval: null,
    };
    gameRooms.set(roomId, state);
    return state;
}

export function getGameState(roomId: string): GameState | undefined {
    return gameRooms.get(roomId);
}

export function deleteGameState(roomId: string): void {
    gameRooms.delete(roomId);
}
Enter fullscreen mode Exit fullscreen mode

The room ID, which is the string form of the match's MongoDB _id, is the key into gameRooms. createGameState seeds a fresh room with no players and no coins. deleteGameState is called once a match finishes, so a process that runs for a long time doesn't accumulate state for matches that ended hours ago.

Next, a couple of functions for placing and identifying players:

export function getStartingPosition(
    slot: 1 | 2
): { x: number; y: number } {
    const { CANVAS_WIDTH, CANVAS_HEIGHT, PLAYER_SIZE } = config;

    if (slot === 1) {
        return { x: PLAYER_SIZE * 2, y: Math.floor(CANVAS_HEIGHT / 2) };
    }
    return { x: CANVAS_WIDTH - PLAYER_SIZE * 3, y: Math.floor(CANVAS_HEIGHT / 2) };
}

export function addPlayerToState(
    state: GameState,
    slot: 1 | 2,
    username: string,
    socketId: string
): PlayerGameState {
    const { x, y } = getStartingPosition(slot);
    const playerState: PlayerGameState = { username, socketId, x, y, score: 0 };

    if (slot === 1) {
        state.player1 = playerState;
    } else {
        state.player2 = playerState;
    }

    return playerState;
}

export function getPlayerBySocketId(
    state: GameState,
    socketId: string
): PlayerGameState | null {
    if (state.player1?.socketId === socketId) return state.player1;
    if (state.player2?.socketId === socketId) return state.player2;
    return null;
}
Enter fullscreen mode Exit fullscreen mode

Player 1 always spawns near the left edge and player 2 near the right edge, both vertically centered, based on the canvas dimensions and player size from config. The frontend runs the same calculation independently, so a player's sprite appears in the right spot before the very first movement broadcast arrives.

getPlayerBySocketId is worth pausing on, because it is the mechanism that keeps a client from acting as a player it doesn't own. When a movement or coin-collect event comes in, the server never trusts a username sent in the payload. It looks up which player is tied to the socket that sent the event, and only that player's state can change as a result. A client has no way to move the other player or claim the other player's coins, because there's no code path that lets a payload name which player it's acting as.

The last two functions are where the server enforces two invariants the game depends on: players can't leave the canvas, and a coin can't be claimed from across the screen.

export function applyMovement(
    player: PlayerGameState,
    direction: MoveDirection
): { x: number; y: number } {
    const { CANVAS_WIDTH, CANVAS_HEIGHT, PLAYER_SIZE, PLAYER_SPEED } = config;

    let { x, y } = player;

    switch (direction) {
        case "up":    y -= PLAYER_SPEED; break;
        case "down":  y += PLAYER_SPEED; break;
        case "left":  x -= PLAYER_SPEED; break;
        case "right": x += PLAYER_SPEED; break;
    }

    x = Math.max(0, Math.min(CANVAS_WIDTH - PLAYER_SIZE, x));
    y = Math.max(0, Math.min(CANVAS_HEIGHT - PLAYER_SIZE, y));

    player.x = x;
    player.y = y;

    return { x, y };
}
Enter fullscreen mode Exit fullscreen mode

applyMovement takes one step of PLAYER_SPEED pixels in the requested direction, then clamps the result with Math.max/Math.min so it can never fall outside [0, CANVAS_WIDTH - PLAYER_SIZE] or [0, CANVAS_HEIGHT - PLAYER_SIZE]. This is the only function in the entire codebase that changes a player's position, and it always writes the clamped result back onto the player state before returning it. There is no way for a player to end up outside the canvas, no matter how fast someone mashes the arrow keys.

export function validateCoinCollect(
    player: PlayerGameState,
    coinX: number,
    coinY: number
): boolean {
    const { PLAYER_SIZE, COIN_SIZE, PLAYER_SPEED } = config;

    const tolerance = PLAYER_SPEED;

    const playerRight  = player.x + PLAYER_SIZE + tolerance;
    const playerBottom = player.y + PLAYER_SIZE + tolerance;
    const playerLeft   = player.x - tolerance;
    const playerTop    = player.y - tolerance;

    const coinRight  = coinX + COIN_SIZE;
    const coinBottom = coinY + COIN_SIZE;

    return (
        playerLeft   < coinRight  &&
        playerRight  > coinX      &&
        playerTop    < coinBottom &&
        playerBottom > coinY
    );
}
Enter fullscreen mode Exit fullscreen mode

This is a standard axis-aligned bounding box overlap test between the player's square and the coin's square, padded on every side by one PLAYER_SPEED unit of tolerance. The client is the one that first notices an overlap, using Phaser's own physics, but the server never takes that claim at face value. It re-runs its own overlap check using the player's server-tracked position before letting a coin count. The tolerance exists solely to absorb the small amount of latency between when a client detects the overlap and when the server validates it. Without it, a coin sitting right at the edge of a fast-moving player's hitbox could get rejected by a matter of a pixel or two.

Spawning Coins on a Timer

Coins need to appear at random positions, capped at some maximum on screen at once, on a fixed interval, and only while a match is actually in progress. This is small enough to live in its own module.

Create backend/game/coins.ts:

import { randomUUID } from "crypto";
import { Server } from "socket.io";
import { config } from "../config";
import { Coin, GameState } from "../types/index";

function randomInt(min: number, max: number): number {
    return Math.floor(Math.random() * (max - min + 1)) + min;
}

export function generateCoin(): Coin {
    const { CANVAS_WIDTH, CANVAS_HEIGHT, COIN_SIZE } = config;

    return {
        id: randomUUID(),
        x: randomInt(0, CANVAS_WIDTH - COIN_SIZE),
        y: randomInt(0, CANVAS_HEIGHT - COIN_SIZE),
    };
}
Enter fullscreen mode Exit fullscreen mode

generateCoin gives every coin a fresh UUID as its ID, which is how the client and server refer to a specific coin across every event related to it. The random x and y are bounded by COIN_SIZE, so the square is always drawn fully inside the canvas and never hangs off an edge.

The spawner itself runs on a setInterval scoped to a single room's GameState:

export function startCoinSpawner(
    io: Server,
    state: GameState
): void {
    state.coinSpawnInterval = setInterval(() => {
        if (state.status !== "in_progress") {
            stopCoinSpawner(state);
            return;
        }

        if (state.coins.size >= config.COIN_MAX_ON_SCREEN) return;

        const coin = generateCoin();
        state.coins.set(coin.id, coin);
        io.to(state.roomId).emit("coin:spawned", coin);
    }, config.COIN_SPAWN_INTERVAL_MS);
}

export function stopCoinSpawner(state: GameState): void {
    if (state.coinSpawnInterval !== null) {
        clearInterval(state.coinSpawnInterval);
        state.coinSpawnInterval = null;
    }
}
Enter fullscreen mode Exit fullscreen mode

Every tick, it checks that the match is still in_progress before doing anything else, since an interval that outlives its match would keep spawning coins into a room nobody is playing in anymore. If the room is already at COIN_MAX_ON_SCREEN, the tick is a no-op. Otherwise, it generates a coin, stores it in the room's coins map, and emits coin:spawned to everyone in that Socket.io room. Notice that state.coins (the server's map) is updated before the broadcast goes out, which matters because that map is the server's own record of what's currently on screen, and it's what coin:collect checks against later.

Handling the Real-Time Game Loop

This is the heart of the game. Every socket event that a player's browser can send is registered here, and this is also where a match's ending gets decided and persisted back to MongoDB.

Create backend/game/socket.ts, starting with the imports and the function that ends a match:

import { Server, Socket } from "socket.io";
import { Db, ObjectId } from "mongodb";
import { config } from "../config";
import {
    RoomJoinPayload,
    PlayerMovePayload,
    CoinCollectPayload,
    GameStartPayload,
    GameEndPayload,
    Match,
} from "../types/index";
import {
    createGameState,
    getGameState,
    deleteGameState,
    addPlayerToState,
    getPlayerBySocketId,
    applyMovement,
    validateCoinCollect,
} from "./state";
import { startCoinSpawner, stopCoinSpawner } from "./coins";

async function endMatch(
    io: Server,
    db: Db,
    roomId: string
): Promise<void> {
    const state = getGameState(roomId);
    if (!state || state.status === "finished") return;

    state.status = "finished";
    stopCoinSpawner(state);

    if (state.matchTimer !== null) {
        clearTimeout(state.matchTimer);
        state.matchTimer = null;
    }
Enter fullscreen mode Exit fullscreen mode

The early if (!state || state.status === "finished") return; guard is what makes endMatch safe to call exactly once, no matter what triggers it. In this game, it's only ever called by the match duration timer, but guarding against a second call costs nothing and rules out a whole class of bugs if you ever add another way to end a match, like a forfeit button.

With the room marked finished and its timers cleared, endMatch figures out the winner and writes the result to MongoDB:

    const p1 = state.player1!;
    const p2 = state.player2!;

    const winner =
        p1.score > p2.score
            ? p1.username
            : p2.score > p1.score
            ? p2.username
            : null; // draw

    const endedAt = new Date();

    await db.collection<Match>("matches").updateOne(
        { _id: new ObjectId(roomId) },
        {
            $set: {
                status: "finished",
                "player_1.score": p1.score,
                "player_2.score": p2.score,
                winner,
                ended_at: endedAt,
            },
        }
    );
Enter fullscreen mode Exit fullscreen mode

The non-null assertions on state.player1! and state.player2! are safe here because endMatch is only ever scheduled by a timer that gets armed after both player slots are already filled, which we'll see shortly. The updateOne call uses dot notation to update player_1.score and player_2.score inside the embedded documents without having to reconstruct the whole Match document, and a null winner represents a draw.

The last thing endMatch does is update player stats and notify both clients:

    for (const { username, score } of [
        { username: p1.username, score: p1.score },
        { username: p2.username, score: p2.score },
    ]) {
        const isWinner = username === winner;
        await db.collection("players").updateOne(
            { username },
            {
                $inc: {
                    total_matches_played: 1,
                    total_wins: isWinner ? 1 : 0,
                },
                $max: { high_score: score },
            }
        );
    }

    const payload: GameEndPayload = {
        scores: {
            player1: { username: p1.username, score: p1.score },
            player2: { username: p2.username, score: p2.score },
        },
        winner,
    };

    io.to(roomId).emit("game:end", payload);
    deleteGameState(roomId);
}
Enter fullscreen mode Exit fullscreen mode

$inc bumps total_matches_played for both players and total_wins for whichever one won. The $max operator is doing quiet but important work here: it only writes high_score if the new value is actually greater than what's already stored. That's the entire "update high score if this match beats it" rule from a single atomic operator, with no read-then-write round trip and no race condition if a player somehow finished two matches back to back. Once the database is updated, the server emits game:end to the room and calls deleteGameState to drop the room's in-memory state for good.

Now for the part of backend/game/socket.ts that registers every socket event. Start with the connection handler and room:join:

export function registerGameSockets(io: Server, db: Db): void {
    io.on("connection", (socket: Socket) => {
        console.log(`Socket connected: ${socket.id}`);

        socket.on("room:join", async (payload: RoomJoinPayload) => {
            const { roomId, username } = payload;

            try {
                let matchDoc = await db
                    .collection<Match>("matches")
                    .findOne({ _id: new ObjectId(roomId) });

                if (!matchDoc) {
                    socket.emit("error", { message: "Room not found." });
                    return;
                }

                if (matchDoc.status === "finished") {
                    socket.emit("error", { message: "This match has already ended." });
                    return;
                }
Enter fullscreen mode Exit fullscreen mode

room:join is where a socket connection turns into an actual seat in a match. It starts by loading the match document from MongoDB using the room ID, which is really just the match's _id as a string. A missing document or a match that has already finished both get rejected immediately with an error event back to that one socket.

Next, the handler works out which slot, player 1 or player 2, the joining username belongs in:

                let slot: 1 | 2;

                if (matchDoc.player_1.username === username) {
                    slot = 1;
                } else if (!matchDoc.player_2) {
                    await db.collection<Match>("matches").updateOne(
                        { _id: new ObjectId(roomId) },
                        { $set: { player_2: { username, score: 0 } } }
                    );
                    slot = 2;
                } else if (matchDoc.player_2.username === username) {
                    slot = 2;
                } else {
                    socket.emit("error", { message: "Room is full." });
                    return;
                }
Enter fullscreen mode Exit fullscreen mode

If the username matches player_1, that's slot 1, most likely the creator of the match, reconnecting. If there's no player_2 yet, this socket becomes player 2, and that assignment is persisted to MongoDB right away, so a second join attempt (say, from a page refresh) recognizes the same username as already seated in slot 2 rather than trying to add a third player. Anyone else gets turned away with "Room is full."

With a slot decided, the handler joins the actual Socket.io room and updates the in-memory game state:

                socket.join(roomId);

                let state = getGameState(roomId);
                if (!state) {
                    state = createGameState(roomId);
                }

                addPlayerToState(state, slot, username, socket.id);

                socket.emit("room:joined", { slot, roomId });
Enter fullscreen mode Exit fullscreen mode

socket.join(roomId) is Socket.io's own room feature, and it's what makes io.to(roomId).emit(...) reach exactly the two sockets seated in this match and nobody else. getGameState/createGameState either finds the existing in-memory state for this room or creates it on the first join. addPlayerToState then places the player's starting position and registers their socket.id, which is the identity check every later movement and coin-collect event relies on.

The last piece of room:join is starting the actual match once both seats are filled:

                if (state.player1 && state.player2 && state.status === "waiting") {
                    state.status = "in_progress";

                    await db.collection<Match>("matches").updateOne(
                        { _id: new ObjectId(roomId) },
                        { $set: { status: "in_progress" } }
                    );

                    const startPayload: GameStartPayload = {
                        players: {
                            player1: state.player1.username,
                            player2: state.player2.username,
                        },
                        coins: [],
                        durationMs: config.MATCH_DURATION_MS,
                        canvasWidth: config.CANVAS_WIDTH,
                        canvasHeight: config.CANVAS_HEIGHT,
                        playerSize: config.PLAYER_SIZE,
                        coinSize: config.COIN_SIZE,
                    };

                    io.to(roomId).emit("game:start", startPayload);

                    startCoinSpawner(io, state);

                    state.matchTimer = setTimeout(
                        () => endMatch(io, db, roomId),
                        config.MATCH_DURATION_MS
                    );
                }
            } catch (err) {
                console.error("room:join error", err);
                socket.emit("error", { message: "Failed to join room." });
            }
        });
Enter fullscreen mode Exit fullscreen mode

The state.status === "waiting" check ensures this block only ever runs once per match, even though room:join can fire multiple times as players reconnect. Once both slots are filled, the match flips to in_progress in both the in-memory state and MongoDB, game:start goes out to the room with every piece of sizing configuration the frontend needs, the coin spawner starts, and the match duration timer that eventually calls endMatch gets armed. This is also the only place that a timer is ever created, which is why endMatch can safely assume both players exist.

With joining handled, the movement and coin collection handlers are much shorter:

        socket.on("player:move", (payload: PlayerMovePayload) => {
            const { roomId, direction } = payload;
            const state = getGameState(roomId);

            if (!state || state.status !== "in_progress") return;

            const player = getPlayerBySocketId(state, socket.id);
            if (!player) return;

            const { x, y } = applyMovement(player, direction);

            io.to(roomId).emit("player:moved", { username: player.username, x, y });
        });
Enter fullscreen mode Exit fullscreen mode

player:move never trusts the payload beyond a roomId and a direction. It resolves which player is actually pressing the key by looking up the calling socket's ID, applies the movement (which, as we saw earlier, always clamps to the canvas), and broadcasts the new authoritative position to the whole room. Neither client moves its local sprite in response to a key press. Both clients wait for this exact player:moved broadcast, including the client that pressed the key.

        socket.on("coin:collect", async (payload: CoinCollectPayload) => {
            const { roomId, coinId, playerX, playerY } = payload;
            const state = getGameState(roomId);

            if (!state || state.status !== "in_progress") return;

            const coin = state.coins.get(coinId);
            if (!coin) return;

            const player = getPlayerBySocketId(state, socket.id);
            if (!player) return;

            player.x = playerX;
            player.y = playerY;

            if (!validateCoinCollect(player, coin.x, coin.y)) {
                return;
            }

            state.coins.delete(coinId);
            player.score += 1;

            io.to(roomId).emit("coin:collected", {
                coinId,
                username: player.username,
                scores: {
                    player1: { username: state.player1!.username, score: state.player1!.score },
                    player2: { username: state.player2!.username, score: state.player2!.score },
                },
            });
        });
Enter fullscreen mode Exit fullscreen mode

coin:collect is a claim, not a command, and the handler treats it that way. It first checks that the coin is still on the server's coins map, since a coin that's already been claimed by the other player (or never existed) is silently ignored. It resolves the calling player the same way player:move does, syncs the player's server-tracked position to whatever the client reported, and only then runs validateCoinCollect. If that check fails, meaning the claim doesn't line up with where the server thinks the player actually is, the event is dropped without any response at all. A bogus claim gets no feedback, not even an error, because there's nothing useful to tell a client that's either buggy or trying to cheat. Only a validated claim deletes the coin from the map, increments the score, and broadcasts coin:collected with the updated scores for both players.

The last handler just logs a disconnect and does nothing further:

        socket.on("disconnect", () => {
            console.log(`Socket disconnected: ${socket.id}`);
        });
    });
}
Enter fullscreen mode Exit fullscreen mode

Per the design of this demo, a disconnect doesn't end the match or award an automatic win. If the match timer is still running, it will fire on schedule and call endMatch with whatever scores existed at that point. That keeps the disconnect logic dead simple, at the cost of a player being able to leave a match hanging until the clock runs out. For a tutorial, that trade-off is fine.

That's the entire backend. It's worth running npm run dev at this point just to confirm the server boots and connects to MongoDB before moving on to the frontend.

Setting Up the Frontend Project

Inside frontend, scaffold a Vite project configured for vanilla TypeScript:

npm create vite@latest . -- --template vanilla-ts
npm install phaser socket.io-client axios
Enter fullscreen mode Exit fullscreen mode

Phaser is the game engine that renders the canvas and handles sprites, input, and overlap detection. socket.io-client is the browser counterpart to the backend's Socket.io server, and axios handles the three REST calls to the backend.

Create a frontend/.env file pointing at the backend:

VITE_API_URL=http://localhost:3000
VITE_SOCKET_URL=http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Vite only exposes environment variables prefixed with VITE_ to client-side code, which is why both variables use that prefix. Both point at the same backend here, since the REST API and the Socket.io server are on the same Express process, but they're kept as separate variables in case you ever split them apart.

Defining the Page Markup and Styles

The Vite scaffold generated a frontend/index.html and a frontend/src/style.css filled with starter content, along with a few demo files that we won't need. You can delete the demo files and replace the contents of both of these files. All three screens of the game, the lobby, the match, and the results, live in this single HTML page. Our TypeScript code will show and hide them as the player moves through the game, so there's no need for a routing library.

In the <head> of frontend/index.html, the only change worth making is setting the <title> to "Coin Race". Then replace the <body> with the following, starting with the lobby:

<body>
    <div id="app">
        <section id="lobby" class="screen">
            <h1>Coin Race</h1>

            <div id="lobby-register">
                <label for="username">Username</label>
                <input id="username" type="text" maxlength="20" placeholder="Enter a username" />
                <button id="register-btn" type="button">Register</button>
                <p id="lobby-error" class="error hidden"></p>
            </div>

            <div id="lobby-actions" class="hidden">
                <p id="welcome-message"></p>

                <div class="action-row">
                    <button id="create-match-btn" type="button">Create Match</button>
                </div>

                <div class="action-row">
                    <input id="room-id-input" type="text" placeholder="Room ID" />
                    <button id="join-match-btn" type="button">Join Match</button>
                </div>
            </div>

            <div id="lobby-waiting" class="hidden">
                <p>Waiting for opponent...</p>
                <p>Share this Room ID: <strong id="waiting-room-id"></strong></p>
            </div>

            <div id="leaderboard">
                <h2>Leaderboard</h2>
                <ol id="leaderboard-list"></ol>
            </div>
        </section>
Enter fullscreen mode Exit fullscreen mode

The lobby has three states, and only one of them is visible at a time. The lobby-register block is where a player enters a username, with lobby-error reserved for problems such as a username that's already taken. Once registered, the player sees lobby-actions, where they can either create a match or paste in a room ID to join one. The player who creates a match is moved to lobby-waiting, which displays the room ID they need to share with their opponent. Everything but the registration form starts with the hidden class. The leaderboard-list element is an empty list that gets filled in from the leaderboard endpoint.

Below the lobby, add the game and results screens:

        <section id="game-screen" class="screen hidden">
            <div id="hud">
                <span id="hud-player1"></span>
                <span id="hud-timer"></span>
                <span id="hud-player2"></span>
            </div>
            <div id="game-container"></div>
        </section>

        <section id="results" class="screen hidden">
            <h1>Match Over</h1>
            <p id="results-winner"></p>
            <p id="results-scores"></p>
            <button id="play-again-btn" type="button">Play Again</button>
        </section>
    </div>
    <script type="module" src="/src/main.ts"></script>
</body>
Enter fullscreen mode Exit fullscreen mode

The hud element holds the score for each player on either side of the match timer, all of which the game scene updates as events come in from the server. The game-container element is empty on purpose. It's where Phaser injects its canvas when a match starts. The results screen has a spot for the winner, a spot for the final scores, and a button to go back to the lobby. Both of these screens start hidden. The <script> tag at the bottom is how Vite loads frontend/src/main.ts as the entry point for the application.

Next, replace the contents of frontend/src/style.css, starting with the base page styles:

:root {
    color-scheme: dark;
    font: 16px/1.4 system-ui, sans-serif;
    color: #e8e8ec;
    background: #14151c;
}

* {
    box-sizing: border-box;
}

body {
    margin: 0;
    min-height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
}

#app {
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
}

.screen {
    width: 100%;
    max-width: 480px;
    padding: 32px;
    text-align: center;
}

.hidden {
    display: none;
}
Enter fullscreen mode Exit fullscreen mode

The page uses a dark color scheme, and both body and app use flexbox to center their content, which is what keeps the 1920x1080 game canvas centered in the browser tab. Each screen is a narrow, centered column by default. The most important rule here is .hidden, because the entire screen switching approach depends on it. It's used for the three screens, as well as the smaller pieces inside the lobby, like the registration form, the match actions, the waiting message, and the error message. Adding or removing the hidden class is how we'll move between screens and between the states of the lobby.

Add the styles for the form controls and the lobby:

h1 {
    margin: 0 0 24px;
}

label {
    display: block;
    margin-bottom: 8px;
    color: #a8a8b3;
}

input[type="text"] {
    width: 100%;
    padding: 10px 12px;
    margin-bottom: 12px;
    border-radius: 6px;
    border: 1px solid #3a3b46;
    background: #1e1f29;
    color: #e8e8ec;
    font-size: 15px;
}

button {
    padding: 10px 18px;
    border-radius: 6px;
    border: none;
    background: #4c6ef5;
    color: #fff;
    font-size: 15px;
    cursor: pointer;
}

button:hover {
    background: #3b5bdb;
}

.action-row {
    display: flex;
    gap: 8px;
    margin-bottom: 12px;
}

.action-row input {
    margin-bottom: 0;
}

.error {
    color: #ff6b6b;
}

#leaderboard {
    margin-top: 32px;
    text-align: left;
}

#leaderboard-list {
    padding-left: 24px;
}

#lobby-waiting strong {
    color: #4c6ef5;
}
Enter fullscreen mode Exit fullscreen mode

Most of this is cosmetic styling for inputs and buttons. The two classes worth pointing out are action-row, which places the room ID input and the join button side by side, and error, which colors the lobby's error message red. The waiting room ID is also highlighted, so it's easy to spot and share.

Finally, add the styles for the game and results screens:

/* --- Game screen --- */

#game-screen {
    max-width: none;
    padding: 16px;
}

#hud {
    width: 1920px;
    max-width: 100%;
    margin: 0 auto 8px;
    display: flex;
    justify-content: space-between;
    font-size: 20px;
    font-weight: 600;
}

#game-container {
    display: flex;
    justify-content: center;
}

/* --- Results screen --- */

#results-scores {
    font-size: 18px;
    margin-bottom: 24px;
}
Enter fullscreen mode Exit fullscreen mode

The game screen removes the 480px limit that every other screen has, since the canvas is much wider. The hud is set to the same 1920px width as the canvas, so the scores line up with its left and right edges, with the timer sitting in the middle. With the page in place, we can move on to the TypeScript that drives it.

Mirroring the Backend's Types on the Frontend

Since there's no shared package between the two projects, the frontend needs its own copy of every type that describes a REST response or a socket payload. Create frontend/src/types.ts starting with the REST models:

export interface Player {
    _id: string;
    username: string;
    high_score: number;
    total_matches_played: number;
    total_wins: number;
    created_at: string;
}

export interface Match {
    _id: string;
    status: "waiting" | "in_progress" | "finished";
    player_1: { username: string; score: number };
    player_2: { username: string; score: number } | null;
    winner: string | null;
    created_at: string;
    ended_at: string | null;
}

export interface LeaderboardEntry {
    username: string;
    high_score: number;
    total_wins: number;
    total_matches_played: number;
}
Enter fullscreen mode Exit fullscreen mode

These mirror the backend's Player and Match interfaces field for field, with one difference: dates come across JSON as strings, so created_at and ended_at are typed as string here instead of Date.

Next, the game objects and socket payloads, which need to match the backend's versions exactly, since they describe the wire format both sides agree on:

export interface Coin {
    id: string;
    x: number;
    y: number;
}

export type MoveDirection = "up" | "down" | "left" | "right";

export interface Scores {
    player1: { username: string; score: number };
    player2: { username: string; score: number };
}

export interface RoomJoinedPayload {
    slot: 1 | 2;
    roomId: string;
}

export interface GameStartPayload {
    players: {
        player1: string;
        player2: string;
    };
    coins: Coin[];
    durationMs: number;
    canvasWidth: number;
    canvasHeight: number;
    playerSize: number;
    coinSize: number;
}

export interface PlayerMovedPayload {
    username: string;
    x: number;
    y: number;
}

export interface CoinCollectedPayload {
    coinId: string;
    username: string;
    scores: Scores;
}

export interface GameEndPayload {
    scores: Scores;
    winner: string | null;
}
Enter fullscreen mode Exit fullscreen mode

Finally, two event map interfaces that give socket.on and socket.emit full compile-time checking against these payload types, instead of writing a wrapper function around every single event:

export interface ServerToClientEvents {
    "room:joined": (payload: RoomJoinedPayload) => void;
    "game:start": (payload: GameStartPayload) => void;
    "coin:spawned": (coin: Coin) => void;
    "player:moved": (payload: PlayerMovedPayload) => void;
    "coin:collected": (payload: CoinCollectedPayload) => void;
    "game:end": (payload: GameEndPayload) => void;
    error: (payload: { message: string }) => void;
}

export interface ClientToServerEvents {
    "room:join": (payload: { roomId: string; username: string }) => void;
    "player:move": (payload: { roomId: string; direction: MoveDirection }) => void;
    "coin:collect": (payload: {
        roomId: string;
        coinId: string;
        playerX: number;
        playerY: number;
    }) => void;
}
Enter fullscreen mode Exit fullscreen mode

Anywhere these two generics get passed to Socket<ServerToClientEvents, ClientToServerEvents>, TypeScript will flag a typo in an event name or a wrong payload shape at compile time rather than at runtime in the browser console.

Talking to the REST API

Create frontend/src/api.ts:

import axios from "axios";
import type { LeaderboardEntry, Match, Player } from "./types";

const api = axios.create({
    baseURL: import.meta.env.VITE_API_URL,
});

export async function createPlayer(username: string): Promise<Player | null> {
    try {
        const res = await api.post<Player>("/players", { username });
        return res.data;
    } catch (err) {
        if (axios.isAxiosError(err) && err.response?.status === 409) {
            return null;
        }
        throw err;
    }
}
Enter fullscreen mode Exit fullscreen mode

createPlayer treats a 409 specially. Since there's no authentication in this game, a username that's already taken isn't really an error from the player's point of view, it just means they're a returning player signing back in with the same name. The function swallows that one status code and returns null instead of throwing an exception, and the lobby code we'll write shortly treats null the same way it treats a successful registration.

The other two functions are simpler, since neither has a special case to handle:

export async function createRoom(username: string): Promise<Match> {
    const res = await api.post<Match>("/rooms", { username });
    return res.data;
}

export async function getLeaderboard(): Promise<LeaderboardEntry[]> {
    const res = await api.get<LeaderboardEntry[]>("/players/leaderboard");
    return res.data;
}
Enter fullscreen mode Exit fullscreen mode

createRoom returns the full Match document, and the lobby only cares about its _id, which becomes the room ID shared with the second player. getLeaderboard is a plain GET with no parameters, since LEADERBOARD_LIMIT is decided entirely on the backend.

Setting Up the Socket Connection

Create frontend/src/socket.ts:

import { io, type Socket } from "socket.io-client";
import type { ClientToServerEvents, ServerToClientEvents } from "./types";

export const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io(
    import.meta.env.VITE_SOCKET_URL,
    { autoConnect: false }
);

export function connectSocket(): void {
    if (!socket.connected) socket.connect();
}
Enter fullscreen mode Exit fullscreen mode

The socket is created once, at module load, but with autoConnect: false, so nothing actually opens a connection until a player is about to create or join a room. connectSocket is idempotent, calling it on an already-connected socket does nothing, which means the lobby code doesn't have to track connection state itself.

One more function belongs in this file:

export function removeAllGameListeners(): void {
    socket.off("coin:spawned");
    socket.off("player:moved");
    socket.off("coin:collected");
    socket.off("game:end");
}
Enter fullscreen mode Exit fullscreen mode

The Phaser scene we're about to write subscribes to four in-match events every time a match starts. Since matches are never reused (the design intentionally forces a new match to be created after each one), those listeners need to be torn down when a match ends, or a second match on the same socket connection would stack duplicate handlers, and every event would fire twice. Notice this function deliberately leaves room:joined, game:start, and error alone, since those belong to the lobby and need to stay registered for the next match.

Building the Lobby Screen

The lobby is plain DOM manipulation, no Phaser involved, since the game canvas doesn't exist yet at this point. It's built against the elements we defined in frontend/index.html earlier: a registration form, a create/join section, a waiting state, and a leaderboard list.

Create frontend/src/screens/lobby.ts, starting with the type for the callback the lobby hands control off to once a match actually starts:

import { createPlayer, createRoom, getLeaderboard } from "../api";
import { connectSocket, socket } from "../socket";
import type { GameStartPayload } from "../types";

const USERNAME_STORAGE_KEY = "coin-race-username";

export type MatchReadyHandler = (
    roomId: string,
    mySlot: 1 | 2,
    startPayload: GameStartPayload
) => void;

function joinRoom(roomId: string, username: string): void {
    connectSocket();
    socket.emit("room:join", { roomId, username });
}
Enter fullscreen mode Exit fullscreen mode

joinRoom is shared by both the "create match" and "join match" flows, since creating a match still means joining the room you just created over the socket. It's the only place in the lobby that opens the socket connection.

The bulk of the file is initLobby, which wires up every DOM element and event listener. Start with the setup:

export function initLobby(onMatchReady: MatchReadyHandler): () => void {
    const usernameInput = document.getElementById("username") as HTMLInputElement;
    const registerBtn = document.getElementById("register-btn") as HTMLButtonElement;
    const lobbyError = document.getElementById("lobby-error") as HTMLParagraphElement;
    const lobbyRegister = document.getElementById("lobby-register") as HTMLDivElement;
    const lobbyActions = document.getElementById("lobby-actions") as HTMLDivElement;
    const lobbyWaiting = document.getElementById("lobby-waiting") as HTMLDivElement;
    const welcomeMessage = document.getElementById("welcome-message") as HTMLParagraphElement;
    const createMatchBtn = document.getElementById("create-match-btn") as HTMLButtonElement;
    const roomIdInput = document.getElementById("room-id-input") as HTMLInputElement;
    const joinMatchBtn = document.getElementById("join-match-btn") as HTMLButtonElement;
    const waitingRoomId = document.getElementById("waiting-room-id") as HTMLElement;
    const leaderboardList = document.getElementById("leaderboard-list") as HTMLOListElement;

    let username = "";
    let pendingRoomId: string | null = null;
    let pendingSlot: 1 | 2 | null = null;

    const savedUsername = localStorage.getItem(USERNAME_STORAGE_KEY);
    if (savedUsername) usernameInput.value = savedUsername;
Enter fullscreen mode Exit fullscreen mode

The three let variables track state across the lobby's lifetime, since this is plain DOM code with no framework managing state for us. username is set once registration succeeds. pendingRoomId and pendingSlot are set as soon as the socket confirms this client has a seat in a room, and both are read the moment game:start arrives. Restoring username from localStorage on load means a returning player doesn't have to retype their name every session.

A few small helper functions keep the event listeners themselves readable:

    function showError(message: string): void {
        lobbyError.textContent = message;
        lobbyError.classList.remove("hidden");
    }

    function clearError(): void {
        lobbyError.classList.add("hidden");
    }

    function resetToActions(): void {
        lobbyWaiting.classList.add("hidden");
        lobbyActions.classList.remove("hidden");
        pendingRoomId = null;
        pendingSlot = null;
    }

    async function loadLeaderboard(): Promise<void> {
        try {
            const entries = await getLeaderboard();
            leaderboardList.innerHTML = entries
                .map((e) => `<li>${e.username} — ${e.high_score}</li>`)
                .join("");
        } catch {
            leaderboardList.innerHTML = "<li>Unable to load leaderboard.</li>";
        }
    }
Enter fullscreen mode Exit fullscreen mode

resetToActions is the escape hatch back to the create/join buttons from the "waiting for opponent" screen, used when a join attempt gets rejected. loadLeaderboard fetches and renders the top players, falling back to a plain message if the request fails rather than leaving the list blank with no explanation.

The registration button ties the username input to createPlayer:

    registerBtn.addEventListener("click", async () => {
        const value = usernameInput.value.trim();
        if (!value) {
            showError("Enter a username.");
            return;
        }

        clearError();
        try {
            await createPlayer(value);
            username = value;
            localStorage.setItem(USERNAME_STORAGE_KEY, username);
            welcomeMessage.textContent = `Welcome, ${username}!`;
            lobbyRegister.classList.add("hidden");
            lobbyActions.classList.remove("hidden");
        } catch {
            showError("Failed to register. Try again.");
        }
    });
Enter fullscreen mode Exit fullscreen mode

Remember that createPlayer returns null rather than throwing on a 409, so a duplicate username still lands in the success branch here and the player moves straight to the create/join screen, exactly as if registration had succeeded for the first time.

Creating and joining a match are two short handlers:

    createMatchBtn.addEventListener("click", async () => {
        clearError();
        try {
            const match = await createRoom(username);
            pendingRoomId = match._id;
            joinRoom(match._id, username);
        } catch {
            showError("Failed to create match.");
        }
    });

    joinMatchBtn.addEventListener("click", () => {
        const roomId = roomIdInput.value.trim();
        if (!roomId) {
            showError("Enter a room ID.");
            return;
        }
        clearError();
        pendingRoomId = roomId;
        joinRoom(roomId, username);
    });
Enter fullscreen mode Exit fullscreen mode

Creating a match calls the REST endpoint first to get a room ID, then immediately joins that same room over the socket. Joining an existing match skips the REST call entirely, since the room already exists, and goes straight to joinRoom with whatever ID was typed in.

The last three listeners handle what comes back from the server:

    socket.on("room:joined", ({ slot, roomId }) => {
        pendingSlot = slot;
        pendingRoomId = roomId;
        waitingRoomId.textContent = roomId;
        lobbyActions.classList.add("hidden");
        lobbyWaiting.classList.remove("hidden");
    });

    socket.on("error", ({ message }) => {
        resetToActions();
        showError(message);
    });

    socket.on("game:start", (payload) => {
        if (!pendingRoomId || !pendingSlot) return;
        onMatchReady(pendingRoomId, pendingSlot, payload);
    });

    void loadLeaderboard();

    return () => {
        clearError();
        resetToActions();
        void loadLeaderboard();
    };
}
Enter fullscreen mode Exit fullscreen mode

room:joined confirms this socket now has a seat, records the slot, and switches to the "waiting for opponent" view, displaying the room ID so it can be shared with a second player. If anything goes wrong on the server's side, the generic error event resets the UI and shows whatever message the server sent. And game:start is the payoff: once both slots are filled and the server broadcasts the start payload, the lobby hands off to onMatchReady with the room ID, this client's slot, and the payload the Phaser scene needs to build itself. Finally, initLobby returns a small function that puts the lobby back into its create/join state and reloads the leaderboard. We'll call it from frontend/src/main.ts when a player comes back from the results screen, so the leaderboard reflects the match that just finished.

Building the Game Scene

This is the biggest single file in the frontend, and the one that most directly reflects the "server decides, client renders" philosophy this game is built around. Nothing in this file changes a score or moves a sprite in response to local input alone. It only forwards input to the server and reacts to what comes back.

Create frontend/src/game/game-scene.ts, starting with the class shape and its init lifecycle hook:

import Phaser from "phaser";
import type { GameEndPayload, GameStartPayload, MoveDirection, Scores } from "../types";
import { removeAllGameListeners, socket } from "../socket";

const MOVE_EMIT_INTERVAL_MS = 60;
const LOCAL_OUTLINE_COLOR = 0x4c6ef5;

export interface GameSceneData {
    roomId: string;
    mySlot: 1 | 2;
    startPayload: GameStartPayload;
    onEnd: (result: GameEndPayload) => void;
}

type PlayerSprite = Phaser.Types.Physics.Arcade.SpriteWithDynamicBody;
type CoinSprite = Phaser.Types.Physics.Arcade.SpriteWithDynamicBody;

export class GameScene extends Phaser.Scene {
    private sceneData!: GameSceneData;
    private player1!: PlayerSprite;
    private player2!: PlayerSprite;
    private localPlayer!: PlayerSprite;
    private coinSprites = new Map<string, CoinSprite>();
    private coinsGroup!: Phaser.Physics.Arcade.Group;
    private cursorKeys!: Phaser.Types.Input.Keyboard.CursorKeys;
    private wasdKeys!: { W: Phaser.Input.Keyboard.Key; A: Phaser.Input.Keyboard.Key; S: Phaser.Input.Keyboard.Key; D: Phaser.Input.Keyboard.Key };
    private moveAccumulator = 0;
    private matchEndAt = 0;

    constructor() {
        super("GameScene");
    }

    init(data: GameSceneData): void {
        this.sceneData = data;
        this.coinSprites.clear();
        this.moveAccumulator = 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

GameSceneData is what main.ts hands the scene when it starts. mySlot is how the scene knows which of the two sprites is the one the local player controls. init runs before create on every scene start and resets per-match state, which matters because Phaser scenes can be restarted, and stale data from a previous match should never leak into a new one.

The create method builds everything the match needs. It starts with the two-player sprites:

    create(): void {
        const { startPayload, mySlot } = this.sceneData;
        const { playerSize, coinSize, canvasWidth, canvasHeight, durationMs } = startPayload;

        this.matchEndAt = Date.now() + durationMs;

        this.generateTextures(playerSize, coinSize);

        const p1Start = this.getStartingPosition(1, canvasWidth, canvasHeight, playerSize);
        const p2Start = this.getStartingPosition(2, canvasWidth, canvasHeight, playerSize);

        this.player1 = this.physics.add.sprite(
            p1Start.x,
            p1Start.y,
            mySlot === 1 ? "player-outline" : "player"
        );
        this.player1.setOrigin(0, 0);
        this.player1.body.setAllowGravity(false);
        this.player1.body.setImmovable(true);

        this.player2 = this.physics.add.sprite(
            p2Start.x,
            p2Start.y,
            mySlot === 2 ? "player-outline" : "player"
        );
        this.player2.setOrigin(0, 0);
        this.player2.body.setAllowGravity(false);
        this.player2.body.setImmovable(true);

        this.localPlayer = mySlot === 1 ? this.player1 : this.player2;
Enter fullscreen mode Exit fullscreen mode

Every sizing value used here, playerSize, coinSize, canvasWidth, canvasHeight, and the match's durationMs, comes straight from the server's startPayload. The local player's sprite gets the "player-outline" texture instead of the plain "player" texture, which is the only visual difference between the two squares, and it's purely cosmetic, so a player can tell which square is theirs at a glance. setAllowGravity(false) and setImmovable(true) matter here because Phaser's arcade physics defaults assume gravity and movable bodies, neither of which applies to a top-down game where the server dictates every position directly.

Next, the coin group and the local overlap check:

        this.coinsGroup = this.physics.add.group();

        this.physics.add.overlap(
            this.localPlayer,
            this.coinsGroup,
            (_player, coin) => this.handleCoinOverlap(coin as CoinSprite),
            undefined,
            this
        );
Enter fullscreen mode Exit fullscreen mode

This is the one piece of client-side game logic the design deliberately calls for: collision detection happens locally using Phaser's built-in overlap check, but only against this.localPlayer, not the opponent's sprite. The callback doesn't touch a score or delete anything from the scene by itself. It only raises a claim, which we'll see in handleCoinOverlap shortly.

The rest of create binds input and subscribes to the server's events:

        const keyboard = this.input.keyboard;
        if (!keyboard) throw new Error("Keyboard input not available");
        this.cursorKeys = keyboard.createCursorKeys();
        this.wasdKeys = keyboard.addKeys("W,A,S,D") as typeof this.wasdKeys;

        socket.on("player:moved", ({ username, x, y }) => {
            const sprite = this.spriteForUsername(username);
            sprite?.setPosition(x, y);
        });

        socket.on("coin:spawned", (coin) => {
            if (this.coinSprites.has(coin.id)) return;
            const sprite = this.physics.add.sprite(coin.x, coin.y, "coin");
            sprite.setOrigin(0, 0);
            sprite.body.setAllowGravity(false);
            sprite.setData("id", coin.id);
            this.coinsGroup.add(sprite);
            this.coinSprites.set(coin.id, sprite);
        });

        socket.on("coin:collected", ({ coinId, scores }) => {
            this.removeCoinSprite(coinId);
            this.updateHud(scores);
        });

        socket.on("game:end", (result) => {
            removeAllGameListeners();
            this.sceneData.onEnd(result);
        });

        this.updateHud({
            player1: { username: startPayload.players.player1, score: 0 },
            player2: { username: startPayload.players.player2, score: 0 },
        });
    }
Enter fullscreen mode Exit fullscreen mode

Look closely at player:moved: it's the only place either player's sprite position ever gets set. coin:spawned creates a new sprite and stores it by coin ID so it can be found again later, and coin:collected removes that sprite and refreshes the score display, again strictly in response to a server event rather than any local prediction. When the match ends, removeAllGameListeners tears down these four handlers before handing control back to main.ts through onEnd.

The update method runs every frame and is where local key presses actually turn into socket emissions:

    update(_time: number, delta: number): void {
        this.updateTimerHud();

        this.moveAccumulator += delta;
        if (this.moveAccumulator < MOVE_EMIT_INTERVAL_MS) return;
        this.moveAccumulator = 0;

        const up = this.cursorKeys.up.isDown || this.wasdKeys.W.isDown;
        const down = this.cursorKeys.down.isDown || this.wasdKeys.S.isDown;
        const left = this.cursorKeys.left.isDown || this.wasdKeys.A.isDown;
        const right = this.cursorKeys.right.isDown || this.wasdKeys.D.isDown;

        const directions: MoveDirection[] = [];
        if (up && !down) directions.push("up");
        if (down && !up) directions.push("down");
        if (left && !right) directions.push("left");
        if (right && !left) directions.push("right");

        for (const direction of directions) {
            socket.emit("player:move", { roomId: this.sceneData.roomId, direction });
        }
    }
Enter fullscreen mode Exit fullscreen mode

The moveAccumulator throttle caps how often player:move gets emitted to once every MOVE_EMIT_INTERVAL_MS (60 milliseconds), regardless of how fast Phaser's own frame rate runs. Without it, a 144Hz monitor would flood the server with movement events at a rate the game doesn't actually need. Opposing keys on the same axis, like holding both up and down, cancel out rather than emitting either direction, which avoids sending a movement event that would just get undone by the next one.

The rest of frontend/src/game/game-scene.ts is smaller, supporting methods. First, the coin overlap handler that raises a claim:

    private handleCoinOverlap(coin: CoinSprite): void {
        const coinId = coin.getData("id") as string | undefined;
        if (!coinId || !this.coinSprites.has(coinId)) return;

        socket.emit("coin:collect", {
            roomId: this.sceneData.roomId,
            coinId,
            playerX: this.localPlayer.x,
            playerY: this.localPlayer.y,
        });
        this.removeCoinSprite(coinId);
    }

    private removeCoinSprite(coinId: string): void {
        const sprite = this.coinSprites.get(coinId);
        if (!sprite) return;
        sprite.destroy();
        this.coinSprites.delete(coinId);
    }
Enter fullscreen mode Exit fullscreen mode

Notice that handleCoinOverlap calls removeCoinSprite immediately, before the server has confirmed anything. That's a small bit of optimistic UI: it stops the same overlap from firing the claim over and over again on every frame the two squares keep touching. If the server's validateCoinCollect check ends up rejecting the claim, this client's coin simply stays gone locally while the opponent's copy of that coin is still sitting on screen, since the server's own map never dropped it. In practice, that's rare enough with the tolerance built into the validation that it's not worth solving for in a demo.

The last few helpers handle rendering details rather than game logic:

    private spriteForUsername(username: string): PlayerSprite | null {
        const { players } = this.sceneData.startPayload;
        if (username === players.player1) return this.player1;
        if (username === players.player2) return this.player2;
        return null;
    }

    private getStartingPosition(
        slot: 1 | 2,
        canvasWidth: number,
        canvasHeight: number,
        playerSize: number
    ): { x: number; y: number } {
        if (slot === 1) {
            return { x: playerSize * 2, y: Math.floor(canvasHeight / 2) };
        }
        return { x: canvasWidth - playerSize * 3, y: Math.floor(canvasHeight / 2) };
    }
Enter fullscreen mode Exit fullscreen mode

This getStartingPosition is a deliberate duplicate of the same-named function in the backend's game/state.ts. Both sides need to agree on where a sprite appears before the very first player:moved broadcast arrives, and since there's no shared package between the two projects, the calculation is written out twice rather than imported from anywhere.

The texture generator draws every square the game needs without loading a single image asset:

    private generateTextures(playerSize: number, coinSize: number): void {
        if (!this.textures.exists("player")) {
            const g = this.make.graphics({ x: 0, y: 0 });
            g.fillStyle(0xffffff, 1);
            g.fillRect(0, 0, playerSize, playerSize);
            g.generateTexture("player", playerSize, playerSize);
            g.destroy();
        }

        if (!this.textures.exists("player-outline")) {
            const g = this.make.graphics({ x: 0, y: 0 });
            g.fillStyle(0xffffff, 1);
            g.fillRect(0, 0, playerSize, playerSize);
            g.lineStyle(4, LOCAL_OUTLINE_COLOR, 1);
            g.strokeRect(2, 2, playerSize - 4, playerSize - 4);
            g.generateTexture("player-outline", playerSize, playerSize);
            g.destroy();
        }

        if (!this.textures.exists("coin")) {
            const g = this.make.graphics({ x: 0, y: 0 });
            g.fillStyle(0xffd43b, 1);
            g.fillRect(0, 0, coinSize, coinSize);
            g.generateTexture("coin", coinSize, coinSize);
            g.destroy();
        }
    }
Enter fullscreen mode Exit fullscreen mode

Each block draws a rectangle with Phaser's Graphics object and bakes it into a reusable texture with generateTexture, then destroys the temporary graphics object since only the baked texture is needed after that. The this.textures.exists(...) guards mean these textures are only ever generated once, even if a player somehow ends up creating a second GameScene instance in the same browser tab.

The last two methods write to the HUD elements that sit outside the Phaser canvas in the surrounding HTML:

    private updateHud(scores: Scores): void {
        const p1El = document.getElementById("hud-player1");
        const p2El = document.getElementById("hud-player2");
        if (p1El) p1El.textContent = `${scores.player1.username}: ${scores.player1.score}`;
        if (p2El) p2El.textContent = `${scores.player2.username}: ${scores.player2.score}`;
    }

    private updateTimerHud(): void {
        const timerEl = document.getElementById("hud-timer");
        if (!timerEl) return;
        const remainingMs = Math.max(0, this.matchEndAt - Date.now());
        timerEl.textContent = `${Math.ceil(remainingMs / 1000)}s`;
    }
}
Enter fullscreen mode Exit fullscreen mode

The countdown in updateTimerHud is purely cosmetic. It's computed locally from matchEndAt, which was set once in create from the server's durationMs, so it can tick smoothly every frame without asking the server for the time remaining. The actual end of the match is still decided entirely by the server's own setTimeout in backend/game/socket.ts. If this client's clock drifts, the worst that happens is the displayed countdown is a little off. The match still ends exactly when the server says it does.

Rendering the Results Screen and Wiring main.ts

The results screen is the simplest file in the project. Create frontend/src/screens/results.ts:

import type { GameEndPayload } from "../types";

export function renderResults(result: GameEndPayload, onPlayAgain: () => void): void {
    const winnerEl = document.getElementById("results-winner")!;
    const scoresEl = document.getElementById("results-scores")!;
    const playAgainBtn = document.getElementById("play-again-btn") as HTMLButtonElement;

    const { player1, player2 } = result.scores;

    winnerEl.textContent = result.winner ? `${result.winner} wins!` : "It's a draw!";
    scoresEl.textContent = `${player1.username}: ${player1.score}  —  ${player2.username}: ${player2.score}`;

    playAgainBtn.onclick = () => onPlayAgain();
}
Enter fullscreen mode Exit fullscreen mode

It takes the same GameEndPayload that the socket delivered and a callback for the "play again" button, and does nothing more than fill in some text content. There's deliberately no way from this screen to rejoin the same match. Per the game's design, playing again always means creating a brand new match from the lobby.

Finally, frontend/src/main.ts ties the three screens together:

import "./style.css";
import Phaser from "phaser";
import { initLobby } from "./screens/lobby";
import { renderResults } from "./screens/results";
import { GameScene, type GameSceneData } from "./game/game-scene";
import type { GameEndPayload, GameStartPayload } from "./types";

const lobbyScreen = document.getElementById("lobby")!;
const gameScreen = document.getElementById("game-screen")!;
const resultsScreen = document.getElementById("results")!;
const gameContainer = document.getElementById("game-container")!;

function showScreen(screen: HTMLElement): void {
    for (const el of [lobbyScreen, gameScreen, resultsScreen]) {
        el.classList.toggle("hidden", el !== screen);
    }
}

let activeGame: Phaser.Game | null = null;
Enter fullscreen mode Exit fullscreen mode

showScreen is the entire routing mechanism for this app. There's no client-side router, just three <section> elements in frontend/index.html that get shown or hidden with a hidden CSS class. activeGame holds the live Phaser instance so it can be destroyed cleanly when a match ends.

The two functions that drive the transitions between screens:

function handleMatchReady(
    roomId: string,
    mySlot: 1 | 2,
    startPayload: GameStartPayload
): void {
    showScreen(gameScreen);

    activeGame = new Phaser.Game({
        type: Phaser.AUTO,
        parent: gameContainer,
        width: startPayload.canvasWidth,
        height: startPayload.canvasHeight,
        backgroundColor: "#14151c",
        scale: {
            mode: Phaser.Scale.NONE,
        },
        physics: {
            default: "arcade",
            arcade: {
                gravity: { x: 0, y: 0 },
            },
        },
        scene: [GameScene],
    });

    const sceneData: GameSceneData = {
        roomId,
        mySlot,
        startPayload,
        onEnd: handleGameEnd,
    };
    activeGame.scene.start("GameScene", sceneData);
}

function handleGameEnd(result: GameEndPayload): void {
    if (activeGame) {
        activeGame.destroy(true);
        activeGame = null;
    }
    showScreen(resultsScreen);
    renderResults(result, () => {
        returnToLobby();
        showScreen(lobbyScreen);
    });
}

const returnToLobby = initLobby(handleMatchReady);
Enter fullscreen mode Exit fullscreen mode

handleMatchReady is the callback the lobby invokes once game:start arrives. The Phaser game's width and height are set from startPayload.canvasWidth/canvasHeight, the exact same values the backend reads from its own .env file, so resizing the canvas is a one-line config change on the backend with nothing to update on the frontend. handleGameEnd destroys the Phaser instance entirely rather than just hiding it, since a new match always means a fresh GameScene, then shows the results screen with a "play again" callback that resets the lobby, refreshes the leaderboard, and routes back to it. The last line, initLobby(handleMatchReady), is what actually starts the whole application, and it holds on to the reset function the lobby returns as returnToLobby.

Running the Game End to End

With both projects built out, start the backend first:

cd backend
npm run dev
Enter fullscreen mode Exit fullscreen mode

You should see Connected to MongoDB followed by Server listening on http://localhost:3000. In a second terminal, start the frontend:

cd frontend
npm run dev
Enter fullscreen mode Exit fullscreen mode

Vite will print a local URL, typically http://localhost:5173. Open that URL in two separate browser tabs (or two different browsers entirely, since two tabs of the same browser work just as well for this).

In the first tab, register a username and click "Create Match." You'll land on a waiting screen with a room ID displayed. Copy that ID.

In the second tab, register a different username, paste the room ID into the "Room ID" field, and click "Join Match." As soon as the server sees both players seated, both tabs should flip to the game screen at the same moment, each showing two white squares, one of them outlined to mark which one you control. Move around with the arrow keys or WASD and watch coins spawn and disappear as either player touches them. After thirty seconds, both tabs should show the results screen with the final scores and a winner (or a draw). Head back to the lobby and check the leaderboard. If either player's score beats their previous high score, you should see it reflected there.

Conclusion

We built a real-time two-player game where MongoDB and Socket.io each do the job they're actually good at. MongoDB stores what needs to survive between matches: player accounts, match history, and a leaderboard sorted by high_score. Socket.io handles everything that happens inside the thirty-second window of a single match, with the server holding the only authoritative copy of every position, every coin, and every score. The client never gets final say over anything that affects the outcome of the game. It only forwards input and renders whatever the server broadcasts back.

A few things would need to change before this could handle more than a tutorial's worth of traffic. The in-memory gameRooms map in backend/game/state.ts lives on a single Node.js process, so running more than one server instance would need those rooms to be moved somewhere shared. MongoDB change streams on the matches collection are worth a look here, since every server instance could watch for the same match updates and stay in sync without owning the room's state itself. There's also no reconnection handling beyond the same username rejoining the same slot, and no automatic winner if a player disconnects mid-match.

As a next step, try adding a rematch button that creates a new match against the same opponent without a trip back through the lobby, or add a MongoDB index on high_score so the leaderboard query stays fast as the players collection grows.

Want to try this project for yourself? Clone the repository on GitHub.

Top comments (0)