1. Architecture

The shape of the app

One Node process. Express and Socket.IO share a single HTTP server, so there is one port, one deployment unit, and no CORS configuration anywhere.

                    ┌─────────────────────────────────────────┐
  Browser           │            Node process                 │
  ───────           │                                         │
                    │  ┌───────────┐                          │
  HTTP  ────────────┼─▶│  Express  │─▶ routes ─▶ controllers  │
  (lobby list,      │  └───────────┘              │           │
   leaderboard)     │        │                    ▼           │
                    │        │                services ──────┼──▶ Prisma ──▶ Postgres
                    │        │                                │      (finished games only)
                    │  ┌───────────┐                          │
  WebSocket ────────┼─▶│ Socket.IO │─▶ handlers ──┐           │
  (join, input,     │  └───────────┘              ▼           │
   collect)         │        ▲            lobbyManager        │
                    │        │                    │           │
                    │        │                    ▼           │
                    │        │              game (in memory)  │
                    │        │                    │           │
                    │        └── 20 Hz broadcast ─┘           │
                    └─────────────────────────────────────────┘

The layers

LayerDirectoryKnows aboutNever imports
Rulessrc/game/nothing but itselfSocket.IO, Express, Prisma
Transportsrc/realtime/Socket.IO + the game layerExpress
HTTPsrc/routes/, src/controllers/Express + services + gameSocket.IO
Persistencesrc/services/, src/db/PrismaSocket.IO, Express
Entrysrc/server.js, src/app.jseverything

Dependencies point inward. src/game/ sits at the centre and depends on nothing; everything else depends on it. That single constraint is what makes the test suite run in ~2.5 seconds with no server and no browser.

You can verify the rule holds:

grep -rE "socket\.io|express|@prisma" src/game/     # returns nothing

The one number that drives the design

10 players in a lobby, each sending movement input ~30 times a second.

ApproachOutbound messages
Naive — rebroadcast each input as it arrives300/sec inbound × 10 recipients = 3,000/sec per lobby → 150,000/sec at 50 lobbies
This design — input mutates memory; one loop broadcasts20 ticks × 10 recipients = 200/sec per lobby → 10,000/sec at 50 lobbies

Outbound cost becomes tickRate × players, independent of how fast clients send input. A client spamming input cannot increase anyone else's load. Almost every other decision in the codebase follows from this one.

Where state lives

In memory (src/game/)In Postgres
player positions and input directionfinished games
which collectible is still unclaimedper-player results and ranks
live scores and leaderboardall-time aggregate leaderboard
reconnect tokens, username claims

A movement update touching Postgres would cost ~1–5 ms against a 50 ms tick budget shared by every lobby. So the hot path never leaves memory, and a match writes roughly 21 rows once, at the end — instead of the ~360,000 position updates that "save everything" would mean for a 3-minute 10-player game.

The trade-off, stated plainly: a restart loses in-progress games. That is acceptable for ephemeral matches. Fixing it properly means moving live state to a shared store, which is where Redis would enter — deliberately out of scope for a single process.

Directory map

src/
├── server.js                    process entry: port, signals, shutdown
├── app.js                       builds the Express app (does NOT listen)
├── config.js                    every tunable number, env-overridable
├── game/                        ◀── PURE RULES. No io/express/prisma.
│   ├── entities.js              createPlayer / createCollectible factories
│   ├── game.js                  one lobby's world + every rule (the core file)
│   └── lobbyManager.js          all lobbies + matchmaking
├── realtime/
│   ├── index.js                 Socket.IO server setup
│   ├── handlers.js              socket events → game function calls
│   └── broadcaster.js           the 20 Hz tick loop + game-over handling
├── routes/                      Express route tables
│   ├── lobby.routes.js
│   └── leaderboard.routes.js
├── controllers/                 thin request/response glue
│   ├── lobby.controller.js
│   └── leaderboard.controller.js
├── services/                    business logic that touches the DB
│   ├── gamePersistence.js       the ONLY writer of finished games
│   └── leaderboardService.js    all-time leaderboard + match history reads
├── db/
│   └── prisma.js                one shared PrismaClient
└── middleware/
    ├── validate.js              username / direction / id validation
    └── errorHandler.js          central HTTP error formatting

public/                          the browser client (no framework, no bundler)
prisma/                          schema + migrations
tests/                           unit (pure) + integration (real sockets + DB)
scripts/                         stress test, seed, cleanup

Why "no classes"

Game state is a plain object and rules are plain functions taking it as the first argument. Three concrete payoffs, beyond taste:

  1. State is directly JSON-serialisable. No toJSON() ceremony when building a snapshot to send over the wire.
  2. Every rule is trivially testable. A test builds a game with createGame() and calls tryCollect(game, 'p0', 'c1') — no instantiation, no mocking, no this binding to get wrong.
  3. No hidden state. Everything a function touches arrives as an argument, so reading a function tells you its full input.

Concurrency model — read this before anything else

Node runs JavaScript on one thread. A synchronous function runs to completion before any other event handler gets a turn. Two players' collect messages are two separate turns of the event loop; they cannot interleave.

This is the property the whole design leans on. It is also fragile in one specific way: it only holds while there is no await between a check and the write that depends on it. Insert an await there and the function yields, the other player's handler runs, both see unclaimed state, and both win.

That is why every function in src/game/ is synchronous, and why persistence always happens after the claim, never in the middle of it. See 02-flows.md and 04-game-engine.md.

Built with LogoFlowershow