4DGSX — the 4D Gaussian Splatting eXchange bundle (v0.2)
A match (or any simulated 4D scene) exported as one portable, self-contained
directory that any platform — web, Quest/WebXR, native — can play back with
a free camera. 4DGSX covers the 3D content (geometry + gaussian splat
sets + a rigid transform track), the UI (semantic data + a layered,
user-toggleable presentation system), and the audio (one or more
user-toggleable sources — broadcast mix, crowd, commentary, spatial mics —
each with an explicit clock map). RFL is the reference producer
(gauntlet/volumetric.py) and index.html in each bundle is the reference
player; the layout is deliberately sport-agnostic.
Format ids carried in the files: 4dgsx (scene manifest), 4dgsx-hud
(data track), 4dgsx-ui (presentation track). Version stamps are
semver-ish: additive fields bump minor, layout changes bump major.
Must-ignore rule everywhere: consumers skip unknown fields, unknown
event types, unknown UI component/anchor/content types. That rule — not
any particular field — is what future-proofs the format.
Design rules (the part other projects should copy)
- Geometry once, transforms per frame. Rigid bodies get one static geometry/splat set each, in the body's local frame; playback is one 4×4 per body per frame. Never re-ship or re-train per-frame surfaces for rigid content.
- Text and UI are data, not pixels. Names, scores, clocks, chatter
ship as a semantic track (
hud.json); how they LOOK ships separately (ui.json+ optional HTML panels) and the user can toggle every layer. Burned-in overlays are wrong under a free camera; 3D text meshes freeze typography; text in splats is both plus blurry. - Audio is a sidecar of independent sources, each with an explicit
clock map. Splat formats have no audio container, and a broadcast
timeline legitimately diverges from sim time (inserted replays). Ship
each track (mix, crowd, commentary, mics) as its own user-toggleable
source with a piecewise-linear
[match_t, audio_t]map; a source may anchor to a body for spatial playback (v0.2 — see the audio section). - One clock. Track frames, hud events, UI bindings and the audio map
are all keyed to match time in seconds. Presentation clocks (count-down,
halves) are declared in
hud.jsonand derived by the player.
Bundle layout
web/
scene.json manifest: version, buffers, prims, draws, bodies, camera,
pointers to hud/ui, audio + time map
geometry.bin mesh vertex + index data
track.bin per-frame rigid transforms (the "4D" in 4DGSX)
hud.json DATA: teams, players, anchors, clock, score, events, sfx
ui.json PRESENTATION: layers + components (see UI system)
ui/*.html designable sandboxed panels referenced by ui.json
audio.m4a v0.1 premix (optional); v0.2 stems live under audio/*
splats/*.ply per-body 3DGS gaussian sets (standard 3DGS PLY fields)
points.bin lightweight splat preview (optional viewer convenience)
index.html reference player (no dependencies, WebGL2)
scene.json
{
"format": "4dgsx", "version": "0.2",
"meta": { "hz": 25.0, "nframes": 2251, "camera": {...}, "grass": {...} },
"hud": "hud.json",
"ui": "ui.json",
"audio": {"file": "audio.m4a", "map": [[0,0], [25.8,25.8], [25.8,30.1], ...],
"sources": [...]},
// or null. map: [match_t, audio_t] breakpoints, slope 1 between;
// a duplicated match_t is inserted media (a goal replay).
// file+map = v0.1 legacy premix; sources[] = v0.2, audio section.
"bodies": ["corner_0", ..., "ball", "r0_pelvis", ...], // track order
"prims": [{"vo","vc","io","ic"}, ...], // ranges into geometry.bin
"draws": [{"p": primIdx, "b": bodyIdx, "rgba": [..], "checker": 0|1}, ...],
"points": [{"b": bodyIdx, "ofs": pointOfs, "cnt": n}, ...],
"buffers": {"vertexCount","vertexBytes","indexCount","indexBytes"},
"times": [t_first, t_last]
}
draws[].bindexesbodiesplus one (0 = world/static, baked in world frame). A draw's vertices are already in that body's local frame.- Many draws may reference one prim — instancing falls out for free (the four RFL robots share one set of link meshes).
geometry.bin
Little-endian, two consecutive sections:
vertexCount × 24 B— interleavedfloat32 x,y,z, nx,ny,nzindexCount × 4 B—uint32triangle indices into section 1
track.bin
float32 [nframes][nbodies][7] — x y z position then w x y z unit
quaternion (MuJoCo convention), world frame, bodies order, at meta.hz.
Interpolate: lerp position, nlerp shortest-path quaternion. 90 s of RFL
≈ 3.6 MB (≈ 40 kB/s); quantization is planned for a later version.
hud.json — the data track
{
"format": "4dgsx-hud", "version": "0.1",
"clock": {"mode": "down", "duration_s": 600, "halves": 2,
"half_breaks": [305.2]},
"teams": [{"id": "A", "name", "code", "color": [r,g,b,a]}, ...],
"players": [{"id": "r0", "team": "A", "number": 1, "name": "CR-7000",
"anchor": {"body": "r0_pelvis", "offset": [0,0,0.62]}}, ...],
"score": [{"t": 0, "a": 0, "b": 0}, {"t": 25.8, "a": 1, "b": 0}, ...],
"events": [{"t": 25.8, "type": "goal", "team": "A", "player": "r2",
"replay_s": 4.3},
{"t": 31.0, "type": "radio", "player": "r1",
"text": "man on!", "dur_s": 3.5},
{"t": 40.0, "type": "coach", "team": "B",
"text": "Drop deep!", "dur_s": 5.0},
{"t": 305.2, "type": "half", "n": 1},
{"t": 88.0, "type": "drop"}],
"sfx": [{"t": 12.4, "kind": "kick", "mag": 0.8}, ...]
}
anchor= a body fromscene.json bodies+ an offset in that body's frame.scoreis a step track (value at t = last step ≤ t).- Events are an open envelope
{t, type, ...}— a sport adds types freely (card,pit_stop,checkpoint); the five above stay stable where they apply.sfxis the impulse tape, usable for platform-side spatialized audio. - hud.json is the TRUTH. A player with no ui.json support must be able to build complete native UI from this file alone.
ui.json — the layered presentation system
The user-facing requirements this answers: labels tracked to bodies in 3D; floating panels; every piece of UI toggleable per user; designable by non-engine people; extensible to rich animated content and media without replacing anything.
{
"format": "4dgsx-ui", "version": "0.1",
"layers": [
{"id": "players.names", "title": "Player names", "default": true},
{"id": "players.radio", "title": "Radio chatter", "default": true},
{"id": "match.scorebug", "title": "Score bug", "default": true},
{"id": "match.panel3d", "title": "Stadium score panel", "default": false}
],
"components": [
{"id": "np_r0", "layer": "players.names",
"anchor": {"type": "body", "body": "r0_pelvis",
"offset": [0,0,0.62], "billboard": true},
"content": {"type": "nameplate", "player": "r0"}},
{"id": "scorebug", "layer": "match.scorebug",
"anchor": {"type": "screen", "pos": [0.5, 0.03], "align": "top-center"},
"content": {"type": "scoreboard", "style": "bug"}},
{"id": "panel3d", "layer": "match.panel3d",
"anchor": {"type": "world", "pos": [0, 0, 3.4], "billboard": "yaw",
"size_m": [4.6, 1.55]},
"content": {"type": "html", "src": "ui/score_panel.html",
"bind": ["teams", "score", "clock", "events"]}}
]
}
Layers are the unit of user control. Players render a toggle menu from
this list, apply default, and persist the user's choices. User prefs
always beat bundle defaults.
Components are {id, layer, anchor, content}:
anchor.type:screen— classic HUD;posin normalized [0..1] viewport coords.world— fixed world transform (pos, optionalbillboard: "yaw" | true,size_mphysical size — in VR this is REAL size at REAL depth).body— tracked to ascene.jsonbody + offset in its frame; follows the transform track automatically (nameplates, bubbles).
content.type, v0.1 built-ins:nameplate,bubble,scoreboard,coach-strip,event-banner— all data-bound to hud.json, styled natively by the player.content.type: "html"— the extension escape hatch. A sandboxed HTML/CSS/JS document from the bundle, rendered as a panel at the anchor (web: iframe/texture; native/VR: web-view texture on a quad). Anything the built-ins can't express becomes an html panel: animated scoreboards, sponsor boards, stats tickers, and later<video>— without any format change. Designers edit one file.- Reserved for later versions (players must skip them today):
media(video/image file or URL at any anchor),text(bare template string), and namespaced custom types ("x-rfl:heatmap").
The html panel contract (host ⇄ panel, JSON postMessage):
- host → panel:
{type:"4dgsx:init", hud}once on load;{type:"4dgsx:tick", t, clock, score, playing}at ≥4 Hz;{type:"4dgsx:event", event}when a hud event fires. - panel → host, reserved:
{type:"4dgsx:action", action:"seek"|"toggle-layer" |"set-camera", ...}— v0.1 hosts may ignore; the names are fixed now so later interactive panels don't fragment. - Sandbox rules:
allow-scriptsonly — no network, no host DOM, no storage. A panel is a pure function of the messages it receives, which is what makes third-party designs safe to ship in bundles.
Panels are display-only in v0.1 (pointer-events off). Interactivity arrives via the reserved action channel without breaking old players.
audio — sources + the time map
scene.audio is null (silent bundle) or an object:
"audio": {
// v0.1 legacy — the premixed broadcast track. Producers SHOULD keep
// shipping it when a single premix exists; v0.1 players read only this.
"file": "audio.m4a",
"map": [[0,0], [25.8,25.8], [25.8,30.1], ...],
// v0.2 — independent, user-toggleable sources on the shared match clock
"sources": [
{"id": "crowd", "title": "Crowd", "file": "audio/crowd.m4a",
"default": true, "gain": 1.0, "map": [...]}, // non-positional
{"id": "pitch", "title": "Pitch sounds", "file": "audio/pitch.m4a",
"default": true, "gain": 1.0, "map": [...]},
{"id": "commentary", "title": "Commentary", "file": "audio/commentary.m4a",
"default": true, "gain": 1.0, "map": [...]},
{"id": "mic_r0", "title": "BLU 1 mic", "file": "audio/r0.m4a",
"default": false,
"anchor": {"type": "body", "body": "r0_pelvis", "offset": [0, 0, 0.5]},
"rolloff": {"ref": 2.0, "max": 45.0}} // spatial, follows the body
]
}
- Each source is an independent audio file keyed to match time.
Per-source
map(optional; identity when absent) has exactly the v0.1 semantics.gain(default 1) is the source's mix level. Unknown fields: must-ignore, as everywhere. - Toggling mirrors the UI layer rules: players list sources
(
title,default) in the same menu as UI layers, persist the user's choices, and user prefs beat bundle defaults. anchormakes a source spatial, reusing the ui.json anchor vocabulary:body(offset in that body's frame — the source moves with the transform track automatically, so a mic follows its player for free) orworld(pos, fixed). No anchor = non-positional. Spatialisation is a SHOULD: a player without positional audio plays anchored sources as plain stereo (theirgainstill applies).- Moving sources are bodies. To animate a sound independently of any
visible character, export a virtual body — an entry in
bodies+track.binthat no draw or splat set references — and anchor to it. One clock, one track, one interpolator; audio never grows a second keyframe system. rolloff(optional):{ref, max}meters — reference distance and attenuation cutoff hints for the player's distance model (defaults 1 / 60). Hints, not physics; players pick the curve.- Compatibility: a v0.2 player reading a v0.1 bundle synthesizes one
source from
file+map. A bundle with onlysourcesmay setfile: null— consumers must guard. RFL v0.2 bundles carrycrowd,pitchandcommentary, all non-positional, plus the premix as the legacyfile.
The time map (per source): the broadcast timeline inserts replay_s
seconds at each goal; map encodes exactly that. Reference behaviour:
skip the inserted windows. A richer player may dwell at a jump and let the
replay audio play over its own replay presentation. For fully synthesized
spatial audio, use hud.sfx instead of (or on top of) the sources.
splats/*.ply
One file per dynamic body, standard 3DGS PLY fields (x y z nx ny nz f_dc_0..2 opacity scale_0..2 rot_0..3, binary little-endian), positions
in the body's local frame, driven by the same track. v0.1 sets are
surface-sampled isotropic gaussians; a trained-appearance upgrade replaces
these files and nothing else. Budget note: the whole RFL scene is ~120k
gaussians — inside the ~400k standalone-headset ceiling.
points.bin (optional preview)
pointCount × 20 B: float32 x,y,z, uint8 rgba, float32 radius,
grouped per body by scene.json points[]. A cheap splat stand-in for
renderers without a gaussian rasterizer.
Producing a bundle (RFL)
- League fixtures record + export automatically (
league.play_next→runs/league/s<N>/m<K>_<home>_<away>/web/). - Ad-hoc matches:
RFL_EXPORT_STATES=1 python -m gauntlet football ..., thenpython scripts/export_match_web.py <match_dir>. - Inputs consumed:
states.npz,scene_build.json,match.json,comms.jsonl/tactics.jsonl,*_tv.mp4(audio source). - Serve anywhere static:
python3 -m http.server -d <dir>/web.
Changelog
- 0.2 (2026-08-19, additive — v0.1 bundles stay valid):
audio.sources[]— multiple independent, user-toggleable audio tracks; optional spatialanchor(body|world, the ui.json vocabulary); the virtual-body pattern for independently moving sources; per-sourcemap/gain/rolloff. Legacyaudio.file+audio.mapunchanged, still recommended as a premixed fallback. RFL emitscrowd+pitch+commentarystems. - 0.1 — initial: geometry/track/splats, hud/ui/panels, single broadcast mix + clock map.