src/domain/GameRoom.ts — one lobby, and every game rule
src/domain/GameRoom.ts — one lobby, and every game rule
In one sentence: this class is a lobby. It owns the players, their positions, the collectibles, the scores and the leaderboard, and it is the only code allowed to change any of them.
Size: 686 lines — the biggest file in the project, and the one to understand first.
Why this file exists
Everything that must stay consistent lives inside one GameRoom. That is a
deliberate choice with a name: the room is the consistency boundary.
Because a room never reads another room's data, two lobbies can never interfere with each other. There is no shared scoreboard to corrupt, no shared list of collectibles. Lobby A finishing has no effect on Lobby B whatsoever.
The second big decision: this file does no input or output. It never opens a
socket, never touches the database, never sets a timer, and never asks what time
it is. Time arrives as a parameter called now. Randomness arrives as an object.
That sounds like extra work, but it is what makes the game testable. A test can say "pretend it is 51 milliseconds later" and check exactly what happened, without waiting and without mocking anything. 53 of the 70 tests exercise this file with no network, no database and no real clock.
The code, line by line
1. Imports (lines 1–17)
import { randomUUID } from 'node:crypto';
import { GameConfig } from '../config/gameConfig';
import { Rng } from './rng';
import { CollectibleState, FinishReason, /* … */ } from './types';
randomUUID— the one and only thing this file imports from outside the game rules. It produces unguessable strings for player tokens. Explained fully in section 16.GameConfig— the settings object (world size, speed, how many collectibles).Rng— the random number generator shape, not a specific generator. The real one is handed in, so a test can hand in a predictable one.- The rest are type definitions from
types.ts. Types disappear when the code compiles; they only catch mistakes while you write.
Notice what is not imported: no Socket.IO, no Prisma, no express. That is
the rule this file lives by.
2. The private player record (lines 19–31)
interface PlayerRecord extends PlayerState {
token: string;
inputX: number;
inputY: number;
disconnectedAt: number | null;
}
PlayerState (in types.ts) is what the browser is allowed to see: id, username,
x, y, score. PlayerRecord extends it — same fields, plus four the browser
must never see:
token— the player's secret. If this ever leaked in a broadcast, anyone could steal that player's slot. There is a test asserting it never appears in a snapshot.inputX,inputY— the direction this player last asked to move. Kept between ticks, so holding a key keeps you moving without sending a message every frame.disconnectedAt— the moment they dropped, ornullwhile connected. Used to decide when an abandoned username can be released.
3. Two safety constants (lines 33–37)
const INPUT_TTL_MS = 600;
const MAX_TICK_DELTA_SEC = 0.25;
INPUT_TTL_MS = 600 — if we have not heard from a player for 600
milliseconds, we stop moving them.
Why: the browser sends "I'm moving right" and later "I stopped". If that second message is lost, the player would slide across the map forever. Expiring old input turns a lost packet into a small glitch instead of a permanent bug.
MAX_TICK_DELTA_SEC = 0.25 — never simulate more than a quarter second in
one step.
Why: if the server stalls for 3 seconds (garbage collection, a slow moment), the next tick would see a 3-second gap and move everyone 780 pixels — a teleport across the whole map. Capping it turns a stall into a stutter.
4. Comparing player ids (lines 39–45)
function comparePlayerIds(a: string, b: string): number {
return Number(a.slice(1)) - Number(b.slice(1));
}
Player ids look like "p1", "p2", "p12".
a.slice(1)— drop the first character, leaving"12".Number(...)— turn the text"12"into the number12.- Subtract them. Negative means
acomes first.
Why not just compare the strings? Because text comparison puts "p12" before
"p2" (it compares 1 against 2 at the second character). Comparing as
numbers gives real join order. Used only to break an exact tie when two players
sit at identical distance from a collectible.
5. The class fields (lines 66–95)
export class GameRoom {
readonly id: string;
readonly createdAt: number;
private readonly config: GameConfig;
private readonly rng: Rng;
private _status: GameStatus = 'waiting';
private _tick = 0;
private startsAt: number | null = null;
// …
private readonly players = new Map<string, PlayerRecord>();
private readonly collectibles: CollectibleState[] = [];
private remaining: number;
Keywords first:
readonly— set once in the constructor, never changed afterwards.private— only code inside this class can touch it. Nothing outside can reach in and change a score.
The fields:
| Field | Meaning |
|---|---|
id | the room code, e.g. "WDZYAP" |
config | all the settings |
rng | the random generator for this room |
_status | waiting → countdown → running → finished |
_tick | how many heartbeats have happened |
startsAt | when the countdown ends |
startedAt / finishedAt | for measuring how long the game took |
players | a Map from player id to their record |
collectibles | an array of all collectibles |
remaining | how many collectibles are still available |
emptySince | when the room last had nobody in it |
leaderboardCache / leaderboardDirty | see section 12 |
Why a Map for players but an array for collectibles? Players are looked up by
id constantly (players.get('p3')), and a Map does that instantly. Collectibles
are only ever walked through from start to finish, which an array does perfectly
well.
The underscore in _status is a convention: the raw field is private, and a
public read-only status is exposed below it. Outside code can read the status
but cannot set it.
6. The constructor (lines 97–106)
constructor(id: string, config: GameConfig, rng: Rng, now: number) {
this.id = id;
this.config = config;
this.rng = rng;
this.createdAt = now;
this.lastTickAt = now;
this.emptySince = now;
this.remaining = config.collectibleCount;
this.spawnCollectibles();
}
Everything the room needs is handed in, not fetched. Look at the last
parameter: now. The room does not call Date.now() — it is told the time.
That single choice is what makes every rule in this file testable.
spawnCollectibles() scatters the collectibles using the injected generator, so
the same seed always produces the same board.
7. Read-only accessors (lines 112–142)
get status(): GameStatus { return this._status; }
get connectedCount(): number {
let n = 0;
for (const p of this.players.values()) if (p.connected) n++;
return n;
}
get isJoinable(): boolean {
return this._status !== 'finished' && this.connectedCount < this.config.maxPlayersPerRoom;
}
A get makes a function look like a plain property — you write room.status,
not room.status().
connectedCount— counts only players currently online. Different fromplayerCount, which includes people who dropped but stay on the leaderboard.isJoinable— a room accepts new players if it has not finished and has a free seat.
8. addPlayer — the most branching function in the file (lines 152–252)
This one function handles four different situations. Let us take them in order.
8a. Reject bad input
const name = username.trim();
if (name.length < this.config.usernameMinLength ||
name.length > this.config.usernameMaxLength) {
return { ok: false, reason: 'invalid_username' };
}
if (this._status === 'finished') {
return { ok: false, reason: 'room_finished' };
}
.trim()removes spaces at both ends, so" bob "becomes"bob"and" "becomes""(which then fails the length check).- Notice the return shape:
{ ok: false, reason: '…' }. This function never throws an exception. A refused join is a normal outcome, not a crash, and the caller has to look atokto find out which.
8b. The name is already in use by someone online
const existing = this.findByUsername(name);
if (existing) {
if (existing.connected) return { ok: false, reason: 'username_taken' };
Someone is actively playing under that name. Refuse.
8c. The name belongs to someone who dropped
if (!token || token !== existing.token) {
const abandonedLongEnough =
existing.disconnectedAt !== null &&
now - existing.disconnectedAt >= this.config.usernameReleaseMs;
if (existing.score === 0 && abandonedLongEnough) {
this.players.delete(existing.id);
this.leaderboardDirty = true;
// fall through and admit them as a brand-new player
} else {
return { ok: false, reason: 'name_reserved' };
}
}
This is the most carefully-reasoned block in the file, so it is worth slowing down.
The slot belongs to a disconnected player, and whoever is asking has not produced the right token. Two bad options present themselves:
- Hand it over anyway → anyone who sees "rishabh is winning" can join as "rishabh" and inherit the score. That is session theft.
- Refuse forever → a player who closed their tab and lost their token is locked out of their own name for the rest of the game.
The rule chosen: does a result exist that is worth protecting?
- They have scored (
score > 0) → their username is part of the leaderboard record. Releasing it would corrupt the final standings. Refuse, always. - They have not scored → there is nothing to corrupt. After
usernameReleaseMs(20 seconds) the old record is deleted and the newcomer is admitted fresh.
return { ok: false, reason: 'name_reserved' } — note this is a different
reason from username_taken. "Someone is playing under that name" and "that is
your own slot but you cannot prove it" need different advice, so the browser can
show different messages.
8d. The token matches — reconnect them
} else {
existing.connected = true;
existing.disconnectedAt = null;
existing.inputX = 0;
existing.inputY = 0;
existing.lastInputAt = now;
this.emptySince = null;
this.leaderboardDirty = true;
return { ok: true, player: existing, token: existing.token, reconnected: true, events: [...] };
}
The same record is reused, so the score survives. Movement is reset to zero so they do not resume sliding in whatever direction they were heading when their connection died.
reconnected: true lets the browser say "your score was restored" instead of
"joined".
8e. A genuinely new player
if (this.connectedCount >= this.config.maxPlayersPerRoom) {
return { ok: false, reason: 'room_full' };
}
const spawn = this.pickSpawnPoint();
const player: PlayerRecord = {
id: `p${this.nextPlayerSeq++}`,
username: name,
x: spawn.x,
y: spawn.y,
score: 0,
connected: true,
lastInputAt: now,
lastSeq: 0,
token: this.makeToken(),
inputX: 0,
inputY: 0,
disconnectedAt: null,
};
this.players.set(player.id, player);
`p${this.nextPlayerSeq++}`— a template string.nextPlayerSeq++uses the current value then adds one, so the first player isp1, the nextp2.token: this.makeToken()— their secret, generated fresh.players.set(...)— file the record under its id.
The function returns events: [{ type: 'player_joined', … }] rather than telling
anyone directly. The room announces; it does not act. The socket layer decides
what to broadcast, and the persistence layer decides what to save. Neither can
interfere with the game.
9. disconnectPlayer (lines 258–277)
disconnectPlayer(playerId: string, now: number): GameEvent[] {
const player = this.players.get(playerId);
if (!player || !player.connected) return [];
player.connected = false;
player.disconnectedAt = now;
player.inputX = 0;
player.inputY = 0;
this.leaderboardDirty = true;
if (this.connectedCount === 0) this.emptySince = now;
return [{ type: 'player_left', roomId: this.id, playerId, username: player.username }];
}
The record is not deleted. That is the important part. The player is marked disconnected:
- They stop moving (
inputX/inputYzeroed, andmovePlayersskips them). - They can no longer collect anything (
resolveCollisionsskips them). - They stay on the leaderboard with the score they earned.
Deleting them would quietly rewrite the result of a game that already happened.
if (!player || !player.connected) return [] — guards against being called twice
for the same socket, which does happen.
10. setInput — where cheating is stopped (lines 285–316)
setInput(playerId: string, input: PlayerInput, now: number): void {
const player = this.players.get(playerId);
if (!player || !player.connected) return;
if (this._status !== 'running') return;
let { dx, dy } = input;
if (!Number.isFinite(dx) || !Number.isFinite(dy)) return;
const magnitude = Math.hypot(dx, dy);
if (magnitude > 1) {
dx /= magnitude;
dy /= magnitude;
}
player.inputX = dx;
player.inputY = dy;
player.lastInputAt = now;
let { dx, dy } = input;— destructuring. Pulls two fields out of the object into their own variables. Same aslet dx = input.dx; let dy = input.dy;Number.isFinite(dx)— rejectsNaNandInfinity. Without this, one bad message could turn a player's position intoNaNpermanently, and every comparison againstNaNis false, so they could never collect anything again.Math.hypot(dx, dy)— the length of the arrow(dx, dy). Pythagoras: √(dx² + dy²).
The anti-cheat, concretely. A modified browser sends {dx: 1000, dy: 0}
hoping to move a thousand times further. Math.hypot(1000, 0) is 1000, which is
greater than 1, so both parts are divided by 1000, giving {dx: 1, dy: 0}. Same
direction, length 1. How far they actually move is decided entirely by the
server's playerSpeed.
A direction says which way, never how fast.
Input coalescing. Notice this only stores the direction — it does not move
anyone. Movement happens once per tick. So a client sending 500 messages a second
costs the simulation exactly as much as one sending 20: the extra messages just
overwrite inputX/inputY and are then forgotten. That is why the rate limiter
protects bandwidth, not correctness.
11. update — one heartbeat (lines 326–381)
The most important function. Called 20 times a second for every room.
update(now: number): GameEvent[] {
const events: GameEvent[] = [];
const deltaSec = Math.min(Math.max(now - this.lastTickAt, 0) / 1000, MAX_TICK_DELTA_SEC);
this.lastTickAt = now;
this._tick++;
deltaSec is how much time passed since the last tick, in seconds. Read it
inside out:
now - this.lastTickAt— milliseconds elapsed, e.g.50.Math.max(…, 0)— never negative (clocks can misbehave)./ 1000— convert to seconds:0.05.Math.min(…, 0.25)— cap it, so a stall cannot teleport anyone.
Movement is calculated from real elapsed time, so players move at the same speed whether the tick was early or late.
The state machine
switch (this._status) {
case 'waiting':
if (this.connectedCount >= this.config.minPlayersToStart) {
this._status = 'countdown';
this.startsAt = now + this.config.countdownMs;
}
break;
waiting — nobody is playing yet. Once enough players have joined, move to countdown and record when it should end.
case 'countdown':
if (this.connectedCount === 0) {
this._status = 'waiting';
this.startsAt = null;
} else if (this.startsAt !== null && now >= this.startsAt) {
this._status = 'running';
this.startedAt = now;
this.startsAt = null;
events.push({ type: 'game_started', … });
}
break;
countdown — a fairness pause so everyone starts together. If everyone leaves during it, go back to waiting. Once the countdown time is reached, start.
game_started is pushed to the events list exactly once, because the status is
now running and this branch will never run again.
case 'running':
this.movePlayers(deltaSec, now);
this.resolveCollisions(events);
if (this.remaining === 0) {
events.push(this.finish('completed', now));
}
break;
case 'finished':
break;
}
running — the real game. Move, then check collisions, then check whether the last collectible has gone.
Order matters. Move first, then collide. If it were reversed, you would be checking collisions against where players used to be.
finished — do nothing. The game is over.
Abandonment
if (
this._status !== 'finished' &&
this.players.size > 0 &&
this.connectedCount === 0 &&
this.emptySince !== null &&
now - this.emptySince >= this.config.emptyRoomTtlMs
) {
events.push(this.finish('abandoned', now));
}
A room that had players and lost them all is finished as abandoned, so its
result is still recorded rather than silently vanishing.
this.players.size > 0 is the important condition: a room nobody ever joined
is not a game, so it is not recorded — GameManager just deletes it quietly.
12. movePlayers (lines 383–406)
private movePlayers(deltaSec: number, now: number): void {
if (deltaSec <= 0) return;
const speed = this.config.playerSpeed;
const r = this.config.playerRadius;
const maxX = this.config.worldWidth - r;
const maxY = this.config.worldHeight - r;
for (const p of this.players.values()) {
if (!p.connected) continue;
if (now - p.lastInputAt > INPUT_TTL_MS) {
p.inputX = 0;
p.inputY = 0;
continue;
}
if (p.inputX === 0 && p.inputY === 0) continue;
const nx = p.x + p.inputX * speed * deltaSec;
const ny = p.y + p.inputY * speed * deltaSec;
p.x = nx < r ? r : nx > maxX ? maxX : nx;
p.y = ny < r ? r : ny > maxY ? maxY : ny;
}
}
The settings are pulled into local variables before the loop. Reading
this.config.playerSpeed inside a loop that runs 2,000 times a tick is wasted
work; reading it once is free.
Three continue guards skip players who need no work: disconnected, stale input,
or standing still.
The movement sum:
new position = old position + direction × speed × time
With speed = 260 pixels per second and deltaSec = 0.05, a player pressing
right moves 1 × 260 × 0.05 = 13 pixels this tick.
The clamp uses nested ternaries. a ? b : c means "if a then b else c". So
nx < r ? r : (nx > maxX ? maxX : nx) reads as:
- If the new x is left of the wall → put them at the wall.
- Else if it is right of the far wall → put them at that wall.
- Else → use the new x.
Subtracting the radius r means the edge of the circle stops at the wall, not
its centre — so players never appear half-buried in the boundary.
13. resolveCollisions — the exactly-once guarantee (lines 421–480)
The most important function in the project. This is what an interviewer is most likely to dig into.
const captureDist = this.config.playerRadius + this.config.collectibleRadius;
const captureDistSq = captureDist * captureDist;
Two circles touch when the gap between their centres is less than their radii
added together. Here: 14 + 10 = 24 pixels.
Why squared? Working out a true distance needs a square root, which is slow,
and this loop runs about 12,000 times per tick. We do not need the actual
distance — only to know which of two distances is smaller. Squaring preserves that
order (for positive numbers, if a < b then a² < b²), so we compare squares and
skip the square root entirely.
for (const c of this.collectibles) {
if (!c.active) continue;
let winner: PlayerRecord | null = null;
let bestDistSq = captureDistSq;
for (const p of this.players.values()) {
if (!p.connected) continue;
const dx = p.x - c.x;
const dy = p.y - c.y;
const distSq = dx * dx + dy * dy;
if (distSq > captureDistSq) continue;
if (winner === null || distSq < bestDistSq) {
winner = p;
bestDistSq = distSq;
} else if (distSq === bestDistSq && comparePlayerIds(p.id, winner.id) < 0) {
winner = p;
}
}
Note the loop order: collectibles on the outside, players on the inside.
That is deliberate. The naive version loops over players and gives each one whatever they are touching — which means the winner depends on the order players happen to be stored in. That is an accident, not a rule.
Looping over collectibles instead lets us ask a proper question: of everyone touching this coin, who is nearest? The answer comes from the game state, not from internal storage order.
comparePlayerIds(p.id, winner.id) < 0 handles an exact tie — identical distance
to the decimal. Then the earlier joiner wins. It is arbitrary but deterministic,
so a test gets the same answer every run.
if (winner === null) continue;
c.active = false;
this.remaining--;
winner.score++;
this.leaderboardDirty = true;
events.push({ type: 'collectible_claimed', … });
}
These four lines are the whole guarantee.
JavaScript runs one thing at a time. There is no "meanwhile". Once this code starts, nothing else can run until it finishes. So:
- Two players cannot both be awarded — the second never gets a look, because the
first pass already set
c.active = false. - A later tick cannot re-award it —
if (!c.active) continueskips it forever.
No lock is needed, and here is the part that is actually engineering: that is
only true while nothing pauses in the middle. In JavaScript await is a pause —
the function stops, other code runs, and it resumes later.
One convenient line like await saveToDatabase(...) right here would silently
reintroduce the exact bug we are claiming to have solved. Two players could both
pass the if (!c.active) check during the pause and both score.
That is why the database work is pushed outside this class entirely. The room pushes an event to a list and moves on; something else deals with saving, later.
14. finish (lines 482–496)
private finish(reason: FinishReason, now: number): GameEvent {
this._status = 'finished';
this.finishedAt = now;
this.finishReason = reason;
return {
type: 'game_finished',
roomId: this.id,
reason,
startedAt: this.startedAt,
finishedAt: now,
durationMs: this.startedAt === null ? 0 : now - this.startedAt,
collectiblesTotal: this.config.collectibleCount,
leaderboard: this.getLeaderboard(),
};
}
Sets the status first, which is what guarantees this can only happen once — every
later tick hits case 'finished': break;.
reason is either 'completed' (all collectibles taken) or 'abandoned'
(everyone left). Both are saved to the database, so an abandoned game is still
part of the record.
durationMs guards against startedAt being null, which happens if a room is
abandoned before the countdown ever finished.
15. getLeaderboard — ranking (lines 504–529)
getLeaderboard(): LeaderboardEntry[] {
if (!this.leaderboardDirty) return this.leaderboardCache;
Caching. The leaderboard is rebuilt only when something actually changed.
leaderboardDirty is set to true whenever a player joins, leaves or scores. At
100 rooms × 20 times a second that avoids a lot of pointless sorting.
const entries = Array.from(this.players.values()).map((p) => ({ … rank: 0 }));
entries.sort((a, b) =>
b.score !== a.score ? b.score - a.score : a.username.localeCompare(b.username),
);
Array.from(this.players.values())— turn the Map into a plain array..map(...)— build a new object for each player, copying only public fields. Thetokenis not copied. This is where the secret is stripped.- The sort comparator: if scores differ,
b.score - a.scoresorts highest first. If scores are equal, fall back to comparing usernames alphabetically, so ties always render in the same order instead of jumping about.
for (let i = 0; i < entries.length; i++) {
const prev = entries[i - 1];
const cur = entries[i]!;
cur.rank = prev && prev.score === cur.score ? prev.rank : i + 1;
}
Standard competition ranking. If two players tie they share a rank, and the next rank is skipped:
| Score | Rank |
|---|---|
| 10 | 1 |
| 7 | 2 |
| 7 | 2 ← same score, same rank |
| 3 | 4 ← 3 is skipped |
prev.score === cur.score ? prev.rank : i + 1 — same score as the person above?
Take their rank. Otherwise take your position in the list.
16. getSnapshot — what gets sent to browsers (lines 536–571)
getSnapshot(includeCollectibles = true): RoomSnapshot {
const players = [];
for (const p of this.players.values()) {
players.push({
id: p.id,
username: p.username,
x: Math.round(p.x),
y: Math.round(p.y),
score: p.score,
connected: p.connected,
});
}
Fields are copied one at a time, on purpose. Copying the whole record would leak the token.
Math.round on positions — x: 412 instead of x: 412.38471629. Fewer
characters to send, 20 times a second, to every player. Nobody can see a third of
a pixel.
let collectibles: Array<{ id: string; x: number; y: number }> | undefined;
if (includeCollectibles) {
collectibles = [];
for (const c of this.collectibles) {
if (c.active) collectibles.push({ id: c.id, x: c.x, y: c.y });
}
}
This is a measured optimisation, not a guess. Collectibles never move, so sending all of them 20 times a second was the single biggest cost in the whole server.
Now the full list goes out only when you join and once every 2 seconds after
that. In between, the browser removes them one at a time as claim events
arrive. Payload shrank by roughly 20×, and ticks over budget dropped from 16 to 3.
...(collectibles === undefined ? {} : { collectibles }) — spread syntax that
adds the field only when there is one. Sending collectibles: undefined would
waste bytes.
serverTime: this.lastTickAt — lets a client measure how long delivery took.
17. isReapable — when to delete the room (lines 588–602)
isReapable(now: number): boolean {
if (this._status === 'finished' && this.finishedAt !== null &&
now - this.finishedAt >= this.config.finishedRoomTtlMs) {
return true;
}
return (
this.players.size === 0 &&
this.emptySince !== null &&
now - this.emptySince >= this.config.emptyRoomTtlMs
);
}
Two ways a room dies:
- It finished, and 20 seconds have passed — long enough for everyone to read the final scoreboard.
- Nobody ever joined — a room created by accident. Deleted quietly with no database record, because it was never a game.
18. spawnCollectibles and pickSpawnPoint (lines 610–652)
private spawnCollectibles(): void {
const margin = this.config.collectibleRadius + 20;
for (let i = 0; i < this.config.collectibleCount; i++) {
this.collectibles.push({
id: `c${this.nextCollectibleSeq++}`,
x: Math.round(this.rng.range(margin, this.config.worldWidth - margin)),
y: Math.round(this.rng.range(margin, this.config.worldHeight - margin)),
active: true,
});
}
}
margin keeps collectibles away from the edges, where they would be awkward to
reach. Positions come from the injected generator, so the same seed gives the same
board.
private pickSpawnPoint(): { x: number; y: number } {
const clearance = this.config.playerRadius + this.config.collectibleRadius + 30;
const clearanceSq = clearance * clearance;
let candidate = { x: 0, y: 0 };
for (let attempt = 0; attempt < 10; attempt++) {
candidate = { x: …, y: … };
let clear = true;
for (const c of this.collectibles) {
if (!c.active) continue;
const dx = candidate.x - c.x;
const dy = candidate.y - c.y;
if (dx * dx + dy * dy < clearanceSq) { clear = false; break; }
}
if (clear) return candidate;
}
return candidate;
}
Players should not spawn sitting on a collectible and score for free. So: pick a random spot, check it is clear of every collectible, and if not, try again.
attempt < 10 matters. On a crowded map a perfect spot might not exist, and
an unbounded loop would hang the server forever. After ten tries we accept the
last candidate. Slightly imperfect beats frozen.
19. makeToken — the one security exception (lines 663–685)
private makeToken(): string {
return randomUUID();
}
One line, but it carries the most important reasoning in the file.
Everything else here uses this.rng, the seeded generator, on purpose — so
tests can replay a board. But a seeded generator is replayable by definition,
and this room is seeded from its room code, which players share with each other.
An earlier version built tokens from this.rng. That was a genuine vulnerability:
anyone holding a room code could reconstruct the sequence and forge another
player's token, then steal their slot and score.
A token is a security credential. It comes from the operating system's cryptographic randomness, which is unpredictable by design.
Game randomness wants to be replayable. A credential must never be.
If an interviewer asks
"How do you guarantee only one player gets the point?"
"Node runs one thing at a time, so the check and the update happen in the same uninterrupted step — nothing can slip between them. The real work was protecting that: keeping every
awaitout of the collision path, which is why the database work is an event listener outside this class rather than something the game waits on."
"Why loop over collectibles rather than players?"
"So the winner is the nearest player, not whoever happened to be stored first. Otherwise the result depends on internal Map ordering, which is an accident rather than a rule."
"How is this class testable?"
"It has no hidden inputs. Time comes in as a parameter, randomness is injected, and it does no I/O at all. A test constructs a room, calls
update(51), and asserts on exactly what happened — no mocks, no waiting, no network."