Add client-side replay recording and playback system - #8
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ReplayRecordercaptures raw server messages, camera track, and roster changes into a.szrcontainer formatReplayStoremanages IndexedDB persistence with automatic retention (newest 10 unpinned replays, pinned replays never age out)ReplayFormatdefines the.szrbinary container: gzip-compressed frame log with header JSON, frame kinds for messages/camera/rosterPlayback & Viewer
ReplayDatasetparses and indexes the frame log with snapshot caching, event timeline, round/marker metadataReplayPlayerdrives playback with seek support, speed control (0.25x–4x), and frame-perfect rendering from recorded snapshotsReplayViewerUIprovides timeline scrubber with KO/hit/pickup markers, round chips, transport controls, and follow-target pickerReplayCameraRigimplements three camera modes: follow (orbit around player), free (fly camera), and playerview (exact recorded POV)Library UI
ReplayLibraryUIlists saved replays with metadata (room code, date, duration, file size, player chips, winner badge)Video Export
Integration
GameClientowns aReplayRecorderand tees raw messages viaConnection.onRawRenderersupports camera override (free/POV modes) and fixed export resolutionEventFxdispatcher shared by live and replay for consistent VFX/SFX/kill-feedInterphelpers extracted for entity interpolation (both live and replay)GameClientorReplayPlayerwith identical frame/destroy interfaceTesting
window.__replayStoreandwindow.__replayfor test accessNotable Details
BUILD_IDstamped in headers with version canary in dataset loadhttps://claude.ai/code/session_01SYcbjtB6AMyYkzBjvNrbxt