4. The game engine — src/game/
4. The game engine — src/game/
The heart of the project. This directory imports no Socket.IO, no Express and no Prisma. It is plain data and plain functions, which is why the 58 unit tests covering it run in well under a second with no infrastructure at all.
grep -rE "socket\.io|express|@prisma" src/game/ # returns nothing
Three files:
| File | Holds |
|---|---|
entities.js | Factories for the two things the world contains |
game.js | One lobby's world and every rule (the core file) |
lobbyManager.js | All lobbies at once, plus matchmaking |
entities.js
Four small functions. No classes, no this, no methods on the data.
createCollectible({ id, x, y })
{ id, x, y, collectedBy: null, collectedAt: null }
collectedBy is the claim flag: null until exactly one player wins it, and
never reset. That one-way transition is what guarantees single collection.
createPlayer({ id, username, token, x, y, now })
| Field | Purpose |
|---|---|
id | Short, lobby-scoped (p0, p1, …). Sent 20×/sec, so it stays cheap. |
username | Display label. Unique within the lobby. |
token | Reconnect secret — a real UUID, because guessing it would steal a session. |
x, y | Server-authoritative position. |
dx, dy | Current input direction, normalised to length ≤ 1. Setting it does not move the player. |
score | Orbs collected. |
connected | false while inside the disconnect grace window. |
disconnectedAt | Timestamp, not a setTimeout — see tickGame. |
lastCollectAt | Leaderboard tie-breaker. |
Why id is short but token is a UUID. The id goes out in every snapshot, so
size matters and it only has to be unique inside one lobby. The token never
appears in a broadcast — only in the private joined payload to its own socket —
so it is optimised for unguessability instead. There is a unit test asserting the
token never leaks into any broadcast payload.
serializePlayer(player) / serializeCollectible(item)
The wire format. Positions are rounded to integers — they are drawn on a pixel
grid anyway, and it keeps 123.45678901234567 out of every frame of JSON.
game.js
GAME_STATUS
ACTIVE → FINISHED (every orb taken) or ABANDONED (everyone left).
createGame({ id, code, collectibleCount, maxPlayers, world, rng, now })
Returns the plain state object:
{
id, code, world: {width, height}, maxPlayers, rng,
status: 'ACTIVE', startedAt, endedAt: null,
players: Map, // playerId -> player
collectibles: Map, // collectibleId -> collectible
tokenIndex: Map, // reconnect token -> playerId
usernameIndex: Map, // lowercased name -> playerId
collectibleCount, remaining,
playerSeq: 0,
}
Four Maps, not arrays. Every lookup here is by id and happens up to 20× a
second across every lobby. Map.get() is O(1); array.find() is a scan. The
three index maps exist so that "is this token valid?" and "is this name taken?"
are also O(1) rather than a scan over players.
rng is injectable so tests are deterministic — pass () => 0.5 and every
spawn lands in the same place.
playerSeq never resets. It gives out p0, p1, … and never reuses an id,
so a departed player's id can't be confused with a new arrival's. It also doubles
as "has anyone ever joined?", which the broadcaster uses to tell a ghost lobby
from a brand-new one.
Orb count is clamped here too, not only in config.js, because
createLobby() accepts a per-lobby override. An unplayable world should be
impossible to construct no matter which door the number came through.
Internal helpers (not exported): spawnCollectibles() places orbs inside a
40 px margin so they are always reachable; randomSpawn() places a player.
Queries
| Function | Returns |
|---|---|
countConnected(game) | How many players are actually connected |
hasSpace(game) | players.size < maxPlayers |
isJoinable(game) | ACTIVE and has space |
usernameKey(name) | Trimmed, lowercased — the index key |
isUsernameTaken(game, name) | O(1) check against usernameIndex |
isFinished(game) | remaining === 0 |
getDurationMs(game) | Uses endedAt if set, else now |
addPlayer(game, { username, now })
Rejects with GAME_NOT_ACTIVE, LOBBY_FULL, or USERNAME_TAKEN; otherwise
creates the player and registers it in all three index maps.
The username check-and-claim is synchronous, exactly like tryCollect: two
people typing the same name and hitting Join at the same instant are two separate
turns of the event loop, so the second always loses.
A name stays claimed while its player exists — including while disconnected. Freeing it early would let somebody take it and produce two identical leaderboard rows the moment the original reconnects.
reconnectPlayer(game, { token })
Looks the token up in tokenIndex, flips connected back to true, clears
disconnectedAt, and zeroes the input direction (so a player who dropped
mid-movement doesn't resume sliding). Same player id, same score, same position.
Rejects UNKNOWN_TOKEN, PLAYER_GONE, GAME_NOT_ACTIVE.
markDisconnected(game, playerId, now)
Does not delete the player. Sets connected = false, records
disconnectedAt, and zeroes dx/dy — a dropped player must not keep sliding
into a wall.
removePlayer(game, playerId)
The only place a player is actually deleted. Cleans up all three maps — players, token, username — so the slot and the name are both freed.
setInput(game, playerId, dx, dy)
Stores intent. Deliberately moves nobody.
const scale = Math.min(len, 1) / len;
player.dx = x * scale;
player.dy = y * scale;
Normalising does two jobs: a client can't move faster by sending
{dx: 1000, dy: 1000}, and diagonal movement isn't 1.41× faster than straight
movement. A vector shorter than 1 is preserved proportionally, so an analogue
stick would work.
Number(dx) || 0 means garbage ('banana', undefined, NaN) becomes 0 rather
than corrupting the position into NaN forever.
tickGame(game, dt, now)
Advances the world one step and returns { expired: [playerId, ...] }.
For each player:
- connected → integrate position:
pos += direction × speed × dt, then clamp to[radius, worldSize - radius]. - disconnected past the grace window → collect the id into
expired, then remove them after the loop.
Two design points:
dtis real elapsed time. A stalled loop moves players a consistent distance instead of teleporting or freezing them.- The grace period is a timestamp comparison, not a timer. No
setTimeoutper player (which would mean thousands of timers at scale), the logic stays pure, and a test can verify a 30-second expiry instantly by passing a futurenow.
Returning expired lets the caller broadcast player:left without this file
ever knowing what a socket is.
tryCollect(game, playerId, collectibleId, now) — the contested operation
if (game.status !== ACTIVE) return { ok: false, reason: 'GAME_NOT_ACTIVE' };
if (!player) return { ok: false, reason: 'PLAYER_NOT_FOUND' };
if (!item) return { ok: false, reason: 'COLLECTIBLE_NOT_FOUND' };
if (item.collectedBy !== null) return { ok: false, reason: 'ALREADY_COLLECTED',
winnerId: item.collectedBy }; // CHECK
if (Math.hypot(px - ix, py - iy) > reach)
return { ok: false, reason: 'TOO_FAR' };
item.collectedBy = playerId; // CLAIM
item.collectedAt = now;
player.score += 1;
player.lastCollectAt = now;
game.remaining -= 1;
Safe without a lock because Node runs this to completion before any other
handler gets a turn. The second player always finds collectedBy set.
The fragile part, worth stating out loud: there must be no await between the
CHECK and the CLAIM. Insert one and the function yields, the other player's
handler runs, both see an unclaimed orb, and both score. This is why persistence
happens at game end and never here.
The distance check uses the server's copy of the player position — which the client can never write to. That is the entire anti-cheat story, and it only works because clients send directions rather than positions.
The loser is told winnerId so their client can drop the orb locally instead of
leaving a ghost on screen.
finishGame(game, status, now)
Returns false if the game already ended — idempotent, so a game can never be
ended twice or persisted twice.
Views — what goes over the wire
getLeaderboard(game) sorts by score desc, then by earliest
lastCollectAt (whoever reached that score first ranks higher), then by username.
Fully deterministic, which matters because tests assert on exact rank order.
getFullState(game, now) — sent once, on join. Everything needed to draw
the world from scratch: all players, all uncollected orbs, world size, and a
rules block (radii, pickup distance, speed).
The client is told the rules rather than hard-coding them, so it draws the same sizes the server checks against. The server still enforces every one of them — sending them is a convenience, not a delegation of trust.
getSnapshot(game, now) — sent 20× a second. Players only:
{ id, x, y, score, connected }.
Collectibles are deliberately excluded from the snapshot. They never move, so
re-sending all 40 every tick would be pure waste. Clients get them once in
getFullState() and remove them one at a time from collected events. With 10
players this cuts the per-tick payload by roughly two-thirds.
lobbyManager.js
Owns every live game in the process. Also no sockets, no database.
State
{
games: Map, // gameId -> game
codeIndex: Map, // joinCode -> gameId
rng,
}
Two indexes so both getGame(id) and findByCode(code) are O(1).
createLobby(manager, { collectibleCount, maxPlayers })
Generates a code and registers the game under both keys.
generateCode() (internal) draws from ABCDEFGHJKLMNPQRSTUVWXYZ23456789 —
no 0/O/1/I, because join codes get read aloud and typed by hand. It retries on
collision rather than assuming uniqueness, with a UUID-slice fallback after 10
attempts. A unit test generates 500 codes and asserts zero collisions.
quickPlay(manager, { username })
The matchmaker. Scans lobbies and applies three rules:
if (!isJoinable(game)) continue; // full or finished
if (username !== undefined && isUsernameTaken(game, username)) continue; // rule 1
if (live === 0 && game.playerSeq > 0) continue; // rule 2
if (live > bestLive) { best = game; bestLive = live; } // rule 3
- Skip a lobby where the name is taken rather than refusing the player. Matchmaking's job is to find somewhere to play; "that name is taken" is a confusing failure for a lobby they never chose. (Consequence: two browser tabs using the same username can never share a lobby.)
- Skip a ghost lobby — one where every player has disconnected. They are
held for their grace period so they can return, which is correct, but the
lobby is effectively empty. Seating someone there hands them a "live game" of
greyed-out ghosts.
playerSeq > 0distinguishes this from a brand-new lobby, which is exactly where the first player should go. - Prefer the busiest lobby with room. First-fit scattered people across half-empty lobbies; best-fit gathers them into a real game.
Falls back to createLobby() when nothing suitable exists.
The scan is O(number of lobbies), which is fine: joining happens once per player, versus 20 ticks per second forever.
getGame / findByCode / removeGame
findByCode trims and uppercases, and returns null for non-strings rather than
throwing. removeGame clears both indexes so a code can be reissued.
getStats(manager)
Feeds GET /health and the stress test:
{ lobbies, activeLobbies, players, connected }.
The singleton
export const lobbyManager = createLobbyManager();
One instance for the running server. Tests build their own with
createLobbyManager(), which is why the factory is exported separately — no
shared state between test files.