dInfinity

Physics and rendering

Design: the tray, a settled roll and the power-saving result are options 1a and 1z of the clickable design (design/). The dice there are flat silhouettes standing in for the 3D render.

Overview

Each roll is a rigid-body simulation of dice inside a tray. The number on a die is read from whichever face normal points closest to “up” once the die has come to rest. Rendering is a passive observer of the simulation.

Coordinates

One right-handed system, shared by the tray, the solver and the renderer: +z is up, the tray’s long side runs along +x and its short side along +y, and the origin is the middle of the floor. Distances are millimetres everywhere above the bridge; the solver runs in centimetres on the far side of it and converts in one place (decision 41).

Nothing is turned over on the way between the three. A renderer that used a different up would have to flip every transform it was handed, and the first thing to go wrong would be a die drawn resting on the face it did not land on. It is also what “up” means everywhere else in this document: the face reading, the reference orientation a shape’s face order is numbered in, and the direction a face’s texture is drawn the right way up in (docs/dice-sets.md).

The table

The table (tray) is a fixed rectangular box whose floor is the phone’s screen: its aspect ratio and apparent size match the visible area, and its walls sit at the screen edges like the rim of a dice box. See docs/tables.md for the geometry, the capacity rule and how the look of the table can be swapped.

Dice bodies

Every die is a convex rigid body:

Timestep and determinism

The simulation clock

A roll is a loop over fixed steps, and the only question is who turns it. That is the whole difference between a roll on screen and a roll in power-saving mode; there is no other one.

Starting a roll

Two ways to start:

  1. Tap / button. Dice are spawned in a cluster above the tray with a randomised (seeded) orientation, angular velocity and a modest downward plus lateral impulse. This is the “drop from the hand” throw.
  2. Shake. See below. The dice are spawned when the shake begins and are driven by the phone’s motion until the user stops shaking, then released.

In both cases the initial angular velocity is large enough that the outcome is not predictable from the starting orientation. (A die dropped from 2 cm with no spin would be predictable. We do not do that.)

Shake input

Settling and reading the result

A die is at rest when both its linear speed is below 1 cm/s and angular speed below 0.05 rad/s for 250 ms of simulated time.

The roll is finished when every die is at rest, or when a hard cap of 12 simulated seconds is hit (then all still-moving dice are force-settled by zeroing velocity, which is logged as an anomaly).

Reading a face: for each face of the die, take the dot product of its outward normal (in world space) with the up vector. The face with the largest dot product is the result if that dot product is at least cos(15°). Otherwise the die is cocked — see below.

The face normals come from simulation/api’s shape geometry, which computes every catalogue solid from its closed form. The renderer’s mesh and the solver’s hull are built from the same arithmetic, so all three agree about which way a face points by construction rather than by inspection. The face order — from the top of the reference orientation down, anticlockwise around each ring — is the same order a set file’s faces list and its texture atlas use (docs/dice-sets.md).

Special cases:

Avoiding stacked and cocked dice

On a real table dice practically never stay stacked on top of each other or balanced on an edge. In a small simulated tray with many dice this can happen, it looks wrong, and it makes the result unreadable.

There are two ways to get this wrong, and the second is worse than the first:

So the rule is: nothing touches a die that has come to rest. Everything below happens either before the dice are thrown or while they are still moving, except the last resort, which is an honest re-throw the player can see.

  1. Prevention — where the work goes. Dice-on-dice friction is lower than dice-on-floor friction. Dice are scaled down so the table always keeps free floor area (capacity rule in docs/tables.md). Spawn positions are spread and staggered in height so dice do not fall onto each other. The throw carries enough energy that a die landing on another slides off it while it still has speed. Tuning these until stacking is rare is the real fix; the steps below only catch what slips through.

  2. Early detection, while the die is still moving. A die is watched from the moment its speed drops below a threshold but before it is at rest. If in that window it is supported by another die, leaning on a wall, or heading for a cocked orientation, a small seeded bias is added to the motion it already has — of the order of the energy still in the die, so it reads as the die finishing its tumble rather than as a kick. The bias is derived from the roll seed, so the roll stays deterministic and reproducible.

  3. Last resort: re-throw that die. If a die does reach full rest cocked or stacked, it is not poked, tilted, or snapped to a face. It is picked up and thrown again — one die, from a low height, visibly, while the others stay where they are. That is exactly what a player does with a cocked die, it is fair (the re-throw is uniform over the faces), and it is honest: the player sees a die being re-rolled instead of a die being moved. Each re-throw is recorded in the outcome (rethrows counter). Re-throws come out of the same twelve-second budget as the rest of the roll — the cap is a cap on the throw, not on each attempt at it — and one die may be thrown again at most three times. A die that has come up cocked three times running is not unlucky, it is a physics bug, and letting it loop would spend the whole budget on one die while the rest of the table waits.

  4. Never. No impulse on a resting die. No tray tilt to slide a settled pile. No snapping a die to its nearest face — that fabricates a result nobody rolled.

The 12-second hard cap above is a safety valve for a simulation that has gone wrong, not part of this ladder. When it fires, every die still moving is force-settled. A die that is still cocked at that point has had its three re-throws and there is no budget left for a fourth: it reports the face that came nearest and is counted in forcedSettles, which makes the outcome clean = false. That is the one place in the app where a number is read off a die that was not properly resting, it is recorded rather than hidden, and Step 5 asserts it never happens.

The whole loop runs inside the simulation, so power-saving mode behaves identically — including the re-throws, which simply do not get drawn.

Targets, verified on a device (docs/TODO.md, Step 5): zero dice at rest supported by another die, fewer than 0.5 % of dice needing any correction at all, and zero corrections applied after rest.

Rendering (normal mode)

Target: 60 fps with 20 dice on the Pixel 10a with headroom; the capacity rule caps a roll at what the table can hold, which on a phone-sized table is in the region of 60–80 small dice. Beyond ~40 dice the renderer drops shadows.

Power-saving mode

Debug tooling