Replay system
1. Foundational mental model
Section titled “1. Foundational mental model”A replay is the list of broadcasts the room already sent, each stamped with milliseconds since the game started. The server appends an event every time it broadcasts GAME_STARTING, NEW_QUESTION, ANSWER_SUBMITTED, REACTION, QUESTION_RESULTS or GAME_ENDED. Playback feeds the same events through the same UI on a clock, so there is no separate replay renderer and no simulation to drift.
At game end the server builds a script, encodes it (JSON, then gzip, then base64url), and posts it to Convex. Convex returns a 10-character short id, which rides along in the GAME_ENDED broadcast so every player can share /replay/s/<id> at once.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”3. Concrete implementation
Section titled “3. Concrete implementation”Recording
Section titled “Recording”ReplayRecorder holds one run per room. startRun is called from resetGameState when a game starts; clear is called from stopGame.
appendFromPayload( roomId: string, payload: ReplayEventPayload, serverNow = Date.now() ): void { const event = createReplayEvent(payload, this.runTMs(roomId, serverNow)); if (event) { this.append(roomId, event); } }
buildScript(room: Room): ReplayScript | null { const run = this.runs.get(room.id); if (!run || run.events.length === 0) { return null; } const hasGameEnded = run.events.some((event) => event.type === "game_ended"); if (!hasGameEnded) { return null; } return buildReplayScriptFromRoom(room, run.events); }Call sites pass the same Date.now() they used for the broadcast, so an event’s tMs matches what players saw to the millisecond.
Event mapping
Section titled “Event mapping”createReplayEvent turns broadcast payloads into compact replay events. NEW_QUESTION keeps the full question, including the correct country: a replay is watched after the fact, so there is nothing left to hide.
| Broadcast | Replay event | Fields kept |
|---|---|---|
GAME_STARTING | game_starting | countdownMs |
NEW_QUESTION | new_question | question (country, options, duration) |
ANSWER_SUBMITTED | answer_submitted | user id, username, points, score |
REACTION | reaction | user id, username, avatar, emoji (dropped on encode) |
QUESTION_RESULTS | question_results | answers, leaderboard, resultDurationMs, awards |
GAME_ENDED | game_ended | final leaderboard |
resultDurationMs accepts either unit: a timer duration of 60 or less is read as seconds and multiplied by 1000, anything larger is taken as milliseconds, and a non-finite value falls back to MULTIPLAYER_RESULTS_HOLD_MS.
The script’s meta comes from roomToReplayMeta: room id, name, host id, invite code, settings, total questions and the member list (id, username, avatar).
Encoding
Section titled “Encoding”function stripNonEssentialReplayEvents(events: ReplayEvent[]): ReplayEvent[] { return events.filter((event) => event.type !== "reaction");}
export async function encodeReplay(script: ReplayScript): Promise<string> { const payload: ReplayPayload = { meta: script.meta, events: stripNonEssentialReplayEvents(script.events), }; const json = JSON.stringify(payload); const bytes = hasCompressionSupport() ? await gzipCompress(json) : new TextEncoder().encode(json); return base64UrlEncode(bytes);}Compression uses the web-standard CompressionStream("gzip"), available in Bun and modern browsers. Without it the JSON is base64url-encoded raw. decodeReplay tells the two apart by the gzip magic bytes 0x1f 0x8b, then normalizes meta (settings through RoomSettingsSchema, missing usernames become "Player") and rebuilds checkpoints and total duration from the events.
Checkpoints and duration are never stored. They are derived on decode, so an old blob always plays with the current seek logic.
Saving to Convex
Section titled “Saving to Convex” const blob = await encodeReplay(script); const playerCount = script.meta.members.length; const totalQuestions = script.meta.totalQuestions;
const url = `${origin}/replay-save`; try { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "x-convex-secret": env.BACKEND_CONVEX_SHARED_SECRET, }, body: JSON.stringify({ blob, roomId, gameStartTime, playerCount, totalQuestions, }), });The origin is CONVEX_SITE_URL, or CONVEX_CLOUD_URL with .convex.cloud rewritten to .convex.site. If neither is set, the forward is skipped with reason unconfigured and GAME_ENDED goes out without a short id.
On the Convex side, the /replay-save HTTP action checks the shared secret (timing-safe, against BACKEND_CONVEX_SHARED_SECRET or its _PREV rotation value), caps the body at 650 000 bytes, validates it, and runs the internal mutation:
if (replayBlobTooLarge(blob, MAX_REPLAY_BLOB_UTF8_BYTES)) { return { ok: false as const, reason: "blob_too_large" }; }
const existingByRun = await findReplayByRoomGameStart(ctx, roomId, gameStartTime); if (existingByRun) { return { ok: true as const, id: existingByRun.id }; }
const contentHash = await computeContentHash(blob); if (contentHash) { const sameContent = await findReplayByContentHash(ctx, contentHash); if (sameContent) { return { ok: true as const, id: sameContent.id }; } }
const now = Date.now(); const id = generateShortId();The replays table stores id, blob, createdAt, and optional roomId, roomCreatedAt, gameStartTime, playerCount, totalQuestions and contentHash, with indexes by_slug, by_created_at, by_room_game, by_room_game_start and by_content_hash.
Sharing and watching
Section titled “Sharing and watching”Interactive
Which share link
Same order as the game: a server short id wins, otherwise the blob stays in the URL only when it fits in 1,900 characters.
Encoded length 3,300 characters
Server short link
/replay/s/<id>
The server already saved this match and sent the id with GAME_ENDED.
shareMultiplayerReplayScriptuses the server’sreplayShortIdwhen present. Otherwise it encodes the client’s own script and goes throughbuildReplayShareUrl.- The public
saveReplaymutation allows 30 saves per rolling hour for a signed-in user and 12 for an anonymous client key matching^[a-zA-Z0-9_-]{12,128}$. It runs the same dedupe checks before consuming a slot. /replay/s/[id]is client-rendered (ssr = false,prerender = false) and fetches the blob with the publicgetReplayquery.getReplayre-hashes the blob againstcontentHashby default; the web client passesverifyIntegrity: falseto skip that work on every view.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”Event count
Section titled “Event count”A finished game is a start, then each question (the question itself, one answer per player, and the results), then the end. Reactions are stripped before the blob is saved, so they are not in this count. For players and questions that is events:
Two limits matter more than the formula. A normal game is already too long for an inline URL, and even the largest room in this table stays under the Convex cap.
- Inline URLs almost never apply. The smallest game here (2 players, 15 questions) encodes to about 3 300 characters, above the 1 900 limit. Every real game uses a short link.
- The Convex cap has headroom. The largest possible room (40 players, Hard, 205 questions) encodes to about 405 KB in this synthetic run, under the 600 000-byte
MAX_REPLAY_BLOB_UTF8_BYTES. The source comment estimates “~500 KB” for normal maximum replays.
QUESTION_RESULTS repeats every player’s answer and leaderboard row, and each player adds an answer_submitted event, so JSON size grows with . The figures below come from a synthetic run through the real createReplayEvent, buildReplayScriptFromRoom and encodeReplay (random UUID seat ids, short usernames, every player answering). Real names and avatar ids are longer, so treat these as lower bounds.
| Events | JSON bytes | Encoded chars | Encoded ÷ JSON | ||
|---|---|---|---|---|---|
| 2 | 15 | 62 | ≈ 26 500 | ≈ 3 300 | 0.12 |
| 4 | 15 | 92 | ≈ 42 500 | ≈ 4 800 | 0.11 |
| 8 | 30 | 302 | ≈ 147 000 | ≈ 14 000 | 0.10 |
| 40 | 30 | 1 262 | ≈ 654 000 | ≈ 61 000 | 0.09 |
| 4 | 205 | 1 232 | ≈ 566 000 | ≈ 55 000 | 0.10 |
| 40 | 205 | 8 612 | ≈ 4 426 000 | ≈ 405 000 | 0.09 |
A least-squares fit is close to bytes. Base64 turns 3 bytes into 4 characters, so the encoded length is of the gzip output; gzip itself shrinks the JSON by a factor of about 8 to 11 here, because usernames, ids and field names repeat on every event.
Short id space
Section titled “Short id space”Ten characters is about 52 bits. A million stored replays still has roughly a 1 in 7 000 chance that two ids collide, and ten million is about 1 in 70. A collision would make by_slug return one of the two rows (.first()), so one link would show the wrong match. Insertion does not check for an existing id.
generateShortId draws 10 random bytes and maps each through byte % 36. Since , the first four characters (0 to 3) appear with probability and the other 32 with . Per-character entropy is
against for a uniform draw. Ten characters give about 51.7 bits, so the bias costs about 0.01 bits per id. With stored replays, the birthday bound gives
which is about at one million replays and at ten million.
5. Threat model, failure modes & edge cases
Section titled “5. Threat model, failure modes & edge cases”- Replays are public to anyone with the id. The blob carries the room id, invite code, host id, every member’s seat id and username. A seat id cannot claim a seat on its own (the reconnect token and account are also required), and the room is usually gone by the time anyone watches. Usernames, however, stay readable for as long as the row exists, and rows are never deleted.
- Enumeration. About 51.7 bits per id makes guessing impractical; both replay pages are
noindex, nofollow. - Forged saves.
/replay-saverequires the shared secret. The publicsaveReplaypath accepts any blob up to 600 000 bytes from any client, so a short link proves only that someone uploaded that blob. It is not proof that the match happened. - Oversized blob. Over 600 000 bytes,
saveReplayFromServerreturnsblob_too_largeandGAME_ENDEDhas no short id. The client fallback then hits the same cap insaveReplay, which throws. - Convex outage. The forward returns
failed, the diagnostic event is recorded, andGAME_ENDEDgoes out without a short id once thefetchsettles. With no timeout, a hung connection holds the scoreboard until the runtime gives up. - Recorder memory. A run is released by
stopGameor replaced by the nextstartRun. The last member leaving callsstopGame, but a finished room deleted by the lifetime sweep does not, so its run stays in memory until the process restarts. - Clock.
tMsis server wall-clock time sincegameStartTime. A system clock step during a game would show up as a jump in playback.
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”Use this format when you need to replay a finished multiplayer match in the normal game UI.
Do not use it for anti-cheat evidence or analytics. Match diagnostics in the game server’s SQLite store are the audit trail; replays are for sharing.
The code does not record reasons for most of these choices. The tradeoff column is this chapter’s analysis.
| Choice | Alternative | Tradeoff |
|---|---|---|
| Record broadcasts as events | Snapshot room state per tick | Playback reuses the live UI and the blob stays proportional to what happened. Seeking needs checkpoints, which are rebuilt on decode. |
| Encode on the server, once | Each client encodes its own copy | One canonical blob per match and one short id for everyone. The server spends CPU and a Convex round trip at game end. |
| gzip + base64url blob in one string column | Structured Convex tables per event | One read per view and no schema migration when the event shape changes. The blob cannot be queried. |
| Strip reactions | Keep them | Smaller blobs; replays lose some atmosphere. |
Inline ?r= links under 1 900 chars | Always short links | Keeps a no-backend path for tiny replays and tests. In practice real matches exceed it. |
| No retention | TTL cron on by_created_at | Links never break. Storage grows without bound, and usernames persist. |
Non-goals: editing or trimming replays, replaying solo games through this path, and proving that a replay is authentic.