docs: add game history and live games spec

This commit is contained in:
Halit Aksoy 2026-10-16 00:03:32 +03:00
parent 20efa40e6c
commit d0004742f5
3 changed files with 315 additions and 0 deletions

View File

@ -0,0 +1,201 @@
# 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.

View File

@ -0,0 +1,89 @@
# Game history and live games: requirements
Status: approved, in development (branch `feature/game-history`, based on `feature/keycloak-auth`).
Scope: backend and web app. The mobile app is out of scope (see `TODO.md`).
## Goals
Every game is saved. Players can come back to a game in progress, see their past games, watch popular games live
from the home page, and step through the moves of any game, finished or in progress.
## Terms
- **Live game**: a game that is being played right now.
- **Finished game**: a game that has ended for any reason (see R3).
- **Spectator**: a user, other than the two players, who has the game page open. Home page previews do not count.
- **Popularity**: number of spectators; for finished games, the highest number of spectators the game had at once.
## Requirements
### R1. Every game is saved
- R1.1 When a game ends, it is stored permanently with its players (id, name, guest flag), result, end reason,
start and end time, every move (player, pit, board after the move, rule events, time of the move), final
board, peak spectator count and number of distinct spectators.
- R1.2 Games in progress survive a backend restart or deploy: after the backend starts again, players who reconnect
continue from the same position, with the same turn.
- R1.3 If the database or the cache is unreachable, games can still be played. A game that could not be saved
when it ended is saved later, once the database is reachable again.
### R2. Ongoing game
- R2.1 A logged-in user (guest or account) who is a player in a live game sees, at the top of the home page, a
banner with the opponent's name, whose turn it is, and a **Continue** button that opens the game.
- R2.2 This works from any device or browser where the user is logged in with the same identity.
- R2.3 Starting a new game while a game is in progress is not possible: the user is told they have a game in
progress and is offered to continue it.
### R3. How games end
- R3.1 **Completed**: the game ends by the rules; the player with more stones in their store wins, or it is a draw.
- R3.2 **Left**: a player who leaves the game loses; the other player wins.
- R3.3 **Timeout**: if the player whose turn it is has been offline for 5 minutes while the opponent is online,
the opponent wins. While the opponent is offline on their turn, the waiting player sees a countdown.
- R3.4 **Inactive**: if both players have been offline for 30 minutes, the game ends without a winner.
### R4. Home page: live and popular games
- R4.1 The home page shows up to 5 live games, most spectated first, each as a preview of the real board with the
players' names and the spectator count.
- R4.2 Previews update live as moves are made, without reloading the page.
- R4.3 If no game is live, the same section shows the 5 most spectated finished games instead, titled as popular
games rather than live games.
- R4.4 Clicking a live game opens it in spectator mode; clicking a finished game opens its replay (R7).
- R4.5 Viewing previews does not change any game's spectator count.
### R5. Home page: my recent games
- R5.1 A logged-in user sees their last 3 finished games in a separate section: opponent, result from their point
of view (won / lost / draw, and why if the game did not end by the rules), final score, date and a preview of the
final board.
- R5.2 Clicking a game opens its replay.
### R6. Move history during a live game
- R6.1 Players and spectators of a live game see the list of moves made so far. Each entry shows the move number,
the player, the pit, and what happened: extra turn, capture, stones added to the player's store, game over.
- R6.2 New moves are appended as they happen.
- R6.3 Selecting a move (or using the arrow keys) shows the board as it was after that move. A banner says which
move is shown and offers **Back to live**. The game keeps going in the background.
- R6.4 While an earlier position is shown, the pits cannot be played. If it becomes the user's turn, the banner says
so.
### R7. Replay of a finished game
- R7.1 Opening a finished game shows its final position, the result and a **Watch from start** button.
- R7.2 Controls: first move, previous move, play/pause, next move, last move, speed (1x, 2x, 4x), and a timeline to
jump to any move. Arrow keys step through moves, space plays or pauses.
- R7.3 Stepping forward animates the move the same way as in a live game; jumping (backwards, timeline, move list)
shows the position without animation.
- R7.4 The move list from R6.1 is shown and can be used to jump to a move.
- R7.5 A link can point to a specific move (`?move=12`).
- R7.6 A player who replays their own game sees the board from their side.
- R7.7 The game's duration is shown.
### R8. Visibility and privacy
- R8.1 Finished games are public: anyone with the link can open them, and popular finished games appear on the
home page with the players' names.
- R8.2 "My recent games" and the ongoing-game banner are only shown to the user they belong to.
- R8.3 Ids of players who use the legacy userKey login (the current mobile app) are secrets. They are never exposed
in finished games, popular lists or live previews; a derived public id is shown instead.
## Out of scope (TODO)
- Mobile app support.
- A page with all of a user's games (only the last 3 are shown).
- Showing deleted accounts differently.
- Live game previews and lists across several backend instances (the backend runs as a single instance).

View File

@ -0,0 +1,25 @@
# Game history and live games: tasks
Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed.
- [ ] 1. **Infrastructure**: `postgres` service with init script for the `keycloak` and `mancala` databases
(replacing `keycloak-db`), `valkey` service, backend `DATABASE_URL` / `VALKEY_URL`, `.env` variables, README.
- [ ] 2. **Core types and history helpers**: `PlayerSummary`, `MoveRecord`, summaries, `GameRecord`,
`TURN_TIMEOUT_MS`, new channels, `offlineSince`; `getInitialBoard`, `getBoardAfterMove`, `describeMove`,
`createGameAtMove` with unit tests; localized strings.
- [ ] 3. **Backend fixes**: remove spectators on disconnect, remove finished games from memory, send the connection
info once at game start.
- [ ] 4. **Postgres repository**: pool, migration runner, `games` and `game_players`, `PostgresGameHistoryRepository`
(save, get, recent for user, popular), integration tests.
- [ ] 5. **Valkey store**: live game snapshots and restore, pending saves, cache; integration tests.
- [ ] 6. **Game lifecycle**: start and move times, spectator counters, outcomes (completed, left, timeout, inactive),
timeout watcher, `offlineSince`, rejecting a new game while playing, saving with retry, restore on startup;
unit tests.
- [ ] 7. **API**: public ids, Bearer auth middleware, `/games/me/active`, `/games/me`, `/games/popular`,
`/game/:id` for finished games, live games channel with throttled push; tests.
- [ ] 8. **Board size**: `--board-unit` in `BoardView`, `PitView`, `StoreView`, `StoneView`; `width` prop.
- [ ] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby
rejection message.
- [ ] 10. **Live game history**: `MoveList`, review mode with `ReviewBanner` and keyboard, timeout countdown.
- [ ] 11. **Replay**: `ReplayView` with controls, speed, timeline, `?move=`, own-side orientation, duration.
- [ ] 12. **End to end and docs**: Chrome tests from `design.md`, README and `TODO.md` updates.