During the 2026 World Cup I kept a side project running: mundial2026.catcom.com.ar — the full knockout bracket in interactive 3D. Results, scorers, penalty shootouts, stadiums — updated match by match through the final. You can fly around the bracket, open any tie, highlight a team's path, and smash the trophy off its pedestal with ball shots.
The stack: vanilla three.js. No framework, no bundler, no client-side build. One index.html, native ES modules with an importmap, and a VPS with nginx serving static files. The analytics backend is pure Python (stdlib) + SQLite. These are the parts I found most interesting to share.
1. The cards are canvas, not HTML
Every match is a BoxGeometry whose front face is a texture drawn with Canvas 2D: round, flags, score, scorers with minutes, venue. Why canvas instead of CSS3D or overlaid HTML? Crisp text at any zoom (I draw at 2× and max out anisotropy), a single draw call per card, and dimming a card is just changing its material.color — there's no DOM to keep in sync with the scene.
When a result changes, I regenerate that card's texture and that's it: all the data lives in a single file (data.js) and the whole scene rebuilds from there.
2. Bézier connectors and a scale trap
The tubes linking the cards are TubeGeometry over cubic Bézier curves, attached edge to edge (not center to center). The treacherous detail: cards are born at scale = 0.0001 (the entrance animation), so localToWorld at that moment collapses any edge point into the center. The fix was computing the edge analytically, ignoring the transient scale:
// Edge point at final scale: position + quaternion·offset
function edgeAt(mesh, localX) {
return new THREE.Vector3(localX, 0, 0)
.applyQuaternion(mesh.quaternion)
.add(mesh.position);
}
The connectors into the third-place match are bronze (they carry the semifinal losers, not winners) — a color telling a tournament rule.
3. The ball: ballistic aiming and OBB collisions
Right click (or the ⚽ button on touch devices) shoots a ball. The aiming is properly ballistic: if the click ray hits a card, I solve for the initial velocity so the projectile passes through that point despite gravity:
const flight = Math.max(0.35, origin.distanceTo(target) / 55);
const vel = target.clone().sub(origin).divideScalar(flight);
vel.y += 0.5 * GRAVITY * flight; // compensate for the drop
Card collisions are oriented bounding boxes (OBB): transform the ball into the card's local space, clamp to the half-extents, reflect velocity over the normal with ~0.7 restitution. With a cheap distance-based broad phase, 8 balls × 33 cards per frame is nothing. Impacts make sound (synthesized Web Audio, zero files), vibrate on Android, and if you hit the trophy… it tumbles down and floats back up six seconds later.
4. Self-hosted analytics: no cookies, no dependencies
I didn't want Google Analytics. The site sends anonymous events via sendBeacon to a ~600-line Python stdlib + SQLite service behind nginx: pageviews, matches opened, ball shots, trophy knockdowns, outbound clicks, campaigns (?src=). No cookies, honoring DNT and GPC, with unique visitors derived from an irreversible hash of IP+day+salt (the IP is never stored) and 90-day retention.
GeoIP is homemade too: DB-IP's free dataset converted into a packed binary searched byte-by-byte (fixed-width big-endian ranges compare as plain bytes). 14 MB, zero dependencies, zero external APIs.
5. Staging the scene
The visual system is semantic before decorative. Three colors tell the tournament state: gold = playing today, cyan = played, faint dashes = upcoming. And a fourth with its own story: the connectors into the third-place match are bronze, because they don't carry winners — they carry the semifinal losers toward the bronze medal.
States are also double-coded: color and stroke (thick glowing solid vs. thin dashed line). If you can't tell cyan from gold, the border style tells you anyway — colorblind-friendly without looking like a concession.
On top of that: ACES filmic tone mapping and a PMREM environment so the trophy's gold reflects like metal instead of yellow plastic; exponential fog, 700 additive particles, a floor grid and a radial halo for nearly-free depth. The HUD is glass (backdrop-filter over the space background), Kanit for type, and cards drawn at 2× so text survives any zoom.
The camera has choreography: opening a match flies in with cubic easing (offsetting the card so the detail panel doesn't cover it), cards pop in staggered with easeOutBack, and auto-rotation never does full turns: it swings like a pendulum between ±70° and bounces, so you never see the empty back of the cards.
6. Performance, privacy, standards
GPU budget. One draw call per card (the canvas texture is the trick), pixelRatio capped at 2, anisotropy only where it matters. The render loop is adaptive: full rate while you interact, ~20 fps at rest — the trophy keeps floating, but the GPU and your battery don't spin for nothing. Physics is cheap on purpose: squared-distance broad phase before the exact OBB test, 8 balls max, and texture dispose() on regeneration.
Privacy by design. No cookies means no cookie banner: there's nothing to consent to. Unique visitors come from an irreversible IP+day+salt hash that rotates daily — real anonymization, not a persistent pseudonym —, retention is 90 days, and DNT and GPC are honored (GPC carries legal weight in California). Ingestion validates everything: event-type whitelist, bounded payloads, nginx rate limiting. The dashboard ships its headers: CSP default-src 'none', X-Frame-Options: DENY, nosniff, no-referrer.
Accessibility. The whole 3D scene has a semantic HTML mirror (sr-only) for screen readers; buttons carry aria-label, the toast announces via role="status" aria-live, Escape closes panels. The UI adapts by device capability — hover/pointer media queries, not user-agent sniffing: the ball-shot button shows up wherever there's no mouse, regardless of screen size. And if your system asks for reduced motion (prefers-reduced-motion), the scene obeys: no camera flights, no entrance animations, no trophy orbit or drifting dust — the ball shot (you trigger it) and the countdown (it's information, not ornament) stay.
Pure web platform. ES Modules with importmap, History API, Web Share, Clipboard, Web Audio (the minigame sounds are synthesized oscillators, zero files), Vibration, Pointer Events, sendBeacon + Page Visibility. Zero frameworks: for this project, the standards were enough.
Numbers
~2,400 lines total: 1,700 of client JS and 650 for the analytics backend. Zero runtime dependencies on the client (three.js vendored).
Takeaways
- Canvas as texture is the way to go for text-heavy UI inside WebGL.
- Privacy-respecting analytics fits in one file and counts exactly what you need.
Try it live: mundial2026.catcom.com.ar — the final is this Sunday.
Credits: 3D trophy by waimus (CC BY-SA 4.0), "FIFA TRIONDA" ball by About PES (CC BY 4.0), Twemoji flags (CC BY 4.0), GeoIP by DB-IP (CC BY 4.0).












