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.
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:
{"seed": 1, "multiplier": 0, "perMarble": [{"rank": 3}, {"rank": 2}, {"rank": 4},
{"rank": 5}, {"rank": 1}, {"rank": 0}], "duration": 56.192,
"modules": ["stadium-loops", "pin-field", "bowl-cascade", "maze-or-fall", "pin-field",
"cube-scatter", "stadium-loops", "switchback-maze", "rubber-gauntlet"],
"rank": 3, "accrued": 2.070435, "pickupsCollected": 30}
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.
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.
Here is seed 1’s whole race, the one in that book line, at double speed, exactly as every browser rebuilds it:
Why we didn’t record the race
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.
We ran the numbers for six marbles and dropped it:
- Book size. 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.
- Sweep time. 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.
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.
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.
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 14.6 kB gzipped 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.
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.
The rules that keep every browser in step
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.
These are the rules the kernel lives by, and why each one is there.
Doubles only, and only + - * / and sqrt. 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.
No sin, cos, tan, exp, log or pow. This is the rule people find surprising. The ECMAScript spec allows those functions to be implementation-approximated, and engines really do differ in the last bit. Two browsers can legitimately return different answers for the same Math.sin call. One last-bit difference, compounded through a minute of collisions, is a different race.
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 sqrt. This is the start burst the backed marble gets in the Comet bet mode, a random direction within 30 degrees of straight down:
// Draw 4 (edge families only): burst within +-30 degrees of straight down.
const sin = (rng() * 2 - 1) * 0.5;
marbles[playerIndex].vx += cfg.edgeBurst * sin;
marbles[playerIndex].vy -= cfg.edgeBurst * Math.sqrt(1 - sin * sin);
A rule like this rots the first time someone reaches for Math.atan2 on a Friday afternoon, so a test enforces it. It reads every kernel source file, strips the comments, and fails on any banned member:
const BANNED_MATH_MEMBERS = [
'sin', 'cos', 'tan', 'atan', 'asin', 'acos',
'pow', 'exp', 'log', 'hypot', 'random', 'cbrt',
];
// Other banned globals: plain substring bans are enough here.
const BANNED_SUBSTRINGS = ['Object.keys', 'new Map', 'new Set'];
The first version of that test had holes, and its own review found them: Math['sin'], destructuring const { sin } = Math, and aliasing const M = Math all slipped through. It now catches each of those forms, and has a test of its own for every evasion.
A fixed collision order. 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 (i, j). 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.
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:
// 2. Statics, ascending segment index — now via the broadphase. Gather
// the marble's overlapping bands, sort, and skip duplicates, so the
// resolution order is identical to a full ascending scan.
candidates.sort((p, q) => p - q);
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.
No hash-ordered containers. Iterating a Map or Object.keys 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.
Our own random numbers, drawn in a fixed order. Math.random() can’t be seeded, so the kernel uses mulberry32, a tiny generator built from integer operations (Math.imul is exact, which is why it is the one other Math member allowed). One generator is seeded per race, and the draws happen in an order written into the source as a contract:
// 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.
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.
A fixed timestep. 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.
Making 2D marbles feel real
Determinism is the easy half to state. The hard half is that the race has to look like marbles, not like a simulation.
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.
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 (5/7) g sinθ, 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”.
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.
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:
- Fast is a bounce, as before: reflect with restitution, apply the surface’s friction.
-
Slow 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
a = (5/7) g sinθfrom linear quantities alone, with nothing but+ - * /.
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:
- Gravity 4.6, 6 modules: median 44.1 s, 0.3% under 30 s, and marbles falling like they were on the Moon.
- Gravity 9, 6 modules: median 31.1 s, 39.3% under 30 s.
- Gravity 9, 9 modules: median 46.7 s, 0% under 30 s.
We kept honest gravity and made the board longer. Every board is nine modules.
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 1 - rng() * noise, so roughness can only ever take speed away.
Every seed has to finish
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.
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 guaranteed passable. The sweep checks the last one over every seed, and every way a marble found to stop became a rule. A few of them:
- A closed cup traps marbles, and it isn’t the solver’s fault. 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.
- A dead-flat facet on a curve. 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.
-
A nearly flat facet stops a marble too. 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 3.72° marbles stop, above it they roll. The library’s floor is now 10% above the cliff, derived from the kernel’s own
dt, gravity and radius, so a config change that moves the cliff fails a test. - Two walls can freeze a marble bit-for-bit. 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.
- Spaces that look dead aren’t. 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.
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.
The finish has to be physical too
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.
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.
Real physics means uneven lanes
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%.
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.
Trust, but check every round
All of this is designed to hold. The game still checks that it does, on every round.
When a round’s book line arrives, the browser simulates the seed and compares the race it built with the line that paid:
- the rank , the multiplier and the number of pickups must match exactly;
- the duration must match within a millisecond;
- the accrued multiplier must match within one millionth.
Any mismatch is logged loudly as KERNEL PARITY BROKEN, 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 is the visible ranking, so playing a diverged race would show the player an order the book didn’t pay.
The first version broke on every round, and the bug is worth passing on. The book also stores payoutMultiplier, 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.
One more: production builds normally strip console calls. We drop only debugger. Those console.error lines are the detector; stripping them would make a broken contract invisible.
The runtime check catches a divergence when it happens. To stop one being introduced, the kernel has a regression pin: kernel-vectors.json, checked in every test run. It holds:
- 56 micro vectors: 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.
-
4 fold vectors: the pickup arithmetic on its own, where order matters, because
(1 + 2) × 3isn’t1 × 3 + 2. - 210 macro races: 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.
Any change that moves a race fails the vectors before it can reach a book.
What it bought us
- A tiny book. 436 bytes a round on average, with no position data at all.
- Fast iteration. Re-sweeping a mode after a module change takes minutes, not days, so the physics and the math could be tuned together.
- One source of truth. The race the player watches is the race the book was generated from, rebuilt from one number, on whatever browser they use.
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 Math.sin. For a race decided by a billionth of a unit, that turned out to be cheap.





Top comments (0)