1. Architecture
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
| Layer | Directory | Knows about | Never imports |
|---|---|---|---|
| Rules | src/game/ | nothing but itself | Socket.IO, Express, Prisma |
| Transport | src/realtime/ | Socket.IO + the game layer | Express |
| HTTP | src/routes/, src/controllers/ | Express + services + game | Socket.IO |
| Persistence | src/services/, src/db/ | Prisma | Socket.IO, Express |
| Entry | src/server.js, src/app.js | everything | — |
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.
| Approach | Outbound messages |
|---|---|
| Naive — rebroadcast each input as it arrives | 300/sec inbound × 10 recipients = 3,000/sec per lobby → 150,000/sec at 50 lobbies |
| This design — input mutates memory; one loop broadcasts | 20 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 direction | finished games |
| which collectible is still unclaimed | per-player results and ranks |
| live scores and leaderboard | all-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:
- State is directly JSON-serialisable. No
toJSON()ceremony when building a snapshot to send over the wire. - Every rule is trivially testable. A test builds a game with
createGame()and callstryCollect(game, 'p0', 'c1')— no instantiation, no mocking, nothisbinding to get wrong. - 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.