Adapter
Vendor quirks die here. Units, origin, axis convention and sample rate are resolved once, at the boundary, and never again.
Simam XR turns positional tracking data into a match you can walk around — broadcast, tactical, pitch level, or a player’s own eye line. It runs in a browser tab and enters VR or AR on a headset or phone. Nine sports arrive through one data contract, because every provider quirk is normalised at the edge and the engine never sees it.

Sample rate, units, pitch dimensions, where the origin sits, which axis points where. StatsBomb gives freeze-frames at events. Tracab and Metrica give continuous 25 Hz. Some measure in yards from a corner, others in metres from the centre spot. A renderer that knows about any of that becomes unmaintainable the moment a second provider arrives.
So none of it reaches the renderer. Each format has an adapter that normalises at the edge into one internal model — metres, origin at the centre circle, +X toward the right goal — and the engine only ever sees that. Adding a sport or a provider touches an adapter and nothing downstream. It is the single decision the whole project rests on, and it is why nine sports ship through one contract rather than nine integrations.
Vendor quirks die here. Units, origin, axis convention and sample rate are resolved once, at the boundary, and never again.
Parsing runs off the main thread. In a browser you have one thread and roughly sixteen milliseconds — parse on it and the world stops.
25 Hz source to 60 fps output, driven by wall clock rather than frame count, so playback speed and seeking stay honest.
One frame callback that mutates objects in place. State changes are event-driven only — play, pause, seek, camera switch.
The invariant that keeps it at frame rate: zero React or store writes per frame. React exists to re-render on change, and a 3D scene changes every frame — so the scene graph is held outside it entirely, and positions are written into flat Float32Array buffers rather than arrays of objects, which keeps interpolating many entities cache-friendly and allocation-free.
The camera set is the product. A tactical top-down view answers a different question from a pitch-level one, and being able to cut between them mid-replay — on the same moment, with the same data underneath — is the thing a flat highlight reel cannot do.



Three failures worth recording, because each one cost real time and none of them was where it looked.
Animation was signed off for weeks from headless renders of a posed frame. Blender 4.4 introduced slotted actions: assigning an action no longer binds it without also assigning the slot, so the rig silently rendered its bind pose. That does not look like an error — it looks like a person standing there. Caught only by printing a bone’s quaternion and finding an identity rotation where a running leg should have been.
Adding the bowl turned the pitch black in every preset. The near stand’s roof sat at 26 m and the broadcast ray from [0, 30, 62] crossed that height behind the roof line. It read as a lighting bug because the shirt-number labels draw with depth testing disabled, so numbers still hovered exactly where players should be. Fixed by leaving the camera-side stand roofless and pushing the bowl back to 12 m — and the sight line is now asserted in tests.
An arm swung up beside the head at the top of the stride. Three attempted fixes all computed motion as a delta from the source rig’s rest pose. The source files are animation only — no mesh, therefore no bind pose — so the reconstructed rests from two clips of the same rig disagreed by 105 to 141 degrees. The formula had been subtracting a fiction. Aiming each bone directly at its counterpart’s direction instead is rest-agnostic by construction: residual aim error 0.00 degrees.
The transferable lesson: a broken test fails loudly, but a broken measuring instrument just quietly agrees with you. Before trusting a verification tool, make it fail on purpose and check that it says so. A wrong theory with three fixes behind it is more expensive than no theory, because each fix makes the premise underneath look better established.
The Match Centre shows possession, shots, expected goals, passes, corners and fouls. All of it is generated — produced from team strength and the scoreline through a seeded random number generator, because a demo fixture has no event feed behind it. It is plausible, deterministic and entirely invented.
That is defensible in a preview. What is not defensible is letting it sit indistinguishably beside numbers that are real, because the invented ones then borrow the credibility of the measured ones — and lend them their own status the moment anyone finds out. So the derived statistics live in a separate tab that states what they are computed from: distance covered, top speed, sprint counts, high-intensity distance, and collective shape.
Finer than the source integrates our own interpolation and inflates distance. Coarser cuts the corner off every change of direction and under-reports it. The rate comes from the source’s declared frame rate — 12.5 Hz on one real match, 20 Hz on a synthetic one.
One glitched frame produces an instantaneous speed in the hundreds of km/h, and because top speed is a maximum rather than an average, that single sample becomes the headline. Faults are dropped and the path re-anchored. Clamping would credit a sprint the player never ran.
A metric that cannot be computed honestly returns NaN. A missing defensive line reported as 0 reads as “the line was on halfway”, and nothing downstream can tell that apart from a real measurement.
A parallel strand explored reconstructing a team’s shape from GPS vests. Real vest data has no ground truth, so before building any of it a simulator produced a team moving as a tactical system with exact known positions, then degraded that truth the way a real vest would. The decisive choice was modelling GPS error as correlated drift rather than random noise — real satellite error wanders, and random noise would have averaged out across four defenders and made the defensive line look far more precise than it can ever be. Every metric was then run against the truth it came from:
Shipped.
Shipped.
Shipped.
It decomposes into 3.77 m of constant bias and only 1.52 m of scatter, so the metric beats sensor noise comfortably and the fault is a registration problem: fitting a pitch rectangle to one team’s positions centres it on that team’s centre of mass, not the pitch centre.
Missed its budget and was kept out of the interface rather than shown with a caveat.
When someone fixes the registration, that test breaks and tells them to remove the entry — otherwise a known-issues list quietly becomes a rubber stamp.
Stated plainly: a number with no error bound is not a measurement, it is a decoration. Telling a coach “the line broke” when that is indistinguishable from sensor drift is worse than telling them nothing, because they will act on it.
Measured in the running application before any choice is made: zero cookies, zero stored keys. The SDK is not merely inert before consent — it is never fetched, because it sits behind a dynamic import inside the function that consent gates.
That is an architectural property. It holds because of how the imports are arranged, and a single careless static import in an unrelated file, or one analytics snippet pasted into the HTML shell, would undo it completely and silently. No behavioural test would notice.
The SDK may only be imported for types or dynamically, no module but the analytics one may reference it at all, the HTML shell may contain no third-party script, the consent guard must exist, and only two storage keys may ever be written.
A tag-manager snippet was pasted into the shell and the suite re-run. It failed, as it should. The same exercise surfaced a real gap: consent could be given but not withdrawn, and the policy’s answer was “clear this site’s data”, which is not as easy as giving it. Withdrawal now lives in the policy and stops collection rather than merely stopping the asking.
The most common question about this project is why it is not built in Unity, and the honest answer is that Unity would probably look better. Motion matching, level-of-detail systems, baked lighting, a real asset pipeline, a profiler that works — every hard problem here has a mature solution sitting in an engine we chose not to use.
Roughly sixteen milliseconds on a single thread, so parsing moves to a worker or the world stops. WebGL 2 is OpenGL ES 3.0 — no compute shaders — so anything repeated must be instanced. No asset pipeline, which is a real part of why the venues read as procedural. Manual GPU memory discipline, because nothing garbage-collects a texture. And React held sideways.
No install, no store, no per-platform build. WebXR means flat screen, VR and AR are one build rather than three targets. A native engine’s advantage is largest at the photoreal end; down in stylised low-poly geometry it narrows sharply, and most of what is still wrong with how this looks is lighting and animation quality, neither of which the browser is what blocks.
The trap worth naming: it is easy to choose the platform that makes the demo most impressive rather than the one that gets the demo in front of the most people.
A case study that only lists wins is a brochure. The build is in beta, and these are the open items we would raise ourselves before anyone else did.
Sixty frames per second on a mid-tier laptop is the stated target and it has not been measured on real hardware — the preview environment throttles the frame loop, so any number from it would be an artefact of the harness. Until it is measured we will not claim it, which is uncomfortable, because it is the headline property of a real-time engine.
The gait blends between three clips by speed. It is the standard approach and it works, but it is why the movement is acceptable rather than right. Motion matching — choosing the pose that fits the movement instead of averaging toward it — is the upgrade with the best ratio of payoff to effort, and it is a rewrite rather than a fix.
Over 1.6 MB in the main chunk with a second larger again, and no meaningful code splitting: opening the landing page downloads the analysis tooling, the XR support, the audio engine and eight sports nobody is looking at. Deferred every time because it never blocked anything, which is exactly how it got this bad.
Everything visual is verified through geometric proxies — is the camera outside the structure, does the sight line clear, do two elements overlap. Those are good tests and they have caught real defects. Nothing in the suite would notice if the pitch turned purple.
The telemetry HUD is fixed at z-index 24; the transport bar wraps to two rows on a phone at 122 px tall, and the HUD offset had been set when that bar was one row. They overlapped by 64 px, precisely where the play button and speed control sit. Both were on screen, visible in a screenshot, and untappable. Only measuring the DOM found it — looking would not have.
A tested beta on its own branch, stopped on a product call: vests give you no ball and no opponents, so they can support analysis of a team’s own shape but never a faithful replay of a match.
And one that measurement could not settle. The landing page briefly ran a dusk lighting preset. Every value was correct and verifiable at runtime — key light placed, four floodlights emitting where there had been none, the match view provably unchanged. Then it looked too dark, and it went back to daylight. An hour reverted, and the clearest boundary in the project: measurement establishes whether something is correct, and says nothing at all about whether it is good.
What the project set out to prove, what it demonstrates, and what it deliberately does not claim.
Credits. Built by Junaid Malik. Inspired by the BBC and Immersiv.io 3D match experience. Demo match data © SkillCorner opendata, CC BY-NC.
The valuable asset here is not the renderer. It is the demonstrated path from an incompatible vendor feed to a person on a phone watching a match from an angle no camera was in — and the discipline of never letting a generated number sit next to a measured one.