2. End-to-end flows
2. End-to-end flows
Each flow traces one user action through every layer. Function names match the source exactly.
Joining a lobby
Trigger: the player types a name and clicks Quick Play.
[browser] main.js attemptJoin()
└─▶ net.js join({username}) emit 'join' with an ack callback
│
▼
[server] handlers.js socket.on('join')
1. guard() wrap so a throw becomes a message, not a crash
2. socket.data.gameId set? → reject ALREADY_IN_GAME
3. playerToken present? → try the reconnect path (see below)
4. validateUsername() → reject USERNAME_TOO_SHORT / _INVALID / _TOO_LONG
5. resolveGame() → by gameId, by code, or quickPlay() matchmaking
6. isJoinable() → reject LOBBY_FULL / GAME_NOT_ACTIVE
7. addPlayer() → reject USERNAME_TAKEN, else create the player
8. socket.data.gameId/playerId = ... remember who this connection is
9. socket.join(game.id) ◀── the entire isolation mechanism
10. emit 'joined' → this socket only (carries the secret playerToken)
11. emit 'player:joined' → everyone else in the room
12. emit 'leaderboard' → the whole room
│
▼
[browser] net.js 'joined' → saveSession({gameId, playerToken}) to sessionStorage
main.js onJoined() → renderer.reset(state) → showGame()
Why matchmaking happens over the socket, not HTTP. If POST /quick-play
picked a lobby and the socket joined it a moment later, the lobby could fill in
between. Doing find-and-seat inside one synchronous handler closes that gap — the
same single-threaded argument that makes tryCollect safe.
Quick Play's three rules (quickPlay() in src/game/lobbyManager.js):
- Skip a lobby where the name is taken — matchmaking should find you somewhere to play, not refuse you over a lobby you never chose.
- Skip a lobby whose players have all disconnected. Those are ghosts inside their grace window; seating you there gives you a "live game" of grey blobs.
- Otherwise prefer the lobby with the most connected players, so people gather into real games instead of scattering across half-empty ones.
Joining by code skips rules 2 and 3: you picked that lobby deliberately, so joining a friend who is briefly disconnected still works.
Moving
[browser] input.js keydown/keyup → held keys → {dx, dy} direction
(a fixed 20/sec timer, and only when the direction CHANGED)
└─▶ net.js sendInput(dx, dy) emit 'input' (no ack — fire and forget)
│
▼
[server] handlers.js socket.on('input')
1. rate limit (60/sec) → silently dropped past the ceiling
2. validateDirection() → silently ignored unless two finite numbers
3. setInput(game, playerId, dx, dy)
└─ normalises to length ≤ 1, stores on the player. MOVES NOBODY.
│
▼ (up to 50 ms later)
[server] broadcaster.js tick()
tickGame(game, dt, now)
└─ per connected player: pos += direction × speed × dt, clamped to walls
io.to(game.id).emit('state', getSnapshot(game))
│
▼
[browser] main.js onState() → renderer.applySnapshot()
render.js draws at 60 fps, easing 25% toward the server position/frame
Clients send a direction, never a position. The server owns every coordinate. This is what makes the distance check on collection meaningful — if client positions were trusted, the anti-cheat check would be validating the cheater's own numbers.
Input rate is decoupled from broadcast rate. setInput deliberately does not
move the player or send anything; movement happens only in the tick. That is the
whole performance story (see 01-architecture.md).
dt is real elapsed time, not a hard-coded 1/20, so a stalled loop moves
players a consistent distance rather than teleporting or freezing them. It is
clamped to 0.25 s so a paused process (debugger, laptop sleeping) does not fling
everyone across the map on resume.
Collecting a collectible — the contested path
This is the part the assessment is really testing.
[browser] main.js onState() → tryCollectNearby()
renderer.collectiblesNearMe() → ids within the pickup radius
(a 400 ms per-orb cooldown stops re-asking every frame)
└─▶ net.js collect(id) emit 'collect' WITH an ack
│
▼
[server] handlers.js socket.on('collect')
1. rate limit (25/sec)
2. validateId()
3. tryCollect(game, playerId, collectibleId) ◀── synchronous, atomic
4a. ok → emit 'collected' + 'leaderboard' to the ROOM, ack the winner
4b. fail → ack only the requester, with the reason
│
▼
[browser] onCollected() → remove the orb, update the counter
a loser told ALREADY_COLLECTED drops the orb locally too,
so no ghost orb is left on screen
Inside tryCollect (src/game/game.js):
if (item.collectedBy !== null) return { ok: false, reason: 'ALREADY_COLLECTED', ... }; // CHECK
if (distance > reach) return { ok: false, reason: 'TOO_FAR' };
item.collectedBy = playerId; // CLAIM
player.score += 1;
game.remaining -= 1;
Why no lock is needed. Node's single thread runs this function to completion
before any other socket event gets a turn. The second player always finds
collectedBy already set.
The property doing the work: no await between the CHECK and the CLAIM. Add
one and the function yields mid-way, the other handler runs, both see an
unclaimed orb, and both score. Persistence therefore happens at game end, never
inside this function.
The equivalent in other settings:
| Where state lives | Correct mechanism |
|---|---|
| one Node process (here) | synchronous check-and-claim; no lock |
| Postgres | UPDATE ... SET collected_by = $1 WHERE id = $2 AND collected_by IS NULL, then check the affected row count |
| multiple servers | an atomic primitive in shared state, e.g. Redis SET NX |
The game does not end here. isFinished() is checked by the tick loop, so
there is exactly one code path that ends a game.
Ending a game
[server] broadcaster.js tick()
isFinished(game)? (remaining === 0)
└─▶ endGame(game, 'FINISHED')
1. finishGame() → returns false if already ended (idempotent)
2. emit 'game:over' to the room ◀── FROM MEMORY, before any DB call
3. release every socket: clear socket.data + socket.leave(room)
4. removeGame() from the lobbyManager
5. persistGame(game) → NOT awaited; failures logged, not thrown
│
▼
[browser] net.js clears the stored session → main.js showGameOver() overlay
Order matters and is deliberate:
- Players see the final leaderboard before Postgres is touched. If the database is down the match still completes correctly; only the history row is lost, and that is logged.
- Sockets are released after the
game:overemit — they must still be in the room to receive it. Skipping this step was a real bug: the socket kept the dead game's id, so the nextjoinhit theALREADY_IN_GAMEguard and "Play again" failed on a connection that was in fact free. persistGameis not awaited, so the tick loop never blocks on the database.
A lobby also ends as ABANDONED when everyone has left (players.size === 0
and playerSeq > 0). The playerSeq check distinguishes "everybody left"
from "nobody has arrived yet" — without it, a lobby created over HTTP would be
destroyed on the very next tick, before anyone could use the join code.
Disconnecting and reconnecting
A dropped connection is not a departure.
socket closes
└─▶ handlers.js socket.on('disconnect')
markDisconnected(game, playerId)
connected = false, disconnectedAt = now, dx = dy = 0 (stop sliding)
emit 'player:left' {reason:'DISCONNECTED'} + 'leaderboard' to the room
│
├── player returns within 30 s ─────────────────────────────────┐
│ browser reconnects; net.js auto-emits 'join' with the │
│ stored playerToken │
│ reconnectPlayer() → same player id, same score, same spot │
│ │
└── grace expires ──────────────────────────────────────────────┘
broadcaster tick → tickGame() returns the id in `expired`
removePlayer() frees the slot AND the username
emit 'player:left' {reason:'TIMEOUT'}
The grace period is a timestamp checked in the tick, not a setTimeout. That
keeps the logic pure, avoids thousands of timers at scale, and lets a test verify
a 30-second expiry instantly by passing a future clock value:
tickGame(game, 0.05, Date.now() + config.lobby.disconnectGraceMs);
The username stays claimed during the grace window. Releasing it early would let someone take the name and produce two identical rows on the leaderboard the moment the original player reconnects.
Socket.IO reconnects the transport on its own. The playerToken restores the
player on top of it — a different thing, and the reason a mid-game refresh
keeps your score.
Reading the all-time leaderboard
The only flow that is plain request/response, with no sockets involved.
[browser] main.js loadAllTime() fetch('/api/leaderboard?limit=10')
│
▼
[server] app.js → leaderboard.routes.js → leaderboard.controller.js
getLeaderboardHandler()
└─▶ leaderboardService.getAllTimeLeaderboard({limit})
clamp limit to 1..100
prisma.playerStat.findMany({ orderBy: totalScore desc, take })
│
▼
[browser] rendered with createElement + textContent (never innerHTML)
One indexed ORDER BY ... LIMIT n on a pre-aggregated table, so the cost does
not grow with match history. See 07-persistence.md.