All work

Dice Merge

A fruit-drop puzzle played with real polyhedral dice. Drop one in, and two of a kind that touch become the next die up. It took about a week, and almost all of that week went on the word “touch”.

RoleDesign, engineering
ScopeThe second quick game inside The DM Screen
StatusLive in production
StackRapier, Three.js, React, Next.js
7rungs on the ladder
1die deep, always
0impact threshold to merge
0account required

The problem

The app already had a physics-backed dice stack, a set of polyhedral meshes, and a corner tray where dice sit and can be flicked. The question was whether any of that could carry an actual game, and a fruit-drop puzzle is the cleanest test: it is nothing but contact resolution, a growth ladder, and a fail state.

The catch is that a fruit-drop game is built around circles in two dimensions. Circles are rotationally symmetric, so orientation is free. Dice are neither, and a d20 that lands on a vertex is not the same object as a d20 that lands on a face.

The Dice Merge jar with a pile of dice at the bottom: a yellow d8, orange d6 cubes, two teal d12s, a green d10 and a large blue d20 resting against the wooden frame.
A run part-way up the ladder. Every solid here is the same mesh the campaign dice tray uses, scaled per rung.

The house rule that makes the dice a set is the one this game had to break.

Three decisions that made it a different world

1. Size had to become meaningful

Every die in the codebase is normalised to the same circumradius, 0.85 world units, so a d4 and a d20 read as one matched set on a table. That is correct everywhere else in the product and exactly wrong here. The entire read of a fruit-drop game is that the thing you are climbing toward is visibly bigger than what you started with.

So the ladder grows about 1.2 times per rung, from a small d4 to a d100 roughly three times its radius, and each rung scales its collider along with its mesh. Getting only one of those right produces dice that overlap or dice that float, and both look like a bug rather than a game.

2. The camera had to stand up

The corner fidget tray uses a near-overhead camera, because dice there spread across a floor. A pile growing toward a line has to be seen from the side, so this stands up its own straight-on orthographic view. Same scene builder, different projection.

3. Two dimensions in translation, three in rotation

Every die has its Z translation locked, so the pile stays exactly one die deep and nothing ever drifts behind anything else. Rotation stays completely free, which sounds like the matching half of the same decision and is not.

Why the mismatch costs

A die constrained to a plane but free to rotate in all three axes will happily come to rest balanced on an edge or a vertex, half of it pointing out of the play plane. It is stable, the solver is content, and it looks broken. Locking rotation instead would fix it and would also make the dice feel like stickers. The answer was a righting pass that measures how far a die is tilted off a sensible seat and applies a small corrective impulse when it has been wrong for long enough, so dice settle onto a face without ever being forbidden from tumbling.

Contact, and why there is no impact threshold

In the corner fidget tray, two dice of the same kind touching is nothing. Here it is the whole game, which inverts a rule the existing dice code had already settled.

The merge deliberately has no impact threshold. Two dice resting quietly against each other combine, because a player who nudges a d8 into another d8 and watches nothing happen has been told the game is broken. Contacts accrue over frames instead of firing on a single collision event, which keeps a jittering solver from producing a merge the player did not intend, and a pending-progress read drives the visual feedback while the pair is still deciding.

The top of the ladder is the percentile die: a d10 body wearing the 00 to 90 tens labels, which is what a physical d100 actually is. Two of them do not combine into anything. They sit in the jar taking up room, and that accumulating dead space is the pressure that eventually ends the run.

Keeping the rules out of the renderer

The entire rule set lives in a module that imports nothing from React, Three.js, or Rapier. The chain, the points per rung, the sizes, the spawn weighting, the combo multiplier, and the best-score storage are all pure functions over plain data.

That split is why the game has a test suite at all. A Node test can import the rules directly and assert that the ladder terminates, that a d20 pays ten and the last step doubles, and that spawn weighting stays inside its bounds, none of which needs a browser or a canvas. The component that needs a physics world and a renderer is then only the part that genuinely needs one.

  • Route-level isolation. Its own route, no campaign row, no store reads, guests welcome, device-local persistence. Same shape as the first quick game.
  • Zero shared-bundle cost. The match component is dynamically imported with server rendering off, so the game and the 3D stack behind it add nothing to the bundle every other page pays for.
  • A colour per rung. The ladder is colour-coded along the bottom of the board, so the thing you are working toward is legible without counting sides.
Dice Merge at phone width, with the jar filling most of the screen and the ladder of die types along the bottom.
At 430px the jar takes the screen and the aim rail sits under the thumb.

Where it landed

  • Live and playable with no account, on the same domain as the product it was built inside.
  • Shares the dice stack with the campaign tools, so improvements to either side land on both.
  • Rules are testable without a renderer, which is the only reason the merge behaviour stayed stable through a dozen feel passes.
  • It proves the platform. A game built on the app's own primitives in a week is the strongest argument that the primitives were worth building.
Next case studyThe DM Screen →