Match history

Persisted record and replay artifact for resolved deployed matches, with a filtered Recent Matches feed on the lobby front page.

Source of truth for: schema, write path, read path, the recording gate, and failure modes. Mirrors of any of these elsewhere (capsules, CLAUDE.md) point here.

Why this exists

Players want to see what was played: who won, on which map, how long, and the score screen breakdown. The server is already the only authoritative actor that knows when a match resolves and what the scores are, so the server is also the only writer.

Storage

Supabase Postgres. Schema in server/migrations/. Match summaries live in matches:

columntypenotes
idbigserial PK
started_attimestamptzWall clock captured at start_match.
ended_attimestamptzWall clock at end_match. Default now().
duration_msintegerServer-computed, clamped to non-negative i32.
map_nametextselected_map at match start.
winner_nametext nullablenull ⇔ draw.
outcometext'win' or 'draw' (CHECK constraint).
participantstext[]Display names in seat order (humans then AI).
score_screenjsonbWhole Vec<PlayerScore> blob, opaque to SQL.
human_countintegerNon-AI players at match start.
debug_modebooleanLobby Debug/quickstart was enabled.
local_onlybooleanHide local developer rows from public servers.

Indexes: (started_at desc) for the front-page query, a partial (started_at desc) index for public rows, and (map_name) for future filtering.

The score_screen JSONB intentionally stores the full payload from Game::scores(). The shape matches contract::PlayerScore (camelCase). Adding fields to PlayerScore requires no migration; old rows simply lack the new fields. Team-capable rows include each player’s teamId, so grouped team results are recoverable without adding SQL columns.

Replay artifacts live in match_replays, keyed one-to-one by match_id:

columntypenotes
idbigserial PK
match_idbigintUnique FK to matches(id), cascade delete.
artifact_schema_versionintegerDeterministic replay artifact schema.
build_shatextServer build that recorded the replay.
map_nametextMap name captured in the artifact.
map_schema_versionintegerMap schema captured in the artifact.
map_hashtextAuthored map content hash captured in the artifact.
duration_ticksintegerReplay duration in simulation ticks.
artifact_jsonjsonbWhole ReplayArtifactV1 blob.
created_at/updated_attimestamptzDefault to now() at insert.

matches.score_screen remains score data only. Replay playback never reads replay payloads from score_screen. The replay artifact_json carries players[].teamId, winnerId, winnerTeamId, and finalScores[].teamId; winner_name remains the display-compatible name for the first living player represented by winnerId.

Migrations are versioned SQL files run by sqlx::migrate! at server boot. Never hand-apply DDL.

Wire

Code seams

What gets recorded

A row is written when all of these are true:

  1. The lobby reached Phase::InGame (so match_started_at was captured).
  2. At least one active participant was present at match start. Solo, player-vs-AI, and AI-only deployed matches record when they resolve.
  3. is_dev_watch() is false — dev scenario rooms never record.
  4. The room/participants do not match automated smoke/integration/regression test fingerprints: itest-*, ai-itest-*, client-smoke-*, reg-*, smoke, or the Alpha/Bravo integration pair. Computer * participants are allowed so player-vs-AI matches record.
  5. The server was started with a working DB connection and RTS_RECORD_MATCHES is truthy.

Anything else (local gate off, DB failures, dev rooms, test rooms, missing DB) silently skips the write. The simulation and lobby flow are unaffected. Replay artifacts use the same eligibility as match rows: if a match row is skipped, no replay row is written. Stored Debug/quickstart and AI-only rows are filtered from /api/matches, but the replay row remains linked to the owning matches row.

Public reads also suppress historical bot/test rows that were written before this eligibility filter existed, and migration 20260609000002_suppress_automated_match_history.sql tags those rows local_only instead of deleting them.

Recording gate (RTS_RECORD_MATCHES)

The gate controls whether the server writes match rows and replay artifacts. It exists because the developer runs many local matches and local dev must not upload replay data into the shared beta/mainline database.

env statereads work?writes happen?public servers show row?
no DATABASE_URLno (returns [])nono
DATABASE_URL set, gate off / unsetyesnono
DATABASE_URL set, gate onyesyes, public rowyes

Truthy values: 1, true, yes, on (case-insensitive). Anything else, including unset, is off. Beta and mainline deploys must set it to 1. Local cargo run with DATABASE_URL can read history, but with the gate off it does not write rows or replay artifacts.

The implementation: main.rs connects the pool once, hands the pool to AppState for reads, and passes it to Lobby::with_match_history(...) only when RTS_RECORD_MATCHES is truthy. If a room receives None, the end_match write branch never fires. /api/matches decides whether to include historical local-only rows from the request peer address. Only loopback peers (127.0.0.1 / ::1) can see those rows.

Failure modes

Secrets and rotation

Display-name identity caveat

Identity is just the display name a player typed in the lobby. Names can collide and there’s no dedup. Per-player stats and W/L are not derivable from the current schema. When (if) accounts become a real feature, add a player_id-keyed table and a join column on matches; the existing JSONB blob is unaffected.

Future evolution