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:

EventRoughly how oftenSaved?
player position changes40,000 per secondnever even an event
collectible_claimed~3,000 per game across all lobbiesno
player_joinedonce per playerno
game_finishedonce per gameyes

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 await anywhere 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 set PERSISTENCE=required."

Built with LogoFlowershow