src/persistence/PersistenceSubscriber.ts — the bridge
src/persistence/PersistenceSubscriber.ts — the bridge
In one sentence: listens for "a game finished" and hands a save job to the queue — then returns immediately.
Size: 50 lines, and the smallest file that carries the most architectural weight.
Why this file exists
This is the only place in the entire project where the game meets the database, and the connection is deliberately one-directional:
GameRoom ──announces──▶ GameManager ──▶ PersistenceSubscriber ──▶ WriteQueue ──▶ Postgres
The game emits. This file listens. The game never asks for anything back and never waits.
That arrangement is what protects the exactly-once guarantee. Recall from
GameRoom that awarding a collectible is safe only because nothing pauses in the
middle of the tick — and await is a pause. If the room had called
await saveToDatabase(...) directly, the double-award race would be back.
So the rule for this file is one sentence: the handler does exactly one thing — hand a job to the queue and return.
The code, line by line
1. The class (lines 20–29)
export class PersistenceSubscriber {
constructor(
private readonly repository: GameRepository,
private readonly queue: WriteQueue,
) {}
attach(manager: GameManager): void {
manager.onEvent((event) => this.handle(event));
}
Two things handed in:
repository— the interface, not Prisma. This class has no idea whether results go to PostgreSQL or an array in memory.queue— where jobs go so they do not all run at once.
attach registers the listener. From that moment, every game event passes through
handle.
constructor(private readonly x) {} with an empty body is TypeScript shorthand:
declaring a parameter as private readonly creates the field and assigns it
automatically.
2. handle — the filter and the hand-off (lines 31–49)
private handle(event: GameEvent): void {
if (event.type !== 'game_finished') return;
One line that decides the whole persistence strategy.
Five kinds of event flow past: player_joined, player_left, game_started,
collectible_claimed, game_finished. This file ignores four of them.
Consider what saving them would mean at full load:
| Event | Roughly how often | Saved? |
|---|---|---|
| player position changes | 40,000 per second | never even an event |
collectible_claimed | ~3,000 per game across all lobbies | no |
player_joined | once per player | no |
game_finished | once per game | yes |
Writing 40,000 rows a second would destroy the system, and nobody would ever read them back. Only the outcome is durable.
: void again — the return type is nothing, so nothing can be awaited.
const job = {
roomCode: event.roomId,
status: event.reason,
playerCount: event.leaderboard.length,
collectiblesTotal: event.collectiblesTotal,
startedAt: event.startedAt === null ? null : new Date(event.startedAt),
finishedAt: new Date(event.finishedAt),
durationMs: event.durationMs,
results: event.leaderboard.map((e) => ({
username: e.username,
score: e.score,
rank: e.rank,
})),
};
The values are copied now, on purpose.
The job runs later — possibly seconds later if the database is slow. By then the room may have been deleted entirely. Taking a copy of exactly what is needed means the job does not depend on anything that might disappear.
.map(...) builds a new array with only three fields per player. Note what is
not copied: no x, no y, no playerId, no token. Only what belongs in the
permanent record.
new Date(event.startedAt) — the game works in milliseconds-since-1970 (plain
numbers, cheap to compare); databases want real Date objects. The conversion
happens here, at the boundary.
event.startedAt === null ? null : … — a game abandoned before the countdown
finished never started, so this is genuinely null.
this.queue.push(() => this.repository.saveGameResult(job));
}
The most important line in the file.
() => this.repository.saveGameResult(job) is a function that will do the
save, not the save itself. It has not started. queue.push stores it and
returns immediately, and the queue decides when to run it.
There is no await. Control returns to the game tick instantly — whether the
write eventually takes 2 milliseconds or 2 seconds.
What this buys, concretely
The game never waits for the database. Verified by running with
PERSISTENCE=off: the whole game works perfectly, just without saved history.
A database outage degrades history, not gameplay. If PostgreSQL vanishes,
writes fail, stats.failed climbs on /metrics, and players notice nothing.
No await in the collision path. Which is the entire basis of the
exactly-once guarantee.
The one thing to be careful about
Handlers run inside the game tick. If this function were slow, every lobby on the server would tick late.
That is why handle only copies a small object and pushes a function — a few
microseconds. And it is why GameManager.dispatch wraps handler calls in
try/catch: if this file ever threw, without that catch it would stop the tick
for every lobby on the server.
If an interviewer asks
"How does the game write to the database without slowing down?"
"It doesn't write at all. It emits an event; this subscriber copies what's needed, hands a function to a bounded queue, and returns. There's no
awaitanywhere in the path, so the tick continues at full speed regardless of how long the write takes."
"Why only save on game_finished?"
"Positions change 20 times a second — 40,000 changes a second at full load — and nobody ever reads them back. Live state belongs in memory; the database holds results, which are read often and must survive a restart."
"What if the database is down?"
"Writes fail, the failure count shows on
/metrics, and gameplay is completely unaffected. If it's unreachable at startup the server degrades to in-memory with a loud warning — or refuses to start entirely if you setPERSISTENCE=required."