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

FindingMeasured resultConsequence
Server frame construction34.42 ms average across 240 sampled framesOne worker cannot produce 60 FPS.
Fighter software raster16.75 ms average, 23.53 ms maxConsumes the full frame budget before stage/compression.
Deflate13.19 ms average, 23.17 ms maxRepeated compression is expensive and scales per viewer.
Compressed frame sizeAbout 103 KB/frame averageApproximately 50 Mbps at 60 FPS.
Parallel workaroundFour workers reach about 95 aggregate FPSWorks, 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

#DecisionReason
D1WebGL2 is the first production GPU backend.Broad support, mature tooling, and ample performance for this scene size.
D2Completed replay state is fetched once over HTTP.A finite replay is a cacheable object; it should not require a live socket.
D3WebSocket is reserved for live/in-progress games.It carries newly committed frames, replacements from rollback, and game-end state.
D4C remains authoritative during migration.The browser implementation is verified against known C state and reference images.
D5CPU evaluates bones first; vertex shader skins.Simple to validate. GPU compute is unnecessary for a few fighters.
D6Existing schema-4 assets are parsed directly first.They are versioned, pointer-free, big-endian, and already contain GPU-ready source data.
D7Integer replay frame is the source of truth.Avoids floating-point drift and ties HUD, animation, seeking, and rendering to one index.
D8The old frame API is removed only after parity gates pass.Keeps a working viewer and a visual oracle throughout the rewrite.
D9Replay routes are stateless and replay-scoped.Concurrent viewers must never race through the server's current global g_active selection.
D10Timeline, live protocol, and asset schemas evolve independently.A state-layout change must not invalidate extracted models or silently reinterpret socket messages.
D11Original-camera samples are required timeline data.Three float arrays are cheap and avoid making camera fidelity depend on server rendering.
D12Live 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

ModuleResponsibilityMust not do
web/main.jsApplication composition, loading state, error boundaryShader math or binary parsing
web/replay/timeline.jsParse immutable timeline, frame lookup, rollback replacementDOM or GL calls
web/replay/clock.jsPlay/pause/seek and integer 60 Hz frame selectionNetwork fetches
web/replay/live.jsWebSocket handshake, batching, sequence checks, reconnectAdvance playback
web/assets/model.jsParse .model into typed arraysCreate renderer-global state
web/assets/anims.jsParse actions/tracks/keysSample current replay state
web/assets/stage.jsParse sections, lights, authored cameraOwn camera controls
web/animation/pose.jsSample tracks and compute bone matricesTransform every mesh vertex on CPU
web/renderer/webgl2.jsContext, pipelines/state, passes, resize, device lossParse replay protocol
web/renderer/model.jsVAOs, GPU buffers, materials, textures, draw rangesKnow about WebSocket
web/renderer/camera.jsView/projection matrices and camera modesMutate timeline
web/renderer/shaders/*GLSL ES 3.00 shader sourcesContain app behavior

Frontend delivery decision

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

FieldTypeUse
animation indexu16Select action/clip without name lookup.
action stateu16HUD/debug and fallbacks.
animation framef32Track sampling input.
x, yf32 × 2Authoritative world root.
facingf32Horizontal model projection/mirror.
percent, shieldf32 × 2HUD and shield effect.
stocks, flagsu8 × 2Visibility 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

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

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

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

ResourceContentsLifetime
VAOComplete vertex attribute layoutAsset lifetime
Vertex buffersposition, normal, UV, color, weights, bone indicesAsset lifetime
Index bufferUNSIGNED_SHORT triangle indicesAsset lifetime
TexturesRGBA material imagesAsset lifetime
Material tabletexture, diffuse, alpha, flags, draw rangeAsset lifetime
Bone textureCurrent matrices as RGBA32F texelsUpdated per pose

Pass order

  1. Backdrop clear/gradient.
  2. Opaque stage sections with depth writes.
  3. Opaque fighter primitive groups with depth writes.
  4. Items and simple effects.
  5. Transparent primitive groups, sorted at group granularity.
  6. Shields and debug overlays.
  7. HTML HUD outside WebGL.

Required GL state discipline

Capability gate before asset loading

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:

09Animation evaluation and GPU skinning

CPU pose evaluation — port in this order

  1. sample_key: step, linear, and guarded cubic Hermite.
  2. sample_joint: base SRT decomposition followed by animated channel overrides.
  3. eval_pose: local matrices followed by parent-to-child world matrices.
  4. Rigid versus weighted skin matrix preparation.
  5. 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

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

Clock rules

elapsed = now - playbackStartedAt
frame = anchorFrame + floor(elapsed * 60 / 1000)
frame = clamp(frame, startFrame, endFrame)
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

LayerTestsFailure signal
TimelineGolden binary, truncation, schema mismatch, rollback replacement, sparse slots/itemsExact field mismatch
Asset parserCounts, offsets, endian values, bounds, malformed inputReject safely; never oversized allocation
AnimationStep/linear/Hermite, loop, start frame, root suppression, hierarchyMatrix epsilon exceeded
RendererBind pose, sampled poses, facing, texture/material, transparency, stageImage difference beyond threshold
Clock60/120/144 Hz simulation, pause/resume, seek, long-duration driftWrong integer replay frame
CameraPan, zoom-at-cursor, follow, fit, mode switch, resizeIncorrect world/screen round trip
LiveDuplicate, gap, rollback, reconnect, delayed snapshot, game endFinal timeline differs from completed parse
SoakFull replay, repeated replay switch, context loss/restoreLeak, crash, increasing frame time
ConcurrencyTwo replay ids selected, uploaded, scrubbed, and played concurrentlyCross-replay state or asset contamination
CapabilitiesMissing WebGL2, insufficient limits, shader failure, float texture failureActionable compatibility screen

Visual differential workflow

  1. Render a fixture with the C reference renderer at a fixed camera.
  2. Render the same frame with WebGL2 at identical dimensions and camera.
  3. Capture the canvas.
  4. Compare per-pixel and structural similarity.
  5. Classify intentional differences; never simply widen tolerance globally.

13Performance and bandwidth budgets

MetricTargetHard 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 compressedMeasured and cached; no repeat per replay
Playback network0 bytes after loadNo frame-image requests
Live state bandwidth< 100 KB/s< 250 KB/s
Long-run drift0 replay frames0 after 30 minutes
Dropped presentation0 on reference machine0 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 allocation0 per replay frameNo upward memory trend

14Risk register and mitigations

RiskImpactMitigation
GPU skinning differs from C rigid/weighted semanticsExploded or offset meshesGolden matrix tests and one model-group-at-a-time rendering.
Transparency differs from painter sortingVisual artifactsOpaque depth pass plus sorted transparent primitive groups; document irreducible cases.
Browser asset parser trusts corrupt countsMemory/availability problemStrict maximums, checked offsets, schema/magic validation.
Large raw RGBA texture assetsSlow first load and GPU memoryMeasure first; atlas/compress only after vertical slice.
Main-thread animation causes jankMissed presentationProfile; move pose evaluation to Worker only if over budget.
WebSocket becomes playback clockNetwork jitter causes stutterArchitectural rule: socket mutates timeline only.
Rewrite stays permanently half-migratedTwo systems to maintainHard phase gates and explicit cutover/removal phase.
Unsupported WebGL2/device lossBlank viewerCapability screen, actionable message, context restore, C fallback during migration.
Global g_active leaks across viewersWrong replay or corrupted timelineReplay-scoped immutable ids and a concurrent-client integration test.
animation_index is not a stable action-table indexWrong poses despite valid stateGolden mapping proof before timeline schema freeze; retain raw diagnostic identity.
Hand-written WebSocket stack grows scope/security riskDelays core rendererDefer live milestone and use a reviewed library or isolated gateway.

15Rollout and cutover

  1. ?renderer=webgl2 development path; existing renderer remains default.
  2. Internal visual/performance fixture suite must pass.
  3. Add user-visible renderer toggle and collect client errors.
  4. Make WebGL2 default while retaining ?renderer=software.
  5. After a stable window, remove frame prefetch, frame decoding worker, and raster playback endpoints.
  6. 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