Skip to content

Add client-side replay recording and playback system - #8

Merged
Advik-B merged 7 commits into
masterfrom
claude/match-replay-system-ycue3b
Jul 13, 2026
Merged

Add client-side replay recording and playback system#8
Advik-B merged 7 commits into
masterfrom
claude/match-replay-system-ycue3b

Conversation

@Advik-B

@Advik-B Advik-B commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Implements a complete replay system for match recording, storage, and playback:

Summary

Adds automatic client-side match recording with IndexedDB persistence, a replay library UI for browsing/managing recordings, a full-featured replay viewer with seeking/scrubbing/speed control, and video export to MP4/WebM formats.

Key Changes

Recording & Storage

  • ReplayRecorder captures raw server messages, camera track, and roster changes into a .szr container format
  • ReplayStore manages IndexedDB persistence with automatic retention (newest 10 unpinned replays, pinned replays never age out)
  • ReplayFormat defines the .szr binary container: gzip-compressed frame log with header JSON, frame kinds for messages/camera/roster
  • Recordings auto-finalize on match-end, disconnect, or session exit

Playback & Viewer

  • ReplayDataset parses and indexes the frame log with snapshot caching, event timeline, round/marker metadata
  • ReplayPlayer drives playback with seek support, speed control (0.25x–4x), and frame-perfect rendering from recorded snapshots
  • ReplayViewerUI provides timeline scrubber with KO/hit/pickup markers, round chips, transport controls, and follow-target picker
  • ReplayCameraRig implements three camera modes: follow (orbit around player), free (fly camera), and playerview (exact recorded POV)

Library UI

  • ReplayLibraryUI lists saved replays with metadata (room code, date, duration, file size, player chips, winner badge)
  • Supports watch, pin/unpin, delete, and .szr file import/export
  • Displays badges for partial recordings, old-build warnings, and pinned status

Video Export

  • Offline MP4 export via WebCodecs (H.264 or VP9) with frame-perfect stepping
  • Realtime WebM fallback with audio capture for browsers without WebCodecs
  • Configurable resolution, FPS, camera mode, and tick range
  • Preserves viewer transport state after export completes

Integration

  • GameClient owns a ReplayRecorder and tees raw messages via Connection.onRaw
  • Renderer supports camera override (free/POV modes) and fixed export resolution
  • EventFx dispatcher shared by live and replay for consistent VFX/SFX/kill-feed
  • Interp helpers extracted for entity interpolation (both live and replay)
  • Main loop can host either GameClient or ReplayPlayer with identical frame/destroy interface

Testing

  • E2E specs for recording (11), playback with seeking (12), video export (13), and storage retention (14)
  • Dev hooks: window.__replayStore and window.__replay for test access

Notable Details

  • Postcard wire bytes are build-specific; BUILD_ID stamped in headers with version canary in dataset load
  • Snapshots cached with LRU (64 entries) to minimize decode work during playback
  • Events never re-fire on seek; crossed events are skipped
  • Tile state rebuilt from pure-function arena shrink schedule (no wire bytes needed)
  • Camera track recorded at 20 Hz, lerped during playback for smooth POV
  • Kill-feed driven by shared event dispatcher with 5s credit window

https://claude.ai/code/session_01SYcbjtB6AMyYkzBjvNrbxt

claude added 7 commits July 13, 2026 18:34
The client already receives the full authoritative world as 20 Hz
postcard snapshots, so a replay is just the raw server bytes teed off
the socket: Connection.onRaw feeds a ReplayRecorder that keeps
Snapshot/PhaseChange/PlayerJoined/PlayerLeft frames (never Welcome —
it carries the session token; the roster travels in ROSTER frames),
plus a 20 Hz camera track for exact POV playback later. Recording arms
on Countdown (or mid-match join), survives reconnects, and finalizes
into a gzipped .szr container at MatchEnd — or as a partial replay on
exit/disconnect. Zero server or protocol changes; ~250 KB per minute.

Storage is a small IndexedDB wrapper (meta + container blobs) keeping
the newest 10 unpinned replays, with header caches (rounds, KO/hit/
pickup markers with kill attribution) so the library can list without
inflating logs. The header records a Vite-injected BUILD_ID because
positional postcard bytes are only guaranteed decodable by the build
that wrote them.

Groundwork extractions, behavior-preserving: GameClient.onEvent's
VFX/SFX/kill-feed switch moves to game/eventfx.ts (live play and
replay share it; combo/hitstop/haptics stay live-only hooks) and the
snapshot-interpolation helpers move to game/interp.ts; main.ts's rAF
loop now hosts anything with frame()/destroy().

Covered by e2e spec 11 (full match: rounds/markers/result/reload
persistence; partial on mid-match exit); specs 00-10 stay green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYcbjtB6AMyYkzBjvNrbxt
ReplayDataset walks a .szr log once into tick indices, event/phase/
roster timelines, the recorder's camera track and recomputed markers,
keeping only raw snapshot bytes plus a 64-entry decode LRU — playback
re-decodes ~2 snapshots per frame, less than a live client does. Decode
canaries (tick echo, monotonicity, id bounds) hard-gate foreign-build
files that positional postcard would otherwise "decode" into garbage.

ReplayPlayer drives the existing renderer straight from snapshots with
the live interpolation semantics (lerped pos/yaw, latched anim/flags),
projectile/pickup sync, damage nameplates, winner dance, and arena
tiles from a headless ClientSim — collapse is a pure function of round
tick, so seeks are reset + re-apply and stay in the microsecond range.
Events fire through the shared eventfx dispatcher (kill feed, bursts,
KO flashes) forward-only, seek-suppressed, burst-capped, muted above
2x; phase chrome (countdown, FIGHT!, round banners) is computed from
phase + playhead so it survives scrubbing. The camera is the live
orbit rig following a selectable player; renderer grew a camera
override + snap seam (free-fly lands next) and transient clearing.

The viewer bar has play/pause, 0.25-4x speed pills, snapshot stepping,
prev/next-KO jumps, round chips, reconnect-gap hatching, and colored
KO/hit/pickup/bomb markers with tooltips on a click/drag scrubber;
keyboard: Space, ,/., n/p, Esc. The library screen (menu > Replays)
lists replays with rosters/duration/size/badges and watch, pin,
delete, save-as-.szr and import; finished matches also offer WATCH
REPLAY from the match-end panel.

Covered by e2e spec 12 (world reconstruction, transport, seeking both
ways, marker DOM, killfeed on crossing a Death, 4x auto-pause at end,
stepping, zero page errors) plus a viewer screenshot artifact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYcbjtB6AMyYkzBjvNrbxt
A ReplayCameraRig owns the three viewer modes. Follow stays the live
orbit rig on a selectable player. Free is an unclamped fly camera
(WASD + Space/Shift, pointer-lock or drag look, wheel-scaled speed,
arrow turning for keyboard users) that seeds itself from the current
view so switching is seamless, driving the renderer's camera override.
Player-view reconstructs what a player saw: the recording player's
camera track plays back exactly (yaw and pitch); other players get a
camera behind their wire yaw, smoothed to hide u8 quantization, and
the button says so. Mode buttons + 1/2/3 shortcuts on the viewer bar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYcbjtB6AMyYkzBjvNrbxt
The MP4 path steps the replay at exact 1/fps tick intervals (never
wall clock), captures each render as a VideoFrame, encodes through
WebCodecs (H.264 probe, VP9-in-MP4 fallback) with encoder
backpressure, and muxes via mp4-muxer (the feature's only new
dependency; dynamically imported so it stays out of the main bundle).
The loop yields through a MessageChannel, so it keeps rendering at
full speed in a backgrounded tab and typically finishes faster than
real time. The WebM path records canvas.captureStream + the game's
master WebAudio bus through MediaRecorder at 1x — the with-sound
option, and the automatic fallback where WebCodecs is missing.

Camera presets: a deterministic chase cam easing behind the chosen
player, or that player's own view (exact recorded camera for the
recording player). The export dialog picks camera/target, 720p/1080p,
30/60 fps, whole-replay or current-round range, and format, with a
progress bar and cancel; the canvas is retargeted to the encode
resolution for the duration and the viewer transport is restored
afterwards. In-world VFX (bursts, damage numbers, KO text) are Three
sprites, so clips include them; DOM chrome stays out by construction.

e2e spec 13 exports a 2 s 720p30 clip in headless Chromium and
verifies container magic (ftyp/EBML), size, transport restoration and
a clean error log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYcbjtB6AMyYkzBjvNrbxt
When IndexedDB can't persist a finished recording (quota, private
mode) the match-end panel offers SAVE REPLAY FILE — a direct .szr
download of the in-memory container — instead of WATCH REPLAY. The
viewer shows a warning chip when a replay's buildId doesn't match the
running build (positional postcard makes cross-build playback
best-effort; the decode canary still hard-rejects incompatible logs).
Settings gains an "auto-record matches" toggle, the library's storage
line uses sane units, and library row buttons no longer inherit the
menu's fixed button width.

e2e spec 14 exercises the store through real IndexedDB: the
10-unpinned retention cap with pinned replays exempt, delete removing
meta+blob, and clean rejection of garbage and truncated imports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYcbjtB6AMyYkzBjvNrbxt
roundAt() now returns the last round that started at or before the
tick, so the arena holds its end-of-round holes through the round-end
pause during playback (matching live) instead of refilling early; the
next countdown's segment change still resets it. Replay finalize
failures are contained (a failed save can't break the match-end flow).
The Dockerfile stamps BUILD_ID into the client (git short SHA at
deploy) for the replay-file build identity, and the README documents
the replay system.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYcbjtB6AMyYkzBjvNrbxt
…once

The countdown title is written by the render loop's next frame, not
the phase change itself, so reading #h-title immediately after the
phase flips races the first countdown paint on slow CI runners. Wait
for the ticker (bounded) instead — still fails if it never renders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYcbjtB6AMyYkzBjvNrbxt
@Advik-B
Advik-B merged commit 1b22c27 into master Jul 13, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants