3D embeds
A flat embed puts a match in a rectangle on your page. This puts it in your world: the stage is a THREE.Group you add to your own scene, and you keep your renderer, your camera, your lighting and your render loop.
access-control-allow-origin: *, so a 3D embed works from any origin — nothing to ask for, nothing to configure. What to plan for is weight: the stage downloads the whole match before the first frame — geometry, track and points, about 38 MB for a seventeen-minute fixture. Mount it on a deliberate action rather than on page load, and show progress while it loads. Progressive track streaming is on the roadmap.Load it
The SDK is ESM served from this origin, and three is a peer — you supply it. Two copies of Three in one page break instanceof across the copies and double the download, so we never bundle it.
With a bundler, npm i three and import the URL directly. Without one, an import map tells the browser what three means:
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.185.1/build/three.module.js"
}
}
</script>Pin whatever Three build you already use; three@0.185.1 is what we test against.
Mount a match
import * as THREE from "three";
import { FourDGSX } from "https://4dgsx.com/sdk/v1/three.js";
// your scene, your renderer, your camera, your loop
const scene = new THREE.Scene();
const gsx = new FourDGSX();
const stage = await gsx.mount(
(await gsx.programme("rfl")).items.find(i => i.state === "replay"),
);
stage.group.position.set(0, 0, 0); // wherever your pitch is
scene.add(stage.group);
stage.play();
// once per frame, from your own loop
function frame(dt) {
stage.update(dt, camera, renderer.domElement.clientHeight);
renderer.render(scene, camera);
}The group is Y-up, in real metres, with the ground at y = 0. Place it, rotate it, scale it — the published look is locked to the match, so moving the stage cannot slide the mown pattern across the pitch or swing the light unless you choose to relight it.
Let it follow the schedule
schedule() polls the programme feed, mounts a match when it airs, tears it down when it ends, and shows a countdown board in between. A match is live at its scheduled time: while a scheduled match is airing its clock is the wall clock, and play/pause/seek return false rather than moving it.
const slot = gsx.schedule("rfl", {
// a match has started — put it on your pitch
mount: (stage) => { stage.group.position.copy(pitch.position); scene.add(stage.group); },
unmount: (stage) => scene.remove(stage.group),
// between matches, show what's next
showFixture: (board) => { board.position.copy(pitch.position).add(up2m); scene.add(board); },
hideFixture: (board) => scene.remove(board),
});What's yours, and what isn't
The animation, the data and the UI content are as published. Where they land in your world is yours:
// layers the publisher declared — toggle any of them
stage.layers.forEach(l => console.log(l.id, l.on));
stage.setLayer("players.names", false);
// dock slots: the bundle names them, YOU choose the surfaces
stage.docks.attach("main", stadiumBigScreen); // broadcast video
stage.docks.attach("left", statsBoardMesh); // publisher HTML panel
stage.docks.attach("right", lineupBoardMesh);
// audio: anchored sources stay where the publisher put them,
// the rest are yours to place
stage.audio.attachListener(camera);
stage.audio.enable(); // from a user gesture
stage.audio.place("crowd", { position: [0, 6, 22], ref: 10, max: 140 });
stage.audio.place("commentary", { position: [0, 9, 0], ref: 14, max: 160 });Dock slots are semantic — a bundle asks for main, left, right, and you decide which of your surfaces carries each one. Nothing is placed automatically. Audio works the same way with one rule: a source the publisher anchored (a player's mic follows that player) stays where it was put, and place() refuses it. Everything else — crowd, commentary, the broadcast mix — is yours to position.
Light it like your world
Left alone, the stage is lit exactly as the match was published. If your world is a floodlit stadium at night, relight it: sun, hemisphere and exposure are yours, and a shadow map you already render can darken the pitch inside the stage's lighting rather than as a multiply over it. Colours, materials and the animation stay as published.
// Relight the match to suit your world. Directions are MATCH space (Z-up);
// anything you leave out keeps the published value.
stage.setLighting({
sun: [0.1, -0.3, 1], sunColor: [1, 0.96, 0.9], sunIntensity: 0.6,
sky: [0.25, 0.28, 0.35], ground: [0.12, 0.12, 0.12],
exposure: 1.1,
output: "linear", // only if you tone-map the stage in a linear target
});
stage.setLighting(null); // back to the published look
// Your shadow map, inside the stage's lighting: it removes direct sun only
stage.setShadow({
map: shadowTarget.depthTexture, // depth in .r
matrix: shadowMatrix, // world → [0,1]³; update it in place
strength: 1,
});React to the match
stage.on("event", (e) => {
if (e.type === "goal") world.fireworks();
});
stage.on("statechange", (s) => world.floodlights(s === "live"));
// hud.json is TRUTH — render your own scoreboard from it if you'd rather
stage.hud.teams; // codes, names, kit colours, badges
stage.score; // { a, b } at the current match time
stage.clock; // "9:53"The programme feed
Public JSON, CORS-open, CDN-cached: https://4dgsx.com/api/v1/programme/<channel>
An unaired match carries its teams, title and kick-off time but no bundle URL and no score — they are withheld server-side until it starts and finishes respectively. There is nothing in the payload to fetch early, which is what makes a countdown safe to render from a public feed.
Attribution
Every stage carries a small 4DGSX mark. Move it or dim it to suit your scene — stage.attribution is a normal object — but please leave it in.
SDK v1 · https://4dgsx.com/sdk/v1/three.js
The bundles it plays are an open format — read the spec.