<?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: Raw Fun Gaming</title>
    <description>The latest articles on DEV Community by Raw Fun Gaming (rawfungaming).</description>
    <link>https://dev.to/rawfungaming</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%2Forganization%2Fprofile_image%2F15354%2F67da5461-07de-4cc2-95df-0c17a998c2b8.jpg</url>
      <title>DEV Community: Raw Fun Gaming</title>
      <link>https://dev.to/rawfungaming</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rawfungaming"/>
    <language>en</language>
    <item>
      <title>One Number, One Race: How Marble Derby Re-Runs Its Physics in Every Browser</title>
      <dc:creator>Raw Fun Gamer</dc:creator>
      <pubDate>Sun, 08 Nov 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/rawfungaming/one-number-one-race-how-marble-derby-re-runs-its-physics-in-every-browser-1m04</link>
      <guid>https://dev.to/rawfungaming/one-number-one-race-how-marble-derby-re-runs-its-physics-in-every-browser-1m04</guid>
      <description>&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%2F036jmcdqufdubyum2e3r.png" 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%2F036jmcdqufdubyum2e3r.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Marble Derby, six glass marbles race down a tower of ramps, pegs and bowls, and the player backs one. Where it finishes, and what it collects on the way, decides the payout.&lt;/p&gt;

&lt;p&gt;Marble Derby is a real-money game, so the result can’t come from a physics engine running on the player’s device. It comes from a pre-computed book of rounds, picked server-side. Here is a whole round, exactly as the book stores it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"seed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"multiplier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"perMarble"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"rank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"rank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"rank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"rank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"rank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"rank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;56.192&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"modules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"stadium-loops"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pin-field"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bowl-cascade"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"maze-or-fall"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pin-field"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="s2"&gt;"cube-scatter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stadium-loops"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"switchback-maze"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rubber-gauntlet"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"rank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"accrued"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.070435&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pickupsCollected"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no position in it. Not one. A book line averages 436 bytes. The 56-second race it describes, every bounce, every near miss and the photo finish, is rebuilt in the player’s browser from the first field: the seed.&lt;/p&gt;

&lt;p&gt;That only works if the browser builds exactly the race the book generator built, on every device, forever. This post is about how we made that hold: why we chose to re-simulate rather than record, the rules that keep a physics engine bit-identical across JavaScript engines, what it took to make 2D marbles feel real, how we made sure every seed finishes, and how the game checks itself on every round.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu2si7mtg05l22t30k0a5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu2si7mtg05l22t30k0a5.webp" alt="A race in progress: six marbles on a ramp-and-ledge module, with glowing pickups" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is seed 1’s whole race, the one in that book line, at double speed, exactly as every browser rebuilds it:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Why we didn’t record the race
&lt;/h2&gt;

&lt;p&gt;Every other burst game in our studio solves this problem the same way: record and replay. The physics runs once, free, in the Unity editor. A sweep records each round’s events and every actor’s position track into the book, and the client plays that recording back as kinematic “ghosts”. It works, the tooling already existed, and it was the obvious starting point.&lt;/p&gt;

&lt;p&gt;We ran the numbers for six marbles and dropped it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Book size.&lt;/strong&gt; Six marbles sampled 10 times a second over a 45-second race is about 2,700 position samples per round. That is roughly ten times the per-round size of Spinblade Rush’s book, our most mature burst game, before multiplying by every mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sweep time.&lt;/strong&gt; A book needs 10,000 rounds per mode. Recorded in real time in Unity, that is about five days per mode, before any verification re-run. Every change to a track module or to the payout math would have cost a week.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The alternative was to write our own physics: a small, purpose-built 2D solver that is fully deterministic, so the same seed always produces the same race. The book then shrinks to “seed and result”, sweeps run headless in minutes, and the client re-runs the race instead of replaying it.&lt;/p&gt;

&lt;p&gt;A tilted 2D board is the one track shape where this is tractable. Everything is circles against line segments and arcs, plus circle against circle. Six marbles, nine modules, fixed timestep.&lt;/p&gt;

&lt;p&gt;The first plan had two copies of that solver: one in C# inside Unity to draw the race, one in TypeScript for the sweeps, with a cross-language test harness to keep them in step. Nine days in, we measured the alternative of drawing the race on a plain web canvas. The kernel plus the entire canvas renderer came to &lt;strong&gt;14.6 kB gzipped&lt;/strong&gt; against a multi-megabyte Unity WebGL build, so Unity went. There is now one kernel, in TypeScript, imported by the Node script that writes the books and by the game in the browser.&lt;/p&gt;

&lt;p&gt;That made the problem smaller and sharper at the same time. There was no second language to keep in parity any more. But the books are generated in Node, on V8, and the race is replayed in whatever browser the player happens to use: V8 in Chrome, JavaScriptCore in Safari, SpiderMonkey in Firefox. A kernel that isn’t bit-identical across those engines would draw a race that contradicts the book that paid the player, on some devices and not on others.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules that keep every browser in step
&lt;/h2&gt;

&lt;p&gt;Most floating-point code is “deterministic enough”: same inputs, nearly the same outputs. A race is chaotic. A marble grazing a peg one billionth of a unit further left can take the other side of it, and a minute later a different marble wins. So “nearly” is not enough. Every step has to produce the same bits on every engine.&lt;/p&gt;

&lt;p&gt;These are the rules the kernel lives by, and why each one is there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doubles only, and only &lt;code&gt;+ - * /&lt;/code&gt; and &lt;code&gt;sqrt&lt;/code&gt;.&lt;/strong&gt; IEEE-754 requires these five operations to be correctly rounded: given the same inputs, every conforming engine must return the same bits. JavaScript numbers are doubles everywhere, so this one is nearly free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No &lt;code&gt;sin&lt;/code&gt;, &lt;code&gt;cos&lt;/code&gt;, &lt;code&gt;tan&lt;/code&gt;, &lt;code&gt;exp&lt;/code&gt;, &lt;code&gt;log&lt;/code&gt; or &lt;code&gt;pow&lt;/code&gt;.&lt;/strong&gt; This is the rule people find surprising. The ECMAScript spec allows those functions to be &lt;em&gt;implementation-approximated&lt;/em&gt;, and engines really do differ in the last bit. Two browsers can legitimately return different answers for the same &lt;code&gt;Math.sin&lt;/code&gt; call. One last-bit difference, compounded through a minute of collisions, is a different race.&lt;/p&gt;

&lt;p&gt;So the kernel has no trig at all. Module rotations are baked into segment endpoints when the modules are authored, and arcs are handled with vector algebra. Where an angle is genuinely needed, it is drawn as a sine and the cosine is derived with &lt;code&gt;sqrt&lt;/code&gt;. This is the start burst the backed marble gets in the Comet bet mode, a random direction within 30 degrees of straight down:&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;// Draw 4 (edge families only): burst within +-30 degrees of straight down.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;marbles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;playerIndex&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;vx&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;edgeBurst&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;marbles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;playerIndex&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;vy&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;edgeBurst&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;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;sin&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rule like this rots the first time someone reaches for &lt;code&gt;Math.atan2&lt;/code&gt; on a Friday afternoon, so a test enforces it. It reads every kernel source file, strips the comments, and fails on any banned member:&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;BANNED_MATH_MEMBERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tan&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;atan&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;asin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;exp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;log&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hypot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;random&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cbrt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="c1"&gt;// Other banned globals: plain substring bans are enough here.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BANNED_SUBSTRINGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Object.keys&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;new Map&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;new Set&lt;/span&gt;&lt;span class="dl"&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 first version of that test had holes, and its own review found them: &lt;code&gt;Math['sin']&lt;/code&gt;, destructuring &lt;code&gt;const { sin } = Math&lt;/code&gt;, and aliasing &lt;code&gt;const M = Math&lt;/code&gt; all slipped through. It now catches each of those forms, and has a test of its own for every evasion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fixed collision order.&lt;/strong&gt; Each step integrates every marble, then resolves marble-against-wall contacts, then marble-against-marble. The order inside each phase is part of the contract: walls by ascending segment index, marble pairs by ascending &lt;code&gt;(i, j)&lt;/code&gt;. Resolve two contacts in a different order and the marble ends the step somewhere slightly different. That is the classic “same seed, different race” bug.&lt;/p&gt;

&lt;p&gt;That rule got tested for real when we sped the kernel up. Checking every marble against every segment of a nine-module board is wasteful, so we added a broadphase: the board is cut into horizontal bands, and a marble only looks at the segments in the bands it overlaps. A broadphase normally hands back candidates in whatever order its buckets hold them. Ours collects them and sorts them before resolving:&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;// 2. Statics, ascending segment index — now via the broadphase. Gather&lt;/span&gt;
&lt;span class="c1"&gt;// the marble's overlapping bands, sort, and skip duplicates, so the&lt;/span&gt;
&lt;span class="c1"&gt;// resolution order is identical to a full ascending scan.&lt;/span&gt;
&lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It shipped with two proofs: every race bit-identical to the brute-force version, and a test fixture of a marble wedged between two walls where the order genuinely changes the outcome, so the sort can’t quietly be deleted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No hash-ordered containers.&lt;/strong&gt; Iterating a &lt;code&gt;Map&lt;/code&gt; or &lt;code&gt;Object.keys&lt;/code&gt; is defined by insertion order in JavaScript, but it is an easy place for order to depend on something incidental. The kernel uses arrays and indices only, and the purity test bans the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Our own random numbers, drawn in a fixed order.&lt;/strong&gt; &lt;code&gt;Math.random()&lt;/code&gt; can’t be seeded, so the kernel uses mulberry32, a tiny generator built from integer operations (&lt;code&gt;Math.imul&lt;/code&gt; is exact, which is why it is the one other &lt;code&gt;Math&lt;/code&gt; member allowed). One generator is seeded per race, and the draws happen in an order written into the source as a contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// DRAW ORDER IS A CONTRACT (spec §5):
// 1. module selection (assemble())
// 2. one op draw per pickup spot, board order (assemble())
// 3. per-marble launch impulse (here)
// 4. edge families only: one draw for the backed marble's start-burst angle
// (here, after the launch loop). Base and bonus consume no draw 4.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Insert a draw in the middle and every seed’s board and launch change, which invalidates every book. When the “edge” bet mode needed one more random number, it went at the end, after the launch impulses, and only that mode consumes it. The other modes’ races stayed bit-identical to what they were before the edge existed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fixed timestep.&lt;/strong&gt; The simulation advances in steps of 1/120 of a second, always. Nothing in the kernel reads a clock or a frame time. The browser simulates the whole race the moment the seed arrives, which takes about 8 ms for a 48-second race in Node on a laptop, and then plays it back from the recorded track at whatever frame rate the device manages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making 2D marbles feel real
&lt;/h2&gt;

&lt;p&gt;Determinism is the easy half to state. The hard half is that the race has to look like marbles, not like a simulation.&lt;/p&gt;

&lt;p&gt;The first kernel resolved every contact as a bounce: reflect the normal velocity with restitution, scale the tangential velocity down by the surface’s friction. A marble on a ramp touches the ramp 30 to 60 times a second, so it lost a slice of its speed 30 to 60 times a second and could never build momentum. It crept.&lt;/p&gt;

&lt;p&gt;We measured it on one module’s 0.5-slope ramp. A real ball rolling without slipping crosses it in about 2 seconds; acceleration down a slope is &lt;code&gt;(5/7) g sinθ&lt;/code&gt;, because two sevenths of the pull goes into spinning the ball. The kernel took 15.5 seconds. Someone watching the viewer called the surfaces “sticky”.&lt;/p&gt;

&lt;p&gt;Worse, the problem had been absorbed by three unrelated fixes, none of which named it: gravity had crept up from 9 to 10.9, one module had a bespoke low-friction material, and the pacing had been fitted around both.&lt;/p&gt;

&lt;p&gt;The fix was a rolling-contact model, without adding spin to the kernel’s state. Each contact is classified by how fast the marble is moving into the surface:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fast&lt;/strong&gt; is a bounce, as before: reflect with restitution, apply the surface’s friction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow&lt;/strong&gt; is rolling: drop the normal velocity, apply a much smaller rolling friction, and take back 2/7 of the step’s gravity impulse along the surface. That reproduces &lt;code&gt;a = (5/7) g sinθ&lt;/code&gt; from linear quantities alone, with nothing but &lt;code&gt;+ - * /&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Gravity went back to 9, the bespoke material went, and marbles got fast. Too fast: at the original six modules the median race fell to 31 seconds, with 39% of races under the 30-second floor. We could buy the time back with lower gravity or with a longer board. Over 400 seeds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gravity 4.6, 6 modules:&lt;/strong&gt; median 44.1 s, 0.3% under 30 s, and marbles falling like they were on the Moon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gravity 9, 6 modules:&lt;/strong&gt; median 31.1 s, 39.3% under 30 s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gravity 9, 9 modules:&lt;/strong&gt; median 46.7 s, 0% under 30 s.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We kept honest gravity and made the board longer. Every board is nine modules.&lt;/p&gt;

&lt;p&gt;One more detail matters for the “never twice the same” feel. Real marbles diverge because of microscopic surface variation, so the kernel adds a small seeded roughness at each bounce. Its first version could multiply the bounce’s speed by slightly more than 1, which manufactures energy. It is now &lt;code&gt;1 - rng() * noise&lt;/code&gt;, so roughness can only ever take speed away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every seed has to finish
&lt;/h2&gt;

&lt;p&gt;A book is 10,000 races per mode, every one of them paid. A race where a marble gets stuck forever can’t be paid, can’t be replayed, and can’t be shipped. So every board the assembler can build has to deliver all six marbles, and we can’t check that by watching.&lt;/p&gt;

&lt;p&gt;Each track module obeys a four-rule contract: a fixed inner width so the side rails run unbroken, flat full-width entry and exit edges so any module stacks under any other, a declared height, and &lt;strong&gt;guaranteed passable&lt;/strong&gt;. The sweep checks the last one over every seed, and every way a marble found to stop became a rule. A few of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A closed cup traps marbles, and it isn’t the solver’s fault.&lt;/strong&gt; We proved it by holding the geometry fixed and changing only the bounciness. Escape rates followed restitution and nothing else: 13% for wood, 67% for pegs, 92% for rubber, and 240 out of 240 with energy loss turned off. So bowls are open hooks with a 150° gap facing down, never closed cups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dead-flat facet on a curve.&lt;/strong&gt; Arcs are drawn as short straight chords. A span symmetric about vertical put a horizontal chord at the bottom for odd chord counts, and 2,000 out of 2,000 releases stopped on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A nearly flat facet stops a marble too.&lt;/strong&gt; Gravity sinks a marble a hair into a shallow facet each step, which creates a second contact on the steeper chord next to it, and the two responses cancel. We found the cliff by releasing 25,001 marbles per angle: below &lt;strong&gt;3.72°&lt;/strong&gt; marbles stop, above it they roll. The library’s floor is now 10% above the cliff, derived from the kernel’s own &lt;code&gt;dt&lt;/code&gt;, gravity and radius, so a config change that moves the cliff fails a test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two walls can freeze a marble bit-for-bit.&lt;/strong&gt; At a concave corner, pushing a marble out of one wall pushed it into the other, and back. One marble sat frozen, identical to the last bit, for over 900 steps. The fix was to detect every contact against the same state, combine them, and apply them once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spaces that look dead aren’t.&lt;/strong&gt; A pocket closed above but open below is reachable from underneath: marbles bounce back up into it from the module below. Those spaces get explicit floors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Finding these needs a stall detector, and the first one was wrong in both directions. It asked whether any racing marble had set a new depth record recently. It declared a race dead while a rubber-launched marble was in free flight at nearly 7 m/s (it finished 0.76 s later), and it missed a genuinely pinned marble for 42 seconds because other marbles were still making progress. The current one tracks each marble on its own: a marble is held while it stays within one radius of where it was, and the race is stalled when the least-held racing marble has been held too long. A trapped marble wanders 0.008 units; a false alarm covers five.&lt;/p&gt;

&lt;h3&gt;
  
  
  The finish has to be physical too
&lt;/h3&gt;

&lt;p&gt;The race doesn’t end at a line. A funnel hangs under the last module, a wide catch narrowing into a single-file channel, and every marble drops in and stays there. A marble is ranked the instant it passes the throat, and the channel is too narrow for one marble to pass another below that point. So the order the marbles come to rest in is the order that was paid, and the sweep checks exactly that for every race.&lt;/p&gt;

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

&lt;p&gt;When the player’s marble crosses the throat within a whisker of a marble whose place pays differently, the playback drops to slow motion. It only stretches the reveal: the order was settled the moment each marble passed the throat.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F12mnbjiituqddudgorfm.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F12mnbjiituqddudgorfm.webp" alt="The settled funnel: six marbles stacked in finishing order, with the payout for each place beside it" width="800" height="811"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Real physics means uneven lanes
&lt;/h2&gt;

&lt;p&gt;A real-physics race has a consequence a scripted one doesn’t. Marble 1 always starts in lane 1, and the modules aren’t perfectly symmetric, so some lanes do better than others. Across a sweep, the rightmost marble wins about 22–23% of races and lane 2 about 13%.&lt;/p&gt;

&lt;p&gt;We could have hidden that with a random start shuffle, or tried to tune the asymmetry out of twenty modules. We did neither: the six marbles really do race differently, and that is part of their character. What can’t differ is the return, so each lane gets its own book. In the plain modes the same 10,000 races are simply read from each marble’s point of view (in the edge mode the edge changes the race, so each lane has its own), and each book’s weights are fitted so every lane returns the same 96.5%. The physics stays honest, and so does the payout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trust, but check every round
&lt;/h2&gt;

&lt;p&gt;All of this is designed to hold. The game still checks that it does, on every round.&lt;/p&gt;

&lt;p&gt;When a round’s book line arrives, the browser simulates the seed and compares the race it built with the line that paid:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;rank&lt;/strong&gt; , the &lt;strong&gt;multiplier&lt;/strong&gt; and the &lt;strong&gt;number of pickups&lt;/strong&gt; must match exactly;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;duration&lt;/strong&gt; must match within a millisecond;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;accrued multiplier&lt;/strong&gt; must match within one millionth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any mismatch is logged loudly as &lt;code&gt;KERNEL PARITY BROKEN&lt;/code&gt;, and the round still pays the book’s number and completes: the book is authoritative for money, the kernel only decides what is seen. A rank, multiplier or pickup mismatch also skips the animation entirely. The funnel’s resting order &lt;em&gt;is&lt;/em&gt; the visible ranking, so playing a diverged race would show the player an order the book didn’t pay.&lt;/p&gt;

&lt;p&gt;The first version broke on every round, and the bug is worth passing on. The book also stores &lt;code&gt;payoutMultiplier&lt;/code&gt;, which is the multiplier times 100, rounded, for the payment system. We compared against it, and it never matched. The check now reads the raw multiplier, and our notes carry the warning.&lt;/p&gt;

&lt;p&gt;One more: production builds normally strip &lt;code&gt;console&lt;/code&gt; calls. We drop only &lt;code&gt;debugger&lt;/code&gt;. Those &lt;code&gt;console.error&lt;/code&gt; lines are the detector; stripping them would make a broken contract invisible.&lt;/p&gt;

&lt;p&gt;The runtime check catches a divergence when it happens. To stop one being introduced, the kernel has a regression pin: &lt;code&gt;kernel-vectors.json&lt;/code&gt;, checked in every test run. It holds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;56 micro vectors:&lt;/strong&gt; single collisions. Five surface angles from flat to near-vertical, crossed with a bounce, rolling, receding contacts and a clean miss. They catch a changed formula.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4 fold vectors:&lt;/strong&gt; the pickup arithmetic on its own, where order matters, because &lt;code&gt;(1 + 2) × 3&lt;/code&gt; isn’t &lt;code&gt;1 × 3 + 2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;210 macro races:&lt;/strong&gt; complete races across all three bet modes, each pinned by finish order, multiplier, pickups, duration and a hash of every marble’s position once a second. They catch drift that builds up over a minute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any change that moves a race fails the vectors before it can reach a book.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it bought us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A tiny book.&lt;/strong&gt; 436 bytes a round on average, with no position data at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast iteration.&lt;/strong&gt; Re-sweeping a mode after a module change takes minutes, not days, so the physics and the math could be tuned together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One source of truth.&lt;/strong&gt; The race the player watches is the race the book was generated from, rebuilt from one number, on whatever browser they use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The price was discipline. No trig, a sort that looks redundant, a draw order nobody may reorder, and a test that greps the kernel for &lt;code&gt;Math.sin&lt;/code&gt;. For a race decided by a billionth of a unit, that turned out to be cheap.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>gamedev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Faking 3D With 2D Lines: How Marble Derby Draws Its Board From the Physics</title>
      <dc:creator>Raw Fun Gamer</dc:creator>
      <pubDate>Sun, 08 Nov 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/rawfungaming/faking-3d-with-2d-lines-how-marble-derby-draws-its-board-from-the-physics-o6</link>
      <guid>https://dev.to/rawfungaming/faking-3d-with-2d-lines-how-marble-derby-draws-its-board-from-the-physics-o6</guid>
      <description>&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%2F3g2ri50tr8gsoeb5f80r.png" 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%2F3g2ri50tr8gsoeb5f80r.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Marble Derby is six glass marbles racing down a tall tower of ramps, bowls, pegs and zigzag channels. On screen the tower looks built: pastel slabs with a lit top and a shaded side, walls you can see the thickness of, plates standing out from the board. None of that exists in the physics. The simulation that decides every race knows two things: discs, and line segments with no thickness at all.&lt;/p&gt;

&lt;p&gt;This post is about how we get from one to the other. It covers the single rule the whole art layer hangs on, the projection that turns a line into a slab, the offline bake that decides how thick each slab may be, the joint problems that took most of the work, how we measured that the art never lies about where a marble is, and the marbles themselves. Along the way are the fixes that looked right and weren’t.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: draw the physics, not a picture of it
&lt;/h2&gt;

&lt;p&gt;Marble Derby’s race is simulated by a small deterministic kernel in TypeScript. The same code builds the books on the server side and replays the race in the player’s browser from a seed, so the race you watch is, step for step, the race that was paid. That kernel models a marble as a disc of radius 0.28 and every surface as a segment: two endpoints, nothing else. A ramp is a segment. A peg is a short segment. A bowl is a ring of short segments.&lt;/p&gt;

&lt;p&gt;We could have drawn the board as separate art: sprites or meshes laid over the physics and lined up by hand. Every game that does that eventually ships a marble that rolls along a ledge half a pixel above the drawn ledge, or through the corner of a wall. With twenty board modules assembled in a different order every race, that was not a risk we wanted to manage forever.&lt;/p&gt;

&lt;p&gt;So the renderer draws &lt;strong&gt;the kernel’s own segments&lt;/strong&gt;. There are no prefabs, no meshes and no hand-placed geometry. If a segment moves in the physics, the art moves with it, because the art is built from it at load time.&lt;/p&gt;

&lt;p&gt;That rule creates the whole problem. A segment has no thickness. To look solid, it has to be given some, and that thickness has to go somewhere. If it goes on the wrong side, or is too deep, a marble rolling on the segment gets drawn inside the wall. So for every segment the renderer needs two answers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which side is solid?&lt;/strong&gt; The side marbles never touch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How thick may it be there?&lt;/strong&gt; Up to wherever a marble came close.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The segment list can’t answer either, and the kernel is off limits: it is a pure function of seed and mode, and the art may never touch it. The answers come from somewhere else, which we’ll get to after the projection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The projection: one vector
&lt;/h2&gt;

&lt;p&gt;The 3D look is a fixed oblique projection. Everything recedes along one vector, up and to the right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Oblique depth, view units: up and to the right. Also sets the light. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEPTH&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.24&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There’s no camera matrix, perspective or z-buffer. The camera is effectively above and to the right of the board, so it sees every surface that faces up or to the right, and none that face down or left. That one sentence turned out to be the most useful rule in the whole layer; more on it later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F56n2m5lntby1jeodo96j.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F56n2m5lntby1jeodo96j.webp" alt="From a zero-thickness segment to a slab: the physics line, the bake’s side and depth, then faces swept back along DEPTH" width="800" height="267"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each segment that has a solid side is drawn as a slab:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;front band&lt;/strong&gt; : its cross-section in the board plane, from the line itself to its depth on the solid side, in two tones as a cheap gradient.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Receding faces&lt;/strong&gt; : each edge that faces along &lt;code&gt;DEPTH&lt;/code&gt; is swept back along it into a parallelogram. That’s the lit top of a ramp, the right-hand face of a wall.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A segment that marbles touch on both sides (a pin, a divider) has no solid side, so it can’t be a slab. It’s drawn as a &lt;strong&gt;thin plate&lt;/strong&gt; : a strip 0.04 either side of the line, swept back as a box.&lt;/p&gt;

&lt;p&gt;Painting is two layers: every receding face, then every front face. The faces are built once per board, not per frame. Each frame draws only the horizontal bands of the tower that are on screen, from an index built at load.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The bake: asking 3,000 races where the marbles went
&lt;/h2&gt;

&lt;p&gt;If the kernel can’t say which side of a segment is solid, the races can. A marble that rolls on a ramp’s top side, thousands of times, tells you the top is open air and the underside can be solid. A marble that comes within 0.1 of the underside tells you exactly how thick that slab may be at that point.&lt;/p&gt;

&lt;p&gt;So an offline script, &lt;code&gt;npm run bake:art&lt;/code&gt;, simulates 1,000 races in each bet family (3,000 races when the board art was last baked) and records every marble sample near every segment of every module. It writes the result to a JSON file the renderer loads. Two passes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sides.&lt;/strong&gt; Count contacts on each side of each segment. One side only: that’s the open side, the other is solid. Both sides, with the minority side above 0.5% of contacts: two-sided, drawn as a plate. Neither: untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Depth.&lt;/strong&gt; At stations every 0.5 units along the segment, find how close any marble disc came to the solid side. Subtract the radius and a 0.02 margin. That is the room at that station, and the slab’s depth there is the room clamped to between 0.04 and 0.38.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Seeds 1 to 1,000 are the bake’s. Seeds 1,001 to 1,100 are never baked, so the result can be measured honestly on races it has never seen (below). The bake script refuses to run past seed 1,000 for exactly that reason.&lt;/p&gt;

&lt;p&gt;That’s the idea, and the first version was little more than that. The rest of the work was everything a list of segments doesn’t know about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sides need context
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Contact counts lie at tips.&lt;/strong&gt; A marble rounding the free end of a ramp touches its far side for a moment. Counted naively, half the ramps in the game came out two-sided and turned into thin plates. That’s why the threshold exists: under 0.5% of contacts on the minority side, it’s a marble wrapping a tip, and the majority side wins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identical props must match.&lt;/strong&gt; A peg field is forty copies of the same plate. If marbles happen to brush one copy’s back and not the others, one plate in forty comes out different, and it shows. So copies share a code: if any copy is two-sided, all are. The subtle part is what counts as a copy. Our first attempt matched segments one at a time, and a wall’s top stub inherited “two-sided” from the bottom stub of a &lt;em&gt;different&lt;/em&gt; wall that happened to be the same length. A copy now has to be the same segment of a copy of the &lt;strong&gt;whole prop&lt;/strong&gt; , matched within 1e-4 with the same winding.&lt;/p&gt;

&lt;p&gt;We also first found copies by turning coordinates into string keys after rounding. One copy’s coordinate rounded to 10200 and its exact twin’s to 10199. Matching within a tolerance is right; rounding never is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Untouched isn’t unknown.&lt;/strong&gt; A bowl that marbles bypass has no contacts on half its segments. Left alone, the untouched half had no solid side and the bowl broke in two. Untouched segments now take the side of the segment they carry on from, around the shape’s winding, so the bowl keeps its solid side all the way round.&lt;/p&gt;

&lt;h3&gt;
  
  
  Depth needs smoothing, then simplifying
&lt;/h3&gt;

&lt;p&gt;Raw clearance is noisy. One marble that clipped a ledge on one race in 3,000 would pinch that ledge to a sliver, and every copy of it with it. The rules that came out of that, in the order the bake applies them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ignore the single closest race.&lt;/strong&gt; A station’s room is the second-closest race, not the closest. Ignoring three races was tried; it thinned fewer props, but frames with a big overlap went from 10 to 27 in the measurement below.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Taper slowly.&lt;/strong&gt; Depth may rise at most 0.5 per unit of length away from a thin station, so a slab tapers instead of stepping:&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Profile whole shapes.&lt;/strong&gt; Consecutive segments of one wall are profiled as one line, so depth flows round a curve with no step at a joint. Mirrored or translated copies share the thinnest profile of all of them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No lumps.&lt;/strong&gt; A lone ledge may thin toward one end but never thin, thick, thin again (&lt;code&gt;monotone&lt;/code&gt;). A multi-segment shape may taper to its tips but never pinch mid-way and swell again (&lt;code&gt;unimodal&lt;/code&gt;), which read as a break.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Straight edges.&lt;/strong&gt; Every segment’s slab gets one straight outer edge: the deepest constant or linear wedge that fits under its profile.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 was a design call, not a geometry one. After steps 1 to 4, the slabs followed the marbles’ paths beautifully, with gentle curves and waists. They also looked bent and broken. The verdict was plain straight boards, and that turned out to be right: a segment that has to be thin somewhere is usually best thin all along.&lt;/p&gt;

&lt;h2&gt;
  
  
  Joints: where 2D-to-3D breaks
&lt;/h2&gt;

&lt;p&gt;Almost every visible defect we fixed was at a joint. A single slab is easy. Two slabs meeting at an angle each extend their bands past the corner and poke through each other’s faces, and the projection makes every such overlap obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inside bends clip.&lt;/strong&gt; Where two slabs meet with their solid sides facing into the corner, each one is clipped by its neighbour’s wall line. That’s one Sutherland-Hodgman pass, and it’s shared by the renderer and the bake, so the bake measures exactly the slab that will be drawn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;clipToSolid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;py&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ny&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]&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;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&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;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&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;side&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;nx&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;py&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ny&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&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;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;n&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;si&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;side&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&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;sj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;side&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;si&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;si&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;sj&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;si&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;sj&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;si&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;si&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;sj&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&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="nx"&gt;out&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;Clipping alone left square ends lying across each other. The bands are now &lt;strong&gt;mitred like a picture frame&lt;/strong&gt; : both end on one cut line from the joint through the points where their edges cross at each fraction of depth.&lt;/p&gt;

&lt;p&gt;Before clipping existed, we used a bisector mitre. It fixed long zigzags and failed on short “teeth”, where the mitre point ran out past the end of the segment. Every acute zigzag corner pinched to the 0.04 minimum.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free ends are chamfered.&lt;/strong&gt; A hook’s tip, touching nothing, used to end square, and its back corner jutted past the tip. It’s now cut at 45 degrees back from the tip. On a leg too short for that cut, it runs straight to the other end’s corner instead of leaving a jog.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plates and slabs are different materials.&lt;/strong&gt; A slab whose end lies on a free-standing divider stops at the divider’s near face, so the divider covers its end. A plate hung off a slab’s side, like a hook’s leg, is part of the same sheet and is mitred to it instead. We tried the picture-frame cut for the divider case too. It opened sky-coloured triangles, because the plate is 0.08 deep and the slab 0.38, and a mitre between two very different depths leaves a gap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbf8rdsxbk5mlhxix5a77.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbf8rdsxbk5mlhxix5a77.webp" alt="Hooks before and after: round-capped strokes and separate legs, then square plates hung on the arm’s solid side as one bent sheet" width="800" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two-sided walls were round-capped strokes&lt;/strong&gt; at first: easy, and they never overlapped anything. They also read as a 2D pen line drawn over a 3D board. Thin plates with square ends replaced them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rails bound everything.&lt;/strong&gt; Every slab is clipped at the side rails’ inner faces, so a diagonal meeting a rail ends under it along a vertical line, rather than its square end lying across the rail.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2hwbnrwmpl41kww5th4x.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2hwbnrwmpl41kww5th4x.webp" alt="A bowl the marbles bypass keeps its solid side all the way round, and the boards stop at the divider’s face" width="800" height="976"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Which faces the camera sees
&lt;/h3&gt;

&lt;p&gt;For a while, slabs only drew the receding face on their &lt;em&gt;marble&lt;/em&gt; side. A channel’s right-hand wall, seen from a camera above and to the right, should obviously show its right face, and didn’t. A fix that drew the solid side’s receding face too made channel walls sprout pale panels across their corridors, so we limited it to thin slabs.&lt;/p&gt;

&lt;p&gt;The panels turned out to be a joint bug, not the rule. Receding faces at a joint were being matched to the neighbour’s faces by which way they faced. They should be matched by which hand each face is on when you travel through the joint. A corner search was also capped short of the band’s mitre. Once that was fixed, the limit came out, and the rule became the plain sentence from the projection section: the camera sees every top and right face.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqwev3d3t2fu5dp5asrl5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqwev3d3t2fu5dp5asrl5.webp" alt="A snake channel before and after: the right-hand walls now show the faces a top-right camera sees" width="800" height="389"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Painting order is depth, not kind
&lt;/h3&gt;

&lt;p&gt;With only two layers, faces within a layer still overlap, and the order decides which one wins. The first rule sorted by kind: sideways faces before upward ones, so a ramp’s top covers the rail it hangs from. It failed for boards ending on the &lt;em&gt;far&lt;/em&gt; side of a divider, whose tops were painted over the divider’s face.&lt;/p&gt;

&lt;p&gt;Receding faces now keep their source line and which way they face. Where two overlap on screen, the one whose source line lies behind the other’s plane is drawn first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Is all of b's source line behind a's plane?&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;behind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;d&lt;/span&gt; &lt;span class="o"&gt;=&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;2&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&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="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;return&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="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;min&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;6&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;That gives a partial order; a topological sort over it does the rest. The old kind-based sort is still there as the starting order and as the fallback if the overlaps ever form a cycle.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnza0dqqsh8u54o3jqrj6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnza0dqqsh8u54o3jqrj6.webp" alt="All twenty modules before and after the review round: the left half at the start of it, the right half at the end" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring that the art doesn’t lie
&lt;/h2&gt;

&lt;p&gt;Rules like these are easy to get subtly wrong, and wrong here means a marble drawn inside a wall. So there’s a measurement, run on demand: it replays the out-of-sample races (seeds 1,001 to 1,100, in all three bet families), takes every second 120 Hz frame, and measures how far each marble’s disc overlaps any front face. It also reports the worst segments by name, which is how most of the joint fixes were aimed.&lt;/p&gt;

&lt;p&gt;The current numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Overlap over 0.05:&lt;/strong&gt; 86 of 5.02 million marble-frames (0.0017%), in 19 of 1,800 marble-races.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overlap over 0.14:&lt;/strong&gt; 25 frames, all in one race.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For scale, the marble’s radius is 0.28. The very first version, every slab a uniform 0.38 deep, overlapped by more than 0.05 on 1.66% of all frames and in 91% of races: nearly every marble, at some point, drawn inside something.&lt;/p&gt;

&lt;p&gt;A unit test guards the other direction, in sample: it fails if any front face covers a marble’s &lt;em&gt;centre&lt;/em&gt; on the bake’s own seeds. That catches a broken bake before anyone looks at a screenshot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trade-offs, priced
&lt;/h3&gt;

&lt;p&gt;Some art choices cost overlap, and some looked better anyway. Those were priced on the bake’s own races (51 million marble-frames) and decided by a person, not by the number. One example was the hooked end of the spiral bowl:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A&lt;/strong&gt; , a thin last plate: no cost, but it read as a separate piece glued on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B&lt;/strong&gt; , an even taper across the tip and its slab: 36 more frames over 0.05, and 5 more over 0.14.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C&lt;/strong&gt; , a full wedge: 89 more frames over 0.05, and 20 more over 0.14.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foi26ug0wbo0mmc00g2ej.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foi26ug0wbo0mmc00g2ej.webp" alt="The spiral bowl’s tip three ways: a thin last plate, an even taper, and a full wedge" width="800" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;B was chosen, and became a rule: a short tip whose clearance can’t take a full wedge tapers across its slab too.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the defects were found
&lt;/h2&gt;

&lt;p&gt;None of these rules came from a design document. They came from about ten days of someone looking at the board and pointing at what was wrong, usually with a screenshot and a red scribble, and from the code changes that answered each one. Much of that code was written by Claude, working from coordinates. An AI editing a renderer can’t see what it draws, so most of the tooling exists to close that loop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A contact sheet.&lt;/strong&gt; &lt;code&gt;npm run art:sheet&lt;/code&gt; renders every module alone, between its rails, with headless Chrome: an overview, plus one image per module, named by number and id (&lt;code&gt;19-bowl-bypass.png&lt;/code&gt;). The file names became the shared vocabulary for every review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Geometry at 4x.&lt;/strong&gt; A sheet URL renders one module four times larger, so a joint is judged as shapes, not antialiased pixels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pixel to polygon.&lt;/strong&gt; To find what drew a wrong pixel, map it back to board coordinates and list every face whose polygon contains it, each tagged with the segment it came from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every fix, across the whole library.&lt;/strong&gt; A bake rule or a joint rule is library-wide, and so is its damage. Each change was diffed before and after across all twenty modules. Several fixes caught their own regressions in modules nobody had mentioned. The diff threshold mattered: pale top faces sit within about 10 levels of the sky, so a threshold of 12 silently hid changes there; it’s 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A stated camera model.&lt;/strong&gt; One sentence from the reviewer, that the camera sits top right and should see the top and right faces of every plate, turned several local patches into one rule that held across every module.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The checklist as a skill.&lt;/strong&gt; Every defect class the reviewer caught became a line in a review checklist that loads whenever the art changes, so the same mistake doesn’t need catching twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Night, for free
&lt;/h2&gt;

&lt;p&gt;Because every face is built from geometry and coloured from a palette, a second look costs almost nothing. Face colour follows depth down the tower from dawn to dusk, by palette lookup, and long segments are drawn in pieces so a rail shades along its length instead of taking one mid-board colour. The Starlight mode uses a second palette, dusk into deep night with stars, through the same code:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp4gyw7425u1077kmm6g3.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp4gyw7425u1077kmm6g3.webp" alt="The same zigzag geometry in the night theme used by Starlight" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The marbles
&lt;/h2&gt;

&lt;p&gt;The marbles are fakes too, and cheaper ones. Each is a filled circle with a radial gradient whose bright point sits up and to the left, matching the board’s light:&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;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createRadialGradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&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;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addColorStop&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="nf"&gt;mixHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;colour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addColorStop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;colour&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addColorStop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;mixHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;colour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#20103a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.45&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Building a gradient per marble per frame is wasteful, so the sphere is pre-rendered once per colour and per 2-pixel radius bucket (the zoom eases continuously, so exact radii would never repeat) and drawn as a sprite.&lt;/p&gt;

&lt;p&gt;A plain sphere doesn’t look like it rolls. It looks like it slides. The fix is a contrast: a faint swirl inside the marble rotates by the marble’s real roll angle (horizontal travel divided by radius, accumulated along its track), while the white highlight on top stays put. The eye reads a pattern turning under a fixed reflection as a ball rolling.&lt;/p&gt;

&lt;p&gt;The same split carries the rank skins players unlock on the stats page. Each skin is a pattern (cat’s-eye vanes, a swirl, onionskin streaks, agate bands) drawn in the marble’s own lane colour and clipped to the disc, rotated by the roll, then covered by one shared layer of shading and the fixed highlight. Colour is what tells the six marbles apart, so a skin may change the pattern but never the hue. Steelie is the one exception to rotating: it’s chrome, which reflects the room rather than turning with the ball, so its horizon band stays level.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjtbp2xommrwe8lc7lyht.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjtbp2xommrwe8lc7lyht.webp" alt="The eight rank skins on the start line, each drawn in the six lane colours" width="800" height="755"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;h2&gt;
  
  
  What we’d keep
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Derive the art from the simulation, and measure the difference.&lt;/strong&gt; Once the drawn surface is the physics surface, the only remaining question is thickness, and thickness can be measured from real races.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bake on one set of seeds, measure on another.&lt;/strong&gt; Without the held-out seeds, every tweak would have looked like an improvement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Most of 3D is joints.&lt;/strong&gt; A single extruded line took an afternoon. The joints took ten days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain beats clever.&lt;/strong&gt; Straight boards over measured curves, square plates over round caps, a stated camera model over a pile of special cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price the trade-offs, then let a person choose.&lt;/strong&gt; Overlap numbers say what a choice costs, not whether it’s worth it.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gamedev</category>
      <category>programming</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Acting Out a Fight That's Already Over: How Spinblade X Moves Its Spintops</title>
      <dc:creator>Raw Fun Gamer</dc:creator>
      <pubDate>Thu, 15 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/rawfungaming/acting-out-a-fight-thats-already-over-how-spinblade-x-moves-its-spintops-fb7</link>
      <guid>https://dev.to/rawfungaming/acting-out-a-fight-thats-already-over-how-spinblade-x-moves-its-spintops-fb7</guid>
      <description>&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%2Fq9pij3opgiiq1cy6bjsf.png" 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%2Fq9pij3opgiiq1cy6bjsf.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Spinblade X, two spinning tops (four in the melee modes) launch into a bowl and fight until one is left. It looks like a fight. It isn’t one. By the time the launcher’s trigger is pulled, the game already knows who wins, every hit that lands, how hard, when the finishing blow comes, and what it pays.&lt;/p&gt;

&lt;p&gt;That is not a shortcut. It is a requirement. Spinblade X is a real-money game, and the outcome of a real-money round can’t depend on a physics engine. The result comes from a pre-computed book of rounds, picked server-side and provably fair, and the 3D battle is a performance of a script it isn’t allowed to change.&lt;/p&gt;

&lt;p&gt;So the movement system has two jobs that pull against each other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Look like real spinning tops.&lt;/strong&gt; Glancing blows, bounces off at odd angles, the occasional top that backs up the slope and comes screaming back down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hit its marks.&lt;/strong&gt; Every recorded clash has to land on screen, on time, between the right two tops, and the round has to end the way the book says.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This post is about how we hold both. It covers the three layers we ended up with: physics that reads as real, an AI that paces itself to a script, and a few hand-authored moves for the moments that matter most. It also covers how all of that is seeded, which part of the result is guaranteed to be identical, and which part we deliberately let vary. Along the way are four designs we built and threw out, because they are most of how we got here.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the script actually says
&lt;/h2&gt;

&lt;p&gt;A round in the book is a list of beats, not a list of positions. Trimmed, one looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"seed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"multiplier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"opponent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"events"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Clash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.196&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"playerTier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"opponentTier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Normal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"playerRpm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4902&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"opponentRpm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4870&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"multiplierAfter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Death"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;8.497&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"victimIsPlayer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"finisher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Spin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"playerRpm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;476&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"opponentRpm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"multiplierAfter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.82&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;Clash&lt;/code&gt; says &lt;em&gt;when&lt;/em&gt; two tops meet, how each strike rolled (miss, normal or critical), what that did to each top’s Shoot Power, and where the multiplier stands afterwards. The &lt;code&gt;Death&lt;/code&gt; says who goes out and how: &lt;strong&gt;Spin&lt;/strong&gt; (ran out of power), &lt;strong&gt;Over&lt;/strong&gt; (knocked out of the bowl) or &lt;strong&gt;Burst&lt;/strong&gt; (shattered).&lt;/p&gt;

&lt;p&gt;What the script does &lt;em&gt;not&lt;/em&gt; say is where anyone is standing. Our books are generated by a math engine, not recorded in Unity. That is a decision we’ll come back to, because it’s the reason the rest of this post exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: making tops behave like tops
&lt;/h2&gt;

&lt;p&gt;Before a top can hit its marks it has to move like a top, and the first version didn’t.&lt;/p&gt;

&lt;h3&gt;
  
  
  Chase where the other top will be, not where it is
&lt;/h3&gt;

&lt;p&gt;The obvious AI is “steer toward the opponent”. Give it to both tops and you get a stable orbit: each chases the other’s tail around the bowl, and after the opening collision they never touch again. Our tops use &lt;strong&gt;lead pursuit&lt;/strong&gt; instead. They aim at where the opponent will be 0.4 seconds from now. Each archetype also hunts at its own speed, from 1.5 m/s for the Defense top to 2.25 m/s for the Attack top, which breaks the symmetry that let the orbit form in the first place.&lt;/p&gt;

&lt;h3&gt;
  
  
  A bowl that pulls
&lt;/h3&gt;

&lt;p&gt;Real stadiums are curved, so a top left alone drifts to the middle. We don’t simulate that curvature. We fake it with a steady pull toward the centre, which keeps the fight mid-bowl where the camera wants it. Two guards sit on top. A &lt;strong&gt;soft wall&lt;/strong&gt; starts pushing inward at 75% of the radius and ramps up steeply, so a pile-up in the melee can’t shove a top over the rim by accident. There’s also a &lt;strong&gt;speed cap&lt;/strong&gt; , because a top that bounces off one opponent straight into another picks up speed from both. Leaving the bowl is the Over finisher’s job and nobody else’s.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bounce apart, and not in a straight line
&lt;/h3&gt;

&lt;p&gt;Real tops don’t grind against each other. They hit and fly apart. On every clash the struck top is pushed away at 1.1 m/s and the striker recoils at 70% of that. That alone fixed the “glued together” look, but it left a stranger one. A duel that started mirrored, with the tops on opposite sides of the centre, shuttled back and forth along one straight line, clash after clash, because the push ran exactly along the line between the two centres and the bowl’s pull sent them straight back along it. It looked staged, because it was.&lt;/p&gt;

&lt;p&gt;The fix is a small piece of noise. Both pushes are swung off that line by a random 10 to 35 degrees, to a random side, and scaled by up to ±20%:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="n"&gt;Vector3&lt;/span&gt; &lt;span class="nf"&gt;Scatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Vector3&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;angle&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Mathf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Lerp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clashDeflectDegrees&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clashDeflectDegrees&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                             &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;clashScatter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NextDouble&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clashScatter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NextDouble&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;angle&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="n"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Quaternion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AngleAxis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;direction&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;On a mirrored start, the tops’ average distance off that line went from 0.023 to 0.079, in a bowl about 0.6 in radius. That’s small as a number and obvious on screen: they glance off each other now.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two physics bugs that look like design problems
&lt;/h3&gt;

&lt;p&gt;Two bugs are worth knowing about, because both looked like “the AI is bad” for a while.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One hit, counted four times.&lt;/strong&gt; Each top is several colliders, so one visible impact fires the collision callback once per collider pair. Every clash was triple- or quadruple-counted until we debounced it: one clash per 0.4 s, shared across &lt;em&gt;both&lt;/em&gt; tops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Equal speeds, no bounce.&lt;/strong&gt; To avoid counting a hit from both sides, only the faster top resolves it. Our first version used &lt;code&gt;&amp;lt;=&lt;/code&gt;, so two tops meeting at &lt;em&gt;exactly&lt;/em&gt; the same speed resolved on neither side. Unity doesn’t re-fire &lt;code&gt;OnCollisionEnter&lt;/code&gt; on a contact that persists, so there was no bounce, and they ground along together for the rest of the round. Now the slower top yields only when it’s &lt;em&gt;strictly&lt;/em&gt; slower, and a persisting contact re-resolves through the same debounced path.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Layer 2: an AI that knows the script
&lt;/h2&gt;

&lt;p&gt;Physics that reads as real is the easy half. The hard half is that the book says the third clash happens at 2.84 seconds, between &lt;em&gt;these&lt;/em&gt; two tops. Free-running physics doesn’t care.&lt;/p&gt;

&lt;p&gt;The answer we settled on we call &lt;strong&gt;beat-bound theatre&lt;/strong&gt;. The tops are still physical, AI-driven bodies, but the AI paces itself to the record:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;After a touch, a top backs off briefly, then drifts and holds a loose distance from its partner instead of chasing.&lt;/li&gt;
&lt;li&gt;Half a second before the pair’s next recorded clash, it starts the approach. In a melee it aims at that beat’s &lt;em&gt;partner&lt;/em&gt;, not the nearest top, because a touch with the wrong top binds nothing.&lt;/li&gt;
&lt;li&gt;A real touch within &lt;strong&gt;0.35 s before&lt;/strong&gt; the beat claims it on the spot: the spark, the damage numbers, the sound and the HUD update all land on that contact.&lt;/li&gt;
&lt;li&gt;If no touch has come by &lt;strong&gt;0.35 s after&lt;/strong&gt; the beat, it fires anyway, on the clock. The visuals are gated on the tops actually being close, so nothing sparks across an empty bowl.&lt;/li&gt;
&lt;li&gt;A top with no beats left holds off for good. A touch the record never made would be a hit the player sees and the payout doesn’t include.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One rule keeps it consistent: a touch can’t claim a beat while an earlier beat of &lt;em&gt;either&lt;/em&gt; top is still pending. Energy and combo are computed from the order of a top’s strikes, so beats have to be consumed in order or the meters on screen drift from the book.&lt;/p&gt;

&lt;p&gt;The result: the on-screen timeline stays within about a third of a second of the record, and most beats land on a real touch rather than a clock tick.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://rawfungaming.com/news/spinblade-x-spintop-movement/melee.webm" rel="noopener noreferrer"&gt;Watch the clip (video)&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A GRAND MELEE round replayed from the book: four tops acting out a record they can't change. The feed on the left is that record, beat by beat.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: custom moves for the moments that matter
&lt;/h2&gt;

&lt;p&gt;Pursuit, bounce and pacing give you a plausible fight. They don’t give you a &lt;em&gt;climax&lt;/em&gt;. Beat-bound theatre treats the killing blow like any other clash, so the round’s biggest moment looked like the round’s most ordinary one. Three things are hand-authored.&lt;/p&gt;

&lt;h3&gt;
  
  
  The charge run
&lt;/h3&gt;

&lt;p&gt;Watch real Beyblade and the big hits come from distance: a top swings wide, climbs the stadium wall and comes back down with everything it has. So before a killing blow, the striker makes a &lt;strong&gt;charge run&lt;/strong&gt;. It backs away from its partner up its own side of the bowl, swung about 35° off to one side so both the climb and the return are curves, then charges back down to land 0.1 s after the beat, inside the binding window.&lt;/p&gt;

&lt;p&gt;The interesting part is the timing. The run has to start at exactly the moment that makes it arrive on the beat, wherever the wind-up happened to end. So the AI solves its own steering equations for the travel time. Under our steering, speed approaches its target exponentially, and the bowl’s pull adds a constant acceleration on the way down. The distance covered by time &lt;em&gt;t&lt;/em&gt; has a closed form, and a 24-step bisection inverts it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// distance covered by time t: exponential approach to vmax, plus the bowl's pull&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vmax&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vmax&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;v0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1f&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;Mathf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Exp&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;
        &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="m"&gt;0.5f&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;bowlPull&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the time remaining until the beat drops to that answer, the top commits, and once committed it doesn’t re-decide. Without that, a top that slowed on contact would drop back into its wind-up mid-strike.&lt;/p&gt;

&lt;p&gt;The steering gain needed its own value. The normal chase closes about 63% of a speed gap per second, which never reaches charge speed between beats. A gain of 8 got there but snapped on like a remote-controlled car. 4.5 gets there and still looks like momentum.&lt;/p&gt;

&lt;p&gt;Then it read &lt;em&gt;wrong&lt;/em&gt;, which surprised us. With only killing blows charging, the charge was the one move in the round unlike every other move, so it looked remote-controlled for a different reason: it announced itself. The fix was to make it vocabulary. About 35% of ordinary clashes with at least 0.4 s of room now get a run as well, at a lower speed (2.8 m/s against 4.0) and by either top. Measured on real book rounds, plain clashes peak at 1.2–1.5 m/s, ordinary runs at 2.0–2.2 and killing blows at 2.65–2.86. The killing blow is still the hardest hit in the round, but it is no longer a different kind of hit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Over: a flight that can’t fail
&lt;/h3&gt;

&lt;p&gt;A real knockout is a physics impulse, and a bowl wall eats impulses. In free play a failed ejection is fine. In playback it isn’t: if the book says Over, the player has to &lt;em&gt;see&lt;/em&gt; the top leave.&lt;/p&gt;

&lt;p&gt;So the Over flight is scripted rather than thrown. The victim goes kinematic and rides a half-second parabola aimed well past the rim on the arena’s long axis. The frame it reads as outside the bowl, physics takes back over with the arc’s own momentum and the ring-out lands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Mathf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Clamp01&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;flightSeconds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 0.5 s flight&lt;/span&gt;
&lt;span class="n"&gt;Vector3&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Lerp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arcHeight&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;4f&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1f&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// parabola, 0.5 high&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The victim is immune to damage for the whole flight, or a pursuing top would land a kill mid-air and turn an Over into a Spin. In the melee, the books also contain Over strikes that the victim &lt;em&gt;survives&lt;/em&gt;. Only an Over followed by that top’s death within 1.2 s is a real knockout. The rest get a weak hop that peaks under the rim, staggers the top toward the wall and lets it roll back in, which is what a failed knockout looks like in real life.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://rawfungaming.com/news/spinblade-x-spintop-movement/over.webm" rel="noopener noreferrer"&gt;Watch the clip (video)&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A base-mode round from the book: glancing bounces, the striker's charge run into the killing blow, then the scripted Over flight out of the bowl in slow motion.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Burst, and the slow motion
&lt;/h3&gt;

&lt;p&gt;A Burst splits the losing top into its three real parts: blade, ratchet and bit. Each part gets a seeded chance to fly out of the bowl (75%, 50% and 15% respectively) and velocities set directly rather than as forces, so they don’t depend on mass. The wreckage stays on the floor until the round ends.&lt;/p&gt;

&lt;p&gt;Both round-ending finishers also play in slow motion: a 60 ms hit-stop at 5% speed, then 25% easing back to full over 0.75 s for an Over and 0.5 s for a Burst. The ring-out’s flight plays slowed and lands as speed returns. The physics timestep scales with it, so the debris doesn’t stutter.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://rawfungaming.com/news/spinblade-x-spintop-movement/burst.webm" rel="noopener noreferrer"&gt;Watch the clip (video)&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The last four seconds of a Burst round, cropped to the bowl: the full-charge strike, the burst, and the slow motion that follows.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How it’s seeded, and what “the same” means
&lt;/h2&gt;

&lt;p&gt;The question we get most: &lt;em&gt;if it’s all random-looking, how is the same round guaranteed to give the same result?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The answer is that Spinblade X has three kinds of randomness, kept strictly apart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The seed stream decides things.&lt;/strong&gt; Unity’s global &lt;code&gt;Random&lt;/code&gt; is initialised from the round’s seed, and the game draws from it in a fixed order: does the player win, which opponents, which spawn points, and in the melee which NPC is the protected champion. That order is a contract. Insert one draw anywhere and every later draw shifts, and old recordings stop reproducing. In playback the book is authoritative anyway (the payout, the finisher, the opponents and the mode all come from the JSON), but the draw order is kept so recordings and generated books line up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Cosmetic randomness never touches the seed stream.&lt;/strong&gt; The clash scatter, the burst debris and the arena’s colour scheme each get their &lt;em&gt;own&lt;/em&gt; &lt;code&gt;System.Random&lt;/code&gt;, never &lt;code&gt;UnityEngine.Random&lt;/code&gt;. This rule has been broken twice in this project, and both times by something that looked harmless. The first burst effect made two cosmetic &lt;code&gt;Random.insideUnitSphere&lt;/code&gt; calls. In a duel that’s invisible, because a Burst ends the round. In the melee the round continues after a Burst, those two draws shifted every later roll, and recorded melee rounds stopped reproducing. Some cosmetic generators are seeded from the round seed (the scatter uses &lt;code&gt;roundSeed ^ 0x5CA77E&lt;/code&gt;, the debris &lt;code&gt;roundSeed * 397 ^ actorIndex&lt;/code&gt;), so they repeat when a round is replayed. The charge-run planner is seeded from the clock on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Physics isn’t deterministic, and we don’t ask it to be.&lt;/strong&gt; A rigidbody simulation varies with frame timing, and on WebGL that means it varies by device.&lt;/p&gt;

&lt;p&gt;So the guarantee comes in two strengths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exact:&lt;/strong&gt; who wins, every clash’s strike tiers and damage, every Shoot Power and multiplier value along the way, the finisher and the payout. These come from the book, never from the simulation. The on-screen counter and power bars play back the recorded curves rather than computing their own. Once the battle ends, the book’s payout overrides whatever the live tracker computed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Similar:&lt;/strong&gt; where the tops are and how they get there. Every beat lands within about a third of a second of its recorded time, between the right two tops. Two viewings of the same round are two takes of the same script.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We decided the second one should &lt;em&gt;not&lt;/em&gt; be exact, and the next section is why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four designs we threw away
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Re-simulate from the seed.&lt;/strong&gt; Replay re-runs the physics with the same seed. The same seed paid 2.37×, then 2.43×, then 2.31× on consecutive runs. A physics engine can’t carry money.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Force the hits on the clock.&lt;/strong&gt; Fire each recorded clash at its time and pull the tops together for it. By each beat the physics had drifted, so the controller dragged tops across the bowl to meet. We called it the magnet snap, and it fought the bounce, so the tops rubber-banded apart and together forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Let collisions consume the beats.&lt;/strong&gt; Let the AI fight freely and have each real contact claim the next recorded clash. It worked for duels. Then four tops in one bowl broke it. The pair that owed the next beat chased other tops, bounces compounded until tops slingshot over the rim, and idle tops ground together without clashing. Four heuristic layers each helped and none sufficed. This one survived in a narrower form: it’s the binding window in today’s design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Ghost playback: record positions too.&lt;/strong&gt; Store a 10 Hz position track for every top and play it back kinematically. It’s the classic racing-game ghost, and it worked beautifully for rounds recorded in Unity, because the tops are exactly where they were when each beat happened.&lt;/p&gt;

&lt;p&gt;Our books weren’t recorded in Unity, though. A 100× maximum win needs a Burst on a round that has already built up 25× before the finisher. Organic Unity battles produce that roughly 0.5 times in 10,000 rounds, and weighting a pool can only redistribute payouts it already contains. So the math engine builds its rounds tail-first: it draws a payout band, then constructs a round that lands in it.&lt;/p&gt;

&lt;p&gt;So we gave those generated rounds positions after the fact. A baker played each line once as theatre, recorded where everything went, time-warped each top’s track so its beats lined up with the recorded times, and wrote the tracks back into the book. It ran for six days over all eight melee books, 80,000 rounds, and every rebuilt book paid out exactly as before.&lt;/p&gt;

&lt;p&gt;Then we scanned what it produced. In &lt;strong&gt;44–61%&lt;/strong&gt; of melee rounds, a top held perfectly still for half a second or more, up to 20 seconds. Some of that was the time-warp: a beat that fired late followed by one that bound early put two anchors almost on top of each other, and the warped track froze between them. The worst was a bug: an Over strike the victim survived had been played as a full knockout up to 18 seconds early, so that top’s recorded track froze outside the arena for the rest of the round.&lt;/p&gt;

&lt;p&gt;We fixed the bug and replayed the worst rounds &lt;em&gt;live&lt;/em&gt;, with beat-bound theatre and no tracks. They showed no stillness, ran their full length and landed 53–74% of beats on a real touch. So we retired the bake. The tracks are still in the books, and nothing plays them.&lt;/p&gt;

&lt;p&gt;The lesson cost us a six-day run: &lt;strong&gt;a recording is only as good as the take you recorded.&lt;/strong&gt; We had been treating “exactly reproducible” as the goal, when the actual goal was “always looks right”. A bad take played back perfectly is still a bad take. Theatre that’s re-performed every time gets a fresh chance to look good, and the outcome is protected either way, because the book never depended on the motion.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we’d tell another team
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Separate what must be identical from what must look right.&lt;/strong&gt; Ours is a JSON file and a physics simulation, and only one of them carries money. Once that line is drawn, the motion can be as loose as it needs to be.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give cosmetic randomness its own generators,&lt;/strong&gt; and treat any new call to the shared seeded one as a bug until proven otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the special move ordinary.&lt;/strong&gt; A killing blow that is the only move of its kind reads as scripted, however good it is. Sprinkling the same move, toned down, through the rest of the round is what made it read as a hit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure stillness, not just accuracy.&lt;/strong&gt; Our baked tracks hit every beat to the frame and still looked broken. The metric that caught it was “how long does anything stand still”, which no beat-accuracy check would ever have asked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://rawfungaming.com/games/spinblade-x" rel="noopener noreferrer"&gt;Spinblade X&lt;/a&gt; is live on Stake. The best way to see all of this is to watch a few rounds of the four-top melee and try to spot which hits were scripted. All of them were.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>news</category>
    </item>
    <item>
      <title>How we built Farm Pop: a slot game where every symbol is a living 3D character</title>
      <dc:creator>Raw Fun Gamer</dc:creator>
      <pubDate>Tue, 18 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/rawfungaming/how-we-built-farm-pop-a-slot-game-where-every-symbol-is-a-living-3d-character-40kk</link>
      <guid>https://dev.to/rawfungaming/how-we-built-farm-pop-a-slot-game-where-every-symbol-is-a-living-3d-character-40kk</guid>
      <description>&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%2Fxehyiwnegstqft6qus16.png" 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%2Fxehyiwnegstqft6qus16.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Farm Pop is a game we built at Raw Fun Gaming: a 7×7 cluster-pays slot where the “symbols” are not spinning icons — they are fully animated 3D pets. A dog, a cat, a chick, a rabbit, each one idling, running as the reels move, jumping when they win, and burning away in a dissolve when a cluster pops. Match five or more touching symbols, they dissolve, everything above tumbles down, and the cascade continues. Trigger free spins and the whole farm sinks into night — lanterns on, fireflies out — until the bonus ends.&lt;/p&gt;

&lt;p&gt;It runs entirely in the browser, built for Stake, where most players are on mobile Safari. That combination — a boardful of skinned, animated characters, on WebGL, on the worst-case browser — is where all the interesting engineering lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The client is a choreographer, not a game
&lt;/h2&gt;

&lt;p&gt;Like every game we ship, the outcome is decided before the first frame renders. A math engine pre-generates the full result set — “books” of simulated rounds, one file for the base game and one for the bonus — and the web layer feeds Unity a script of typed events for the round it drew: reveal (here is the board, with per-column anticipation flags), win (these clusters, these amounts), tumble (remove these, drop in those), grid boosts, free spins.&lt;/p&gt;

&lt;p&gt;Unity’s job is to perform each event beautifully and then say “done.” That handshake is the entire contract: every event ends with a completion message back to the web layer, which only then sends the next one. Nothing advances on a timer; nothing races. It also makes the system forward-compatible in a way we like: if the web sends an event type the engine does not handle yet, the interface auto-completes it back immediately instead of deadlocking the round. A new mechanic can ship in the math and the webfront before the Unity presentation exists, and the game degrades gracefully instead of hanging.&lt;/p&gt;

&lt;p&gt;The deeper win is what this does to animation sequencing — the thing slot games live or die on. Because each event is a discrete, awaited performance, the tumble choreography (dissolve → despawn → compact → spawn → settle) can be genuinely asynchronous inside while still being strictly ordered outside. Column-by-column settles, staggered win animations, anticipation delays — all free to overlap internally, all guaranteed to finish before the next event starts. Even anticipation is authored by the math: the reveal event flags which columns should tease, and turbo mode’s settle-everything-at-once fast path is explicitly disabled when a flag is present. You never skip the tease.&lt;/p&gt;

&lt;p&gt;A side effect we use constantly: a debug controller can load a book straight from disk in the editor and replay it against the same handshake, so the entire game is testable — every mechanic, every cascade — without a webfront running at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  A state machine wearing a fur coat
&lt;/h2&gt;

&lt;p&gt;The heart of the engine is a small state machine on every symbol: Idle, Spinning, Settling, Landed, Anticipating, Win, Dissolving, Clicked. The engine drives states; a game-specific subclass translates them into whatever the art can do. Farm Pop’s translation layer maps states onto the pet animators: idle loops (picked at random so a row of seven cats does not breathe in unison), a run cycle while scrolling and falling, a jump for wins, a landing animation that plays only on a symbol’s &lt;em&gt;first&lt;/em&gt; settle — tumbles re-settle existing symbols and should not retrigger it.&lt;/p&gt;

&lt;p&gt;Because symbols are characters, they can do character things. Tap one and it plays a reaction animation with its own sound. The scatter — a little farmer — spawns with randomized outfits, hair, and a 50/50 hoe-or-pitchfork prop, so no two look alike; during anticipation, when one more scatter would trigger the bonus, he &lt;em&gt;digs&lt;/em&gt;. That is the kind of storytelling a flat symbol atlas cannot do, and it costs nothing extra: it is the same anticipation state every game built on this engine gets.&lt;/p&gt;

&lt;p&gt;The price of living symbols is that everything must survive pooling. Forty-nine skinned characters churn through every tumble, so all of them come from object pools — and Unity’s Animator makes pooled characters expensive by default, because re-enabling one triggers a full avatar rebind. A one-line setting (keepAnimatorStateOnDisable) skips that. The pooling discipline runs deep: when the backend awards position multipliers, the winning symbol visually duplicates into a vertical stack of clones, and those clones are pooled too, with their animators rebound on dequeue and their materials restored on return. When a symbol dissolves, its clones share the &lt;em&gt;same&lt;/em&gt; dissolve material instance, so the whole stack burns away in perfect sync without any coordination code.&lt;/p&gt;

&lt;p&gt;That multiply stack is our favorite piece of pure juice in the game. It is not a loop — it is a recursive wave: the original squashes and pops, and at the peak of its overshoot the first clone starts rising out of it; when that clone arrives, &lt;em&gt;it&lt;/em&gt; pops and launches the next one, like a stadium wave traveling up the stack. The glow flash is derived from the same motion — rim-light intensity is computed from how far the scale overshoots 1.0 — so one animation curve drives both the bounce and the flash, through a shared material property block, with zero extra material instances. And because a tall stack clips the top of the frame, the effect requests a ref-counted camera pullback that scales with the stack’s depth in the scene — computed against a fixed reference distance, because computing it against the &lt;em&gt;current&lt;/em&gt; distance turns your camera into a positive feedback loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safari, again, but worse
&lt;/h2&gt;

&lt;p&gt;On &lt;a href="https://stake.com/casino/games/rawfungaming-highway-rush" rel="noopener noreferrer"&gt;Highway Rush&lt;/a&gt; we learned that Safari compiles and reflects shaders on first draw with no persistent cache — a multi-second main-thread stall the first time an effect appears. A slot game is the pathological case for this: almost every interesting visual is &lt;em&gt;conditionally&lt;/em&gt; spawned. Win bursts, dissolve variants, cluster highlights, floating win amounts, grid-boost overlays — none of it renders during the loading screen naturally, so all of it stalls mid-celebration on a player’s first win. The worst possible moment. Our dissolve shader alone — four passes, times shadow, light, fog, and dissolve keyword combinations — put roughly 194 shader programs through first-use uniform reflection at once, at about 3ms per uniform on WebKit versus about 0.1ms on Chrome. First cluster win on iOS: a three-second freeze.&lt;/p&gt;

&lt;p&gt;The fix grew into a proper engine service. A shader warmup system stages prefabs under a hidden transform, moves it in front of the real camera for three frames during boot, and holds the webfront’s splash screen until every scheduled pass completes. Two details turned out to matter far more than the basic idea:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The warmup must produce the same pipeline state the gameplay draw will.&lt;/strong&gt; Unity’s render pipeline picks shader variants from global lighting state at render time — how many additional lights, which cascades, fog on or off. Warm up too early, or in the wrong part of the scene, and you compile pristine variants of shaders the game will never use, then stall on the real ones anyway. So the service waits about a second for scene lighting to stabilize, and stages geometry at a &lt;em&gt;gameplay anchor&lt;/em&gt; — the grid root — so warmup symbols sit in the same light volume as real ones and the pipeline resolves the same lighting keywords.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each spawning system registers its own warmup.&lt;/strong&gt; The win handler warms its popups (actually showing one so the text-outline keyword toggles), the dissolve effect warms its burn variant half-dissolved so both branches of the fragment shader execute, the boost manager warms its overlays. New engine systems warm themselves on startup, so the next game gets the coverage for free instead of maintaining a fragile central list.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even that was not quite enough — per-prefab warmup does not cover the &lt;em&gt;combination&lt;/em&gt; of states a real win produces. So the boot sequence ends with a fake spin: three real grid symbols play a full win-then-dissolve cycle behind the splash screen, with a warmup flag that turns the sound and web-event calls into no-ops so the webfront never knows it happened. The single most hazardous frame of the session — the first cluster win — gets pre-played where nobody can see it.&lt;/p&gt;

&lt;p&gt;Safari had more lessons. WebGL caps fragment shaders at 16 texture units, and feature-rich toon shaders blow past it — Unity logs a link error but keeps rendering, &lt;em&gt;silently dropping the shadow-receiving variant&lt;/em&gt;. Objects look fine and simply never show shadows, which took a while to stop blaming on light settings. Everything that needs to receive shadows now uses a minimal custom toon shader that binds three textures.&lt;/p&gt;

&lt;p&gt;Fonts were their own saga. TextMesh Pro’s dynamic atlas mode — the convenient one, where glyphs rasterize on demand — calls FreeType, which is a null function pointer in a WebGL wasm build. Works perfectly in the editor, breaks in the browser. So every font in the game is a static SDF atlas with its glyphs pre-baked: currency symbols for every market baked into the Latin font, and the CJK fonts for localized bonus text baked down from 3–5 MB of raw font files to atlases of exactly the seventeen Chinese and fourteen Japanese characters the translation table actually uses, with the source font reference cut so the build drops the file entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free spins turn off the sun
&lt;/h2&gt;

&lt;p&gt;Farm Pop’s bonus does not cut to a different scene — the same farm transitions from day to night in front of you. One environment controller lerps everything at once: directional light color and intensity, the ambient spherical-harmonics gradient, fog, the toon shader’s shadow tint, even per-symbol emission and outline colors so the pets stay readable in the dark. Then the night lights come on: lanterns, window glow, fireflies.&lt;/p&gt;

&lt;p&gt;Nineteen point lights is a lot for WebGL, and the way it is affordable is an unusual renderer split: the project ships three render pipeline configurations — Forward for mobile, Forward+ for desktop, and &lt;em&gt;deferred&lt;/em&gt; for the WebGL build, where many small lights are close to free. But the lights still caused the best bug of the project: a visible flicker on the ground plane whenever the wild symbol appeared. The wild dog carries a real light for its glow, and on the big ground plane it competed with all the night lights for per-object light slots — as the sorted list reshuffled each frame, the ground popped. The fix was rendering layers: the ground got its own layer, the nineteen night lights were excluded from it entirely, and only the wild’s glow light opts in. The non-obvious part is that light layers are a &lt;em&gt;shader&lt;/em&gt; feature, not a free engine feature — our hand-written environment shader had to add the light-layers variant and test each light’s layer mask inside its own light loop before any of it worked.&lt;/p&gt;

&lt;p&gt;Shadows got the same kind of platform-specific care: shadow distance doubled to 100 (the portrait camera pullback had pushed the camera past the old range, silently killing shadows on phones), and the WebGL renderer runs three cascades split so the middle cascade covers exactly the band of world the grid sits in — better effective shadow resolution with no increase in shadowmap size.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multiplier patch that grows
&lt;/h2&gt;

&lt;p&gt;One mechanic is pure Farm Pop: grid boosts. Cells accumulate “+N” multiplier levels as wins land on them, and each boosted cell grows a little ecosystem — a patch of grass that gains blades with every level (10 blades at +1, capped at 55) and butterflies whose color escalates through tiers as the number climbs. Blade positions are hashed from the cell’s world position, so every cell’s patch is unique but identical every time it regrows. The overlays are pooled like everything else, with an explicit teardown on return — an early bug had a level-9 overlay going back to the pool with fifty grass blades still attached, ready to reappear fully grown on some other cell’s +1.&lt;/p&gt;

&lt;p&gt;It is a small system, but it is the game’s personality in miniature: a math-engine integer arriving over the bridge and becoming something that feels &lt;em&gt;grown&lt;/em&gt; rather than displayed.&lt;/p&gt;

&lt;h2&gt;
  
  
  One engine, thinner games each time
&lt;/h2&gt;

&lt;p&gt;Farm Pop is the first of the “Pop” series, and it shares its engine with the “Rush” runners — same web bridge, same pooling, same warmup service, same build hardening. The whole slot layer (grid manager, tumble choreography, anticipation, win presentation, overlay UI, day/night system, boost manager) lives in the shared engine. What is actually Farm Pop? Ten scripts, about 1,300 lines: the animator adapter, the farmer’s costume randomizer, the butterflies and grass, the wild dog’s glow. The next game — different animals, different theme — reuses everything else.&lt;/p&gt;

&lt;p&gt;Even the 2D art pipeline leans on this. An editor tool renders the actual 3D symbol prefabs to transparent PNGs for the paytable and marketing — posing them mid-jump by sampling animation clips in edit mode, on an isolated layer with its own throwaway camera, no Play button involved. The paytable cannot drift from the game because the paytable &lt;em&gt;is&lt;/em&gt; the game, photographed.&lt;/p&gt;

&lt;p&gt;That was the bet with this architecture, and Farm Pop is the proof: the second game in a genre should mostly be art.&lt;/p&gt;

&lt;p&gt;If you want to see the result, it is live: &lt;a href="https://stake.com/casino/games/rawfungaming-farm-pop-3d" rel="noopener noreferrer"&gt;&lt;strong&gt;Farm Pop on Stake&lt;/strong&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built with Unity 6 (WebGL) on the Raw Fun Gaming engine, for Stake Engine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>gamedev</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>How we built Highway Rush: turning a server seed into a browser runner that feels alive</title>
      <dc:creator>Raw Fun Gamer</dc:creator>
      <pubDate>Sun, 09 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/rawfungaming/how-we-built-highway-rush-turning-a-server-seed-into-a-browser-runner-that-feels-alive-3oa</link>
      <guid>https://dev.to/rawfungaming/how-we-built-highway-rush-turning-a-server-seed-into-a-browser-runner-that-feels-alive-3oa</guid>
      <description>&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%2Fhxqjxg0jyx5auz0d6pbw.png" 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%2Fhxqjxg0jyx5auz0d6pbw.png" alt=" " width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://stake.com/casino/games/rawfungaming-highway-rush" rel="noopener noreferrer"&gt;Highway Rush&lt;/a&gt; is a game we built at Raw Fun Gaming: a single character sprints down a highway, its head swelling as it passes through multiplier gates and shrinking when it clips a cone, a stop sign, or a roadblock. At the finish line the head size is cashed out against a column of stop signs. It looks and plays like a hyper-casual mobile runner. It is actually a provably-fair casino game, running entirely in the browser, on Stake.&lt;/p&gt;

&lt;p&gt;That one sentence hides the whole engineering story, so let us pull it apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint that shapes everything: the run is already decided
&lt;/h2&gt;

&lt;p&gt;On a normal runner, the game &lt;em&gt;is&lt;/em&gt; the source of truth — you steer, the physics respond, the score is whatever you earned. On a provably-fair casino game it is the opposite. The moment a bet is placed, a server-seeded math engine has already decided the entire outcome: which gates you will hit, what your head size will be, what you pay out. That result is signed and verifiable — the definition of “provably fair.”&lt;/p&gt;

&lt;p&gt;So Unity is not playing the game. &lt;strong&gt;Unity is replaying a result it was handed.&lt;/strong&gt; The whole craft of the client is making a predetermined multiplier feel like a live, breathing run that you are watching unfold for the first time.&lt;/p&gt;

&lt;p&gt;That reframes everything downstream:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No gameplay input may change the outcome.&lt;/strong&gt; The client takes the seed, derives the exact sequence of events, and renders it. Player “control” is presentation, never causation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Determinism is non-negotiable.&lt;/strong&gt; The same seed has to produce a byte-identical run on our laptops, on your phone, and on the server’s own verification pass. One stray random call in the gameplay path, one frame-rate-dependent physics step, and the replay diverges from the proof. So gameplay randomness comes only from the seeded source; anything cosmetic is kept on a separate private RNG so it can never perturb the deterministic run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The client is a pure replayer.&lt;/strong&gt; A lot of the hardening work was exactly this: hunting down every path where live input, timing, or engine state could leak into gameplay, and cutting it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The engineering problem is not “build a runner.” It is “make a fixed, server-authored outcome feel earned, tense, and alive — every single time, identically, everywhere.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping it to the browser — including mobile Safari
&lt;/h2&gt;

&lt;p&gt;Stake games run as WebGL. No native app, no install; the game streams into a browser tab, and roughly two-thirds of players are on mobile Safari / iOS. That platform is the real boss fight.&lt;/p&gt;

&lt;p&gt;A few things we learned the hard way:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VFX Graph is out.&lt;/strong&gt; WebGL has no compute support, so GPU-driven particle systems simply do not render. Everything visual ships as plain CPU particle systems with standard URP particle shaders — old-school, but they build and render reliably on every browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bloom needs a different path.&lt;/strong&gt; URP’s built-in bloom is unreliable in our WebGL builds, so the glow you see runs through a dedicated renderer feature instead. The editor lies to you here: it renders at the desktop quality tier by default, a different renderer than the tier a WebGL build actually ships with. More than once an effect looked perfect in the editor and vanished in the build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Safari shader stall.&lt;/strong&gt; The first time a browser renders a given shader program, it caches uniform locations on the main thread. On Chrome that is about 0.1ms and invisible. On Safari it is about 3ms per program, with no persistent cache — and a single complex prefab can carry a hundred-plus uniforms across its passes. So the first time a fancy effect appears mid-game, Safari can freeze the main thread for up to three seconds. The fix is a warmup pass: during the loading splash we stage every runtime-spawned prefab off-screen, render it for a few frames so the browser compiles and caches its shaders, then despawn it. The hitch happens where nobody is looking.&lt;/p&gt;

&lt;p&gt;The through-line for WebGL is humility: assume nothing transfers from the editor, budget for the worst browser, and pay your costs during the loading screen rather than mid-run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the world feel alive without spending the frame budget
&lt;/h2&gt;

&lt;p&gt;A runner needs a world going past, not just a track. Highway Rush has a low-poly city, birds, a day/night atmosphere, and lanes of background traffic. None of it is gameplay, and all of it has to be nearly free.&lt;/p&gt;

&lt;p&gt;The background traffic is a good example. It is a stop-and-go jam of around 80 cars that move like real traffic, play engine audio, and can even physically block the character when it dies — but during the normal run it costs almost nothing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Movement&lt;/strong&gt; is one controller over a flat list of lightweight structs — no per-car scripts. The cars follow a car-following model tuned so a packed lane still creeps instead of gridlocking, with each move committed to a distance rather than a timer so the stop-and-go reads convincingly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio&lt;/strong&gt; is a looping engine bed per car whose volume is driven in code — a car is only audible when it is actually moving and within earshot, so out of ~80 cars only a handful ever hold a live voice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Physics&lt;/strong&gt; is deferred entirely: colliders start disabled and only wake up near the character at the one moment they are needed, on their own layer so they never trip the gameplay triggers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The player can only see, hear, and touch a sliver of the world at any instant, so build every system to spend effort only on that sliver.&lt;/p&gt;

&lt;p&gt;On top of that sits the polish that sells a casual game: haptic feedback, colour-matched bursts when the head passes a gate, camera work, and per-car colour variety. Small touches, but they are what make a deterministic replay feel like a game instead of a cutscene.&lt;/p&gt;

&lt;h2&gt;
  
  
  One engine, two very different games
&lt;/h2&gt;

&lt;p&gt;Highway Rush is not a one-off. It is built on the Raw Fun Gaming engine, a shared Unity codebase that ships two streams of games onto Stake: a slots stream of cluster/tumble “Pop” games, and a burst/casual stream of “Rush” runners, of which Highway Rush is one. The web-to-Unity bridge, the seed and replay plumbing, the WebGL hardening, the shader warmup, the ambient systems — all of it lives in the engine so the next game gets it for free.&lt;/p&gt;

&lt;p&gt;A slot game and a highway runner look nothing alike to a player, but underneath they are the same problem: take a signed, server-authored outcome and make it a joy to watch, in a browser, on a phone, provably fairly. Solve that once, well, and you can ship a lot of games.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually special here
&lt;/h2&gt;

&lt;p&gt;Plenty of studios make runners. Plenty make casino games. What made Highway Rush a fun build was the collision of the two: the discipline of provably-fair determinism, wrapped in the presentation language of a hyper-casual mobile game, delivered through the browser to the worst-case device — and made to feel effortless.&lt;/p&gt;

&lt;p&gt;If you want to see the result, it is live: &lt;a href="https://stake.com/casino/games/rawfungaming-highway-rush" rel="noopener noreferrer"&gt;&lt;strong&gt;Highway Rush on Stake&lt;/strong&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built with Unity 6 (WebGL) on the Raw Fun Gaming engine, for Stake Engine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>gamedev</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
