Skip to content

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.

ReplayRecorder holds one run per room. startRun is called from resetGameState when a game starts; clear is called from stopGame.

apps/server/src/lib/replay/replay-recorder.ts
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.

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.

BroadcastReplay eventFields kept
GAME_STARTINGgame_startingcountdownMs
NEW_QUESTIONnew_questionquestion (country, options, duration)
ANSWER_SUBMITTEDanswer_submitteduser id, username, points, score
REACTIONreactionuser id, username, avatar, emoji (dropped on encode)
QUESTION_RESULTSquestion_resultsanswers, leaderboard, resultDurationMs, awards
GAME_ENDEDgame_endedfinal 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).

packages/shared/src/replay/replay-encode.ts
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.

apps/server/src/lib/replay/convex-replay-forward.ts
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:

apps/web/src/convex/replays.ts
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.

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.

  • shareMultiplayerReplayScript uses the server’s replayShortId when present. Otherwise it encodes the client’s own script and goes through buildReplayShareUrl.
  • The public saveReplay mutation 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 public getReplay query. getReplay re-hashes the blob against contentHash by default; the web client passes verifyIntegrity: false to skip that work on every view.

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 PP players and QQ questions that is 2+Q(P+2)2 + Q(P + 2) events:

E=1⏟starting+Q (1⏟question+P⏟answers+1⏟results)+1⏟ended=2+Q (P+2)E = \underbrace{1}_{\text{starting}} + Q\,(\underbrace{1}_{\text{question}} + \underbrace{P}_{\text{answers}} + \underbrace{1}_{\text{results}}) + \underbrace{1}_{\text{ended}} = 2 + Q\,(P + 2)

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 Q⋅PQ \cdot P. 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.

PPQQEventsJSON bytesEncoded charsEncoded ÷ JSON
21562≈ 26 500≈ 3 3000.12
41592≈ 42 500≈ 4 8000.11
830302≈ 147 000≈ 14 0000.10
40301 262≈ 654 000≈ 61 0000.09
42051 232≈ 566 000≈ 55 0000.10
402058 612≈ 4 426 000≈ 405 0000.09

A least-squares fit is close to JSON≈Q (705+532 P)\text{JSON} \approx Q\,(705 + 532\,P) bytes. Base64 turns 3 bytes into 4 characters, so the encoded length is 43\tfrac{4}{3} 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.

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 256=7⋅36+4256 = 7 \cdot 36 + 4, the first four characters (0 to 3) appear with probability 8256\tfrac{8}{256} and the other 32 with 7256\tfrac{7}{256}. Per-character entropy is

H=−(4⋅8256log⁡28256+32⋅7256log⁡27256)≈5.1686 bitsH = -\left(4 \cdot \tfrac{8}{256}\log_2\tfrac{8}{256} + 32 \cdot \tfrac{7}{256}\log_2\tfrac{7}{256}\right) \approx 5.1686 \text{ bits}

against log⁡236≈5.1699\log_2 36 \approx 5.1699 for a uniform draw. Ten characters give about 51.7 bits, so the bias costs about 0.01 bits per id. With NN stored replays, the birthday bound gives

P(collision)≈N22⋅3610≈N27.3×1015P(\text{collision}) \approx \frac{N^2}{2 \cdot 36^{10}} \approx \frac{N^2}{7.3 \times 10^{15}}

which is about 1.4×10−41.4 \times 10^{-4} at one million replays and 1.4×10−21.4 \times 10^{-2} 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-save requires the shared secret. The public saveReplay path 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, saveReplayFromServer returns blob_too_large and GAME_ENDED has no short id. The client fallback then hits the same cap in saveReplay, which throws.
  • Convex outage. The forward returns failed, the diagnostic event is recorded, and GAME_ENDED goes out without a short id once the fetch settles. With no timeout, a hung connection holds the scoreboard until the runtime gives up.
  • Recorder memory. A run is released by stopGame or replaced by the next startRun. The last member leaving calls stopGame, but a finished room deleted by the lifetime sweep does not, so its run stays in memory until the process restarts.
  • Clock. tMs is server wall-clock time since gameStartTime. A system clock step during a game would show up as a jump in playback.

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.

ChoiceAlternativeTradeoff
Record broadcasts as eventsSnapshot room state per tickPlayback reuses the live UI and the blob stays proportional to what happened. Seeking needs checkpoints, which are rebuilt on decode.
Encode on the server, onceEach client encodes its own copyOne 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 columnStructured Convex tables per eventOne read per view and no schema migration when the event shape changes. The blob cannot be queried.
Strip reactionsKeep themSmaller blobs; replays lose some atmosphere.
Inline ?r= links under 1 900 charsAlways short linksKeeps a no-backend path for tiny replays and tests. In practice real matches exceed it.
No retentionTTL cron on by_created_atLinks 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.