src/persistence/GameRepository.ts — the saving contract

In one sentence: describes what saving means, without saying how. Also contains a fake implementation that stores results in memory.

Size: 102 lines. Depends on: nothing — not even Prisma.


Why this file exists

This file is a boundary. It answers "what can be saved and loaded?" without mentioning PostgreSQL, SQL, or Prisma anywhere.

Two implementations exist:

ImplementationWhere it livesUsed when
PrismaGameRepositoryPrismaGameRepository.tsproduction — writes to PostgreSQL
InMemoryGameRepositorythis filetests, and PERSISTENCE=off

The rest of the server only ever refers to the interface. Nothing outside this folder knows which one is in use, so swapping them is a one-line change where the app is wired together.

That is what makes two claims true and testable:

  1. Tests need no database. They use the in-memory version, so npm test runs in seconds with nothing installed.
  2. The game runs without a database. PERSISTENCE=off works, and if Postgres is unreachable at startup the server degrades to in-memory rather than refusing to start.

The code, line by line

1. What one player's result looks like (lines 10–14)

export interface PersistedResult {
  username: string;
  score: number;
  rank: number;
}

Deliberately small. Note there is no x, no y, no player id.

Positions are never saved. They change 20 times a second and nobody ever asks where a player stood three seconds ago. Only the outcome is durable.

2. What a finished game looks like (lines 16–25)

export interface GameResultInput {
  roomCode: string;
  status: 'completed' | 'abandoned';
  playerCount: number;
  collectiblesTotal: number;
  startedAt: Date | null;
  finishedAt: Date;
  durationMs: number;
  results: PersistedResult[];
}

Everything worth keeping about one game.

  • status'completed' (all collectibles taken) or 'abandoned' (everyone left). Both are saved, so the history is honest rather than only recording tidy endings.
  • startedAt: Date | nullnull if the game was abandoned before the countdown ever finished. The | null forces readers to handle that.
  • collectiblesTotal — lets you check afterwards that the scores add up. Used in the audit query: total of all scores should equal lobbies × collectibles.

3. Read shapes (lines 27–40)

export interface TopPlayer {
  username: string;
  gamesPlayed: number;
  totalScore: number;
  bestScore: number;
}

The all-time leaderboard. These are running totals kept up to date as games finish, not calculated on demand.

That matters: the alternative is scanning every result ever recorded every time someone opens the leaderboard. Keeping totals current makes it a simple sorted read.

4. The contract (lines 42–47)

export interface GameRepository {
  saveGameResult(input: GameResultInput): Promise<void>;
  getTopPlayers(limit: number): Promise<TopPlayer[]>;
  getRecentSessions(limit: number): Promise<SessionSummary[]>;
  disconnect(): Promise<void>;
}

Four methods. That is the entire surface of persistence in this project.

Every one returns a Promise, meaning "this takes time, the answer arrives later". That is exactly why saving must never happen inside the game tick — waiting for a promise means pausing, and a pause in the collision path would break the exactly-once guarantee.

limit on both read methods, always. An endpoint that returns "all sessions" works fine on day one and falls over after a year.

disconnect() closes the database connection cleanly on shutdown.

5. The in-memory version (lines 54–101)

export class InMemoryGameRepository implements GameRepository {
  readonly saved: GameResultInput[] = [];

  async saveGameResult(input: GameResultInput): Promise<void> {
    this.saved.push(input);
  }

implements GameRepository — TypeScript checks that every method exists with the right shape. Forget one and it will not compile.

Saving is one line: push onto an array.

readonly saved is public on purpose, so a test can look at what was saved:

await server.writeQueue.flush(3000);
const saved = repository.saved.filter((s) => s.roomCode === 'FIN001');
expect(saved).toHaveLength(1);
expect(saved[0].results.find((r) => r.username === 'winner').score).toBe(5);

That test proves a finished game is persisted exactly once, with correct scores — with no database involved at all.

  async getTopPlayers(limit: number): Promise<TopPlayer[]> {
    const byUser = new Map<string, TopPlayer>();
    for (const session of this.saved) {
      for (const r of session.results) {
        const entry = byUser.get(r.username) ?? {
          username: r.username, gamesPlayed: 0, totalScore: 0, bestScore: 0,
        };
        entry.gamesPlayed++;
        entry.totalScore += r.score;
        entry.bestScore = Math.max(entry.bestScore, r.score);
        byUser.set(r.username, entry);
      }
    }
    return Array.from(byUser.values())
      .sort((a, b) => b.totalScore - a.totalScore)
      .slice(0, limit);
  }

Rebuilds the leaderboard by walking every saved game. Slow — but this only ever holds a handful of games in a test, so it does not matter.

The real version does the opposite: it keeps totals current as games finish, so reading is instant. Same contract, different trade-off, which is precisely what an interface is for.

  • ?? { … } — if this username is new, start from zeros.
  • Math.max(entry.bestScore, r.score) — keep the best single game, not the last.
  • .slice(0, limit) — respect the caller's cap.
  async disconnect(): Promise<void> {
    // nothing to release
  }
}

Nothing to close, but the method must exist to satisfy the interface.


How it is chosen at startup

In index.ts:

if (process.env.PERSISTENCE === 'off') {
  return new InMemoryGameRepository();
}
try {
  const prisma = new PrismaClient();
  await prisma.$queryRaw`SELECT 1`;
  return new PrismaGameRepository(prisma);
} catch (err) {
  // loud warning, then:
  return new InMemoryGameRepository();
}

The rest of the server receives a GameRepository and never knows which it got.


If an interviewer asks

"Why an interface rather than calling Prisma directly?"

"So the game doesn't depend on a database existing. Tests use an in-memory implementation and run in seconds with nothing installed, and the server stays playable if Postgres is down. Persistence observes the game rather than sitting in its path."

"Isn't the in-memory version's leaderboard inefficient?"

"Yes, deliberately. It rebuilds from scratch, which is fine for a handful of games in a test. The Prisma version keeps running totals so reads are instant. Same contract, different trade-off — that's what the interface is for."

"Why does everything return a Promise?"

"Because saving takes time. That's exactly why it can't happen inside the game tick — awaiting a promise is a pause, and a pause in the collision path would reintroduce the double-award race."

Built with LogoFlowershow