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, or null while 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 number 12.
  • Subtract them. Negative means a comes 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:

FieldMeaning
idthe room code, e.g. "WDZYAP"
configall the settings
rngthe random generator for this room
_statuswaitingcountdownrunningfinished
_tickhow many heartbeats have happened
startsAtwhen the countdown ends
startedAt / finishedAtfor measuring how long the game took
playersa Map from player id to their record
collectiblesan array of all collectibles
remaininghow many collectibles are still available
emptySincewhen the room last had nobody in it
leaderboardCache / leaderboardDirtysee 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 from playerCount, 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 at ok to 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 is p1, the next p2.
  • 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/inputY zeroed, and movePlayers skips them).
  • They can no longer collect anything (resolveCollisions skips 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 as let dx = input.dx; let dy = input.dy;
  • Number.isFinite(dx) — rejects NaN and Infinity. Without this, one bad message could turn a player's position into NaN permanently, and every comparison against NaN is 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:

  1. now - this.lastTickAt — milliseconds elapsed, e.g. 50.
  2. Math.max(…, 0) — never negative (clocks can misbehave).
  3. / 1000 — convert to seconds: 0.05.
  4. 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) continue skips 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. The token is not copied. This is where the secret is stripped.
  • The sort comparator: if scores differ, b.score - a.score sorts 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:

ScoreRank
101
72
72 ← same score, same rank
34 ← 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:

  1. It finished, and 20 seconds have passed — long enough for everyone to read the final scoreboard.
  2. 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 await out 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."

Built with LogoFlowershow