<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ciro</title>
    <description>The latest articles on DEV Community by Ciro (@cirotron).</description>
    <link>https://dev.to/cirotron</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4035481%2Fa9e0c0bc-28bb-4431-98f9-0d88f0098caf.jpg</url>
      <title>DEV Community: Ciro</title>
      <link>https://dev.to/cirotron</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cirotron"/>
    <language>en</language>
    <item>
      <title>A 3D World Cup bracket in vanilla three.js: canvas cards, ball physics, and cookie-less analytics</title>
      <dc:creator>Ciro</dc:creator>
      <pubDate>Sat, 18 Jul 2026 16:35:52 +0000</pubDate>
      <link>https://dev.to/cirotron/a-3d-world-cup-bracket-in-vanilla-threejs-canvas-cards-ball-physics-and-cookie-less-analytics-58n6</link>
      <guid>https://dev.to/cirotron/a-3d-world-cup-bracket-in-vanilla-threejs-canvas-cards-ball-physics-and-cookie-less-analytics-58n6</guid>
      <description>&lt;p&gt;During the 2026 World Cup I kept a side project running: &lt;strong&gt;&lt;a href="https://mundial2026.catcom.com.ar/?src=devto" rel="noopener noreferrer"&gt;mundial2026.catcom.com.ar&lt;/a&gt;&lt;/strong&gt; — 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 &lt;strong&gt;smash the trophy off its pedestal with ball shots&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmbm3h4oa4pamdhfq234e.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmbm3h4oa4pamdhfq234e.gif" alt=" " width="640" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The stack: &lt;strong&gt;vanilla three.js&lt;/strong&gt;. No framework, no bundler, no client-side build. One &lt;code&gt;index.html&lt;/code&gt;, native ES modules with an importmap, and a VPS with nginx serving static files. The analytics backend is &lt;strong&gt;pure Python (stdlib) + SQLite&lt;/strong&gt;. These are the parts I found most interesting to share.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The cards are canvas, not HTML
&lt;/h2&gt;

&lt;p&gt;Every match is a &lt;code&gt;BoxGeometry&lt;/code&gt; whose front face is a &lt;strong&gt;texture drawn with Canvas 2D&lt;/strong&gt;: 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 &lt;code&gt;material.color&lt;/code&gt; — there's no DOM to keep in sync with the scene.&lt;/p&gt;

&lt;p&gt;When a result changes, I regenerate that card's texture and that's it: &lt;strong&gt;all the data lives in a single file (&lt;code&gt;data.js&lt;/code&gt;)&lt;/strong&gt; and the whole scene rebuilds from there.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Bézier connectors and a scale trap
&lt;/h2&gt;

&lt;p&gt;The tubes linking the cards are &lt;code&gt;TubeGeometry&lt;/code&gt; over cubic Bézier curves, attached &lt;strong&gt;edge to edge&lt;/strong&gt; (not center to center). The treacherous detail: cards are born at &lt;code&gt;scale = 0.0001&lt;/code&gt; (the entrance animation), so &lt;code&gt;localToWorld&lt;/code&gt; at that moment collapses any edge point into the center. The fix was computing the edge analytically, ignoring the transient scale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Edge point at final scale: position + quaternion·offset&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;edgeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mesh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;localX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;applyQuaternion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mesh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quaternion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mesh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connectors into the third-place match are &lt;strong&gt;bronze&lt;/strong&gt; (they carry the semifinal &lt;em&gt;losers&lt;/em&gt;, not winners) — a color telling a tournament rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The ball: ballistic aiming and OBB collisions
&lt;/h2&gt;

&lt;p&gt;Right click (or the ⚽ button on touch devices) shoots a ball. The aiming is &lt;strong&gt;properly ballistic&lt;/strong&gt;: if the click ray hits a card, I solve for the initial velocity so the projectile passes through that point &lt;em&gt;despite gravity&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;flight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;distanceTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;55&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;divideScalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;vel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;GRAVITY&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;flight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// compensate for the drop&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Card collisions are oriented bounding boxes (OBB): transform the ball into the card's local space, &lt;code&gt;clamp&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Self-hosted analytics: no cookies, no dependencies
&lt;/h2&gt;

&lt;p&gt;I didn't want Google Analytics. The site sends anonymous events via &lt;code&gt;sendBeacon&lt;/code&gt; to a &lt;strong&gt;~600-line Python stdlib + SQLite&lt;/strong&gt; service behind nginx: pageviews, matches opened, ball shots, trophy knockdowns, outbound clicks, campaigns (&lt;code&gt;?src=&lt;/code&gt;). No cookies, honoring DNT and GPC, with unique visitors derived from an &lt;strong&gt;irreversible hash of IP+day+salt&lt;/strong&gt; (the IP is never stored) and 90-day retention.&lt;/p&gt;

&lt;p&gt;GeoIP is homemade too: DB-IP's free dataset converted into a &lt;strong&gt;packed binary searched byte-by-byte&lt;/strong&gt; (fixed-width big-endian ranges compare as plain bytes). 14 MB, zero dependencies, zero external APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Staging the scene
&lt;/h2&gt;

&lt;p&gt;The visual system is &lt;strong&gt;semantic before decorative&lt;/strong&gt;. 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 &lt;strong&gt;bronze&lt;/strong&gt;, because they don't carry winners — they carry the semifinal losers toward the bronze medal.&lt;/p&gt;

&lt;p&gt;States are also &lt;strong&gt;double-coded&lt;/strong&gt;: color &lt;em&gt;and&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;On top of that: &lt;strong&gt;ACES filmic tone mapping&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;easeOutBack&lt;/code&gt;, and auto-rotation never does full turns: it &lt;strong&gt;swings like a pendulum between ±70°&lt;/strong&gt; and bounces, so you never see the empty back of the cards.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Performance, privacy, standards
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GPU budget.&lt;/strong&gt; One draw call per card (the canvas texture is the trick), &lt;code&gt;pixelRatio&lt;/code&gt; capped at 2, anisotropy only where it matters. The render loop is &lt;strong&gt;adaptive&lt;/strong&gt;: 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 &lt;code&gt;dispose()&lt;/code&gt; on regeneration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy by design.&lt;/strong&gt; No cookies means no cookie banner: there's nothing to consent to. Unique visitors come from an irreversible IP+day+salt hash that &lt;strong&gt;rotates daily&lt;/strong&gt; — real anonymization, not a persistent pseudonym —, retention is 90 days, and &lt;strong&gt;DNT and GPC&lt;/strong&gt; 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 &lt;code&gt;default-src 'none'&lt;/code&gt;, &lt;code&gt;X-Frame-Options: DENY&lt;/code&gt;, nosniff, no-referrer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accessibility.&lt;/strong&gt; The whole 3D scene has a &lt;strong&gt;semantic HTML mirror&lt;/strong&gt; (&lt;code&gt;sr-only&lt;/code&gt;) for screen readers; buttons carry &lt;code&gt;aria-label&lt;/code&gt;, the toast announces via &lt;code&gt;role="status" aria-live&lt;/code&gt;, Escape closes panels. The UI adapts by &lt;strong&gt;device capability&lt;/strong&gt; — &lt;code&gt;hover&lt;/code&gt;/&lt;code&gt;pointer&lt;/code&gt; 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 &lt;strong&gt;reduced motion&lt;/strong&gt; (&lt;code&gt;prefers-reduced-motion&lt;/code&gt;), 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pure web platform.&lt;/strong&gt; ES Modules with importmap, History API, Web Share, Clipboard, Web Audio (the minigame sounds are synthesized oscillators, zero files), Vibration, Pointer Events, &lt;code&gt;sendBeacon&lt;/code&gt; + Page Visibility. Zero frameworks: for this project, the standards were enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers
&lt;/h2&gt;

&lt;p&gt;~2,400 lines total: 1,700 of client JS and 650 for the analytics backend. Zero runtime dependencies on the client (three.js vendored).&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Canvas as texture&lt;/strong&gt; is the way to go for text-heavy UI inside WebGL.&lt;/li&gt;
&lt;li&gt;Privacy-respecting analytics fits in one file and counts &lt;em&gt;exactly&lt;/em&gt; what you need.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Try it live: &lt;a href="https://mundial2026.catcom.com.ar/?src=devto" rel="noopener noreferrer"&gt;mundial2026.catcom.com.ar&lt;/a&gt; — the final is this Sunday.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>threejs</category>
      <category>javascript</category>
      <category>webgl</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
