src/domain/GameManager.ts — the lobby registry
src/domain/GameManager.ts — the lobby registry
In one sentence: holds every lobby, decides which one you join, drives them all from one clock, and passes on the announcements they make.
Size: 248 lines. Depends on: GameRoom, rng, gameConfig, types.
Why this file exists
GameRoom knows how to run one lobby. Something has to hold the collection of
them and answer three questions:
- Which lobby does this player go into?
- Which lobbies still exist?
- Who wants to know when something happens?
The dividing line is strict: GameManager contains no game rules. It never
moves a player, never awards a point, never touches a score. If you find yourself
adding a rule here, it belongs in GameRoom.
The scaling seam. This class is where multi-server support would be added later. Because a room is self-contained and never reads another room's state, spreading lobbies across processes means changing only how
getRoomandfindJoinableRoomfind a room — the game logic is untouched.
The code, line by line
1. Imports and the handler type (lines 1–6)
import { GameConfig } from '../config/gameConfig';
import { GameRoom } from './GameRoom';
import { createRng, Rng } from './rng';
import { GameEvent, JoinResult } from './types';
export type GameEventHandler = (event: GameEvent) => void;
GameEventHandler describes a function shape: something that takes an event
and returns nothing. Anyone wanting to be told about game events supplies a
function of this shape.
Still no Socket.IO and no Prisma. The domain folder stays clean.
2. The room code alphabet (lines 15–16)
const ROOM_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
Look carefully at what is missing: no I, no O, no 0, no 1.
Room codes get read aloud and typed by hand. 0 and O look identical in many
fonts, as do 1 and I. Removing them means a code can be shared over voice
without confusion. 32 characters remain.
3. Class fields (lines 31–38)
private readonly rooms = new Map<string, GameRoom>();
private readonly config: GameConfig;
private readonly rng: Rng;
private readonly handlers: GameEventHandler[] = [];
private roomSeq = 0;
rooms— a Map from room code ("WDZYAP") to the room object. A Map because looking up by code happens on every single join.handlers— the list of functions that want to hear about events. Two things subscribe: the socket layer (to broadcast) and the persistence layer (to save).roomSeq— a counter that increases forever. Used to make each room's seed different even if two rooms somehow got the same code.
4. The constructor (lines 40–43)
constructor(config: GameConfig, seed: number = Date.now()) {
this.config = config;
this.rng = createRng(seed);
}
seed: number = Date.now() — a default parameter. Called without a seed, it
uses the current time, so real servers get different room codes each start.
A test passes a fixed seed and gets predictable codes.
5. The event bus (lines 51–70)
onEvent(handler: GameEventHandler): void {
this.handlers.push(handler);
}
Anyone can register interest. This is the observer pattern: the game announces what happened, and whoever cares listens. The game itself has no idea who is listening or what they do about it.
private dispatch(events: GameEvent[]): void {
if (events.length === 0) return;
for (const event of events) {
for (const handler of this.handlers) {
try {
handler(event);
} catch (err) {
console.error('[GameManager] event handler failed', err);
}
}
}
}
if (events.length === 0) return;— most ticks produce no events at all. This skips two loops entirely, 20 times a second, for every room.- The
try/catchmatters. These handlers run inside the game tick. If the persistence subscriber threw an error, without this catch it would crash the tick — and with one global loop, that means every lobby on the server stops. Catching keeps one broken listener from taking down the game.
The rule for handlers: they must not block. The persistence subscriber starts a database write and returns immediately rather than waiting for it. That is what keeps
awaitout of the game's critical section.
6. Simple lookups (lines 76–86)
getRoom(roomId: string): GameRoom | undefined {
return this.rooms.get(roomId);
}
listRooms(): GameRoom[] {
return Array.from(this.rooms.values());
}
get roomCount(): number {
return this.rooms.size;
}
| undefined in the return type forces the caller to handle "no such room".
TypeScript will refuse to compile getRoom(x).status without a check first —
which is exactly the null-check bug it is designed to prevent.
7. createRoom (lines 88–98)
createRoom(now: number, roomId?: string): GameRoom {
const id = roomId ?? this.generateRoomCode();
const existing = this.rooms.get(id);
if (existing) return existing;
const room = new GameRoom(id, this.config, createRng(this.hashSeed(id, ++this.roomSeq)), now);
this.rooms.set(id, room);
return room;
}
roomId?: string— the?means optional.roomId ?? this.generateRoomCode()— the nullish coalescing operator. "UseroomId, but if it is null or undefined, generate one instead."if (existing) return existing;— asking for a room that already exists gives you that room rather than wiping it out. Without this, two people typing the same code at the same moment could destroy each other's lobby.createRng(this.hashSeed(id, ++this.roomSeq))— each room gets its own generator, seeded from its code. Two rooms therefore have different layouts, and the same code always produces the same board.
8. findJoinableRoom — matchmaking (lines 107–117)
findJoinableRoom(now: number): GameRoom {
let best: GameRoom | null = null;
for (const room of this.rooms.values()) {
if (!room.isJoinable) continue;
if (room.status === 'finished') continue;
if (best === null || room.connectedCount > best.connectedCount) best = room;
}
return best ?? this.createRoom(now);
}
Walk every room and keep the fullest one that still has space.
Why fullest rather than emptiest? If you spread players out one per lobby, nobody ever has anyone to play against. Filling up the busiest lobby first means games start sooner and feel populated.
return best ?? this.createRoom(now) — if no room qualified, open a new one.
On performance: this walks every room on every join. With a few hundred rooms that is microseconds, and a secondary index would be extra state to keep correct for no measurable gain. If a profile ever showed it mattering, the fix is a list of joinable room ids. Worth saying out loud: "I checked, and it isn't the bottleneck."
9. join — three different intents (lines 131–159)
join(username, now, options = {}): { result: JoinResult; room: GameRoom | null } {
let room: GameRoom | undefined;
if (!options.roomId && options.forceNew) {
room = this.createRoom(now);
} else if (options.roomId) {
room = this.rooms.get(options.roomId);
if (!room) {
if (!options.createIfMissing) {
return { result: { ok: false, reason: 'room_not_found' }, room: null };
}
room = this.createRoom(now, options.roomId);
}
} else {
room = this.findJoinableRoom(now);
}
const result = room.addPlayer(username, now, options.token);
if (result.ok) this.dispatch(result.events);
return { result, room };
}
This function fixed a real bug. Originally, joining without a room code meant "match me anywhere" — but players read the empty box as "create a new room for me". Two people both pressing join ended up in the same lobby when they expected separate ones, or vice versa.
The fix was not smarter guessing. It was to stop guessing:
| What the caller wants | What they pass | What happens |
|---|---|---|
| "That specific lobby" | roomId | joins that code |
| "My own fresh lobby" | forceNew: true | always a brand-new room |
| "Anywhere with space" | neither | auto-match |
The browser now has three separate buttons, so the intent is chosen by the player rather than inferred from an empty text box.
Order matters in the if chain: an explicit roomId beats forceNew. If you
typed a code, you meant that code.
if (result.ok) this.dispatch(result.events) — announce only on success. A
rejected join is nobody else's business.
10. update — driving every room (lines 175–192)
update(now: number): { reaped: string[] } {
const reaped: string[] = [];
for (const room of this.rooms.values()) {
const events = room.update(now);
if (events.length > 0) this.dispatch(events);
}
for (const [id, room] of this.rooms) {
if (room.isReapable(now)) {
this.rooms.delete(id);
reaped.push(id);
}
}
return { reaped };
}
Called 20 times a second by the game loop. Every room gets the same now,
so all lobbies advance in lockstep.
Why two separate loops? Deleting from a Map while walking through it is a classic source of subtle bugs — entries can be skipped. Simulating in the first pass and deleting in the second avoids the problem entirely.
11. stats (lines 194–212)
stats(): ManagerStats {
const roomsByStatus: Record<string, number> = {
waiting: 0, countdown: 0, running: 0, finished: 0,
};
let players = 0;
let connectedPlayers = 0;
for (const room of this.rooms.values()) {
roomsByStatus[room.status] = (roomsByStatus[room.status] ?? 0) + 1;
players += room.playerCount;
connectedPlayers += room.connectedCount;
}
return { rooms: this.rooms.size, roomsByStatus, players, connectedPlayers };
}
Feeds the /metrics endpoint. All four statuses start at zero so the output has
the same shape even when a status has no rooms — a chart reading this never sees
a field appear and disappear.
12. generateRoomCode (lines 218–228)
private generateRoomCode(): string {
for (let attempt = 0; attempt < 20; attempt++) {
let code = '';
for (let i = 0; i < 6; i++) {
code += ROOM_CODE_ALPHABET[Math.floor(this.rng.next() * ROOM_CODE_ALPHABET.length)];
}
if (!this.rooms.has(code)) return code;
}
return `R${Date.now().toString(36)}${this.roomSeq}`;
}
Build a six-character code by picking six random letters.
Math.floor(this.rng.next() * 32) — the standard "pick a random item from a
list". next() gives something like 0.734; times 32 is 23.5; Math.floor
rounds down to 23; that is the index.
if (!this.rooms.has(code)) return code; — reject a code already in use. With 32
characters and 6 positions there are about 1.07 billion possible codes, so a
clash is very unlikely, but "unlikely" is not "impossible".
The fallback after 20 attempts uses the current time in base 36 plus the room counter, which cannot collide. It should never run — but a loop that could never terminate is not something to leave in a server.
13. hashSeed (lines 241–247)
private hashSeed(roomId: string, salt: number): number {
let hash = salt;
for (let i = 0; i < roomId.length; i++) {
hash = (hash * 31 + roomId.charCodeAt(i)) % 2147483647;
}
return hash;
}
Turns a room code like "WDZYAP" into a number, used to seed that room's
generator.
The classic string hash, one character at a time:
charCodeAt(i)— the character's numeric code."A"is 65,"B"is 66.hash * 31 + code— multiply the running total by 31, then add the character.% 2147483647— keep the remainder so the number never grows too large for JavaScript to hold exactly.
Why 31? It is odd and prime. Multiplying by an odd number spreads similar
strings far apart, so "ROOM01" and "ROOM02" produce completely different
results rather than adjacent ones — which matters, because adjacent seeds would
give near-identical boards.
Worked example with "AB" and salt 1:
start: hash = 1
'A': 1 × 31 + 65 = 96
'B': 96 × 31 + 66 = 3042
If an interviewer asks
"How does matchmaking work?"
"Three explicit intents rather than guessing: a typed code joins that lobby, a 'new room' flag always opens a fresh one, and neither auto-matches into the fullest lobby with space. I originally inferred intent from an empty text box and it surprised players — the fix was to stop inferring."
"Why does the manager have no game rules?"
"The room is the consistency boundary. Keeping rules in one place means two lobbies can't interfere, and it's also the seam for scaling — sharding rooms across processes changes only how a room is found, not how the game works."
"What happens if an event handler throws?"
"It's caught and logged. Handlers run inside the tick, and with one global loop an uncaught error would stop every lobby on the server, not just one."