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:
| column | type | notes |
|---|---|---|
id | bigserial PK | |
started_at | timestamptz | Wall clock captured at start_match. |
ended_at | timestamptz | Wall clock at end_match. Default now(). |
duration_ms | integer | Server-computed, clamped to non-negative i32. |
map_name | text | selected_map at match start. |
winner_name | text nullable | null ⇔ draw. |
outcome | text | 'win' or 'draw' (CHECK constraint). |
participants | text[] | Display names in seat order (humans then AI). |
score_screen | jsonb | Whole Vec<PlayerScore> blob, opaque to SQL. |
human_count | integer | Non-AI players at match start. |
debug_mode | boolean | Lobby Debug/quickstart was enabled. |
local_only | boolean | Hide 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:
| column | type | notes |
|---|---|---|
id | bigserial PK | |
match_id | bigint | Unique FK to matches(id), cascade delete. |
artifact_schema_version | integer | Deterministic replay artifact schema. |
build_sha | text | Server build that recorded the replay. |
map_name | text | Map name captured in the artifact. |
map_schema_version | integer | Map schema captured in the artifact. |
map_hash | text | Authored map content hash captured in the artifact. |
duration_ticks | integer | Replay duration in simulation ticks. |
artifact_json | jsonb | Whole ReplayArtifactV1 blob. |
created_at/updated_at | timestamptz | Default 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
- Read:
GET /api/matches?limit=N— JSON array, newest first,limitclamped server-side to[1, 100], defaults to 20. Returns[]when no DB is configured (so the client never needs to special-case missing-DB). The Recent Matches feed includes only rows with at least one human player anddebug_mode = false; AI-only and lobby Debug/quickstart rows may be persisted with replay artifacts but stay out of the lobby table. Local-only rows are included only when the request peer address is loopback; public beta/mainline requests filter them out. Each summary includesreplayAvailableplusreplayUnavailableReason. Availability is true only when a replay row exists and its artifact schema, build SHA, map schema, and map content hash are compatible with the running server. - Replay launch:
POST /api/matches/{id}/replay— read-only launch request. The server loads the persisted artifact only if the match is visible to the request scope, validates it against the running build, map metadata, and the shared replay faction/loadout validator used by replay rooms, creates a spectator replay room, and returns{ "room": "..." }. Incompatible or missing replays return a clear JSON{ "error": "..." }instead of trying partial playback. - Write: none. Clients cannot write history. Period.
Code seams
server/src/db.rs—Db(pool + migrate),record_match,recent_matches,replay_artifact_for_match,MatchRecord,MatchSummary.server/src/main.rs—.envloading, pool construction,/api/matcheshandler, thePOST /api/matches/{id}/replaylaunch handler, replay compatibility checks, and theRTS_RECORD_MATCHESgate.server/src/lobby/mod.rs—Lobby::with_match_history()injects anOption<Arc<Db>>into spawned rooms and can create persisted replay rooms from launch-approved artifacts.server/src/lobby/room_task.rs— capturesmatch_started_at,match_map_name,match_participantsatstart_match; capturesReplayArtifactV1from the endingGame; writes the match row and optional replay row inend_matchvia a detachedtokio::spawn. Detachment is load-bearing: a slow Supabase write must never stall the room transitioning back to lobby.client/src/match_history.js— fetches and renders the lobby table; row click expands the score screen and, when compatible, exposes a replay launch action.client/src/app.js— mountsMatchHistorywhen the lobby shows;refresh()is called from normal-roomonBackToLobbyso the freshly-written row appears without a page reload.?replayRoom=...auto-joins a server-created replay room through the normal WebSocket join flow, and its back-to-lobby action navigates to/so only that viewer leaves the replay room.
What gets recorded
A row is written when all of these are true:
- The lobby reached
Phase::InGame(somatch_started_atwas captured). - At least one active participant was present at match start. Solo, player-vs-AI, and AI-only deployed matches record when they resolve.
is_dev_watch()is false — dev scenario rooms never record.- The room/participants do not match automated smoke/integration/regression test fingerprints:
itest-*,ai-itest-*,client-smoke-*,reg-*,smoke, or theAlpha/Bravointegration pair.Computer *participants are allowed so player-vs-AI matches record. - The server was started with a working DB connection and
RTS_RECORD_MATCHESis 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 state | reads work? | writes happen? | public servers show row? |
|---|---|---|---|
no DATABASE_URL | no (returns []) | no | no |
DATABASE_URL set, gate off / unset | yes | no | no |
DATABASE_URL set, gate on | yes | yes, public row | yes |
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
- DB unreachable at boot:
try_connect_from_env()logs and returnsNone. Server runs without history (reads return[], no writes attempted). - DB drops mid-run: write attempts log an error and continue. No retry, no outbox. This is acceptable; match history is non-critical.
- Migration fails at boot:
Db::connectreturnsErr, server runs without history. Checkmigrations/filenames are timestamp-prefixed and sequential. - Slow write: detached task means the room is unblocked. Worst case the row appears seconds
later in
/api/matches. - Replay incompatible with current build/map: summaries show
replayAvailable: falsewith a reason, and launch returns409with the same class of explanation. The server never attempts best-effort playback across build or map drift.
Secrets and rotation
DATABASE_URLmust include?sslmode=require(Supabase rejects un-TLS connections).- Pool capped at 5 connections (Supabase free-tier safety margin), 5s acquire timeout.
.envis gitignored..env.exampledocuments the required vars.- If the password leaks, rotate via Supabase dashboard and
flyctl secrets set DATABASE_URL=....
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
- Pagination / filter by player/map: add LIMIT/OFFSET to
recent_matches, expose as query params. Index onmap_namealready exists. - Leaderboard: a separate aggregate query is fine; do not denormalize into
matchesuntil there’s a real perf reason. - Crash-safety: if matches start dropping due to DB outages, add a small bounded
in-memory outbox in
Db. Not worth it today.