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:

FileHolds
entities.jsFactories for the two things the world contains
game.jsOne lobby's world and every rule (the core file)
lobbyManager.jsAll 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 })

FieldPurpose
idShort, lobby-scoped (p0, p1, …). Sent 20×/sec, so it stays cheap.
usernameDisplay label. Unique within the lobby.
tokenReconnect secret — a real UUID, because guessing it would steal a session.
x, yServer-authoritative position.
dx, dyCurrent input direction, normalised to length ≤ 1. Setting it does not move the player.
scoreOrbs collected.
connectedfalse while inside the disconnect grace window.
disconnectedAtTimestamp, not a setTimeout — see tickGame.
lastCollectAtLeaderboard 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

ACTIVEFINISHED (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

FunctionReturns
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:

  1. dt is real elapsed time. A stalled loop moves players a consistent distance instead of teleporting or freezing them.
  2. The grace period is a timestamp comparison, not a timer. No setTimeout per 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 future now.

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 ABCDEFGHJKLMNPQRSTUVWXYZ23456789no 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
  1. 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.)
  2. 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 > 0 distinguishes this from a brand-new lobby, which is exactly where the first player should go.
  3. 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.

Built with LogoFlowershow