- Connection pool and a small migration runner (schema_migrations, one transaction per migration, advisory lock) - games and game_players tables with indexes for a user's games and for popular games - PostgresGameHistoryRepository: idempotent save, get, recent games of a user, popular games - Integration tests against a real Postgres via testcontainers; a custom Jest environment gives tests Node's web globals, which Jest 27 leaves out - GameRecord carries the number of distinct spectators
12 KiB
Game history and live games: design
See requirements.md for what is built; requirement ids (R1.1, …) are referenced below.
Decisions
| Decision | Reason |
|---|---|
| PostgreSQL for saved games, not MongoDB | MongoDB moved to SSPL. Postgres already runs for Keycloak, so it adds no new technology; jsonb stores the move list as a document and the queries (a user's games, most spectated games) are relational. FerretDB was considered (Mongo API on Postgres) but adds a translation layer and compatibility gaps for no gain. |
| One Postgres server, separate databases and users for Keycloak and the game | One container to run and back up on a small server. |
| Valkey for live game state and short-lived caches, not Redis | Redis changed its license in 2024; Valkey is the BSD-licensed fork and is protocol compatible. Client: iovalkey (MIT). |
| The backend's memory stays the source of truth for live games; Valkey is a write-through copy | The backend runs as a single instance; reading from memory keeps moves fast and simple. Valkey only matters on restart (R1.2). |
| A game is written to Postgres once, when it ends | Live state is already durable in Valkey; one write per game keeps the database simple. |
| Database and cache failures never stop a game | R1.3. Writes are best effort with retry; reads fall back to empty lists. |
| Legacy ids are replaced with a hash before anything is saved or listed | R8.3. The database never holds a legacy userKey. |
Infrastructure
apps/compose.yaml:
postgres(postgres:17.6) replaceskeycloak-db. An init script (apps/postgres/init/01-databases.sh) creates thekeycloakandmancaladatabases, each with its own user, fromKEYCLOAK_DB_PASSWORDandMANCALA_DB_PASSWORD. Exposed on127.0.0.1:5433for local development.valkey(valkey/valkey:9.1.2, append-only file on so live games survive a Valkey restart). Exposed on127.0.0.1:6379.- Backend configuration:
DATABASE_URL(defaultpostgres://mancala:mancala@localhost:5433/mancala) andVALKEY_URL(defaultredis://localhost:6379).
Data model (PostgreSQL)
Migrations live in apps/backend/src/db/migrations/ as TypeScript files exporting SQL, applied in order at startup
and recorded in schema_migrations.
CREATE TABLE games (
id uuid PRIMARY KEY,
player1_id text NOT NULL,
player2_id text NOT NULL,
result text CHECK (result IN ('player1', 'player2', 'draw')), -- NULL: no winner (inactive)
end_reason text NOT NULL CHECK (end_reason IN ('completed', 'left', 'timeout', 'inactive')),
left_user_id text,
started_at timestamptz NOT NULL,
ended_at timestamptz NOT NULL,
move_count integer NOT NULL,
final_board jsonb NOT NULL, -- 14 stone counts
moves jsonb NOT NULL, -- MoveRecord[]
peak_spectators integer NOT NULL DEFAULT 0,
total_spectators integer NOT NULL DEFAULT 0
);
CREATE TABLE game_players (
game_id uuid NOT NULL REFERENCES games (id) ON DELETE CASCADE,
seat smallint NOT NULL CHECK (seat IN (1, 2)),
user_id text NOT NULL, -- public id (see "Public ids")
name text,
is_guest boolean NOT NULL,
PRIMARY KEY (game_id, seat)
);
CREATE INDEX game_players_user_id ON game_players (user_id);
CREATE INDEX games_popular ON games (peak_spectators DESC, ended_at DESC);
Saving uses INSERT … ON CONFLICT (id) DO NOTHING in one transaction, so retries are safe.
Public ids (R8.3)
publicUserId(id): Keycloak ids (kc:…) are returned unchanged; any other id is a legacy userKey and becomes
legacy: + the first 20 hex characters of its SHA-256. Applied to every id in saved games, popular lists and live
previews. Live game messages to the players and spectators of a game, and GET /game/:id for a live game, keep raw
ids because the current mobile app compares its userKey with them; this exposure already exists and goes away with
LEGACY_USERKEY_AUTH (see TODO.md).
Shared types (packages/core)
interface PlayerSummary { userId: string; name?: string; isGuest: boolean }
interface MoveRecord { playerId: string; moveIndex: number; boardSnapshot: number[]; gameSteps: GameStep[]; at: number }
type GameResult = "player1" | "player2" | "draw";
type EndReason = "completed" | "left" | "timeout" | "inactive";
interface LiveGameSummary {
id: string; player1: PlayerSummary; player2: PlayerSummary; board: number[];
turnPlayerId: string; spectatorCount: number; moveCount: number; startedAt: number;
}
interface FinishedGameSummary {
id: string; player1: PlayerSummary; player2: PlayerSummary; board: number[];
result: GameResult | null; endReason: EndReason; leftUserId?: string;
startedAt: number; endedAt: number; moveCount: number; peakSpectators: number;
}
interface GameRecord extends FinishedGameSummary { moves: MoveRecord[]; totalSpectators: number } // replay payload
Live Game messages gain startedAt and moveTimes: number[] (one per history entry), and UserConnectionInfo
gains offlineSince?: number. TURN_TIMEOUT_MS (5 minutes) is exported so the client can show the countdown.
Backend
Modules
src/db/postgres.ts pool, migrations
src/history/GameHistoryRepository.ts interface + PostgresGameHistoryRepository
src/live/LiveGameStore.ts interface + ValkeyLiveGameStore (snapshots, pending saves)
src/cache/Cache.ts JSON get/set/delete with TTL in Valkey
src/game/GameSerialization.ts BackendGame <-> snapshot, rebuilding the engine objects
src/game/GameRecorder.ts turns a finished game into a GameRecord and saves it, with retry
src/game/LiveGamesBroadcaster.ts top live games, subscriptions, throttled push
src/game/publicIds.ts publicUserId and helpers
Game lifecycle (GameManager)
- Start: as today, plus
startedAt, emptymoveTimes, spectator counters; the snapshot is written to Valkey. - Move: the time is appended to
moveTimes; the snapshot is written to Valkey; live previews are scheduled. - End (R3): the outcome is decided, the game is removed from memory and from Valkey, players and spectators get
the final update, and
GameRecordersaves the record.- completed:
mancalaGame.getWonPlayerId(), draw if none. - left: the leaving player loses (
left_user_idis set). - timeout / inactive: decided by a watcher that runs every 10 seconds, using the time each player went offline
(
offlineSince, kept per user and cleared when they reconnect).
- completed:
- New game while playing (R2.3):
MatchMakeris not called; the user getson_new_game_rejectedwith the game id. - Restart (R1.2): on startup all snapshots are loaded from Valkey and put back into memory; both players count as offline from that moment, so the timeout rules start fresh.
- Fixes needed by this feature: spectators are removed when they disconnect (the
for…inloop inGameStoreImpl), finished games are removed from memory, and the connection info is sent once at game start.
Saving with retry (R1.3)
GameRecorder.save(record): on failure the record is stored in Valkey under mancala:pending-save:{id} (and kept in
memory if Valkey is down too) and retried every 30 seconds and on startup.
Valkey keys
| Key | Content |
|---|---|
mancala:live:{gameId} |
snapshot of a live game (JSON) |
mancala:pending-save:{gameId} |
game record that could not be saved yet |
mancala:cache:popular-finished |
popular finished games, 10 s TTL |
mancala:cache:recent:{userId} |
a user's recent games, 10 s TTL |
Caches are deleted when a game ends. The client uses enableOfflineQueue: false so commands fail at once instead of
queueing while Valkey is down.
HTTP API
| Route | Auth | Response |
|---|---|---|
GET /games/me/active |
Bearer token | { game: LiveGameSummary | null } |
GET /games/me?limit=3 |
Bearer token | { games: FinishedGameSummary[] } (limit 1–20) |
GET /games/popular?limit=5 |
none | { kind: "live" | "finished", games: [...] }; live games if any, else finished |
GET /game/:id |
none | a live game as today, else { status: "finished", record: GameRecord }, else 404 |
The token is the same Keycloak access token used for the WebSocket, checked with the existing TokenVerifier.
WebSocket channels (added to channel_names)
| Channel | Direction | Payload |
|---|---|---|
subscribe_live_games / unsubscribe_live_games |
client → server | – |
on_live_games |
server → client | { games: LiveGameSummary[] }, sent on subscribe and at most once per second when the top 5 change |
on_new_game_rejected |
server → client | { gameId } |
Ordering for live games: spectator count, then most recent move. Preview subscribers are tracked per user and are not spectators (R4.5).
Web app
Board size
BoardView gets an optional width (px). All board components size themselves from a CSS variable
--board-unit (default 1vw, else width / 100 px) instead of vw, so the same board renders at full width in a
game and small in previews.
Home page (routes/Home.tsx)
ActiveGameBanner: fromGET /games/me/active; opponent, whose turn, Continue.LiveGamesSection:GET /games/popularon load, thensubscribe_live_games; a grid ofGamePreviewCards (read-only board, names, spectator count). Title "Live games" or "Popular games" bykind.RecentGamesSection:GET /games/me?limit=3; cards with opponent, result for this user, score, date, board.
Lobby
On on_new_game_rejected the lobby shows "You have a game in progress" with Continue.
Move history (shared by R6 and R7)
packages/core/src/history/:getInitialBoard(),getBoardAfterMove(moves, n)(n = 0 is the start position),describeMove(moves, n)→{ number, playerId, pit (1–6), extraTurn, capture, storeGain, gameOver }, andcreateGameAtMove(game, n), which builds the engine objectsPitAnimatorneeds to animate move n.- extra turn: a
GAME_STEP_LAST_STONE_IN_BANKstep; capture:GAME_STEP_LAST_STONE_IN_EMPTY_PITorGAME_STEP_DOUBLE_STONE_IN_PIT; game over:GAME_STEP_BOARD_CLEARED; store gain: the mover's store after the move minus before.
- extra turn: a
MoveList: the list, highlighting the shown move; clicking selects a move.- Live game (
GamePage):viewedMovestate. When set, the board showsgetBoardAfterMoveand pits are disabled;ReviewBannershows the move number, "Your turn" when it applies, and Back to live. ← / → keys step. - Countdown: if the opponent is offline on their turn, the info panel shows the time left from
offlineSince.
Replay (ReplayView, R7)
GamePage renders ReplayView when GET /game/:id returns a finished record. It starts at the last move with the
result and Watch from start. Controls: ⏮ ◀ ▶/⏸ ▶| ⏭, speed 1x/2x/4x, a range input as the timeline, the
MoveList, keyboard (← → space). Forward steps go through PitAnimator with the games from createGameAtMove;
jumps set the board directly. ?move=n is read on load and updated with history.replaceState.
Testing
packages/core: unit tests for the history helpers against games played with the engine.- Backend: unit tests for outcomes, timeouts and public ids with in-memory fakes; integration tests for the Postgres
repository and the Valkey store against real containers (
testcontainers); the existing WebSocket tests keep passing. - End to end in Chrome: two guests play a full game; the home page shows it in recent games and as popular; replay works; reviewing an earlier move during a live game; restarting the backend in the middle of a game.