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.
match_run_idtext nullableStable live-match/log correlation id; set for newly recorded matches.
map_nametextselected_map at match start.
winner_nametext nullableWinner display name for wins only; null for draws and aborted matches.
outcometext'win', 'draw', or 'aborted' (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_modebooleanVisibility flag for debug rows. One-human, no-AI sandbox rows write true; normal product rows write false.
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.

outcome is the source of truth for distinguishing no-winner results: draw is ordinary match resolution with no winner, while aborted is server-controlled shutdown finalization. winner_name must not be overloaded for that distinction.

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 versioned 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. New replay rows write artifactSchemaVersion: 3, whose startState carries the launch map binding and an embedded tick-zero GameCheckpointV1 text payload. The authoritative command stream, durationTicks, winnerId, winnerTeamId, and finalScores are attached when the artifact is finalized at match end. Schema 2 rows have no startState and are intentionally rejected by current replay compatibility checks. 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. Player-vs-AI and AI-only deployed matches record when they resolve. One-human, no-AI sandbox matches also record if they resolve, but write debug_mode = true so they are treated as debug sessions and stay out of Recent Matches.
  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. AI profile-label participants such as AI 2.1 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 historical debug and AI-only rows, plus solo sandbox rows, are filtered from /api/matches, but the replay row remains linked to the owning matches row.

AI observation sessions

An all-AI normal-room match with at least two active seats is an observation session. It is still the ordinary server-authoritative live game, so the watcher sees the normal live stream followed by the automatic in-memory post-match replay. Unlike a human match, it has a fixed 25,000-tick horizon: a winning primary-base elimination on tick 25,000 wins; otherwise the match records a draw at that tick. This prevents an inconclusive strategy matchup from running indefinitely.

At live start the server assigns match_run_id. Once the watched match resolves, it sends observationReady before the normal score/replay transition; the browser retains that id through automatic post-match replay and prints it as Observation ID in the score screen. Persisted rows store the same value in matches.match_run_id, and GET /api/observations/{matchRunId} returns that hidden AI-only row. The returned numeric match id launches the replay with POST /api/matches/{id}/replay; Fly/server logs can be filtered by the identical match_run_id. This is intentionally a narrow lookup rather than adding all AI-only rows to the player-facing Recent Matches table.

Outcome vocabulary:

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. Migration 20260628000001_suppress_solo_match_history.sql marks historical one-human, no-AI sandbox rows as debug instead of deleting them, preserving replay artifacts while removing them from Recent Matches.

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