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.
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.
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 (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.
docs/tables.md).dieScale in the ThrowSpec), down to a minimum. Below that minimum the
roll is refused before any body is created. Smaller dice that roll honestly
beat big dice that jam.Every die is a convex rigid body:
docs/dice-sets.md):
tetrahedron, cube, octahedron, pentagonal trapezohedron (d10), dodecahedron,
icosahedron, enneagonal trapezohedron (d18), coin (d2), etc.docs/dice-sets.md), so every body is one
of eight known solids. A set changes a die’s size, material, face values and
artwork, never its geometry — which is what makes the tuning below hold for
every installed set.RollResult so a roll can be replayed exactly.CROSS_PLATFORM_DETERMINISTIC=ON (docs/build-setup.md) — stepped by a
single-threaded job system, and no System.nanoTime() takes part in any
simulation decision. Single-threaded because a roll is at most eighty small
convex bodies, where the threads would cost more than they save, and because
it removes a whole class of question about what “deterministic” depends on.docs/architecture.md, decision 41).StrictMath through
simulation/api’s Exact. Math.sin may be an intrinsic and is allowed to
be an ulp out; one ulp in a starting quaternion is a different face a hundred
steps later (docs/architecture.md, decision 43).test-fixtures/src/main/resources/fixtures/golden/cases.tsv. They are
asserted in two halves, split where the engine begins. The JVM half runs on
every CI build and checks everything the engine is handed — which dice the
formula resolved to, how far the capacity rule shrank them, every die’s
starting placement and hull, and the gravity of every step of the shake, as
one digest. The device half runs on the emulator and the phone and checks
what the engine did with it: the faces, the steps to rest, the corrections
and the re-throws, exactly. Both halves assert the digest, which is what
makes the CI half worth running: it is only evidence about a real roll while
the device still agrees with it about what the roll was. Any diff is a bug —
re-recording is deliberate and reviewed (docs/build-setup.md).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.
FrameClock accumulates the time
a frame actually took, cuts it into whole 1/120 s steps and carries the
remainder to the next frame, where it becomes the interpolation a renderer
blends the last two states with. The world is advanced by a fixed step or it
is not advanced.docs/TODO.md).Renderer, so turning the
renderer off cannot change what a roll comes to — which is what makes
power-saving mode the same roll rather than a second implementation
(docs/architecture.md, decision 48).Two ways to start:
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.)
SENSOR_DELAY_GAME.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:
read = "vertex-up" and the values are mapped to
vertices rather than faces; the cocked check then uses the vertex direction
instead of a face normal.RollPlan. 0 + 0 reads as 100.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.
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.
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.
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.
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.
docs/tables.md). Only the inside is modelled:
the floor, the inner walls up to the 60 mm rim, and a 6 mm band across the
top of it. The camera looks down into the tray, so the outside of the walls
is never in shot, and the near wall’s inner face points away and is culled —
which is what lets the player see over it rather than at the back of it. The
rounded corners are drawn as six segments to the quarter, which is under a
pixel of a 12 mm arc at any size this is drawn at.Stage interface. FilamentStage is the one
file in the module that talks to Filament, and everything that decides
what a roll looks like sits on the near side of it and is tested on a JVM
(docs/architecture.md, decision 47).TrayRenderer remembers the throw, the tray and the last frame, and
replays them onto the new stage. With no stage at all it draws nothing, which
is the right thing to be while the app is in the background — the roll goes
on and the dice are where they should be the moment there is somewhere to put
them.Choreographer (docs/architecture.md, decision 49). TrayDriver is that
thread and the surface it draws to; TrayLoop is what it does each frame,
and is tested on a JVM.FilamentStage and TrayDriver are the two files excluded from the coverage
figure — a GPU context and a thread. Neither is excluded from static
analysis (.claude/CLAUDE.md).docs/architecture.md, decision 40). Filament hands out native handles
rather than objects a garbage collector knows about, so everything made
there is destroyed in reverse; a roll’s own entities go at the end of the
roll, and the engine and the compiled material stay.docs/tables.md, “Table looks”; docs/TODO.md, After v1).#1f5e3a, which is the space a screen shows and a person
picks colours in; light adds up in linear space. Handing a renderer sRGB
makes every midtone too bright — mid grey is 21 % of the light, not 50 % —
in a way nobody can point at and everybody sees. Alpha is coverage rather
than light and is left alone.floor_tiling and wall_tiling ask. On
the walls the texture walks continuously around the tray — a stretch running
along the long side repeats as often as the look asks for that side, one
along the short side as often as it asks for that, and a corner takes the
rate of whichever it is nearer — so a change of rate stretches the pattern
rather than cutting it.docs/architecture.md, decision 45). Face textures are applied via a
per-face UV atlas (see docs/dice-sets.md); dice without textures render
numbers with a built-in SDF font on a plain PBR material with the set’s
colour — and a d4 draws three of them per triangle, one at each corner,
because its values belong to corners rather than to faces
(docs/dice-sets.md, “The d4”). A coin’s rim belongs to neither face and
carries no cell: it is drawn in the die’s own colour.RenderFrame rather than in each
renderer, so two of them cannot disagree about where the same die was.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.
headless renderer is used.docs/statistics.md).