WebGL2 replay renderer
A staged replacement for server-rendered replay frames
Decision: WebGL2 production renderer · C remains parser, extractor, and visual reference
Plan date: 2026-08-16 · Asset schema baseline: 4
Outcome. A replay downloads state and reusable assets once, then plays and scrubs locally at a deterministic 60 Hz. WebGL2 renders the stage, animated fighters, items, and effects. Camera movement is instant and never reaches the server. Completed replays use a compact HTTP snapshot; live games use batched WebSocket state updates. The existing C software renderer remains available only as a reference until visual parity is proven.
Success means
- No
/api/frame or /api/frames during normal playback.
- Exact 60-frame simulation progression.
- Free pan/zoom, original camera, fit, and player-follow modes.
- Completed replay payload measured in MB, not hundreds of MB.
- Reusable assets cached across matches.
Not in the first slice
- WebGPU backend.
- All 26 characters and every stage.
- Perfect TEV/material emulation.
- Particles and every item model.
- Deleting the C renderer before parity.
01Measured baseline and reason for the rewrite
| Finding | Measured result | Consequence |
| Server frame construction | 34.42 ms average across 240 sampled frames | One worker cannot produce 60 FPS. |
| Fighter software raster | 16.75 ms average, 23.53 ms max | Consumes the full frame budget before stage/compression. |
| Deflate | 13.19 ms average, 23.17 ms max | Repeated compression is expensive and scales per viewer. |
| Compressed frame size | About 103 KB/frame average | Approximately 50 Mbps at 60 FPS. |
| Parallel workaround | Four workers reach about 95 aggregate FPS | Works, but burns server CPU for every connected viewer. |
Root constraint: playback must no longer depend on generating, compressing, transferring, decoding, and caching a full raster image for every replay frame.
02Decisions that should not drift mid-implementation
| # | Decision | Reason |
| D1 | WebGL2 is the first production GPU backend. | Broad support, mature tooling, and ample performance for this scene size. |
| D2 | Completed replay state is fetched once over HTTP. | A finite replay is a cacheable object; it should not require a live socket. |
| D3 | WebSocket is reserved for live/in-progress games. | It carries newly committed frames, replacements from rollback, and game-end state. |
| D4 | C remains authoritative during migration. | The browser implementation is verified against known C state and reference images. |
| D5 | CPU evaluates bones first; vertex shader skins. | Simple to validate. GPU compute is unnecessary for a few fighters. |
| D6 | Existing schema-4 assets are parsed directly first. | They are versioned, pointer-free, big-endian, and already contain GPU-ready source data. |
| D7 | Integer replay frame is the source of truth. | Avoids floating-point drift and ties HUD, animation, seeking, and rendering to one index. |
| D8 | The old frame API is removed only after parity gates pass. | Keeps a working viewer and a visual oracle throughout the rewrite. |
| D9 | Replay routes are stateless and replay-scoped. | Concurrent viewers must never race through the server's current global g_active selection. |
| D10 | Timeline, live protocol, and asset schemas evolve independently. | A state-layout change must not invalidate extracted models or silently reinterpret socket messages. |
| D11 | Original-camera samples are required timeline data. | Three float arrays are cheap and avoid making camera fidelity depend on server rendering. |
| D12 | Live transport cannot delay completed-replay cutover. | WebSocket support is a separate milestone after local GPU playback is proven. |
03Target architecture
reusable, immutable
┌────── model / anim / stage assets ──────┐
│ HTTP + browser cache │
│ ▼
.slp ── C parser ─────┼── replay-scoped timeline.bin ─ HTTP ► browser scene store
│ ▲
live Slippi events ───┴── committed/replaced batches ─ WebSocket ┘
│
deterministic frame clock
│
animation evaluation + camera state
│
▼
WebGL2 vertex skinning + rasterization
│
▼
canvas
Playback/network separation: network arrival may add or replace timeline data, but it never advances the playback clock. Playback consumes local state only.
Stateless replay identity
POST /api/replays upload, returns immutable replay id
GET /api/replays/{id}/manifest identity, players, stage, schemas
GET /api/replays/{id}/timeline completed binary state snapshot
WS /api/replays/{id}/live live state only
Never: GET /api/set?f=... followed by requests against shared g_active
The server may internally cache parsed replays by id, but request correctness cannot depend on whichever replay another connection selected most recently.
04Proposed module boundaries
| Module | Responsibility | Must not do |
web/main.js | Application composition, loading state, error boundary | Shader math or binary parsing |
web/replay/timeline.js | Parse immutable timeline, frame lookup, rollback replacement | DOM or GL calls |
web/replay/clock.js | Play/pause/seek and integer 60 Hz frame selection | Network fetches |
web/replay/live.js | WebSocket handshake, batching, sequence checks, reconnect | Advance playback |
web/assets/model.js | Parse .model into typed arrays | Create renderer-global state |
web/assets/anims.js | Parse actions/tracks/keys | Sample current replay state |
web/assets/stage.js | Parse sections, lights, authored camera | Own camera controls |
web/animation/pose.js | Sample tracks and compute bone matrices | Transform every mesh vertex on CPU |
web/renderer/webgl2.js | Context, pipelines/state, passes, resize, device loss | Parse replay protocol |
web/renderer/model.js | VAOs, GPU buffers, materials, textures, draw ranges | Know about WebSocket |
web/renderer/camera.js | View/projection matrices and camera modes | Mutate timeline |
web/renderer/shaders/* | GLSL ES 3.00 shader sources | Contain app behavior |
Frontend delivery decision
- Use native browser ES modules rather than a production bundle initially.
- Use JSDoc types plus
tsc --checkJs for protocol, math, and renderer checking without a runtime build dependency.
- Use Node's test runner for pure timeline, asset, clock, and animation modules.
- Teach the C server to serve allowlisted static files with correct JavaScript, CSS, and binary MIME types.
- Keep shaders as JavaScript string modules initially so shader loading cannot race or require another fetch.
05Completed replay timeline contract
Add a versioned binary endpoint, conceptually:
GET /api/replays/{id}/timeline
Content-Type: application/vnd.melee.timeline
ETag: "replay-hash:timeline-schema"
Vary: Accept-Encoding
header
magic[4] "RPL2"
timeline_schema u16
flags u16
start_frame i32
end_frame i32
stage_id u16
slot_count u8
section offsets u32...
players[] identity, character, costume, display metadata
slot_presence[] bitset per frame
slot[N].x[] structure-of-arrays, directly typed and indexed
slot[N].y[]
slot[N].animation_index[]
slot[N].animation_frame[]
slot[N].facing[]
slot[N].percent[]
slot[N].shield[]
slot[N].stocks[]
items[] sparse records grouped by frame
stage_events[] sparse FoD / Whispy / Stadium events
camera.x[] required original-camera samples
camera.y[]
camera.zoom[]
Per-player frame — first schema
| Field | Type | Use |
| animation index | u16 | Select action/clip without name lookup. |
| action state | u16 | HUD/debug and fallbacks. |
| animation frame | f32 | Track sampling input. |
| x, y | f32 × 2 | Authoritative world root. |
| facing | f32 | Horizontal model projection/mirror. |
| percent, shield | f32 × 2 | HUD and shield effect. |
| stocks, flags | u8 × 2 | Visibility and state flags. |
Size target: a normal two-player completed timeline should be comfortably below 2 MB compressed. Measure before optimizing field widths further.
Why structure-of-arrays
Fields are stored as contiguous arrays rather than repeated frame objects. This improves HTTP compression, permits direct typed-array views, avoids per-frame allocations, and makes random frame lookup a constant set of indexed reads.
Protocol rules
- Every multibyte field has documented byte order and alignment.
asset_schema, timeline_schema, and live_protocol are separate version numbers.
- Unknown trailing sections are skipped using offsets/lengths.
- Schema mismatch fails visibly; never reinterpret bytes optimistically.
- The endpoint is immutable for a completed replay and supports ETag caching.
- Compression is negotiated by HTTP via
Accept-Encoding; Brotli is not part of the binary contract.
- JSON is retained only for small manifests and human-readable errors.
Index proof before freeze: verify across golden fixtures that Slippi's animation_index maps consistently to asset_anims_t.actions[]. Until proven, preserve both the raw index and resolved identity in diagnostic output.
06WebSocket live-state protocol
Independent milestone: implement this only after completed-replay WebGL2 playback is viable. Live transport cannot block the GPU renderer becoming the default for completed games.
WS /api/live?f=current.slp
client → HELLO { supported_schema, resume_sequence }
server → SNAPSHOT { replay header + recent committed range }
server → FRAMES { sequence, first_frame, count, binary records }
server → REPLACE { sequence, first_frame, count, binary records }
server → GAME_END { final_frame, reason }
either → PING/PONG
server → ERROR { code, message }
Correctness requirements
- Sequence numbers are monotonic. A gap triggers snapshot recovery.
REPLACE overwrites prior state by frame number; latest rollback version wins.
- Messages batch frames. Never emit one WebSocket message per player per frame.
- Reconnect resumes by sequence when possible and falls back to a snapshot.
- The client exposes buffer depth but does not couple render timing to socket timing.
- Backpressure has a bound; stale intermediate updates may be consolidated into a fresh snapshot.
Do not casually hand-roll a production WebSocket stack inside the minimal HTTP server. At implementation time, select a small reviewed C WebSocket library or isolate live transport in a dedicated gateway; keep the binary state contract independent of that choice.
07GPU asset delivery and parsing
The existing cache format is suitable for the first browser implementation. It is explicitly serialized big-endian and already contains bones, vertices, weights, indices, primitive groups, materials, textures, animations, and stage sections.
Immutable routes
/assets/v4/models/fox-0.model
/assets/v4/anims/fox-0.anims
/assets/v4/stages/fd.stage
Cache-Control: public, max-age=31536000, immutable
ETag: content hash
Browser parsing strategy
- Use
ArrayBuffer + DataView for big-endian headers and records.
- Copy final arrays once into native typed arrays suitable for
gl.bufferData.
- Validate counts before allocation and reject truncated/oversized inputs.
- Upload raw RGBA textures directly first; atlas/compressed texture work is deferred.
- Key the in-memory asset cache by schema, character, and costume.
Do not optimize the wire format early. Prove one exact Fox/Falco/FD vertical slice with schema 4. Introduce atlases or KTX2 only after measuring actual asset transfer and GPU memory.
08WebGL2 render architecture
GPU resources per model
| Resource | Contents | Lifetime |
| VAO | Complete vertex attribute layout | Asset lifetime |
| Vertex buffers | position, normal, UV, color, weights, bone indices | Asset lifetime |
| Index buffer | UNSIGNED_SHORT triangle indices | Asset lifetime |
| Textures | RGBA material images | Asset lifetime |
| Material table | texture, diffuse, alpha, flags, draw range | Asset lifetime |
| Bone texture | Current matrices as RGBA32F texels | Updated per pose |
Pass order
- Backdrop clear/gradient.
- Opaque stage sections with depth writes.
- Opaque fighter primitive groups with depth writes.
- Items and simple effects.
- Transparent primitive groups, sorted at group granularity.
- Shields and debug overlays.
- HTML HUD outside WebGL.
Required GL state discipline
- One renderer-owned state cache; application code never calls raw GL.
- Explicit state setup per pass: program, VAO, depth test/write, blend mode, cull mode.
- Development builds check
gl.getError() at pass boundaries.
- Handle
webglcontextlost and rebuild all resources after restoration.
- Use device-pixel-ratio-aware canvas sizing with a configurable upper bound.
Capability gate before asset loading
- Require a WebGL2 context and sufficient vertex texture image units.
- Record maximum texture size, combined texture units, uniform limits, and shader precision.
- Compile/link the skeletal shader and validate a floating-point bone texture before downloading large assets.
- Show an actionable compatibility screen on failure; never leave an empty black canvas.
Transparency parity policy
The C reference renderer sorts individual triangles. The initial GPU renderer sorts transparent primitive groups, not every triangle. This difference is accepted for the first vertical slice, recorded in image-diff results, and promoted to per-triangle sorting only where the artifact is material.
Projection mapping to preserve
The first shader must match render_pose_profile, not invent a new view:
- Model Z contributes horizontal screen position.
- Model Y contributes vertical screen position.
- Model X supplies painter/depth ordering.
- Negative replay facing mirrors the profile horizontally.
- Replay x/y is the authoritative fighter root; animation root locomotion is not double-applied.
09Animation evaluation and GPU skinning
CPU pose evaluation — port in this order
sample_key: step, linear, and guarded cubic Hermite.
sample_joint: base SRT decomposition followed by animated channel overrides.
eval_pose: local matrices followed by parent-to-child world matrices.
- Rigid versus weighted skin matrix preparation.
- Upload matrices to the fighter's floating-point bone texture.
Vertex shader contract
#version 300 es
in vec3 a_position;
in vec2 a_uv;
in vec4 a_color;
in vec4 a_weights;
in uvec4 a_bones;
uniform sampler2D u_boneMatrices;
uniform mat4 u_viewProjection;
uniform vec2 u_replayRoot;
uniform float u_facing;
// Fetch 3x4 affine matrices, blend by weights, preserve rigid-vertex rule,
// convert model profile axes to replay world axes, then apply camera.
Validation invariants
- Animation frame quirk (
frame <= 0 ? 1 : frame + 1) matches C.
- Loop wrapping matches action
end_frame.
- Root animation translation is suppressed where replay x/y is authoritative.
- Facing changes only the intended profile axis.
- Missing animation uses an explicit, tested fallback.
10Camera, controls, and exact frame clock
Camera state
{
mode: "free" | "melee" | "fit" | "follow" | "stage",
centerX: number,
centerY: number,
zoom: number,
targetPort: number | null,
smoothing: number
}
Controls
- Pointer drag: pan.
- Wheel/pinch: zoom around cursor.
- Double click: reset to selected mode.
- Number keys: follow player.
- Mode button: free, original Melee, fit players, stage, follow.
- Camera control remains active while paused and while scrubbing.
Clock rules
elapsed = now - playbackStartedAt
frame = anchorFrame + floor(elapsed * 60 / 1000)
frame = clamp(frame, startFrame, endFrame)
- Never repeatedly add
1000/60; derive from the anchor to avoid drift.
- Seeking sets an integer frame and resets the time anchor.
- On 120/144 Hz displays, a state may render more than once; simulation remains 60 Hz.
- If one presentation is missed, jump to the correct current replay frame rather than slowing simulation.
Meaning of exact 60: simulation is guaranteed to advance exactly 60 replay frames per second with zero long-run drift. Presentation is measured separately because the browser, compositor, display refresh, and operating system can still drop a visual presentation.
11Implementation phases and hard exit gates
Phase 0 — Golden fixtures and contractsNo rendering yet
- Select representative frames: idle, run, attack, shield, airborne, facing flip, rollback, Nana.
- Record C frame state and reference images for Fox/Falco on FD.
- Write timeline schema and WebSocket message specification.
- Define independent asset, timeline, and live protocol versions.
- Inventory schema-4 asset transfer size and decoded CPU/GPU memory.
- Specify stateless replay ids and remove new-feature dependence on
g_active.
- Choose native ES module serving, JSDoc type checking, and test commands.
- Define renderer interface and camera coordinate conventions.
Exit gate: fixtures and byte-level contracts are reviewed before browser implementation starts.
Phase 1 — State and asset deliveryNo pixel streaming
- Add completed timeline serializer and endpoint.
- Use replay-scoped stateless routes and a structure-of-arrays payload.
- Add immutable, allowlisted asset routes.
- Implement browser parsers for model, animations, and stage.
- Compare browser-decoded records with C structs across fixtures.
- Add payload size and parse-time measurements.
Exit gate: two clients can load different replay ids concurrently without interference; browser state matches C; timeline is under the agreed size target.
Phase 2 — Static WebGL2 vertical sliceFox/Falco/FD
- Create context, resize policy, error boundary, and resource lifecycle.
- Run the capability gate before large downloads.
- Upload one fighter model and draw bind pose with textures.
- Draw accepted FD sections and backdrop.
- Implement free pan/zoom camera.
- Match C projection, facing, materials, alpha, and depth.
Exit gate: static reference poses and stage pass image-diff tolerance; camera movement makes zero network requests.
Phase 3 — Animation and deterministic playback60 Hz
- Port key sampling, SRT decomposition, and bone hierarchy evaluation.
- Implement floating-point bone texture and vertex-shader skinning.
- Apply replay position, facing, animation index, and animation frame.
- Add integer-frame clock, play/pause, stepping, and scrub.
- Add original, fit, and follow camera modes.
Exit gate: 600 consecutive frames play at 60 Hz, then a 10-minute soak shows zero simulation drift, zero dropped presentations on the reference machine, no steady-state allocation growth, and no frame-image requests.
Phase 4 — Complete replay sceneParity
- Multiple ports, same-character matches, followers/Nana.
- Items, lasers, shields, stocks, HUD, and debug overlays.
- Dynamic stage events already present in parser data.
- Missing assets and animations degrade visibly and safely.
- Run full-match soak tests and context restore tests.
Exit gate: sample replays complete without errors; camera modes work throughout; visual discrepancies are enumerated rather than hidden.
Phase 5 — Live WebSocket modeRollback-safe
- Implement handshake, snapshot, batched frames, replacement, and game-end messages.
- Add sequence-gap recovery and reconnect.
- Test artificial latency, reordering, disconnect, duplicate messages, and rollback.
- Expose live buffer depth and connection state separately from playback.
Exit gate: rollback replacements converge to the same final state as parsing the completed SLP.
Phase 6 — Default and removalDelete only after proof
- Ship GPU renderer behind a query flag, then opt-in toggle, then default.
- Collect errors, frame timing, asset failures, and context-loss metrics.
- Retain C reference endpoints for one release window.
- Remove raster-frame playback code and prefetch cache after acceptance.
Exit gate: GPU mode is stable as default and the raster transport has no remaining product dependency.
12Test plan
| Layer | Tests | Failure signal |
| Timeline | Golden binary, truncation, schema mismatch, rollback replacement, sparse slots/items | Exact field mismatch |
| Asset parser | Counts, offsets, endian values, bounds, malformed input | Reject safely; never oversized allocation |
| Animation | Step/linear/Hermite, loop, start frame, root suppression, hierarchy | Matrix epsilon exceeded |
| Renderer | Bind pose, sampled poses, facing, texture/material, transparency, stage | Image difference beyond threshold |
| Clock | 60/120/144 Hz simulation, pause/resume, seek, long-duration drift | Wrong integer replay frame |
| Camera | Pan, zoom-at-cursor, follow, fit, mode switch, resize | Incorrect world/screen round trip |
| Live | Duplicate, gap, rollback, reconnect, delayed snapshot, game end | Final timeline differs from completed parse |
| Soak | Full replay, repeated replay switch, context loss/restore | Leak, crash, increasing frame time |
| Concurrency | Two replay ids selected, uploaded, scrubbed, and played concurrently | Cross-replay state or asset contamination |
| Capabilities | Missing WebGL2, insufficient limits, shader failure, float texture failure | Actionable compatibility screen |
Visual differential workflow
- Render a fixture with the C reference renderer at a fixed camera.
- Render the same frame with WebGL2 at identical dimensions and camera.
- Capture the canvas.
- Compare per-pixel and structural similarity.
- Classify intentional differences; never simply widen tolerance globally.
13Performance and bandwidth budgets
| Metric | Target | Hard gate |
| GPU render CPU submission | < 2 ms/frame | < 4 ms p95 |
| Animation evaluation | < 1 ms/frame | < 2 ms p95 |
| Total app frame work | < 5 ms/frame | < 10 ms p95 |
| Completed timeline | < 2 MB typical | < 5 MB typical match |
| Reusable initial assets | < 25 MB compressed | Measured and cached; no repeat per replay |
| Playback network | 0 bytes after load | No frame-image requests |
| Live state bandwidth | < 100 KB/s | < 250 KB/s |
| Long-run drift | 0 replay frames | 0 after 30 minutes |
| Dropped presentation | 0 on reference machine | 0 during 10-minute acceptance soak |
| CPU timeline memory | < 10 MB typical | < 20 MB |
| CPU decoded asset memory | < 100 MB | < 150 MB |
| GPU buffers and textures | < 192 MB | < 256 MB |
| Steady-state allocation | 0 per replay frame | No upward memory trend |
14Risk register and mitigations
| Risk | Impact | Mitigation |
| GPU skinning differs from C rigid/weighted semantics | Exploded or offset meshes | Golden matrix tests and one model-group-at-a-time rendering. |
| Transparency differs from painter sorting | Visual artifacts | Opaque depth pass plus sorted transparent primitive groups; document irreducible cases. |
| Browser asset parser trusts corrupt counts | Memory/availability problem | Strict maximums, checked offsets, schema/magic validation. |
| Large raw RGBA texture assets | Slow first load and GPU memory | Measure first; atlas/compress only after vertical slice. |
| Main-thread animation causes jank | Missed presentation | Profile; move pose evaluation to Worker only if over budget. |
| WebSocket becomes playback clock | Network jitter causes stutter | Architectural rule: socket mutates timeline only. |
| Rewrite stays permanently half-migrated | Two systems to maintain | Hard phase gates and explicit cutover/removal phase. |
| Unsupported WebGL2/device loss | Blank viewer | Capability screen, actionable message, context restore, C fallback during migration. |
Global g_active leaks across viewers | Wrong replay or corrupted timeline | Replay-scoped immutable ids and a concurrent-client integration test. |
animation_index is not a stable action-table index | Wrong poses despite valid state | Golden mapping proof before timeline schema freeze; retain raw diagnostic identity. |
| Hand-written WebSocket stack grows scope/security risk | Delays core renderer | Defer live milestone and use a reviewed library or isolated gateway. |
15Rollout and cutover
?renderer=webgl2 development path; existing renderer remains default.
- Internal visual/performance fixture suite must pass.
- Add user-visible renderer toggle and collect client errors.
- Make WebGL2 default while retaining
?renderer=software.
- After a stable window, remove frame prefetch, frame decoding worker, and raster playback endpoints.
- Keep a command-line/reference C renderer for golden-image generation.
First vertical slice: statelessly load one completed Fox vs. Falco replay on Final Destination, download its structure-of-arrays timeline and three reusable schema-4 assets, draw textured animated fighters and the stage in WebGL2, play 600 consecutive frames at 60 Hz, allow free camera pan/zoom, then pass a 10-minute no-drift/no-growth soak—with zero frame-image requests. Do not broaden scope until this passes.
16Definition of done
- Completed replay playback is fully local after initial state/assets load.
- Live mode is batched, sequence-safe, reconnectable, and rollback-correct.
- WebGL2 reproduces accepted C reference poses and stage output.
- Playback clock advances exactly 60 replay frames per second without drift.
- Reference-machine acceptance shows zero dropped presentations over the defined soak.
- Camera is locally controllable in every playback state.
- Concurrent users can operate on different replay ids without shared mutable selection state.
- Measured performance and bandwidth meet the budgets above.
- Raster-frame streaming is no longer used by the product path.
- The old playback implementation is removed, not merely disabled forever.