# 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`) replaces `keycloak-db`. An init script (`apps/postgres/init/01-databases.sh`) creates the `keycloak` and `mancala` databases, each with its own user, from `KEYCLOAK_DB_PASSWORD` and `MANCALA_DB_PASSWORD`. Exposed on `127.0.0.1:5433` for local development. - `valkey` (`valkey/valkey:9.1.2`, append-only file on so live games survive a Valkey restart). Exposed on `127.0.0.1:6379`. - Backend configuration: `DATABASE_URL` (default `postgres://mancala:mancala@localhost:5433/mancala`) and `VALKEY_URL` (default `redis://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`. ```sql 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`) ```ts 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[] } // 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`, empty `moveTimes`, 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 `GameRecorder` saves the record. - completed: `mancalaGame.getWonPlayerId()`, draw if none. - left: the leaving player loses (`left_user_id` is 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). - **New game while playing** (R2.3): `MatchMaker` is not called; the user gets `on_new_game_rejected` with 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…in` loop in `GameStoreImpl`), 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`) 1. `ActiveGameBanner`: from `GET /games/me/active`; opponent, whose turn, **Continue**. 2. `LiveGamesSection`: `GET /games/popular` on load, then `subscribe_live_games`; a grid of `GamePreviewCard`s (read-only board, names, spectator count). Title "Live games" or "Popular games" by `kind`. 3. `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 }`, and `createGameAtMove(game, n)`, which builds the engine objects `PitAnimator` needs to animate move n. - extra turn: a `GAME_STEP_LAST_STONE_IN_BANK` step; capture: `GAME_STEP_LAST_STONE_IN_EMPTY_PIT` or `GAME_STEP_DOUBLE_STONE_IN_PIT`; game over: `GAME_STEP_BOARD_CLEARED`; store gain: the mover's store after the move minus before. - `MoveList`: the list, highlighting the shown move; clicking selects a move. - Live game (`GamePage`): `viewedMove` state. When set, the board shows `getBoardAfterMove` and pits are disabled; `ReviewBanner` shows 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.