One Number, One Race: How Marble Derby Re-Runs Its Physics in Every Browser
Marble Derby pays from a pre-computed book, yet every race is a real physics simulation, re-run from a single seed in the player's browser. Here is how we keep it identical.

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.
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.
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.
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.
So the renderer draws the kernel’s own segments. 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.
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:
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.
The 3D look is a fixed oblique projection. Everything recedes along one vector, up and to the right:
/** Oblique depth, view units: up and to the right. Also sets the light. */
export const DEPTH: readonly [number, number] = [0.3, -0.24];
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.

Each segment that has a solid side is drawn as a slab:
DEPTH is swept back along it into a parallelogram. That’s the lit top of a ramp, the right-hand face of a wall.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 thin plate: a strip 0.04 either side of the line, swept back as a box.
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.
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.
So an offline script, npm run bake:art, 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:
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.
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.
Contact counts lie at tips. 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.
Identical props must match. 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 different wall that happened to be the same length. A copy now has to be the same segment of a copy of the whole prop, matched within 1e-4 with the same winding.
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.
Untouched isn’t unknown. 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.
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:
Ignore the single closest race. 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.
Taper slowly. Depth may rise at most 0.5 per unit of length away from a thin station, so a slab tapers instead of stepping:
export function taper(depths, h, slope) {
const out = Array.from(depths, Number);
const rise = slope * h;
for (let s = 1; s < out.length; s++) out[s] = Math.min(out[s], out[s - 1] + rise);
for (let s = out.length - 2; s >= 0; s--) out[s] = Math.min(out[s], out[s + 1] + rise);
return out;
}
Profile whole shapes. 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.
No lumps. A lone ledge may thin toward one end but never thin, thick, thin again (monotone). A multi-segment shape may taper to its tips but never pinch mid-way and swell again (unimodal), which read as a break.
Straight edges. Every segment’s slab gets one straight outer edge: the deepest constant or linear wedge that fits under its profile.
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.
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.
Inside bends clip. 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:
export function clipToSolid(pts: number[], px: number, py: number, nx: number, ny: number): number[] {
const out: number[] = [];
const n = pts.length / 2;
const side = (i: number) => (pts[i * 2] - px) * nx + (pts[i * 2 + 1] - py) * ny;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
const si = side(i);
const sj = side(j);
if (si <= 0) out.push(pts[i * 2], pts[i * 2 + 1]);
if ((si < 0 && sj > 0) || (si > 0 && sj < 0)) {
const t = si / (si - sj);
out.push(pts[i * 2] + (pts[j * 2] - pts[i * 2]) * t, pts[i * 2 + 1] + (pts[j * 2 + 1] - pts[i * 2 + 1]) * t);
}
}
return out;
}
Clipping alone left square ends lying across each other. The bands are now mitred like a picture frame: both end on one cut line from the joint through the points where their edges cross at each fraction of depth.
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.
Free ends are chamfered. 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.
Plates and slabs are different materials. 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.

Two-sided walls were round-capped strokes 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.
The rails bound everything. 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.

For a while, slabs only drew the receding face on their marble 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.
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.

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 far side of a divider, whose tops were painted over the divider’s face.
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:
// Is all of b's source line behind a's plane?
const behind = (a: number[], b: number[]) => {
const d = [0, 2].map((i) => (b[i] - a[0]) * a[4] + (b[i + 1] - a[1]) * a[5]);
return Math.max(...d) <= 1e-6 && Math.min(...d) < -1e-6;
};
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.

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.
The current numbers:
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.
A unit test guards the other direction, in sample: it fails if any front face covers a marble’s centre on the bake’s own seeds. That catches a broken bake before anyone looks at a screenshot.
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:

B was chosen, and became a rule: a short tip whose clearance can’t take a full wedge tapers across its slab too.
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:
npm run art:sheet renders every module alone, between its rails, with headless Chrome: an overview, plus one image per module, named by number and id (19-bowl-bypass.png). The file names became the shared vocabulary for every review.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:

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:
const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.4, r * 0.05, x, y, r * 1.15);
g.addColorStop(0, mixHex(colour, '#ffffff', 0.6));
g.addColorStop(0.4, colour);
g.addColorStop(1, mixHex(colour, '#20103a', 0.45));
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.
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.
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.

Marble Derby pays from a pre-computed book, yet every race is a real physics simulation, re-run from a single seed in the player's browser. Here is how we keep it identical.
The winner, every hit and the payout are decided before the tops launch. How we make the fight look real anyway, and why we stopped trying to make it identical.
A 7×7 cluster slot whose symbols are fully animated 3D pets, running in the browser on WebGL, with mobile Safari as the target.