Design: the tray, a settled roll and the power-saving result are options 1a and 1z of the clickable design (design/); “Preparing the dice”, over the tray while a material compiles, is option 1za. 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. On the screen +x is up and
+y is to the left. 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):
the long d4 (an elongated square bipyramid), 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.HullMargin.SHARE, 0.48 mm on a
16 mm die), so they tumble instead of catching on the floor. The share is
each die’s own: a set may ask for 1.5 % to 6 % with edge_rounding
(docs/dice-sets.md), and the face designer’s Edges control writes it
(docs/face-designer.md, “Material, colour and edges”; decision 94). It is Jolt’s
convex radius: the hull’s face planes are pulled in by it and the smaller
solid is grown back out by a ball of the same size, so the die that collides
has every edge a strip of a cylinder and every corner a patch of a sphere.
Jolt gives less than is asked where a corner is sharp — no rounded corner may
stand more than mMaxErrorConvexRadius inside the sharp one — and never more
than half the die’s thinnest width. That error is set per die, in
proportion to the share asked for (HullMargin.maxErrorMm): Jolt’s own
0.5 mm at the default 3 % and 1 mm at 6 %. Held at 0.5 mm, a rounder die
would have got what it asked for only where its corners are blunt — a d6
would stop at 0.68 mm — so the error grows with the share and every shape is
rounded in the proportions it has at the default. Every shape in the
catalogue gets all of what it asks for: the long d4’s points are blunt enough
(the tetrahedron it replaced got half, decision 109). At the
default the bridge hands Jolt exactly the floats it always did
(HullRoundingTest), so the built-in set and every golden are the same
bodies. The share is of the nominal size, clamped to the set file’s limits,
and not of the size the capacity rule shrank the die to: a die thrown at
half size asks for the same radius in millimetres. The renderer draws the
same rounding (“Rounded edges”, under Rendering).RollResult so a roll can be replayed exactly.Seeds, and the seed is stirred
before it becomes a generator. A seed handed straight to
kotlin.random.Random becomes an xorwow state by way of sixty-four warm-up
steps, and sixty-four is not enough to separate two seeds that differ only in
their low bits: 200,000 d18 throws seeded 0, 1, 2, … start in orientations
spread more evenly than chance allows, χ² of 0.73 against 17 degrees of
freedom. Too even is a correlation like any other, and a die is only fair
because its starting turn is drawn evenly and independently of everything
else about the throw. Rolls the app starts are seeded from SecureRandom and
were never affected; an exploding die was — its extra throws used to be
seeded one, two, three more than the throw that set them off. SplitMix64’s
finaliser fixes it in three lines: a bijection, so two seeds are still two
streams, and pure arithmetic, so a roll still replays to itself on every
device.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 a hundred
small convex bodies (TableCapacity.MAX_DICE), 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 questions are who turns it and how fast. That is the whole difference between a roll on screen and a roll in power-saving mode; there is no other one.
A watched roll does not run at real time. RollPace scales the time a
frame is worth before FrameClock sees it, so the same roll is shown over
more wall clock than the physics took: RollPace.WATCHED of a second of real
time per second of simulated time, which at the current 0.4 means a roll
takes two and a half times as long to watch as it takes to happen. It was
0.5 until the owner judged the livelier tumble on the Pixel 10a: the spin
read right, the dice still crossed the tray a little too fast. At 0.4 the
median 20d20 (0.96 s) takes 2.4 s to watch and the ninety-ninth (1.76 s)
4.4 s; a single die stops well inside the median, so 1d20 still answers
in about two seconds. Impacts are played on the frame whose steps produced
them and the result is read when the simulated roll settles, so sound,
haptics and the result sheet follow the pace without a number of their
own.
It is a presentation change and nothing else. The seed, the steps, their order, the corrections, the re-throws and the faces are all exactly what they were; what changes is when the steps are asked for. Nothing here reaches the solver.
It exists because the physics has nothing left to give. Measured on the
Pixel 10a over 200 rolls of 20d20, the die/table friction pair moves the
median settle from 0.73 s (0.34/0.42) through 0.81 s (0.5/0.6, what is
shipped) to 0.85 s (0.7/0.85), and the turns a die makes after landing from
1.45 to 1.59 — and the high end also pushes the dice into one another, from
5.04 mm of overlap to 5.59 mm. Restitution was the one physical number with
room in it (“Dice bodies”): at 0.55 the median 20d20 settles in 0.96 s
rather than 0.83 s, and spends it tumbling rather than sliding. Twenty d20 in
a tray the size of a phone genuinely stop in about a second. Two or three
seconds of tumbling would mean energy a hand does not put into dice, and
would push 100d4 further into the twelve-second cap.
A roll stops being paced once it stops landing. The pace holds for
RollPace.WATCHED_SECONDS of simulated time and then gives the frame back
whole. The reason is 100d4: the twelve-second cap counts simulated time,
so pacing cannot change when a roll gives up, only how long somebody waits
to be told — and a flat 0.4 would turn that into thirty seconds of
watching dice that were never going to stop. Three seconds is chosen against
the measurements: the median 20d20 settles in 0.96 s and the ninety-ninth in
1.76 s, so a roll that is behaving is paced from first step to last and
never meets the bound at all. What meets it is a roll that is not landing,
and a roll that is not landing is being waited for rather than watched.
A big roll’s tail is quickened (decision 88). A hundred d4 settle in a
median 1.29 s of simulated time, but their slowest one in a hundred takes
3.09 s (Pixel 10a, 200 throws) — 7.7 s on the screen at 0.4, nearly all of
it a few dice rocking while ninety-odd lie still. So for a roll of at least
RollPace.TAIL_FROM_DICE (10) dice the pace follows the share still unread:
WATCHED while a quarter or more is moving, real speed once a tenth or less
is, and a straight line between (RollPace.paceFor). It is a gradual climb
rather than a jump, so it does not read as a dropped frame; it never passes
real speed, so the last dice still move the way dice move; and it decides
only when steps are taken, so the faces are the same at any pace. A smaller
roll is watched at WATCHED to its last die, because there the last die
tumbling is the roll.
Otherwise RollPace.WATCHED is one constant. The other place the speed
changes is the moment the hand lets go, which is a moment the player caused
and at which the dice’s motion changes character anyway — the tray stops
hauling them about and there is nothing but gravity left. A slower roll is
also a slower answer, so the number is meant to be judged on a phone and
changed.
RollPace.eased):
from real speed down to the pace over RollPace.EASE_STEPS (36 steps, 0.3 s
of the roll, about half a second on the screen), along a smoothstep, so there
is no corner at either end. Switched in one frame, the dice lurched from fast
to slow motion as the hand let go. TrayLoop remembers the last step a hand
drove the roll and hands the pace how long ago that was; like everything
else here it decides when steps are taken, never what they are.WatchedRoll.driven, which is ShakeDriver.stillShaking — the same
question the roll asks before it is allowed to end. So the part of a roll
that may not be declared over because the hand is on it is exactly the part
that is not slowed down for somebody to look at, and the two can never
disagree. Every throw now starts with a hand on it, because a shake is the
only way to start one (“Starting a roll”).TrayLoop.frame and nowhere else. Power-saving mode asks for a fixed helping
of simulated time and has no frame clock at all, so it never passes through
that line and cannot be paced by accident — a stronger promise than a flag
somebody has to remember to clear (“Power-saving mode”).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). LiveRoll.droppedSteps counts them, but
nothing in the app shows the count yet — putting it on the debug overlay is
an open item in 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).A shake is the throw. It is the only way to put dice in the air, and every
other control in the app stops at filling the formula field. That is absolute
(docs/architecture.md, decision 66): no button, no key, no accessibility
action and no setting that switches shaking off. The dice are spawned when
the shake begins and are driven by the phone’s motion until the hand stops
(“Shake input”, below), and the hand then tosses them (“The hand lets go”,
below).
There was a Roll button, and it is gone. It was not a second way to throw so much as a way of making the shake optional: a tap on a saved roll threw, the button threw, the welcome threw, and a player could use the app for a week without discovering the thing it is for. The button also owned the one line of the screen that said what a throw would be worth, which is now the ready plate (see “What the screen says before the throw”).
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.)
A shake finishes what it starts. Three states leave a roll part-way
through — a chain that earned a throw, a throw that left dice it could not read,
and a throw that gave up on dice that never stopped — and the same shake
answers all three. None has a button, and the screen says how many dice the
next shake will throw, both on the plate across the bottom and in an accent
shake prompt over the tray that announces itself to a screen reader and
stays until the shake (docs/architecture.md, decision 84). They never compete for one shake: the
dice nobody could read come first, because a throw earns a chain’s next die
only once every die of it has a face.
Tapping the tray does not roll — decided, not pending. It is the largest target on the screen and the most tempting one, which is exactly why it is not spent here: the tray is where the camera is moved and where individual dice are picked up for the next shake to throw again (“Picking a die up and throwing it again”), and a surface that threw the whole formula the moment it is touched would have nowhere left to put either. A roll is also not something to start by accident — it replaces a result somebody may still be reading.
Decision 105; what is still to tune is docs/TODO.md, Step 5.8.
Shaking a box is not throwing dice. Put side by side with a slow-motion film of four real d6 being thrown, the tray showed the difference plainly: the real dice leave a hand with height, speed and spin, land hard, bounce several of their own heights and tumble over two to four faces before they stop. On the Pixel 10a, four d6 shaken in the tray raced about while the phone moved and were all still about 0.3 s after it stopped — the last stroke braked them into a wall or each other, and nothing was left to tumble with.
So the shake is as it was, and the hand tosses the dice when it stops. The dice are spawned when the shake begins and rattle about the tray under it, in sight, as they always did (“Shake input”) — the first version of the toss, tried on a branch, took them off the table while the phone was shaken, and the owner rejected it: with nothing on the table, nothing said the shake was reaching the dice. Then:
ShakeDriver.lettingGo): 8 steps, a fifteenth of a second, after the last
hard moment — over the shake detector’s 0.6 g and at least a quarter of the
recent peak — and never before the shake has gone on for 0.3 s. A slow
shake waits for its own rhythm (ShakeDriver.quietToLetGo): a hand that
has already gone quiet between two of its strokes for longer than that has
to stay quiet half as long again before it has stopped, up to 0.3 s. Far sooner than the detector’s 0.4 s of quiet, which is right
for deciding a roll may end and too late for a throw. Once per throw, and
for good: a shake after the toss drives nothing and tosses nothing. Two
other rules were tried on the Pixel 10a and dropped. Let go only after the
hand had fallen under 0.6 g, a hand braking from several gravities was let go
of a quarter of a second after it stopped, from a heap the stop had slammed
into a wall, and the dice jumped. Let go once it fell under a quarter of its
own recent peak, slow strokes along the phone’s length counted as stopping —
six let-gos in one throw, the dice tossed and grabbed back on every stroke,
each a hop and a lurch from real speed to slow motion and back. And with
the fixed wait alone, a slow shake was let go of mid-shake: at two strokes
a second the acceleration stays under 0.6 g for about a tenth of a second
in the middle of every stroke, the hand let go there, and the dice settled
while the phone was still being shaken (the owner, 2026-10-20).Release.of). The
moments are the phone’s acceleration in the tray’s frame; summed over time
they are its velocity, with the drift taken out as a straight line because a
phone picked up still and stopped still begins and ends at rest. The throw
is the last stroke in the final 0.35 s before the hand went quiet — the last
run of moments at 60 % or more of the fastest there, and its peak — not the
fastest moment, which in a window one stroke long is as likely to be the
stroke before, going the other way. The dice leave at 1.5 times the hand’s
speed, held to 600–1,600 mm/s, tapered for a crowded tray as the spawn
throw is. A shake straight at the glass has no direction along the floor; its
toss takes one from the seed.Toss): up, at the speed that
would lift it 15–60 mm, and along the stroke within 17° and 15 % of its
speed, with a fresh spin of 60–120 rad/s. Its pose is kept exactly. One
after another: each die leaves the hand on its own step, within a tenth of
a second of the let-go (Toss.Handful). And onward: a die already
sliding is tossed the way it is going, at the hand’s speed or its own if
faster; only a die barely moving takes the stroke’s direction — tossed along
the stroke whatever it was doing, a sliding heap reversed. And never into
a wall it is against (Toss.offTheWalls): within 30 mm of a wall, the part
of a toss aimed at it is turned back. A stroke that stops slams the heap into
the wall it was heading for, and tossed along the stroke the dice bounced off
it as one. Tossed on one step and lifted
40–90 mm, every die hopped together and the owner saw the dice jump. Every
die, because every die is still in the hand: no die is read before the table
has settled and the hand is off it, and a die lying still under a hand that
is still shaking is a roll caught at the top of a swing, not a die at rest —
nothing touches a die that has come to rest, and none has.It is a function of the record and nothing else: the let-go step and the toss
are read off the moments ShakeDriver already files by step, so a live roll and
its replay toss on the same step, the same way. A throw thrown by a shake says
so (ThrowSpec.toss); a throw recorded before there was a toss — every golden
case — does not, and replays exactly as it did.
Measured on the Pixel 10a with tools/harness.sh --release 1200, which shakes
each roll with a synthetic hand (HandStroke: four strokes along a line the
roll’s seed draws, then quiet) and tosses it as a live one is, 500 rolls a row:
| re-thrown | median / p99 settle | deepest overlap | turns after first touch | |
|---|---|---|---|---|
| 4d6, grid throw, no shake | 0.20 % | 0.80 / 1.03 s | 4.8 mm | 2.96 |
| 4d6, shaken and tossed | 0.40 % | 1.53 / 2.13 s | 5.8 mm | 10.5 |
| 20d20, grid throw, no shake | 1.17 % | 0.96 / 1.99 s | 5.9 mm | 2.67 |
| 20d20, shaken and tossed | 3.93 % | 1.81 / 2.86 s | 6.4 mm | 9.1 |
The settle times include the synthetic shake, about half a second, and the
turns include the tumbling under it, so neither column is yet the figure that
matters — the turns after the hand lets go (docs/TODO.md, Step 5.8). What
does read straight off: twenty dice tossed one way pile against the far wall
and are thrown again three times as often, so a crowded toss needs less speed.
On the phone, four d6 shaken by hand rattled in sight while the phone moved,
left the felt as it stopped and tumbled for about a second before they rested. The owner’s first verdict, on the phone: “The tumbling looks much better.
Dice twirl around and tilt over.”
Every constant here is a first guess, to be set from the owner’s recordings of
real throws.
There used to be two: a custom accessibility action on the table, Throw the
dice, and Enter in the formula editor. Both are gone (docs/architecture.md,
decision 66). The editor’s key says Done and only closes the editor; the
table carries a spoken description of what is on it and no action. Nothing in
Settings turns shaking off either, because with it off nothing could roll.
The cost is stated rather than hidden: somebody who cannot shake the phone —
including a TalkBack user who cannot also shake it — cannot start a roll. The
owner accepted that as the price of the throw being the hand’s
(docs/architecture.md, “Accessibility”).
A button said “Roll”. With no button, the plate in its place says what rolling
would get you: the lowest the formula can come to, the highest, and the exact
average. The ends are RollBounds, the same calculation the counting plate
draws mid-roll, asked of a throw that has read no dice at all; the average is
core/probability’s exact distribution (docs/probability.md). A formula too
large to graph exactly keeps its range and loses only its average.
It is on the screen for as long as a shake is the next thing that happens,
and in exactly one place at a time. Before the throw it is on the ready plate;
while the dice are moving it is the counting plate’s live range; on the three
plates a roll can wait on — a chain that earned a throw, dice nobody could
read, a throw that gave up — it is that same live range under a STILL TO
COME kicker; and once the dice
have landed it is in the result sheet’s grip, beside the total, where it
survives the sheet being pushed down.
That last move is the fix for what the second device session found: during a chain of re-rolls the figures appeared for an instant and were then covered by the result. They were under the breakdown, so they went away with it, and a player deciding whether to shake again was left with nothing to decide with (see “What is drawn over the table”).
A player who does not like how a die landed picks it up and throws it again.
That is the third way a die can be thrown, and it is the only one that is not
the app’s idea. The tray’s one-finger tap is spent on it, and on nothing
else — which is why a tap on the biggest target on the screen still does not
roll. The finger picks; the shake throws (docs/architecture.md, decision
68): a finger that threw would be a throw no shake started, which decision 66
rules out for a roll.
It is not the invisible hand. The rule further down — nothing touches a die that has come to rest — is about the app reaching into a finished roll, and what makes that intolerable is that nobody sees it happen: the player is shown a number that was arranged rather than rolled. A hand is the opposite in every particular. The player chooses the die, watches it go, watches it land, and the face that comes back is the physics’ exactly as the first one was. The die is still never shoved, never tilted and never snapped to a face. It is thrown, in front of everybody, which is what a person does at a table.
That is the easy half. The hard half is that a feature like this can become the invisible hand by accident, and the rest of this section is what stops it.
There is one way to throw dice in this app and one path to a number
(docs/architecture.md, goal 1), and the throw a hand asks for already exists:
it is the one 8d6! makes when a six comes up. One die, into a tray that
already has dice at rest in it, seeded from the roll’s own seed through
Seeds.derived, dropped rather than hurled into the floor ClearSpace picks
out — and with none of the settled dice in its world. That is how the rule
is kept here, exactly as it is kept there: not by tuning a spawn until it
usually misses the pile, but because there is nothing in that world for a die
to hit (see “The dice an explosion or a reroll adds”). The settled dice travel
as ThrowSpec.among so the picture is honest too, and the renderer puts them
back where the simulation left them and never moves them again.
flowchart LR
shake["Shake"] --> spec["ThrowSpec"]
chain["An explosion or a reroll<br/>(ThrowSpec.among)"] --> spec
unread["Dice a throw could not read,<br/>thrown by the next shake<br/>(ThrowSpec.among)"] --> spec
stalled["Dice a throw gave up on<br/>(ThrowSpec.among)"] --> spec
hand["Dice a finger picked,<br/>thrown by a shake<br/>(ThrowSpec.among)"] --> spec
spec --> sim["DiceSimulator"]
sim --> faces["The faces, and where each die stopped"]
faces --> score["Scoring, which decides no number"]
faces -.-> chain
faces -.-> unread
faces -.-> hand
So there is nothing to invent, and nothing new to keep in step with power-saving mode: a hand re-throw is a throw, and it settles, is read and is scored the way every other throw is.
It stands, and it counts. The die is not picked up in the literal sense —
nothing moves it — so it stays where it fell, and its face stays in the
breakdown, struck through, with the new die listed beside it. That is not a
special presentation invented for this: it is exactly what r n already does
(docs/dice-notation.md, “The order modifiers are applied in”), and a player
who has seen 4d6r1 has seen it.
It also stays in the statistics. docs/statistics.md’s rule for a dropped die
is that it is still counted, “the die was thrown and landed on that face”, and
a die thrown again is the same case: two throws happened, so two throws are
counted. A die that was counted once would be a histogram quietly missing the
rolls somebody did not like, which is the exact shape of a lie about fairness.
The roll itself is rescored, not re-rolled. Scoring is pure — the same
faces always give the same total — so it is run again from the beginning with
the new face in hand, which is what the tray already does every time an
explosion lands (RunningScore). Nothing about the other dice changes, because
nothing about the other dice happened.
Two decisions, and neither of them belongs inside a gesture.
Which die the finger is on is arithmetic over the camera and the places the
dice stopped: a ray through the touch point, against the ball around each die
at the scale the capacity rule threw it, nearest to the camera first. It is the
inverse of TrayCamera and it lives beside it, as TrayPick, with JVM tests
that project a die through the frustum it was drawn in and ask for it back
(docs/architecture.md, decision 62). Where two dice lie against each other,
the answer is the one the player can see, and the answer to that ambiguity is
the pinch: looking closer separates them on screen.
Whether that die may be thrown again is a question about the formula, and
it is the one that keeps this from becoming the invisible hand. 8d6! threw a
seventh die because the sixth came up six; 4d6r1 threw a fifth because the
second came up one. Those dice are lying in the tray, their faces have been
read, and they are in the statistics. Throwing the die that called for them
again would leave the roll holding a die the formula no longer asks for — and
the only two ways out of that are to take a die off a table nobody threw it
off, or to keep a die whose reason has gone. Both are the app moving dice
behind the player’s back.
So the offer stops at the group: a group carrying ! or r n offers
nothing, and every other group offers all of its dice, including the ones
kh/dl struck through — which are the dice a player most wants to throw
again, and which cost nothing, because which die a keep/drop leaves out is
arithmetic over the faces and is redone from whatever faces there are. That is
PickUp, beside GroupRoller, which is what built the chains it refuses to
guess at. It is conservative on purpose: a die it refuses is a die the player
throws again by shaking the whole roll again, and a die it wrongly allowed
would be a roll the app had rearranged.
Yes, and it is deliberately the player’s business, exactly as it is at a table. The app is not a referee and has no way to be one: it cannot see who is at the table, what was agreed, or whether the die went off the mat. What it can do — and what makes a hand at a table tolerable in the first place — is make sure the throw is visible and that the record does not flatter it:
What the app must never do is let a re-throw cost nothing to the record, and that is decided (below).
Once a roll has a total, one finger on a die picks it up. A tap — one
finger, down and up again within the touch slop and before a long press would
fire — is read through TrayPick against the camera, the pinch and the lean
the picture was drawn with, and PickUp says whether that die may go; the
machine keeps the pick (RollMachine.pick). A second tap on the same die
puts it back (docs/architecture.md, decision 76). A finger that wanders, a
finger held down and any gesture a second finger joins are not taps: the last
of those is the camera’s. A tap is a tap only once the double-tap timeout has
passed without a second — two taps in quick succession clear the controls off
the table instead (“Clearing the table”, decision 83) — so a pick shows about
300 ms after the finger lifts, and the first half of a double tap never picks. A tap on the bare floor, on a die no hand may go
near, or before the roll has a total does nothing.
Picking moves nothing. The die stays exactly where it lies; what changes is
a ring drawn round it over the picture, in the accent on a halo of the plates’
ground, and a shake prompt over the tray — “Shake to throw the 2 picked
dice”, and under it “Tap a ringed die again to put it back” — announced as it
changes, and gone with the shake (docs/architecture.md, decisions 76 and
84). The tray’s spoken description says the count too.
A pick that would need more clear floor than the tray has left is refused, for
the reason a chain stops at TrayFull: the new die is dropped into clear floor
and there would be none.
stateDiagram-v2
[*] --> Settled: every die read
Settled --> Settled: tap a die (pick, or put back)
Settled --> Rolling: shake with dice picked (those dice only)
Settled --> Rolling: shake with nothing picked (the whole roll again)
Rolling --> ThrowAgain: a die lands cocked
ThrowAgain --> Rolling: shake (the unread dice)
Rolling --> Settled: rescored, old face struck through
The next shake throws the picked dice, and only those
(RollMachine.throwPicked), through the path the unread dice and an
explosion’s die already take: a world of its own, seeded from the roll’s seed
and the count of hand throws (Seeds.byHand), every die still down carried as
ThrowSpec.among — the picked one among them, drawn where it lies — and the
new die dropped into the clearest floor. A shake with nothing picked throws the
whole roll again, as it always has.
Unread dice come first. Nothing can be picked until every die of a throw is read: a roll waiting on dice that landed cocked (decision 70) has no total, so it offers nothing to pick, and the shake it is owed throws those. A throw by hand that lands a die cocked waits the same way before it is scored.
The roll is rescored, not re-rolled. When the new dice are read their faces
replace the old ones in the faces the roll is scored from, the scoring runs
again from the beginning, and PickUp.withEarlierThrows puts each replaced
face back in front of its replacement, struck through, exactly as r n shows
a reroll. A die thrown again lies on the felt beside the one it replaced, and
only the newer of the two can be picked again.
The history keeps every throw of the die and one total for the roll. The
roll is written down when it first lands; a throw by hand amends that same row
with the new total and breakdown and counts only the dice thrown again, once
more each (docs/statistics.md, “A die thrown again by hand”). A d6 that
went 6, 6, 6, 4 contributes four readings to its own fairness figure, because
it really did land on those faces four times, and the roll contributes one
total.
Measured on the Pixel 10a and worth knowing, though it is now history rather than advice: while there was still a correction ladder, the reason a shaken throw ended up spread across the tray rather than packed into one end was not prevention. It was two accidents.
The first was the bias. A bias always carried a little upward, so it was what lifted a die out of a pile; take it away and the dice stayed where the shake put them. It has been taken away — there is no bias any more — and the dice do now stay where the shake puts them. That is no longer treated as a failure: a cluster of dice that can all be read is what a sideways shake looks like, and a heap that cannot be read is counted and left for the player’s shake to throw again rather than spread by a hand nobody can see. The second is the solver’s own error. At 1/120 s a die travelling a metre a second crosses half its own width between collision checks, so two dice are first seen already deep inside each other and are pushed apart hard. Resolve collision in sub-steps and that stops happening — and the dice were expected to pack, because the popping apart was doing the spreading.
That expectation has now been measured, and it was too pessimistic. The solver takes two collision steps per simulation step, and on 200 rolls of 20d20 on the Pixel 10a it costs nothing that shows: no die is left standing on another either way, and what it buys is the deepest die-into-die overlap falling from 9.9 mm to 6.6 mm and the re-throw share from 3.1 % to 2.8 %. It does take 17 % off how far the middle die turns after it lands — 1.72 turns to 1.43 — which is the packing the old reasoning feared, showing up as less tumbling rather than as a heap. The throw buys that back (“How hard the dice are thrown” below). A step costs 0.37 ms against a budget of 8.33.
Four and eight were tried against the livelier throw, and two stays
(docs/architecture.md, decision 77). On the Pixel 10a, 2026-10-04, the
harness’s 20d20 over seeds 1–3 (1,000 + 5,000 + 5,000 rolls) and 60d20 over
1,000:
| collision steps | 2 (kept) | 4 | 8 |
|---|---|---|---|
| deepest overlap, 20d20 | 6.94–7.90 mm | 4.66–4.98 mm | 2.51–2.67 mm |
| dice re-thrown, 20d20 | 1.19–1.23 % | 1.20–1.22 % | 1.20–1.38 % |
| median / p99 settle, 20d20 | 0.95–0.96 s / 1.94–1.96 s | 0.98 s / 1.98 s | 0.98 s / 2.00–2.03 s |
| turns after landing, 20d20 | 2.69–2.72 | 2.62 | 2.79–2.80 |
| p99 step, 20d20 / 60d20 | 0.18–0.23 / 0.46 ms | 0.23–0.27 ms / — | 0.37–0.42 / 1.63 ms |
| 60d20 overlap / re-thrown / median settle | 6.17 mm / 2.62 % / 1.67 s | — | 2.45 mm / 2.87 % / 1.75 s |
| shaken 20d6 seeds ending clustered | 7 of 16 | 7 of 16 | 7 of 16 |
The packing that sub-stepping was feared for did not happen: the same
seven of sixteen shaken throws ended in one end of the tray at every count, and
the dice turned no less. What eight steps cost is small but the same on every
seed — two to four steps more to settle at twenty dice, ten at sixty, and a
quarter of a point more re-throws at sixty — and it still leaves the overlap at
twelve times the 0.2 mm bar. So the count was not changed: a change that moves
every golden case and wants the fairness run again has to meet a target, and
this one cuts a miss to a third and still misses. Eight is one constant away
in World::Step if the owner, watching the tray, finds the overlap visible
(docs/TODO.md, 5.4).
Both are measured, with numbers, in docs/TODO.md (Step 5.5). The point for
anyone changing this file is that the re-throw rate and the overlap depth
cannot be fixed independently of deciding what a sustained sideways shake should
do to a tray of dice, which is an open question below. A tray of dice under a
1.8 g lateral drive packing against the far wall may well be right — it is what
a hand does — but until that is decided, a change that improves the overlap will
look like a regression in how a shaken roll reads. (Collision sub-steps no
longer do: at the current throw they leave the shaken heap exactly where it
was, as the table above says.)
The hand also lets go (“The hand lets go”, under “Starting a roll”; decision 105). Everything below holds while the phone is shaken; a moment after the last hard stroke the hand tosses the dice and drives them no more for the rest of the throw.
SENSOR_DELAY_GAME. Registered while the roll screen is resumed and let go
when it is not — an accelerometer running behind a backgrounded app is a
battery bill for nothing.KeepTheScreenAwake (HoldTheScreenStill.kt)
sets View.keepScreenOn for as long as the screen is composed and clears it
on the way out — the view’s flag rather than the window’s, so leaving the
screen gives it back by itself. Only this screen: the rest of the app is
reading and scrolling, which is what the system timeout is for. It is not a
wake lock, needs no permission, and does not keep the display on once the
app is not in front. There is no setting for it, because the only thing a
setting could offer is a display that goes out in the middle of a throw.PhoneAxes maps a sensor vector into the tray using
Display.getRotation(), so a screen held at the rotation it opened at
reports an upright phone while it is being shaken upside down. The map is
right and is handed a lie, and the dice pool at the end away from the hand
rather than the end towards it. The screen asks to keep its shape instead —
a quarter turn still refused, because that rebuilds the table; a half turn
allowed, because it does not (docs/tables.md).ShakeRecorder counts steps from the start of the shake at
the simulation’s own 120 Hz, which is a wall clock; the world counts the
steps it has actually taken. The two are not the same clock — a watched roll
is paced (“The simulation clock”), and a frame that ran long drops steps
either way — so ShakeDriver.add files each arriving sample on the step the
world will take next and rewrites the sample to that step. The record it
hands back is therefore what actually drove the roll, and replaying it drives
exactly the same steps. No gate, no waiting, and the live roll and its replay
are the same roll.A second shake at dice still in the air keeps them moving. A hand that shakes again has not waited for the dice to stop, so that shake starts no throw: nothing is spawned, nothing replaces the roll in progress, and its moments simply join the ones already driving it. The roll also cannot end while it lasts, because the settle rule waits on the last sample and there is now a later one.
The mechanism is one thing, and it is the thing that was wrong. A second
shake that numbered its moments from zero named steps the running roll had
taken a second earlier, and ShakeDriver never reached them: shaking the
phone at moving dice did nothing at all. Two things keep it reaching them
now. A shake that begins while a roll is running goes on numbering from
where the first one left off, and only a shake that actually throws dice
starts the clock over — SensorShakeSource is told which it is, asked per
sample because a roll may settle between two readings. And the driver files
every arriving sample on the step the world is about to take, whatever
number it came with, so no amount of drift between the two clocks can put
the hand out of reach.
The pace comes off the moment the hand is back. A roll being watched is slowed so the dice can be seen to land; a roll being driven is not, and a second shake makes it driven again on the frame it starts (“The simulation clock”). The dice answer the hand immediately, and the slow motion returns a tenth of a second after the last reading.
What it deliberately does not do is replace the roll. The dice are the ones already tumbling; a second shake is more of the same throw, which is what it is at a table.
SENSOR_DELAY_GAME is about
50 Hz and the simulation runs at 120, so most steps have no reading of their
own. A step with no reading keeps the last one rather than falling back to
plain gravity: an arm does not stop between two moments of a shake, and
letting the force go on every step without a sample drove the dice on two
steps in five and let them coast through the rest. The hold lasts a tenth of
a second, which is long against the gap between readings and short against a
shake — so a shake that has actually ended stops driving and the dice come
down.ThrowSpec goes into the world empty — the
dice are spawned the instant the shake is confirmed and the moments arrive
afterwards — so the spec a roll started as is not the spec that would
replay it. The moments are accumulated where they land, in ShakeDriver,
which is also where they are deduplicated by step and kept in step order; the
roll reports them as drivenBy, the tray hands them back beside the outcome,
and the roll screen writes them into the spec it kept: spec.copy(shake =
drivenBy). What comes out is one object that rolls these dice again, rather
than a spec and a list of samples that somebody has to join up.
HistoryEntry has no seed on it to show and the exports have no
column for one, so neither has anywhere to put a shake
(docs/architecture.md, decision 13, and docs/statistics.md). The record
is for a bug report about a roll that is still in front of you.ShakeRecorder and ShakeDriver stop there.ShakeThresholds).
Two thresholds rather than one, with a gap between them: a single threshold
would flicker on and off through the quiet moment at the top of every swing.
A session that runs past 30 s is ended anyway — it has stopped being an
input.+x across the screen to the right,
+y up it, +z out of the glass. The tray’s long side is the screen’s long
side and runs along +x, its short side along +y — so the two frames are a
quarter turn apart before the phone is turned at all, and the roll screen
holds its shape but not its rotation, so the map follows the display’s.
Used straight,
a sideways shake loads the dice along the length of the tray and the dice
move in a direction with nothing to do with the hand. PhoneAxes is the one
place that map lives, and both the accelerometer and the gyroscope go through
it.GravityTracker starts each shake at “straight down relative to the screen”
and integrates rates with nothing to re-anchor them, so a vigorous shake
could leave down pointing sideways in the tray — and it stayed there for the
rest of the roll, because the last sample’s direction is the one that sticks.
A tray whose down points at a wall is a chute: the dice slide into it, pack
against it and stop tumbling, which is what a hundred d6 heaped into one
corner looked like on the Pixel 10a. Twenty dice shaken along the tray’s own
length end in a heap at every seed tried with the tilt and at none without
it.docs/TODO.md, Step 5.6).The wrist’s swing is taken out of the shake before it is recorded
(SwingCorrection, docs/architecture.md, decision 87). The owner, on the
Pixel 10a: “Dice tend to group at the top of the phone. When shaking up and
down, left and right, and back and forth.” A hand does not only carry the
phone, it swings it about the wrist, and the wrist is below the sensor.
Anything turning about a point it is not on is pulled towards that point by
ω × (ω × p), p running from the pivot to the sensor — a pull that goes
with the square of the rate of turn, so it does not reverse with the
stroke: left or right, up or down, towards the player or away, every stroke
pulls towards the bottom of the phone. Applied inverse, it is a steady push
up the tray. The tray here does not turn (“The gyroscope no longer turns the
world”, above), so the one part of a swing only a turning tray would feel
does not belong in it.
The pivot depends on the grip, so it is estimated from the shake itself: the
gyroscope gives ω, each acceleration sample is one equation
a ≈ (ωωᵀ − |ω|²I)·p, and p is their least-squares answer, started afresh
with each shake’s first hard moment and weighted by the time each sample
stands for, so a faster sensor is not more certain. The carrying and the
tangential part of the swing reverse every stroke and fall out of the fit;
what is subtracted is the pull for the rate of turn now. A phone that has
stopped turning has nothing taken away — which is why this is not a
high-pass filter, which would answer the hand stopping with a kick the other
way. The detector still hears the shake as it was, so nothing about what
counts as a shake changes, and the record holds the corrected moments, so a
roll replays from its record as before.
Measured off the phone, through ShakeSession and the linux-x86_64 build of
the bridge the coin replay used (“Why a die could rock for ever”): 300 throws of 10d6 per row, each a different
synthetic hand — 2.5–4.5 Hz, four to seven strokes, carried 20–50 mm, swung
10–25° about a pivot 70–150 mm below the sensor, sampled at 50 Hz. “Where”
is the dice’s mean position along the tray, −1 at the bottom wall and +1 at
the top; one die in three in the top third is an even spread.
| shake | where, before | top third, before | where, after | top third, after |
|---|---|---|---|---|
| none | −0.01 | 35.5 % | −0.01 | 35.5 % |
| carried only, any of the three directions | −0.02 to −0.01 | 31.6–35.0 % | the same | the same |
| swung, left and right | +0.22 | 51.6 % | +0.02 | 36.2 % |
| swung, up and down | +0.16 | 45.3 % | −0.01 | 34.6 % |
| swung, back and forth | +0.16 | 47.4 % | +0.01 | 36.2 % |
| swung, any direction and axis | +0.28 | 55.1 % | +0.00 | 33.4 % |
| swung 20–50°, any direction and axis | +0.51 | 74.3 % | +0.01 | 34.0 % |
A shake that only carries the phone was never the problem: it leaves the
dice spread with and without the correction, and is passed through
unchanged. The stiffness of the estimate (SwingCorrection.DAMPING) was
picked from these runs: a tenth of it pushed the up-and-down shake’s dice
towards the bottom, three times it left a sixth of the pull in and thirty
times two thirds. The hands are synthetic; with a real one, on the Pixel
10a, the owner found the dice no longer pool at the top of the screen.
A roll is only honest if the dice roll. v0.1.1’s did not: they arrived,
gripped the felt and stopped, which reads as a number being placed on the
table rather than thrown onto it.
Nothing could see it, and that was the real fault. Every bar the harness scored was met by those rolls — they settled fast, never stacked, never ran out the cap — because settle time cannot tell a die that tumbled from a die that landed flat and slid to a halt. So the measurement came first.
Tumble (simulation/api) counts how far a die turns after it first
touches the table, in whole turns, and the harness scores the middle die of
each roll against a floor of one turn — its only target that is a floor rather
than a ceiling, for the reason above. The spin a die is given in the air is a
constant somebody chose, so counting that would be marking our own homework;
what nobody sets directly is how much of it survives the landing. One turn is
the bar because that is about what it takes to watch a die topple off the face
it landed on onto the one it is read from.
Like RollDiagnostics, it is a reading and never an input: nothing it
computes reaches the solver, so the same seed comes to the same faces whether
or not anybody is counting turns. A die that has been read stops counting, so
one slow neighbour cannot make a throw look worse than it was; a die the player
throws again is a throw of its own and is counted there. A roll of several
passes reports its first pass’s figure, which threw every die.
And it is a throw, not a drop. A die leaves the hand at up to 1100 mm/s sideways with 60–120 rad/s of spin, from 60 mm above the floor. Those numbers were chosen against the figure above rather than by eye: at the 250 mm/s it used to be, a die travelled about 34 mm before it landed — it came down roughly where it was let go, with nothing left to turn into tumbling.
A handful is thrown; a hundred is tipped in. The sideways speed tapers with how many dice are in the tray, from the full throw at one die to a quarter of it at the capacity rule’s hundred. A tray that is already full has no floor to tumble across, and throwing each of a hundred dice at a metre a second piles them against a wall: a hundred coins, the flattest shape in the catalogue, stacked 28 deep at a bound of 10 before the taper existed.
The taper reads the count, not the room in a die’s cell, and that was measured the wrong way round first. Cell slack sounds like the better measure, because it is what actually says whether a die has anywhere to go; but twenty d20 have barely a third of a radius of slack each and throw perfectly well, so tapering on slack throttled the ordinary roll to under half speed and gave back most of the tumbling, while a hundred coins — whose slack is a rounding error — stayed stacked either way. What separates the two cases is how many dice are in the tray.
Measured on the Pixel 10a, 200 rolls of 20d20, before and after the four changes (the throw, the taper, the restitution floor and two collision steps):
v0.1.1 |
now | |
|---|---|---|
| turns after landing (middle die) | 0.89 | 1.52 |
| deepest die-into-die overlap | 9.02 mm | 5.04 mm |
| dice re-thrown | 3.20 % | 2.20 % |
| median settle | 0.78 s | 0.81 s |
| p99 settle | 1.48 s | 1.47 s |
| p99 step time | 0.32 ms | 0.37 ms |
Every bar is better than it was or unchanged; the dice simply roll now. The two that still fail — the re-throw share and the overlap depth — fail by less than they did, and both were failing before any of this.
Then the owner held it and said it stopped too soon. “The tumble feels a bit too quick and stops too early; the dice do not have enough initial spin.” Each candidate was measured on the Pixel 10a over 1,000 rolls of 20d20 (seed 1), and the spin turned out to be the smaller half of the answer:
| turns after landing | median / p99 settle | re-throws | overlap, deepest / p99 roll | |
|---|---|---|---|---|
| 37.5–75 rad/s, restitution 0.3 (before) | 1.55 | 0.82 s / 1.53 s | 2.65 % | 5.29 / 4.67 mm |
| 60–120 rad/s | 1.67 | 0.83 s / 1.50 s | 2.76 % | 5.86 / 5.12 mm |
| 60–120 rad/s, restitution 0.4 | 2.01 | 0.84 s / 1.57 s | 1.87 % | 6.75 / 5.47 mm |
| 60–120 rad/s, restitution 0.5 | 2.42 | 0.91 s / 1.70 s | 1.47 % | 6.68 / 5.45 mm |
| 60–120 rad/s, restitution 0.55 (shipped) | 2.70 | 0.96 s / 1.76 s | 1.18 % | 7.76 / 5.68 mm |
| 60–120 rad/s, restitution 0.6 | 2.97 | 1.01 s / 1.85 s | 0.93 % | 6.30 / 5.62 mm |
| 37.5–75 rad/s, restitution 0.55 | 2.55 | 0.95 s / 1.77 s | 1.38 % | 6.98 / 5.31 mm |
More spin on its own is mostly spent in the air and on the first contact; what had been ending the tumble was the bounce. A die that rolls over an edge lands on the next face, and at 0.3 each of those landings took most of what was left. At 0.55 it keeps enough to roll over the next one. The dice also come apart better — the re-throw share halves — at the cost of about a millimetre on the typical roll’s deepest overlap. 0.55 rather than 0.6 because the larger throws pay for the last tenth: at sixty d20 one roll in a thousand took 10.5 s against a twelve-second cap (none gave up), where 0.5 kept its slowest at 2.4 s.
Two other things were tried and dropped. Die-on-felt friction (0.5 against
0.65) moved nothing. A forward roll added to the spin in the direction of the
throw — what a hand puts on a die it bowls — gained nothing at half the rolling
rate and, at the full rate, gave a roll enough energy to run out the cap.
RollPace.WATCHED stayed at 0.5 for this table: the roll lasts longer
because the dice are doing more, and slowing the picture as well was a
judgement for the phone, not one this table could make. Judged there, the
tumble was right and the travel still a little fast, so the pace went to 0.4
(“The simulation clock”) and none of the numbers above moved.
None of it touches a die that has come to rest. What changed is what a die is given before it lands, what it is made of, and how well the solver resolves what happens after — which is the only half of this the app is allowed to work on.
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. A die is never force-settled: if the 12-second backstop is reached first, the roll gives up without an outcome for the dice still moving, and the screen offers those dice back (“A roll that cannot finish says so”, below).
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:
docs/dice-sets.md, “The d4”); one
found on an end has no face within fifteen degrees of up and is cocked. It
replaced the tetrahedron, which had no face on top and was read from the
corner pointing up (decision 109).RollPlan. 0 + 0 reads as 100.Two 60d20 rolls in 10,000 (tools/harness.sh -n 10000 -c 60, seed 1:
rolls 7196 and 8091) ran the whole twelve seconds without a re-throw — the
dice never came to rest, so nothing was ever counted. Replayed on the Pixel
10a with every die’s motion logged over the last second, each had one die
moving and fifty-nine stopped dead (0.000 rad/s):
| roll | the die | what it was doing |
|---|---|---|
| 7196 | 8.8 mm up, touching the tray but not the floor, on no die | rocking with a fixed beat of about 0.2 s: up to 7.4 mm/s and 5.9 rad/s, ±3°, between a readable face and 15–21° cocked |
| 8091 | 10.9 mm up, against the tray and on another die | trembling in place: under 1.4 mm/s, 0.1–0.8 rad/s, ±0.5°, cocked 18–19° |
Both were 5 mm from the surface of the post that stood in each corner. The rounded corner was built as a whole cylinder tangent to both walls, which is not a fillet but a post 24 mm across, standing on floor the tray mesh draws as open. A die leaning on it and on a neighbour has one contact on a curve and nothing flat to settle into. The third-slowest roll of that run, 8926 at 1,153 steps against a median of 106, was the same picture.
The evidence that it was the post and not the throw: those dice’s positions,
rebuilt at rest in a fresh world, did it again — roll 8091’s die trembled to
the cap. On that frozen scene, applying restitution only above 50 mm/s instead
of 10 and doubling the solver’s iterations did not stop it; a flat-faced
post stopped it in eight steps. So the corners are now what docs/tables.md
says they are — six flat wedges to the quarter, the outline the mesh draws —
and the three rolls are kept in StuckRollTest, which throws them as the
harness does and wants them settled. All three now are.
Measured on the Pixel 10a, 10,000 rolls of each at seeds 1–5 (50,000 a count), before and after (decision 81):
| 20d20 before | 20d20 after | 60d20 before | 60d20 after | |
|---|---|---|---|---|
| rolls that gave up | 1 | 2 | 9 | 2 |
| rolls of 600 steps or more | 1 | 2 | 13 | 7 |
| dice re-thrown | 1.19–1.23 % | 1.03–1.06 % | 2.62–2.67 % | 2.20–2.22 % |
| settle, median / p99 | 0.95 / 1.95 s | 0.96 / 1.95 s | 1.67 / 2.01 s | 1.65 / 2.00 s |
| turns after landing | 2.70 | 2.66 | 2.09 | 2.10 |
| deepest overlap, by seed | 6.9–7.9 mm | 7.2–7.9 mm | 6.4–7.4 mm | 6.1–8.3 mm |
The re-throw share falls with the post because a die propped on it was
cocked. Every golden case moved and was re-recorded, and FairnessTest at
20,000 a shape passes on the new tray with no throw given up (χ² 4.73 for the
d6, 20.36 for the d20, 0.03 for the coin; the d18 0.91 % off at its worst
face, inside its 1 % bound).
The coin FairnessTest lost was the same post. One coin throw ran out the
cap before the fillets (seed 5897839758308530927, throw 14,476 of the run). It
was replayed on the JVM against a linux-x86_64 build of the bridge, made from
the same sources with Jolt’s deterministic flags. That build reproduces all ten
golden cases exactly, and the first coin it gives up in FairnessTest’s seed
sequence is this one, the seed the phone printed. The trace:
The other coin give-up in that 100,000, -5535098112695128242, is the same
lean on the -x side of the same post, centre at (90, 46) mm. On the tray as it is now,
both coins land and are read: the first in 89 steps, face 1. Over the same
100,000 throws (FairnessTest’s SplitMix64 seeds 0–99,999) none gives up
(faces 50,145 / 49,855); the slowest throw takes 1,014 steps over three passes.
StuckRollTest keeps the seed and wants it settled within three seconds. So the
coin needs no physics of its own: what held it up was a surface the tray no
longer has.
What still never settles is a die that spins on an axle. Every one of the
four rolls that gave up after the change, and the four others traced from
before it, is the same picture: one die with its centre still to a hundredth
of a millimetre, turning at 1–31 rad/s while the rest have stopped — mostly
against a wall or up on other dice. Where its contacts were logged (roll 4518
of seed 2’s 60d20), it was held at two single points by two neighbours and
spinning about the line through them, and its spin was falling at exactly the
bodies’ angular damping and nothing more. Contact points on the axis do not
slip, so friction has nothing to work on; Jolt models no rolling or drilling
friction, and a damping of 0.02 a second would take minutes to stop it.
A real die cannot do this — its contacts are patches, not points, and its
corners knock — so the missing physics is drilling friction, and the open
question is how to give a die some without slowing the tumble it is thrown
with (docs/TODO.md, Step 5.5). Calling it at rest is not an answer: it is
visibly turning.
The app’s central claim is that the physics is the roll: no generator decides a face. That claim is worth nothing unless the physics turns out honest dice, and the only way to know is to throw a great many and count.
FairnessTest in simulation/jolt is that count. It throws every catalogue
shape headlessly — no renderer, no frame clock, no screen — and checks the
histogram two ways, because a loaded die can fail either:
Headless, but the same simulation a watched roll steps: the only difference is who asks for the steps (“Power-saving mode”), so a fairness result here is a fairness result for the app.
Every release is shown fair, and the release checks it. CI has no device,
so the hundred-thousand-roll run is made on the Pixel 10a before the tag
(tools/fairness.sh) and committed as fairness/record.txt, which carries the
fingerprint of the physics it measured. The release workflow refuses a tag
whose physics has no passing record of its own (docs/build-setup.md,
“Fairness before a release”). The ordinary device suite’s 200 throws a shape
only catch a die that never shows a face.
A throw that gives up is a figure, not a crash. A throw that is still
moving at the twelve-second backstop has no face to count
(SettleRule.HARD_CAP_SECONDS). Before the corner fillets, that was about one
coin in 50,000, leaning on the corner post (“Why a die could rock for ever”).
On today’s tray, 100,000 coin throws replayed on the JVM have given up none.
The run used to stop on a give-up and lose everything it had counted. Now each
one is a give-up: it is left out of both judgements above, because a
give-up is not a face and counting it as one would be the made-up number the
backstop exists to refuse, and it is reported per shape with its seed so the
throw can be replayed and watched. A run fails on give-ups only
past one in ten thousand throws, and never fewer than one
(FaceTally.GIVE_UP_SHARE in simulation/harness, tested on the JVM) — over
three times the worst the Pixel 10a has shown, and loose enough that the quick
run does not trip on the one it meets about once in sixty runs.
The roll count is an instrumentation argument, because the honest number and the affordable number are not the same. The default is small enough to sit in the ordinary device suite and still catch a die that is grossly loaded; the claim above needs the hundred thousand and is run deliberately:
./gradlew :simulation:jolt:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=de.drehtuer.dinfinity.simulation.jolt.FairnessTest \
-Pandroid.testInstrumentationRunnerArguments.rolls=100000
A rounder die is asked about the same way. Rounding is symmetric — every
edge of a solid is rounded by the same radius, so its faces stay alike and
nothing in the construction favours one — but a rounder die rolls on further
and cocks differently, and a claim about the built-in die is not a claim about
it. edgeRounding throws every shape at that share of its size instead of the
default, through the same check (HarnessRequest.edgeRoundingOf, refusing
anything outside what a set file may say); the harness takes the same with
tools/harness.sh --rounding 0.06:
./gradlew :simulation:jolt:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=de.drehtuer.dinfinity.simulation.jolt.FairnessTest \
-Pandroid.testInstrumentationRunnerArguments.rolls=100000 \
-Pandroid.testInstrumentationRunnerArguments.edgeRounding=0.06
Where the range stops, and why, is the next section.
1.5 % to 6 % of the die’s size, measured on the Pixel 10a on 2026-10-05
(DieMaterial.EdgeRoundingRange; decision 100). The face designer’s
Edges slider runs over exactly that range in half per cents
(docs/face-designer.md, “Material, colour and edges”; decision 98), and a
set file asking for more is clamped to it with a warning
(docs/dice-sets.md). Rounding is symmetric, so between two values that
pass nothing new can happen — the radius moves the bodies smoothly — and the
ends are the measurement. The range was 1.5 % to 12 % until this run, chosen
on geometry; the run said no to the top of it.
Fairness, every catalogue shape, 20,000 throws each
(FairnessTest, -e rolls 20000 -e edgeRounding <share>):
| Rounding | Coin throws that never settled (bar: 2) | d18 worst face (bound: 1 %) | Every other shape |
|---|---|---|---|
| 1.5 % | 0 | 0.50 % | fair |
| 6 % | 0 | 0.62 % | fair |
| 8 % | 2 — on the bar | 0.68 % | fair; the d10’s χ² 24.6 against 27.9 |
| 12 % | 42 — fails | 0.98 % | fair |
The coin is what gives first: rounded enough, its rim is a wheel, and a throw that leaves it rolling runs out the twelve-second backstop. The d18’s worst face drifts towards its bound as it rounds (0.39 % at the default). 6 % — twice the default — is the last value with both well clear, and 8 % is not a margin.
Settling, 200 throws of 20 dice each (tools/harness.sh -n 200 -c 20
-s <shape> --rounding <share>):
| long d4 | d6 | d20 | |
|---|---|---|---|
| re-thrown at 1.5 % / 3 % / 12 % | 3.88 / 3.68 / — % | 0.88 / 0.48 / 0.20 % | 1.10 / 1.28 / 1.20 % |
| median settle at 1.5 % / 3 % / 12 % | 1.30 / 1.01 / — s | 0.85 / 0.85 / 0.85 s | 0.96 / 0.96 / 0.94 s |
| turns after landing at 1.5 % / 3 % / 12 % | 2.65 / 2.70 / — | 2.44 / 2.52 / 2.86 | 2.64 / 2.69 / 2.78 |
The long d4 was measured after decision 109 (Pixel 10a, 2026-10-23), when
12 % was already past what a set may ask for. The tetrahedron it replaced was
re-thrown less (2.43 / 2.35 %) and settled sooner (0.76 s). Why, from the
harness’s breakdown of 20d4 at the default (“Why a die was thrown again”,
below; 2026-10-24): of 3.675 %, 1.500 % came to rest on another d4, 1.225 %
cocked against one and 0.950 % cocked against a wall — and none on the floor
alone, so a long d4 never rests on an end. A 20d6 loses 0.125, 0.125 and
0.225 % the same three ways. A long die stacks on another more than it leans
on one (docs/TODO.md, Step 5.3).
No throw gave up, none was forced to settle and no die came to rest on another at any of the nine; the overlap and re-throw bars fail at every value exactly as they do at the default (Step 5.4 and 5.5). Rounder dice tumble a little longer and are re-thrown less, so the harness would have allowed 12 % — it throws no coins.
To measure past 6 % again, widen DieMaterial.EdgeRoundingRange on a
branch first: the harness’s --rounding and FairnessTest’s edgeRounding
refuse anything a set file may not say, which is what makes their figures
claims about dice a player can have.
The seeds are stirred, not counted. A roll’s seed becomes a
kotlin.random.Random, and two seeds differing only in their low bits do not
give two independent streams — throws seeded 0, 1, 2, … start in orientations
spread more evenly than chance allows. Throws that are not independent break
the assumption chi-squared rests on, in both directions, so the harness puts
each roll number through SplitMix64 first. The roll number still decides the
seed, so a run is as repeatable as it was. That the seeds themselves need
stirring is a bug of its own, and has its own task (docs/TODO.md, Step 5.2).
The release record (fairness/record.txt, 2026-11-06): 100,000 rolls of each
shape on the Pixel 10a, 800,000 in about twenty-one minutes. Every count is the
same as in the record of 2026-10-24. The seeds are fixed, so that shows the
glowing dice and the dead-code removal did not move a single landing; it is not
a second sample of the dice:
| shape | χ² | limit | worst face off |
|---|---|---|---|
| coin | 1.00 | 10.83 | 0.158 % |
| long d4 | 12.11 | 16.27 | 0.420 % |
| cube | 2.86 | 20.52 | 0.158 % |
| octahedron | 7.48 | 24.32 | 0.244 % |
| pentagonal trapezohedron (d10) | 18.42 | 27.88 | 0.275 % |
| dodecahedron | 12.69 | 31.26 | 0.221 % |
| enneagonal trapezohedron (d18) | 267.30 | 40.79 | 0.609 % |
| icosahedron | 9.53 | 43.82 | 0.093 % |
The seven that pass sum to χ² 64.09 against 55 degrees of freedom, less than one standard deviation out — as fair as chance predicts. That is also what makes the eighth worth believing. (The tetrahedron the long d4 replaced measured χ² 0.34 in the run before, decision 109.)
The long d4 passes, but nearer its limit than any other shape, and its two pairs of faces, which are mirror images of each other, split 50,491 to 49,509: about three standard deviations. Two more runs of 100,000 on seeds of their own (2026-10-24, starting a million and two million rolls further on) put it down to chance:
| run | χ² | ±z faces | ±y faces | split |
|---|---|---|---|---|
| the release record | 12.11 | 50,491 | 49,509 | +3.1σ |
| seeds from 1,000,000 | 2.54 | 49,849 | 50,151 | −1.0σ |
| seeds from 2,000,000 | 3.46 | 50,122 | 49,878 | +0.8σ |
| all 300,000 | 5.18 | 150,462 | 149,538 | +1.7σ |
The second run splits the other way, and the three together are χ² 5.18 on three degrees of freedom, as fair as a die gets.
267 against a limit of 41 is not an unlucky run. The bias is reproducible: the same faces are heavy across three independent seed schemes, the deviation patterns of separate runs correlate at +0.6 to +0.9 where independent samples of a fair die would sit near ±0.24. Some faces come up 6 % more often than their share; no single face passes 1 %, which is why the worst-face bound does not catch it and the chi-squared test does. Both are in the harness for this reason.
An enneagonal trapezohedron is isohedral — its symmetry group carries any
face onto any other — and the dice start in an orientation drawn evenly over all
of them (SpawnLayout, Shoemake’s method). Turning the starting orientation by
one of the solid’s own symmetries gives a physically identical throw with the
face labels permuted, so a fair sample of orientations has to produce a fair
sample of faces whatever happens in between. A bias means the die the engine
collides is not the solid the arithmetic describes. What has been ruled out, each
measured on the phone rather than argued:
What the pattern does say: the deviations pair up antipodally. A face and the face opposite it move together, and they are the two readings of the same landing, so it is certain axes that finish vertical too often rather than certain faces that are sticky. Nine axes, spread from −4.4 % to +6.5 %.
The d10 is the same family of solid and is fair (χ² 5.29 against 27.88), so whatever this is, it bites when the kites get narrow. Finding it is Step 5.2.
The body is not it, and neither is the engine. Two things were suspected first and both have been measured rather than argued:
DieBodyTest asks the solver what it built. Every catalogue shape comes back
with exactly the faces it should have — the d18 with eighteen — every face at
the same inradius to six decimal places, the centre of mass on the origin,
and an inertia tensor with the solid’s own symmetry: isotropic for the
Platonic solids, two matching moments and one apart for the trapezohedra and
the coin. The die the engine collides is the die the arithmetic describes.So the solid is right and the solver treats it evenly. The throw was suspected next — a starting turn and the force it is thrown with come out of one stream, one after the other, and the symmetry argument assumes they are independent — and that is now measured rather than assumed:
Seeds, above), and the harness re-run on top of
that still gives the d18 χ² 135.86, against 197.34 before. The other
seven still pass, summing to 57.63 against 55 degrees of freedom;Three runs of a hundred thousand — two ABIs, three seed schemes — put the same faces on top: their deviation patterns correlate at 0.86 to 0.90, where independent samples of a fair die would sit near ±0.24. It is one fixed bias, not three unlucky samples.
The sharpest clue, and the one to start from next. A d18’s eighteen faces fall into two orbits of nine under the solid’s own ninefold turn, and the bias is almost entirely within those orbits rather than between them: χ² 94.2 and 40.5 against 8 degrees of freedom each, and only 1.34 against 1 between the two rings.
That is the combination symmetry forbids. A ninefold turn maps the solid onto itself, so it maps the throw onto the same throw with its faces relabelled; with starting turns drawn evenly, the nine faces of an orbit have to come up equally often. They do not, by a margin of one in 10¹⁶.
Every premise of that argument holds to the precision it was measured at — and
precision turns out to be the answer. The hull is exactly symmetric in double
arithmetic and reaches the engine as float32, where it is symmetric to about
one part in 10⁷; Jolt’s ConvexHullShape stores its points as Vec3, which is
single precision whatever JPH_DOUBLE_PRECISION does to body positions, so
there is no double-precision hull to compare against.
What can be done instead is to make the asymmetry bigger and watch what happens. Twenty thousand throws of each, with every corner of the hull moved by a random fraction of the die’s radius:
| hull | d18 | d10 |
|---|---|---|
| exact | χ² 52.2 | χ² 9.6 |
| every corner nudged by up to 10⁻⁶ | χ² 50.7 | — |
| every corner nudged by up to 10⁻⁴ | χ² 293.7 | χ² 19.8 |
| the limit at p = 0.001 | 40.8 | 27.9 |
Three things fall out of that.
Hull asymmetry is what biases a trapezohedron. A ten-thousandth of a radius takes the d18 from 52 to 294, and two different nudges of the same size give biases that are unrelated to each other (their deviation patterns correlate at −0.21) — so the pattern is an arbitrary consequence of the particular asymmetry, which is why the exact hull’s pattern is stable: float32 rounding is deterministic, so it is the same asymmetry every time.
The d18 amplifies it about five times harder than the d10. The same nudge costs the d18 5.6× its baseline and the d10 2.1×, and only the d18 crosses its threshold. That is the narrow-basin argument measured rather than asserted: the d18’s adjacent faces are 28.4° apart where a d10’s are 51.8°.
And a nudge at the float32 scale changes the pattern without changing the size. At 10⁻⁶ — ten times the hull’s own rounding — the magnitude is unmoved (50.7 against 52.2) while the pattern shifts (correlation falls from 0.80 to 0.42). A bias that is regenerated, the same size but differently shaped, by a perturbation the size of the representation itself is a bias made of the representation.
So the d18 is as fair as a single-precision rigid-body engine can make a solid with basins that narrow. In the terms that matter to a player it is very fair indeed — no face is off its share by more than 0.609 %, against the 1 % this project set itself and against the 1–2 % a moulded plastic d20 manages. What it cannot pass is a chi-squared test at one in a thousand over a hundred thousand throws, which detects a bias far below anything anybody could play with.
That was a judgement rather than a measurement, and it has been taken: the d18 is held to the worst-face bound and not to chi-squared.
It is not exempt from being fair. It is held to the bound it can meet and that a player would recognise — no face off its share by more than one percent, where a moulded plastic d20 manages one to two — rather than to a test that detects a bias far below anything anybody could play with, and that no single-precision rigid-body engine can pass for this solid.
The exemption is one shape, by name, in FairnessTest.HELD_TO_THE_FACE_BOUND
with the reason beside it. It is a set rather than a rule about basin widths, so
a second shape landing in it is a deliberate edit rather than a threshold
quietly swallowing something nobody measured. Its chi-squared is still computed
and still printed on every run, so a regression shows up in the table even
though it no longer fails the build; the figures to compare against are the two
in the sections above.
The app says nothing about this to the player. A warning would be a worse answer than the limitation: it would put a number a player cannot act on in front of somebody who came to roll dice, about a die that is fairer than the plastic one in their hand. It is written down here, where somebody choosing to trust the app can find it, and that is the right audience for it.
Nor is the shape changed to pass the test (decision 110). The bias lives in how narrow a face’s basin is, so the only cure is wider basins, and no eighteen-faced solid has them and the symmetry that makes a die fair by construction:
| solid | every face alike | adjacent faces apart |
|---|---|---|
| enneagonal trapezohedron (the d18) | yes | 28.4° |
| enneagonal bipyramid | yes | about 35° around the middle, with its height |
| rhombicuboctahedron’s eighteen squares | no: six and twelve | 45° |
The two isohedral ones are the only convex solids with eighteen faces that are all alike, and both have nine faces round one axis: the bipyramid would gain a few degrees and fail the same way. The rhombicuboctahedron’s squares are 45° apart, wider than a d10’s margin needs, but six of them sit on the axes and twelve on the diagonals with different neighbours, so nothing makes them come up equally often. It would be fair only once tuned, by how far out one ring sits, against this engine, and its eight triangles would need raising into points so that it could not rest on them. That trades the one guarantee every other die has for a fit that has to be re-made whenever the physics moves.
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 a re-throw the player makes, by shaking, and can watch.
All of this is about the dice of a roll. The dice waiting on the board before a throw are not dice of a roll: no face of theirs is read, so nothing here is applied to them, and a board die that lands cocked or on another stays that way until the shake throws everything (“The dice waiting to be thrown”).
It is a rule about the app, and a player’s own hand is not an exception to it but the other side of it: a die a player throws again is chosen, watched and recorded, and it is still thrown rather than moved (see “Picking a die up and throwing it again”).
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.
Counting. When the dice have stopped, every die that came to rest showing a face is read — and that reading is its answer for the rest of the roll. Nothing leaves the table.
A die standing on another one is not counted even when its own face is perfectly readable: it is resting on something that is about to be taken away, and a reading taken from a die that is about to fall is not a reading of anything.
Stopping, and saying so. If every die was read, the throw is over.
If some could not be — cocked against a wall, on an edge, standing on
another die — the throw stops there too. Nothing is thrown again by
the app: the dice that were read stay read, the ones that could not be
lie exactly where they fell, and the screen says how many need another
throw and that a shake throws them — on a plate over the tray, in an
accent prompt over the felt (“Shake to re-throw 2 dice”) a screen reader
announces and that stays until the shake, and in the tray’s own spoken
description
(SimulationOutcome.unread, RollState.ThrowAgain; docs/architecture.md,
decision 70). The player looks at the heap as it lies until they shake.
This used to throw them again by itself, as often as it took, and the
player on the Pixel 10a saw dice go back up into the air with nobody’s hand
on them. It was an honest re-throw — visible, physical, never a nudge — but
it was the app’s, and a throw is the player’s to make
(docs/architecture.md, decision 66).
The shake throws those, and only those. The next shake is a throw of
the unread dice in a world of their own — the same path a die an explosion
adds takes (ThrowSpec.among, Passes.next). The dice the last pass read
are lifted off the table by that throw, which is the moment they leave the
picture: their faces stay in the result, and their floor is the room the
throw needs. Dice that were already down before the throw began — a chain’s
earlier rounds — stay where they are and are carried as among, drawn and
avoided, with no body in the new world. The shake drives the new dice like
any throw, and they are counted again; whatever still cannot be read waits
for the shake after that. Each pass reads most of what is on the table, so
what remains shrinks fast — at the share measured on the Pixel 10a, a pass
leaves under 3 % of its dice.
Where they are thrown from depends on what is on the table. With
nothing else down, the unread dice are thrown from the spawn grid like any
first throw — it is a first throw of those dice, and the shake is what
throws them. Into a tray that already holds a chain’s earlier dice they are
dropped into the clearest floor, as an explosion’s die is. Either way the
placement is drawn from the pass’s own seed (Seeds.again, the throw’s seed
and the pass number), so a roll still replays to itself, re-throws and all.
And the lift outlives the throw. A die lifted in step 4 has left the
table for good, and the floor it stood on may be under the die that was
thrown again onto it. So what a roll reports is where the dice still on
the table stopped and not where the lifted ones did
(SimulationOutcome.restingAt, which is empty for a pass that left dice
unread and therefore shorter than faces for a roll of several passes). A
lifted die keeps its face — it is part of the result — but the next throw
of a chain is neither drawn over it nor aimed around it.
flowchart TD
throw["A throw<br/>(the first, a chain's round, or a re-throw)"] --> rest["Every die at rest"]
rest --> count["Count: each die showing a face is read"]
count --> all{"Every die read?"}
all -->|yes| done["The throw is over<br/>scored, or a chain earns its next die"]
all -->|no| wait["ThrowAgain: the unread dice lie where they fell<br/>plate + shake prompt + spoken tray say how many"]
wait -->|"the player shakes"| lift["Lift the dice that were read"]
lift --> again["Throw only the unread dice<br/>a world of their own, among the dice still down"]
again --> rest
wait -->|"type a new formula, or leave the screen"| gone["The roll is abandoned, nothing is recorded"]
There is no other rung, and that is the point. Nothing biases a die, nudges one, pops a pair apart or places one anywhere. The share of dice needing a correction is not a number to tune any more: there is no code in the loop that could correct one, so it is zero by construction, and “it does not look like it cheats” stops being a separate claim from “it does not cheat”.
The order is fixed: unread dice before a chain. A throw is scored — and an explosion or a reroll earns its next die — only once every die of it has a face, so a throw that leaves dice unread asks for them first and the chain waits for the shake after. Both cannot be owed at once. A die a chain added that lands cocked waits the same way, and comes back as the chain’s die rather than the plan’s.
The roll is written down once, when the last die is read: one total, one
history entry, its re-thrown dice counted in rethrows. A die’s statistics
count the face it was read on and nothing else, because a die that landed
cocked was not read on anything (docs/statistics.md).
Taking a counted die off the table is not moving it. Its face has been read and nothing about it can change again; it is out of play, which is the one thing the rule above allows.
But being counted is not what takes it off. A re-throw is. The two used to be one act, and the cost of that was a roll which went perfectly clearing itself off the felt: the dice were read, lifted, and the player was left looking at an empty tray with a total floating over it. Most rolls are one or two dice that settle on the first pass, so most rolls looked like that, and what the app is for is watching dice land.
They are two acts now, and the second waits for the player: every die that can be read is read, and nothing comes off until a shake throws the dice that could not be — and then everything read so far comes off at once, before any placement is aimed at the floor it freed.
Leaving them is safe precisely because of when counting happens. A die is only read once the whole table has settled, so no reading can be knocked out of date by a die still in flight. The one thing that could put a second die where a counted one stands is a die thrown again — and a die thrown again is exactly what lifts them. Measured on the Pixel 10a before the renderer took counted dice out at all: 33 pairs of dice sharing a spot across 8 seeds of 20, the worst overlapping by 10.9 mm of a 16 mm die. Every one of those rolls had re-throws in it, and every one of them still lifts.
So a roll that settles first time leaves every die where it landed, a throw
that left dice unread leaves every die where it landed until the shake, and a
roll that needed another shake shows the dice of its last pass with the
total (docs/TODO.md, Step 5.5).
A roll that cannot finish says so, and offers its dice back. There used to be a twelve-second cap that ended a roll by force-settling every die still moving and reading it off whatever face it was nearest — a number nobody rolled, which is the one thing this app may not produce. A pass now runs until its dice have stopped, and a die the player throws again is thrown as often as they shake.
What is left of the twelve seconds is a backstop, and it is a pass’s: each throw of a roll — the first, every re-throw, every die a chain adds — has its own twelve seconds, and the time the roll spends waiting for a shake is no time at all to the simulation. When a pass reaches it, the backstop is visible rather than silent. The dice that could be read are read; the screen says how many never settled and offers to throw those and only those again, with the same shake that throws a cocked die (the two waits differ only in their words). A headless run — the harness, where there is no screen to walk away from — fails instead of answering, because a roll whose dice never stopped has no faces to report.
It costs nothing at ordinary loads: 2,000 rolls of 20d20 on the Pixel 10a,
and not one of them gave up. Where it bites is where the throw was never
settling in the first place — 100d4, the flattest shape at the capacity
limit, a sustained sideways shake — and there it replaces a fabricated answer
with an honest refusal.
What it costs is time, and once in a while all of it. Measured on the
Pixel 10a over sixteen seeds of twenty dice under a hard sideways shake, while
the roll still threw unread dice again by itself: fifteen resolve in 243 to
709 steps — two to six seconds — with nought to five re-throws between twenty
dice, none left standing on another, and none read off a face it had not
landed on. The sixteenth runs the twelve-second cap out with no re-throws at
all, which says where it goes wrong: the dice never came to rest, so the roll
never reached the point where anything is counted. That is a settling problem
rather than a counting one, the same family as 100d4, and it is bounded in
the device suite at today’s worst case so that the next change to the shake or
the settle rule improves it or is noticed.
Headless, a scripted hand shakes. The harness, the golden suite and every
device test that wants a whole roll go through JoltDiceSimulator.run, which
follows each pass that leaves dice unread with the throw of those dice at once,
with no shake in it — exactly the passes and seeds the screen would make, minus
the wait (Passes.scripted). So the harness still measures the share of dice
that needed another throw, and a die that never comes good gives a headless run
up after sixteen passes rather than holding it for ever.
One share of dice re-thrown says how often and not why, and the cures differ:
a long d4 leaning on its neighbour wants a throw that spreads the dice, a coin
on its rim wants a rim that does not roll. So every die a pass leaves unread
carries its reason (UnreadReason), decided once, when the pass is counted,
from what the die was touching:
flowchart TD
unread["a die left unread"] --> onDie{"standing on<br/>another die?"}
onDie -- yes --> a["on another die"]
onDie -- no --> rest{"at rest?"}
rest -- no --> b["still moving"]
rest -- yes --> die{"touching<br/>another die?"}
die -- yes --> c["cocked against a die"]
die -- no --> wall{"touching<br/>a wall?"}
wall -- yes --> d["cocked against a wall"]
wall -- no --> e["cocked on the floor"]
The first that applies is the one a cure would have to address, so a die
wedged between a die and a wall is the pile-up’s. “Touching another die” is a
contact flag of its own (kTouchingDie), raised for both dice of any
die-to-die contact whichever way it points; the solver never sees it, so the
physics is the same with it as without. Passes counts a reason when the die
is actually thrown again, so the reasons add up to the re-throws; a pass that
gave up throws its dice again as still moving. The harness prints the
breakdown above its scorecard (docs/build-setup.md, “The physics harness”).
Jolt 5.6.0 was tried on 2026-10-31 and offered dice no advantage, so 5.3.0
stays (docs/architecture.md, decision 112). It was not faster, it re-threw
more dice, and those dice tumbled less after landing. All of it was measured on
the Pixel 10a, with Jolt’s new GPU compute switched off, because a roll has to be
decided the same way on every device.
The issue is more dice left cocked. Nearly all of the extra re-throws were
dice that came to rest cocked, and re-tuning the dice did not close the gap. Each
setting below is 2,000 × 20d20, or 40,000 dice:
| friction / restitution | dice re-thrown, 5.3 | dice re-thrown, 5.6 |
|---|---|---|
| 0.4 / 0.55 | 0.96 % | 1.14 % |
| 0.4 / 0.6 (the best on both) | 0.76 % | 0.99 % |
| 0.5 / 0.55 (what ships) | 1.02 % | 1.35 % |
| 0.5 / 0.6 | 0.82 % | 1.11 % |
| 0.6 / 0.55 | 1.10 % | 1.35 % |
| 0.6 / 0.6 | 0.90 % | 1.12 %, and one roll gave up |
| 0.7 / 0.55 | 1.18 % | 1.46 % |
| 0.7 / 0.6 | 0.92 % | 1.26 % |
Best against best, 5.3 re-throws about a quarter fewer dice, and the gap is about four times its standard error. Both are at their best on the lowest friction tried, so neither was followed below 0.4.
The likely cause, which was not proved, is that 5.6 applies friction at the average contact point, so a coefficient does not grip a die on its edge as it did on 5.3. Weaker grip on an edge would leave dice cocked, which is what was measured. It also moves every roll, so a move means new golden cases.
The rest of the comparison, 5.6 against 5.3:
| measure | 5.6 | 5.3 |
|---|---|---|
step p99, 20d20 |
0.19 ms | 0.20 ms |
step p99, 100d6 |
1.11 ms | 1.02 ms |
dice re-thrown, 20d4, at the shipped settings |
4.34 % | 3.96 % |
dice re-thrown, 100d6, at the shipped settings |
1.67 % | 1.35 % |
turns after landing, 20d20, at friction 0.4 and restitution 0.6 |
2.73 | 2.97 |
Median settle time was the same on both. Both passed FairnessTest at 100,000
throws a shape, and the differences between their figures look like the spread
between any two runs. The d18 stays outside chi-squared on either: 330 on 5.6
against 267 on 5.3, with its worst face 0.574 % off against 0.609 %.
Look again when a release says it changes friction or how contacts are
resolved, or when one makes convex-hull stepping faster on the CPU. A release
that only adds GPU compute changes nothing for dice. Compare best against best,
because settings tuned on one engine flatter that engine: run the grid above on
both with tools/harness.sh -n 2000 --friction <n> --restitution <n>, on 20d20
with 20d4 and 100d6 beside it, and include friction below 0.4. Move only if
the new release re-throws fewer dice at its best setting, without giving up the
turns after landing or the step time, and with FairnessTest passing.
docs/build-setup.md, “Moving to a new Jolt”, has what the move itself takes.
Tapping a saved roll, or a die in the picker, puts dice on the table rather
than throwing them. The board follows the formula as it is typed and as the
picker adds to it, so what a player is looking at before they shake is what
they are about to throw (docs/dice-notation.md, “Picking dice without
typing”).
A die that is new to the board is dropped onto it and tumbles to a stop,
under real physics. It used to appear in a laid-out spot, and then to fall
into that spot along a scripted path; dice spaced evenly over a table do not look
like dice somebody dropped there. Then it was let go over a random clear spot,
and a player adding five dice could not tell which five were new. Now every
die is let go over the same spot, one after another, and the engine brings
them down among the dice already there (docs/architecture.md, decisions 67
and 69):
BoardDrops.DROP_SPOT, the
middle of the tray, within SPOT_JITTER_MM (1.5 mm) of it — enough that two
dice do not come down centre on centre, too little for the eye to see the
spot move. The middle rather than the end by the dice pull-down, because the
pull-down lies over the top of the table while it is open, which is exactly
when dice are being tapped in. The eye stays on one point and follows each
die as it leaves it; the dice spread out by knocking into each other and into
the dice already down, which is physics.40d6 — are let go in index order,
DROP_INTERVAL_SECONDS (0.1 s, DROP_INTERVAL_STEPS = 12 steps) apart: the
first at once, each of the others a tenth of a second after the one before,
by which time that one is most of the way down. A bigger handful than fits
in LONGEST_STREAM_SECONDS (4 s, forty-one dice) at that pace is let go
closer together so that it does (intervalFor), never two in one step.
Single taps in quick succession get the same stream through the board
being simulated again on every tap (below), and a die tapped while others
are still falling is let go over the same spot.BoardDrops.letGo, CrowdedFloor.stackedHeight), keeping the gap the
biggest die on the board needs. Every die the player adds is let go,
because the shake that follows counts it — so only when the lift would put
it through the lid does it go somewhere else: over the least crowded point
of the tray, lifted the same way (CrowdedFloor.spot).DROP_HEIGHT_MM (60 mm) above the felt, beyond its own
radius and always under the lid; turned evenly over every orientation;
drifting sideways at LEAST_SLIDE_MM_PER_SECOND–MOST_SLIDE_MM_PER_SECOND
(40–150 mm/s) in a random direction — the drift is what sends each die of a
stream its own way off the spot — and downwards at up to
MOST_DOWNWARD_MM_PER_SECOND (150 mm/s); and spinning about a random axis at
LEAST_SPIN_RADIANS_PER_SECOND–MOST_SPIN_RADIANS_PER_SECOND (9–18 rad/s).
One height for every die: the stream already staggers the landings.BoardSettler opens a short-lived headless Jolt world on the visit’s
board thread: every die on the board as a dynamic body where it is drawn now
and moving as it is moving, plus the new dice. A new die is a body from the
start — the bridge only adds bodies before the first step — but until its
step (BoardBody.dropStep) it is parked a metre under the tray, in a row of
its own, where it touches nothing (PARKED_BELOW_MM); at its step it is put
over the spot once (PhysicsWorld.respawn), and from then on it is a die
like the others. Gravity is the throw’s (ShakeDriver.GRAVITY_MM_PER_SECOND2),
the world is stepped at SettleRule.TIMESTEP_SECONDS until the last die has
been let go and RestTracker says every die has stopped, or three seconds
after the last die was let go (MOST_BOARD_STEPS); one pose per die per step
is written down, and the world is closed before the recording (BoardTrack)
is handed back. Playback maps the board’s clock to a step and a fraction and
draws between the two, as a roll is drawn — so 60 Hz and 120 Hz see the same
drop, and nothing is running between frames.BoardTrack.inPlay). A frame leaves
out every die not yet let go, the renderer takes a die a frame leaves out out
of the scene and puts it back the frame it is let go (Stage.put), so it is
built with the rest of the board and appears at the spot, never in the
middle of the floor.BoardTrack.STILL_DRIFT_MM, STILL_TURN_RADIANS). That is a drawing
decision, not a force: a resting die must not shiver by a solver’s few
microns each time another is dropped beside it.And none of it is a roll. This is the one thing that has to be true, and it is held by structure rather than by care:
BoardSettler.settle returns a
BoardTrack, which is positions and orientations and nothing else, and the
board code never calls FaceReader. Nothing on the board is scored or
recorded, and the shake that follows throws every die from a spawn of its own,
exactly as before — the board’s poses are not where a throw starts.Seeds.waiting(board, die), which is the board’s number within the
visit under the purpose Seeds.WAITING, a purpose no throw uses. A board
built between two throws therefore cannot move a number in either of them,
and the golden fixture does not shift (“Timestep and determinism”). The number
is a per-visit counter rather than the spec’s seed, which every board shares,
so a d6 taken off and put back does not land the same way every time.Every change is a new drop of what is on screen. Adding a die, removing one
— a die resting on it may fall — and a capacity rescale that shrinks every die
all snapshot the dice as they are drawn at that moment and simulate again.
Which dice carry over is matched by what each die is and the size it is drawn
at rather than by where it sits in the formula, so taking the d6 out of
2d6 + 1d20 leaves the d20 where it was; a rescale changes every die’s size,
so every die is dropped afresh (docs/tables.md, “Capacity rule”).
Only the latest board is ever drawn. Tapping out 8d6 quickly asks for
boards faster than they may come back. Each request is numbered and built
against what is on screen; a drop that comes back for a board that is no longer
the pending one is thrown away, and a drop that comes back late starts as far
in as the board on screen has moved since it was asked for. A throw, a new
table or a cleared tray forgets the pending board altogether, so a late drop
never paints over a roll. A drop that fails to be worked out at all — the
bridge would not open — never takes the app down: it is logged and the board is
stood still instead, each moving die straight below where it was, square on the
felt — or at the clearest point of the tray when a die already stands there,
since every die being added is over the same spot (settleOrStand,
BoardTrack.standing).
The frames it costs are the only frames it costs. A board whose drop is playing
wants one per vsync, and a board whose drop has ended wants none, which is what
TrayLoop.wantsFrames says; a drop with nowhere to draw wants none either. A
throw takes the board away when it starts — the dice that were waiting have
been thrown, and a half-finished drop belongs to a board that no longer exists.
The drop height is deliberately higher than the 25 mm a die an explosion adds
is dropped from (SpawnLayout.ADDED_DROP_HEIGHT_MM): that drop happens among
dice whose faces are being read and should not upstage them, and this one is
the thing the player is looking at. What is still open is a question only a hand
can answer — whether the release constants above read as a die dropped and
tumbled on a table, and whether the spot, the jitter and the interval make a
stream the eye can follow (docs/TODO.md).
8d6! does not know how many dice it is until the first eight have landed, and
4d6r1 does not know whether it is four dice or five. So a roll is not always
one throw: every die an explosion or a reroll adds is a throw of its own, made
once the last one has come to rest, into the same tray and in front of the
player.
It is a real simulation of one die. There is no branch anywhere that picks a
number for the second die of an exploding six (docs/architecture.md, goal 1),
and the throw is seeded from the roll’s own seed through Seeds.derived, so a
formula with explosions in it replays like any other.
ClearSpace picks the point of the tray furthest from
every die already down, and among the points that are as clear as each other,
the one nearest the middle — the clearest spot in a tray with one die in it is
a corner, and a corner is the worst place to tumble. It is a fixed grid of
points rather than a search, so the same tray always gives the same answer and
a roll replays to itself.ThrowSpec.among; the renderer puts one renderable per die
back exactly where the simulation left it and never moves it again. What the
player sees is the six they rolled, and then a die landing beside it.among: they keep their faces and they are not
drawn back, because drawing a die where another die is standing is the very
picture this section exists to forbid, and counting their floor as taken
would hide the room the lift made (“Avoiding stacked and cocked dice”,
step 4).docs/dice-notation.md), and this one —
no clear floor left for another die, or a hundred dice in the tray, which is
the engine’s cap (docs/tables.md). The die that would have exploded is
marked in the breakdown either way. A reroll with nowhere to land does not
happen either, and the die stands as it fell.In power-saving mode the added throws happen exactly as they do here, with
nobody watching: the same loop over the same world, the same seeds, the same
faces. The only difference is that the dice among are not drawn, because
nothing is.
A roll that lands in silence is a number appearing. What makes it read as dice is the two things a table gives back — the knock in the hand and the clatter — and both of them come from the same place: a list of impacts the roll reports as it goes.
Impact is one moment where a die hit something: which step, which die, what it
struck, how hard, and how big the die was at the scale the capacity rule threw
it. It is a reading of the roll and never an input to it. Nothing about an
impact reaches the solver, nothing that decides a roll consults it, and the
same seed comes to the same faces with something listening and with nothing —
which ImpactRecorderTest asserts rather than assumes, on a roll driven through
counting and left with dice for the player to throw again.
It is decided in Kotlin over the PhysicsWorld seam, like everything else about
a roll that only looks like physics (docs/architecture.md, decision 40).
Nothing was added to the JNI wire format for it: a velocity vector per die per
step would be three more floats across the boundary a hundred and forty thousand
times a roll, to say something the scalar speed already says.
The rule is subtraction. Between one step and the next, gravity alone can
change a free die’s speed by |g| × 1/120 s and no more, and friction on a
sliding die takes away less than that again. So the part of a change that
gravity does not explain is the part something else did — and that is an
impact. Two consequences fall straight out of it, and they are exactly what
Step 5.6 asks for:
The allowance is twice the step’s gravity rather than once, because a shake can reverse which way the force points between one step and the next, and a die falling through that reversal changes speed by twice the allowance without touching anything. Under ordinary gravity that is 163 mm/s, which no landing comes near; under a four-gravity shake it rises to about 830 mm/s and only real slams are reported, which during a shake that hard is the right answer anyway.
What a die struck is read off the contacts the bridge already reports: a wall, the floor, or — when it is touching nothing the tray owns and has just lost speed — another die.
Three bounds keep it cheap and keep it honest. A die is left alone for six steps after an impact, so one landing is one event rather than the burst of contact steps it actually is. The record stops at 4,096 impacts, an order of magnitude above the worst case measured, so a physics bug cannot turn it into a leak. And when neither haptics nor sound is on, the roll records nothing at all rather than recording and then muting: that is the one thing those two settings save.
The same list of impacts is played in both modes, and the only difference is the clock laid over it.
Both go through ImpactTrack.cues, which is one function with one parameter for
the spread, so there is no second player to keep in step with the first. That is
the same argument LiveRoll makes about the two modes one level down
(docs/architecture.md, decision 48).
It also thins. A hundred dice landing together are a hundred impacts inside a few steps, and a phone can neither tick nor speak a hundred times in that window — it would be one long buzz and one smeared noise. At most one cue survives per 45 ms, and the one that survives is the hardest of them: the sound of a roll is its loudest moments, not its average. A second of playback is therefore at most twenty-two cues however many dice were thrown.
A tick, never a buzz: 8 ms at the faintest impact worth feeling and 22 ms at the hardest, with the amplitude following the same curve. A die landing is an event rather than a state.
VibratorManager and VibrationEffect, through SystemBuzzer, which is the
only file in the app that names a vibration API.VibrationAttributes.USAGE_TOUCH, which is the
usage Android’s touch-feedback switch applies to — so a player who has turned
haptic feedback off in their phone gets none from here without this app having
an opinion about it.EFFECT_TICK is used instead
of a one-shot the phone would round to “on” anyway.android.permission.VIBRATE is declared by feedback’s own manifest rather
than the application’s, because that is the module that calls the API. It is a
normal permission, granted at install, with nothing to ask the player at
runtime.A table names one of five presets — felt, wood, glass, stone,
plastic — rather than shipping audio, because an audio file is large and every
decoder is an attack surface (docs/tables.md). The app therefore has to have
the five, and there are two ways to have them: ship them as assets and decode
them, or generate them.
They are generated, in plain Kotlin, and written straight into a static
AudioTrack buffer as sixteen-bit mono PCM. So no decoder takes part at
all — not on a stranger’s file and not on ours. That is the format’s own
argument carried one step further than it had to be, and it is also what makes
the five testable: a decoded asset would be a file to trust, and this is
arithmetic with an answer.
A die hitting a table is a short burst that dies away: some ringing at a pitch
the surface decides, and some noise in a mix the surface also decides. Four
numbers per preset say the whole of it — a base frequency, a decay time, a noise
share and a second partial that is deliberately not a whole multiple, because a
struck plate or block is not a tone generator. Felt is almost all noise and gone
in a fiftieth of a second; glass is almost all ring and hangs on about four
times as long (ImpactWaveform: a decay of 0.018 s against 0.075 s). The noise
comes from a Seeds-stirred stream keyed by the preset and the ringing from
Exact, so a table sounds the same on every launch and on every
phone.
Pitch tracks impulse and die size (docs/TODO.md, Step 5.6), and both
halves are physical rather than decorative. A small solid rings higher than a
big one of the same stuff in inverse proportion to its size, so a die shrunk by
the capacity rule comes up brighter and a 25 mm d20 comes down — which is what a
handful of real dice sounds like. A harder knock excites more of the high
partials of whatever it hits, so strength lifts the pitch a little as well as
the volume. The whole range is clamped to an octave either way, which is as far
as a short impact sound stays recognisable.
Dice hitting each other sound like dice, whatever the table is made of, so
they take the plastic preset — which is what a set of acrylic dice is. The table
decides everything else, which is what a sound preset means.
PcmSpeaker keeps a pool of three voices per sound in play — the table’s, and
the plastic that dice use on each other — and plays round-robin, cutting the
oldest tail short to make room, which is what a real pile of dice does to
itself. An audio device that will not give it a track is a phone that plays no
impact sounds rather than a crash in the middle of a roll.
FillLight.harmonics): Filament’s diffuse
ambient is three bands, a lamp’s clamped cosine in three bands is exact to a few per
cent, and the arithmetic is a JVM’s to check (TrayLightingTest). It is
turned into the studio’s frame with the studio, and divided by the room’s
intensity, which Filament multiplies the whole room by. A fill has no
highlight and casts no shadow, which is what a fill is for. The key’s colour
is scaled to a luminance of one before it is given to Filament, so 80,000
lux is what lands whatever its colour-temperature conversion normalises to.The room is a real one (docs/architecture.md, decision 89). It was
first one spherical-harmonic band — the same light from every direction —
then a generated gradient, a cool sky over a warm floor (RoomLight). The
gradient got the direction of the light right and nothing else: a lacquered
die mirrors whatever is around it, a gradient has nothing in it to mirror,
and the render gallery showed dice that read as matte plastic under a grey
sky. So the room is now a photograph — Poly Haven’s Brown Photostudio 02,
a bright studio with a big window and ceiling lights, CC0 (StudioLight,
docs/assets/README.md).
Why that one. Of the seven indoor and studio panoramas compared, it is the one that is both neutral — its average is within 2 % of grey in every channel, so a table look’s colour is not tinted by the room — and structured: a window and lamps for a polished face to catch, brighter overhead than underfoot (about three to one, close to the gradient’s 2.8), and no single sun-like source (its brightest pixel is 120 times its average, where a lounge with a bare bulb is 19,000). The small studios with only softboxes were either lit from the floor or nearly featureless. 1k (1024 × 512) is the smallest Poly Haven makes and exactly enough: a quarter of its width is the 256 pixels a cube face needs, and a die’s lacquer blurs its reflections well below that. It is 1.6 MB, 1.3 MB in the APK.
How it reaches Filament. As a cubemap for reflections and as three bands of irradiance for matte surfaces. The cubemap is made in three steps, two of them small enough to own and one borrowed:
Radiance, a Radiance .hdr reader of about a hundred
lines — the header, the run-length code and a shared exponent — that
reads the file as if it were downloaded: every count is checked against
the row and the file, the size is bounded, and anything that does not add
up is the gradient rather than a crash (RadianceTest, which also holds
it to TwelveMonkeys’ reading of the shipped file);StudioCube.faces: each texel
looks along the GPU’s own cube mapping and takes the panorama pixel that
StudioLight.direction says looks that way, blended between the nearest
four. That is the mapping the irradiance is projected on, so the window a
die mirrors and the window that lights it are in the same place, and
StudioLightTest checks it by reading the folded cube back and projecting
it again;Texture.generatePrefilterMipmap — the part that is genuinely hard, and
the same CPU prefilter the gradient has always gone through, spread over
the engine’s worker threads — at 32 samples a texel rather than its
default 8, which leaves a bright window speckled, and with Filament’s
mirroring off because the faces are folded the right way round already.Filament’s HDRLoader and IBLPrefilterContext do steps one and two on
the GPU, and were used at first; they live in filament-utils-android,
whose native library links against gltfio’s, and the pair cost 17.5 MB of
native code over the four ABIs the APK carries — for a header, a run-length
code and a texture lookup (docs/architecture.md, decision 89). The
irradiance is nine numbers, projected from the same file and written into
StudioLight.IRRADIANCE: nine numbers need no work on the device, and
StudioLightTest projects the shipped file again on every build, so a
panorama swapped without its numbers fails rather than lighting the felt
with a room that is not there. The room is built once per engine, on the
roll thread’s first draw (FilamentEngine.room), and shared by every
stage made from it, so a rotation re-lights with the same room for nothing.
The fold is kept on disk (StudioCache). Decoding and folding is Kotlin
over half a million pixels — about 10 and 40 ms on a desktop JVM, and most
of the 558 ms the Pixel 10a spent building the room on every cold start,
which MaterialCache had just brought down to 0.8 s. The six faces depend
only on the file and the face size, so the first launch of a version folds
them and writes them to codeCacheDir/studio — 4.7 MB of floats and a
CRC-32 — and every launch after reads them straight into the buffer the
prefilter takes, with no conversion. Like the material cache it lives in
the directory Android empties on every update; its name still carries a
hash of the panorama, the face size, a fold version and the byte order, and
anything wrong with the disk folds again: no directory, an unreadable
file, the wrong length, a checksum that does not match. The prefilter still
runs every launch: Filament’s JNI cannot upload a cube level by level
(RoomLight.LEVELS), so its result cannot be kept. StudioLightDeviceTest
builds the room twice against an empty cache directory: the first launch,
which folds, must stay under a second, and the second must have read
the cube and stay under half a second. A missing or undecodable panorama
lights the tray with the gradient instead, which is also the room the
studio is calibrated against.
Which way is up, and which way the room faces. The panorama is stored
with +y up, as every equirectangular image is; this app’s up is +z in
the tray, in the physics and in the renderer. Filament takes a rotation for
its indirect light and applies it to the world it shades in, so
StudioLight.rotation turns the tray’s +z onto the panorama’s +y — and
then turns the room about that up until its window stands behind the key
light (yawToward), so the highlight a die mirrors sits where its shadow
says the light is. A window on one side and a shadow thrown from the other
is a picture with two suns. That axis was once why the gradient did not
exist, and it is a test now, not a comment: RoomLightTest and
StudioLightTest evaluate the harmonics the way the shader does and assert
that looking up finds the bright half. A sign error there is invisible in
review and looks, on a screen, like a perfectly plausible table lit from the
floor.
TrayLighting.ambientUpward),
which takes an intensity of about 15,400 (TrayLighting.studioIntensity).
Sideways the studio is more uneven than the gradient was — a face turned to
the window gets about 1.4 times the upward light, one turned away a fifth of
it — which is what a real room does and what gives a die’s faces their
modelling; the fill keeps the darkest of them readable.
TrayLightingTest holds that as numbers (TrayLighting.faceLevel): with
the key, the room and its fill, the darkest face a camera above the tray
can see is lit to 0.14 of white (a white face there shows at about 105 of
255, black ink on it at ten to one), and the brightest — tilted towards the
key and the window together — to 1.13.#1f5e3a as (0, 70, 22), and every shadowed face
of a die lost the same few per cent of white, which on a face lit to a tenth
is most of it. So the tone mapper is linear (FilamentEngine.colorGrading):
a colour lit to a level shows at that level, up to the knee of the curve in
the next point. A linear mapper stops dead at one rather than rolling
highlights off, so the exposure leaves room for it — a white
floor facing up lands at 0.95 (TrayLighting.WHITE_LEVEL), what it
receives from the key, the room and the fill worked out by
TrayLighting.whiteFloorLuminance. What reaches white is a pure white
face turned to the key and the window (1.13); the built-in set’s bone
resin, a felt, a table and — since the lacquer was roughened (decision
103) — the lamp’s reflection in a die’s face do not.
That exposure, about 2.1 × 10⁻⁵, is within a fifth of Filament’s default
(f/16, 1/125 s, ISO 100) and is reached the way a photographer would: f/16
and 1/125 s kept, ISO 80 (TrayLighting.sensitivity). Not through
Camera.setExposure(float), whose one argument is not this number: it sets
f/1, 1.2 s and an ISO of 100 over its argument, which Filament clamps to
204,800, and the camera came out at an exposure of 2,048 — twenty-six stops
over, a white frame with or without the post pass, which is how the first
build of this looked on the phone. TrayLightingTest works Filament’s
formula on the JVM, and StudioLightDeviceTest reads the aperture, shutter
and ISO back off the camera, after Filament’s clamps, and holds the felt
within ten levels of #1f5e3a in each channel with the post pass and green
and far under white without it.Highlights past white are bent, not cut (decision 108). Filament
draws in floating point, so light brighter than white exists until the
linear mapper cuts it at one: a metal die’s highlight at 1.8 of white and a
face at 1.02 both came out 255, a flat patch with nothing in it. The
grading now runs two stops darker (ToneCurve.GRADING_EV), so its cut lands
at four times white, into a half-float frame of the tray’s own
(TonePass). One full-screen pass then brings the light back up, bends it,
encodes it for the screen and dithers it once.
flowchart LR
tray["tray view<br/>4× MSAA, shadows, PCF"] --> grading["Filament's grading<br/>linear, −2 EV"]
grading --> frame["half-float frame<br/>light up to 4× white"]
frame --> pass["tone pass<br/>×4, curve, sRGB, dither"]
pass --> screen["screen"]
The curve is the identity below 0.8, so every table, felt and set
colour is drawn exactly as before (StudioLightDeviceTest and
TableTextureDeviceTest hold them to the same tolerances). Above 0.8 it is
the shoulder of Khronos’ PBR Neutral and nothing else of it: the brightest
channel is rolled off so it meets one only at infinity, the others follow it
so the hue holds, and light far past the knee is let go towards white
(ToneCurve.shown). PBR Neutral’s toe, which crushed the felt, is not
there. A highlight on ivory metal is cream; the lamp’s own reflection is
white. On the Pixel 10a a polished metal face at the key’s mirror angle is
kept at four times white in the frame and drawn with none of it at 255
(DieGlintDeviceTest). Over the gallery’s metal throw, 5,462 clipped pixels
became none, and every floor read back the same. The pass costs a read of
the frame and a write of the screen: over 20 rolls of 20d20 on green felt,
the GPU’s p50 went from 4.26 to 5.26 ms and its p99 from 6.91 to 7.77 ms,
still 60.0 fps. A stage with the post pass off (an emulator’s software
backend) draws straight to the screen, as before.
ShadowSample_EVSSM) samples a
mip-mapped exponential variance shadow map, and Filament’s notes on
variance shadows ask for every shadow receiver to be a caster too — the one
thing this tray must not be. On the Pixel 10a the rim’s shadow came back:
a hard dark band across the felt, 25 to 40 mm in from the top and left walls
with a rounded corner, at 0.40 of the lit felt — the rim is 60 mm up and the
key comes down at two in one, so its shadow lands about 30 mm out. PCF reads
a plain depth map that holds only what casts, which is the dice, and is the
shadow the tray had before; its four-tap filter softens the edge by about a
millimetre at this map’s half-millimetre texel. A die’s shadow is equally
firm from base to tip, as it was. StudioLightDeviceTest draws the empty
tray and holds the felt in strips 3 to 50 mm out from every wall at 0.9 or
more of the felt in the middle, before and after the post pass, so a cast
rim shadow (about 0.5), a variance-map band or an occlusion band (0.6 to
0.85) fails by name.DIE_COAT_ROUGHNESS is 0.4): a die has been in a bag with
other dice. Felt with a clear coat is a table nobody owns, so the tray has
none. A metal die has none either — its shine is the metal’s own — and a
body partly metal has the coat in proportion to what is not
(DiceMaterial.coatOver). The shader sets the coat on every surface,
nought included, because Filament starts a coated material’s every pixel
with a full coat as smooth as a mirror: until decision 106 the tray and
anything else given no coat were drawn under exactly that.DieGlintDeviceTest turns a d6 and a dark d6 through the mirror angle,
half a degree at a time, and fails if more than 1 % of a face is blown out
at any tilt; at 0.12 it fails by 50 % and 34 % (decision 103).Only the dice cast a shadow. The one shadow-casting light stands off to
one side, so the wall and the six-millimetre band of rim on top of it threw
a hard-edged stripe down the inside of their own felt — and a stripe down
the table is not a rim, it is a smear. The first device session said so
twice, and the second said it again: there should be no shadow on the
table. So the three tray renderables are built with castShadows(false)
and every die with castShadows(true); everything still receives,
because a die’s shadow on the felt is the only shadow that says anything.
It is a flag per renderable (Stage.add’s casts) rather than a setting
on the scene, so the promise the app makes — “the dice are still drawn in
perspective and still cast their shadows” — is kept by construction and not
by remembering.
Stopping the tray casting was not the whole of it. A device session reported the rim’s shadow still on the felt afterwards, and it was right: what was left was ambient occlusion, which is not a cast shadow and so was untouched by any of this (below). The wall being darker than the floor is neither — that is the lighting, and a surface turned away from the key light is simply less lit.
The shadow map is given the tray, not five metres of nothing. A
directional shadow map covers the camera’s whole frustum, and this camera can
see 5,000 mm because a far plane has to be somewhere. The tray is 240 mm
long. So Filament’s default 1,024-pixel map was spread over twenty times the
scene, at about 5 mm a texel — a third of a die’s face — and what a phone
showed was a wall’s shadow with a visibly stepped edge standing a few
millimetres clear of the wall that cast it, which is what a shadow biased
away from its own caster looks like.
Four times the map, and a shadow distance that stops just past the tray, puts a texel at under half a millimetre — 900 mm over 2,048 pixels, about 0.44 mm. The biases come down with it, because they are in world units too and Filament’s default normal bias of 1.0 is a whole millimetre of push on a die 16 mm across.
There is no ambient occlusion, because the table may not shade itself. It was here for the darkening where a die meets the felt — a cast shadow puts a die above the table and contact occlusion puts it down on it — and that argument is sound. It is not what this scene needed.
Occlusion darkens every concave corner it can see, and the largest one in the tray is the tray: the join where the wall meets its own floor, running the whole way round. The felt wore a soft dark band hugging the wall, and a band along the rim reads as the rim throwing a shadow — which is the one thing a table must not do. Filament’s occlusion is a property of the view rather than of a renderable, so it cannot be asked for on the dice and not on the tray.
It was settled by rendering the tray both ways on the Pixel 10a and looking, rather than by argument: with occlusion the felt carries the band; without it the felt is clean and the die keeps the cast shadow it always had, which turns out to be what was doing the work. A die reads as being on the table because of the shadow under it, not because of the darkening around it.
Screen-space contact shadows are left off for the same reason. They
march from each pixel towards the key light through the depth buffer, and
the wall is in the depth buffer: the band along the far wall would be back.
The wall-strip measurement in StudioLightDeviceTest would catch any of the
three.
DiceMaterial.SOURCE — the shader
the translucent dice are being rewritten in on the same branch. It waits for
that to land rather than becoming a second table-texture pipeline.Before and after (decision 89), the same seeded throws through
tools/gallery.sh on the Pixel 10a:
| before | after | |
|---|---|---|
| room | a 32 px generated sky-to-ground gradient, two bands of irradiance | Brown Photostudio 02 at 1k, folded into a 256 px cube (kept on disk after the first launch) and prefiltered by Filament, three bands of irradiance |
| fill | a second directional light, which Filament never drew | 25,000 lux in the room’s three-band irradiance |
felt #1f5e3a, read back |
(28, 102, 69): too bright and too blue | held within ten levels of (31, 94, 58) by StudioLightDeviceTest |
| tone mapper and exposure | ACES (Filament’s default); f/16, 1/125 s, ISO 100 | linear; f/16, 1/125 s, ISO 80: a white floor facing up at 0.95 |
| anti-aliasing | FXAA (Filament’s default) | 4× MSAA, no FXAA |
| the key light’s shadow | PCF | PCF (PCSS was tried and brought the rim’s shadow back) |
| APK | 92.2 MB | 93.6 MB: +1.3 MB of panorama and the reader; no native code added |
What they look like is the owner’s to judge from the gallery; what they cost a frame is the rendered harness’s to measure (“Performance, and how it is measured”).
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).The camera frames the whole tray, and only the player moves it. It never
closes in on its own, not even when the dice settle: a camera on the dice
takes the table away, and a player cannot then tell four dice from two.
Looking closer is theirs to do — pinch to zoom, two fingers to pan — and
what that produces is a TrayView, which cannot leave the table.
The table comes with the fingers: two fingers dragged up carry the felt up with them, as though a hand were on the cloth. It is the table that moves, not the camera, which is the only one of the two a player is thinking about. The pinch is anchored the other way round, at the place on the screen the fingers are closing on rather than at a movement of them, so the corner somebody is pinching into is the corner they get.
At the whole tray there is nowhere to pan to, and the room to move grows
with the zoom until, at TrayView.CLOSEST, the middle of the screen reaches
the corner of the floor. Every millimetre of table can therefore be
brought to the middle of the screen, which is where somebody who has pinched
in to settle an argument about a face is looking. The limit used to keep the
whole frame inside the tray, side / 2 · (1 − 1/zoom), and the cost of
that was the dice nearest the walls: they could be seen only at the extreme
edge of the picture and never looked at properly. The rule is the same shape
divided by what it allowed at CLOSEST, so it is still nothing at the whole
tray, still continuous and still monotonic — it simply goes further. What it
shows past the old limit is the tray’s own wall rising beyond the edge of the
floor, which is a picture of the table rather than of the void beside it, and
a fling still cannot leave the table.
A pinch happens about the point the fingers are gathered at, not about the middle of the screen, so a player can pinch into the corner they have spotted a die in rather than towards the middle and out of it.
A new throw goes back to the whole table, because the dice can land anywhere
in it — and the gesture reads where the camera is from the screen rather
than remembering it, so the first touch after a throw carries on from the
whole tray instead of snapping back to the corner the last roll was read in.
A rotation does not go back: where the player was looking is part of the
picture that is rebuilt. One finger moves the camera not at all and consumes
nothing; a tap with it picks a die up rather than rolling — and which die a
finger is on is TrayPick, the inverse of this camera (“Picking a die up and
throwing it again”).
How far it leans is the player’s, and it leans less than it did.
TrayCamera.TILT_DEGREES was 22° and not a setting. It is Table view in
Settings now, with two positions: straight down, which is the default
and puts every die square to the screen, and angled, which is the 22° shot
and shows the top and left walls. The reason is what a phone showed: at
411 × 923 dp a 22° shot spends a large share of the frame on the rim and
leaves the felt a tall trapezoid inside it, and the furniture is not what
anybody is looking at. The argument the other way is in TrayCamera’s own
KDoc and still holds — straight down is a diagram, and the point of rolling
real dice is watching them tumble — which is exactly why it is two positions
rather than a new constant. It is a camera, not a projection: straight
down still draws the dice in perspective and still casts their shadows, it
just stops leaning; the tray is framed with the same margin either way, and
at 0° the camera stands directly over the middle of the table instead of off
its near end.
The lean is an argument to the framing rather than a constant in it, so
TrayCamera stays what it was — arithmetic about a frustum, tested on a JVM
at both positions. It is read when the roll screen opens and never
watched, like power saving, the shake, the haptics, the sound and the
rounding (docs/architecture.md, decision 16): the tray a visit is given is
built with one answer, a rotation rebuilds the picture with the same one, and
changing it while dice are in the air changes nothing until the next visit.
A finger is read against the shot the screen is actually taking —
TrayPick.through takes the same lean — because a pick worked out against
the other one lands on the die next door. What does not follow the setting
is the table picker’s thumbnails: those are pictures of a table rather than a
roll in progress, and the angled shot is what shows a look’s walls
(docs/tables.md, “Thumbnails”).
TrayRenderer remembers the throw, the tray and the last frame, and
replays them onto the new stage. The roll is not paused with it either.
The frame callback is what steps the simulation, so a roll that stops being
asked for frames is a roll that stops — and one stopped half way is never
read, never reported and never over, leaving the screen on “Rolling…” for
good. A roll therefore asks for frames whether or not there is anywhere to
draw; only the still pictures need a surface before they are worth one.
The engine is not rebuilt with it, and not with a visit either. The
material is compiled on the device for the driver that is actually there, and
that costs long enough that doing it again for every rotation was itself the
black tray: what a surface owns is its swap chain and its viewport, and
FilamentEngine keeps the rest across all of them. The same line is drawn
once more around a visit to the screen — leaving for the menu and coming
back rebuilt the engine and showed the same black — so the engine and the
thread it lives on belong to the application (RollThread,
docs/architecture.md, decision 50). The world and the scene still go: a
throw the player walked away from never landed. 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, which goes on
delivering to a backgrounded process. That was an assumption the whole design
rested on and nothing had checked; InterruptedRollTest checks it on the
phone, including that the dice land while the app is away rather than only
once somebody looks again. A rotation is not an interruption at all — the
activity declares the config changes and the roll screen pins its shape — and
an activity that is recreated loses the roll on purpose, which is the same
rule as walking away. Low memory is the process being killed, which
nothing survives to handle: what makes it safe is that a roll is written in
one transaction or not at all, so the worst it can cost is a statistic that
is missing rather than a history that disagrees with itself
(docs/statistics.md).DiceTray
therefore holds its stage only while the screen is started — not while it
is resumed, because a sheet over the tray pauses without hiding it and
blacking the tray behind the sheet somebody just opened is the wrong answer —
and hands the surface over again on the way back in. The roll is not stopped
with it, for the reason above: it asks for frames either way.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.TrayThumbnails posts to
RollThread, opens a readable swap chain with no surface behind it, builds
the scene with FilamentDiceRenderer exactly as a throw does, reads one
frame back and gives the stage up again. Nothing about it is a roll: no
physics world, no step, and the die placed where the arithmetic says. A frame
that comes back all one colour is discarded rather than shown, because some
drivers render correctly to a screen and hand back an empty buffer when asked
to read one — and the picker’s swatch is a better answer than a black
rectangle (docs/tables.md, “Thumbnails”; docs/architecture.md,
decision 60).Stage.capture is the only thing on that seam that waits for the GPU, and no
frame of a roll ever calls it. Reading a frame back means blocking until the
driver has finished; a still picture drawn once, off screen, can afford it
and a tray at 120 Hz cannot. The rows come back from the top, the way
Filament hands them over, and are kept as they come. They used to be turned
over on the assumption that they arrived the way OpenGL counts them, and
every thumbnail was upside down; which way up a frame is turned out to be a
question only a device can answer, and the device suite now asks it with
geometry alone (PrintedNumbersDeviceTest).FilamentEngine, FilamentStage, TrayDriver, RollThread, GlowPass and
MaterialDeclarations are the files of this module excluded from the coverage
figure — a GPU context, the thread it lives on, and native handles with no
arithmetic of their own. None is excluded from static analysis
(.claude/CLAUDE.md); sonar-project.properties says why each one is there.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.libfilamat compiling the two dice materials for every backend Filament has.
Three changes bring it to 0.8 s, and to 3.2 s on the first launch after
an install or update:
TargetApi.ALL;codeCacheDir (MaterialCache),
which Android empties on every update, so a new Filament never reads an
old packet; its name carries the material source’s hash as well, and
anything wrong with the disk falls back to compiling;What is left — the compiles after an install or update, and the first translucent die or textured table — is no longer a black tray nobody explains: the roll screen puts “Preparing the dice” over it, with a bar and the time left (“Preparing the dice”, below; decision 95).
docs/tables.md,
“Table looks”; docs/TODO.md, After v1).docs/dice-sets.md, “Textures”). Until decision 101 an opaque pixel
came out as baseColor × atlas, kept from when a table’s floor picture went
through this material; tables have their own now, and on a die the multiply
drew yellow pips on a black die black — the face designer’s ink never
reached the tray. AtlasTextureTest holds a black die wearing an opaque red
atlas to the frame a red die draws.#C01010 was 75 %
red rather than 52 % and every drawing came to the tray paler than drawn.Stage, in :app over dicesets/install
(docs/dice-sets.md, “How an atlas reaches the tray”).#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); a coin’s rim belongs to neither
face and carries no cell: it is drawn in the die’s own colour.number_color on the set’s body colour, laid out in that same per-face
atlas grid — so a printed die and a painted one are the same surface with
the same coordinates and the renderer samples them the same way. A face
the artwork draws on is not printed: which faces those are is read off the
decoded atlas once, when it is uploaded (AtlasCoverage), asked of the
stage before a die is added (Stage.drawnCells), and those cells are left
out of the die’s number field (PrintedDice, keyed by the die and the
faces drawn; docs/architecture.md, decision 96). Every die draws one
number in the middle of each face, the long d4 included.1 on a square face produces, are averaged, so it comes out in the
middle rather than against an edge.core/glyphs). It is built once per die rather than once per body, because
20d20 is twenty of the same die.A die you can see into is resin, and resin is a second material. A
translucent die is drawn with Filament’s screen-space refraction: the
opaque scene is drawn first and copied with a chain of ever-blurrier
levels, and the die is then drawn on top of it, looking into that copy along
a ray bent by the resin and displaced by the die’s thickness. What shows
through a clear d20 is the felt under it, its own shadow and the dice beside
it, moved the way a lens moves them — a solid lump of clear stuff rather than
a see-through picture of one (docs/architecture.md, decision 90).
Refraction is baked into a material when Filament compiles it, so the resin
is DiceMaterial.RESIN_SOURCE, the opaque material’s surface with a
different ending, compiled lazily and cached like the opaque one; which one
a surface gets is whether its set called it translucent at all
(DiceMaterial.variantOf, docs/dice-sets.md).
flowchart LR
opaque["Opaque pass<br/>tray, solid dice"] --> copy["Copy of the frame<br/>with blurred levels"]
copy --> resin["Resin dice<br/>look into the copy"]
opaque --> resin
resin --> post["Post-processing"]
Everything the resin is, it gets from fields a set already writes; the
format has no resin fields (Resin.of):
| Filament input | From | Value |
|---|---|---|
transmission |
translucency |
1 − (1 − translucency)²: the share of the light leaving the bare body that came through it. Not the translucency as it stands — the body is lit by the key light while the felt under it lies in the die’s own shadow, so weighed in a straight line a die at 0.6 was its own colour with the felt a few per cent under it. 0.6 now lets 84 % through, 0.2 lets 36 % |
roughness (blur of what is seen through) |
translucency, roughness |
the larger of the die’s roughness and 0.6 × (1 − translucency): barely translucent is milky, wholly clear is glass, and a rough die is frosted |
ior |
— | 1.5, cast acrylic and epoxy |
thickness |
size_mm, the throw’s scale |
0.7 of the die’s drawn size, between a d6’s inscribed sphere and a d20’s, in the scene’s millimetres: how far what is seen through the die is displaced |
baseColor (the tint) |
color |
the body colour moved towards its square root, channel by channel, by as much as light passes through — the colour one pass through the resin leaves, where color is the two passes (down to the table and back) that make a clear die look its colour over a pale table |
The tint is applied once. Filament multiplies what it sees through a
surface by that surface’s base colour itself (Ft *= pixel.diffuseColor in
its evaluateRefraction), and drops the body’s own diffuse light by the
transmission (Fd *= 1 − transmission). The first resin also handed it an
absorption worked out from the colour, which tinted the felt by the colour
and then again: green felt (linear green 0.11) through amber (linear green
0.22, and about half that again from the absorption) kept a tenth of its
green, under the die’s own shadow, and a glassy amber die came out black
with its highlight and its numerals on top. A solid-sphere ray also only
ever crosses between three quarters and all of the thickness (the refracted
ray leaves at no more than 42° from the normal at an index of 1.5), so the
absorption could never have made the middle visibly deeper than the edges
— it was a uniform second tint and is gone. The square root is what keeps a
near-white glass die nearly clear and a saturated one coloured, and turns
green felt under amber olive rather than black.
The die keeps its clear coat over all of it, so it still has the sharp
highlight of a polished die however milky the inside is. What is printed
stays opaque: the shader tracks how much of a pixel is ink or artwork
rather than body, and where something is printed the transmission is nought
and the roughness the author’s. Ink is paint on the outside of the resin,
and paint does not go clear because the die did — which is the whole of
docs/dice-sets.md’s promise that a face you cannot read is not a die.
What it costs and what it cannot do. On a frame with a resin die in view, Filament draws the opaque scene into a texture, builds its blur levels and draws the resin dice after: one extra full-frame copy and a few downsamples, and one more texture read per resin pixel — nothing on a frame without one. What the copy holds is the opaque scene, so a translucent die seen through another translucent die is not there: the felt shows instead. A resin die still casts a whole shadow, because Filament’s shadows have no partial coverage for an opaque material, and the light that would pool inside a real one (a caustic) is not modelled at all.
FilamentStageTest.aGlassyDieTakesTheColourOfTheFloorUnderIt holds the
see-through to a number on the device: a near-white glass d20 over a red
floor and then a blue one, and the share of its body pixels that take each
floor’s hue (logged under dinfinity.startup) must be at least 30 %.
The die used to be blended instead — the same surface with its alpha turned
down — which drew a translucent die as a dusty, faded opaque one: nothing
behind it bent, nothing in it deepened, and the felt came through as a flat
wash. Filament’s SUBSURFACE shading model was the other candidate and is
not used: it wraps direct light round the back of a thin object, cannot
refract, and has no clear coat.
A die that glows gives off light of its own (docs/dice-sets.md,
glow and glow_color; docs/architecture.md, decision 111). It is
Filament’s emissive colour, set in the surface code both die materials
share, so a solid die and a resin one glow the same way and no third
material is compiled. The emission is added after the lamps and the room,
which is what makes a glow read as one on a tray that is always lit: the
face turned from the key and the side in the die’s own shadow come out the
glow colour instead of dark, and a lit face is pushed past the tone curve’s
knee towards the glow colour’s own white.
| Filament input | From | Value |
|---|---|---|
emissive.rgb |
glow, glow_color (or color) |
the glow colour, linear, × glow × 0.6 (Glow.FULL), × 1 − printed |
emissive.w |
— | 0: the screen’s units, one being white, not the camera’s |
In the screen’s units, not the camera’s. Filament can take an emissive
colour in physical units, scaled by the exposure like every lamp, or as a
share of white on the screen. The tray’s exposure is worked out from an
80,000-lux key and a photographed room (TrayLighting), and a glow given
in physical units would have to be re-tuned with every change to either; on
the screen’s scale a glow is a glow whatever the lamps do. At full glow it
adds 0.6 of white before its colour — enough that a shaded side is the glow
colour, not so much that a pale die under the lamp is one flat patch.
Ink does not glow. The emission is multiplied by the share of the pixel
that is body rather than print, the same printed that holds a numeral
opaque on a resin die, so the black faces of the Halloween set’s pumpkin
stay black on a green body, as they do on the real dice.
A halo, from the glow alone (GlowPass). The glow is drawn a second
time, on its own, into a mask half the screen’s size each way: every
glowing die in nothing but its glow (Glow.MASK_SOURCE, its ink dark), and
every other surface — the tray, the dice that do not glow — black, so a wall
or a die in front of a glowing one hides it there as it does on the screen.
The mask’s smaller levels are made on the GPU, and the tone pass reads five
of them, each filtered, adds them by Glow.HALO_WEIGHTS — most of it near
the die, a little far out — and lays that over the light before the tone
curve bends it, at Glow.HALO_STRENGTH.
flowchart LR
tray["Tray view<br/>the frame"] --> tone["Tone pass<br/>frame + halo, bent"]
mask["Glow view<br/>glow only, half size"] --> mips["Mip chain<br/>the blur"]
mips --> tone
tone --> screen["Screen"]
Why not Filament’s bloom. It was tried first. Bloom blurs whatever in the frame is bright, and the frame does not know which light is a glow: with its threshold at white too little of a lit glowing face is past it to show anything, and without the threshold the felt went grey, the rim glowed and every highlight bloomed with the dice. The mask is what the owner suggested — the glow as a screen-space pass of its own — and nothing but the glow spreads: on the phone, round one die on dark felt, 3,878 pixels of felt are lit and 97.5 % of the felt is untouched.
What it costs. On a tray with a glowing die: one more draw of the scene,
unlit and at a quarter of the pixels, a mip generation and five texture
reads a pixel in the tone pass. On a tray without one, nothing: the mask is
made the first time a glowing die is thrown, compiled lazily like the resin
(Variant.GLOW), and the tone pass skips the reads at a strength of nought.
What it does not do. It lights nothing: the felt next to a glowing die
is lit by the lamps alone, and what looks like light on it is the halo,
laid over the picture rather than shining on the floor — so it does not
pick out the far side of a neighbouring die. A real light per
die would be up to a hundred moving lights in Filament’s froxel grid on
every frame, and is left until somebody misses it (docs/TODO.md).
FilamentStageTest.aGlowingDieIsItsGlowColourAllOver holds it on the
device: a grey d20 glowing green has to come out greener than the same die
not glowing on at least 60 % of its body pixels, lit side and shaded side
alike. aGlowingDieThrowsAHaloOntoTheFeltAroundIt holds the halo: drawn
with the post pass, on a dark floor, open felt round a glowing die has to
come out greener than round the same die not glowing, and at least 80 % of
the felt has to be as it was — a halo, not a haze.
setImage uploads them as they stand, so
buffer row 0 is v = 0 and DieMesh.TextureFrame computes v growing down
the image to match. Filament’s MaterialBuilder would otherwise turn that
over a second time — flipUV defaults to true, as a kindness to assets
authored for a bottom-left origin — so it is switched off explicitly. With
it on, every glyph on every die is drawn reflected, which is what v0.1.0
shipped.v0.1.0’s reflection was found, and its fix confirmed, by looking at a phone,
and a look cannot settle it: a reflection in u and one in v differ by a
half-turn, and a die lands at an arbitrary orientation. So
PrintedNumbersDeviceTest does not land a die. It turns one face square to
the camera with its texture-up as the camera’s up (Quaternion.carrying,
one frame onto another), reads the frame back, and asks each axis on its
own whether the ink leans the way DieNumbers.fieldOf says that cell’s ink
leans — left-right for a u reflection, top-bottom for a v one. No golden
image and no pixel projected anywhere: the face is square to the camera, so
the frame is the face at one scale, and the only number worked out is how
far back the camera stands for the frame to be a known square of the cell
(the square inside the face’s inscribed circle, for every solid). The d6,
the d20 and the d4 are asked on every face that leans
far enough to tell; two controls print a deliberately reflected field and
fail exactly the question their reflection belongs to, so a pass is not the
instrument being blind. Underneath it the frame’s own orientation is pinned
by geometry — a die set off towards the camera’s up and right comes back in
the top right — because a readback the wrong way up is itself a v
reflection.tools/generate-font.py
— the same source and the same licence note as the mark
(docs/assets/README.md). Live text would render in whatever font the device
happens to have, and a traced approximation would be somebody’s guess at a
typeface.RenderFrame rather than in each
renderer, so two of them cannot disagree about where the same die was.RollPace.WATCHED of real time once the hand has let go
(“The simulation clock”), so 120 Hz of physics arrives at 60 steps of wall
clock a second and every frame in between is an interpolated one. Nothing
blurs and nothing stutters — there are more frames per simulation step than
before, not fewer — and the dice are seen to turn down onto a face instead of
being on one by the time the eye arrives. A 20d20 throw that the solver
finishes in 0.96 s takes 2.4 s to watch.Target: 60 fps with 20 dice on the Pixel 10a with headroom; the capacity rule
caps a roll at what the table can hold, and never above a hundred dice
(TableCapacity.MAX_DICE). Every die casts a shadow, whatever the count.
Felt and oak are drawn from pictures; plain is still a colour. A look that
names a colour picture, a normal map or a roughness map (docs/tables.md,
“Textures”) is drawn with DiceMaterial.TABLE_SOURCE: the picture times the
look’s colour, the normal map bending the surface’s normal, and the roughness
map giving the surface its sheen — moved to average out at the look’s
roughness when the look is in color_mode = "average", in place of it when
the look multiplies. Everything else — plain, a look
whose package is gone — is drawn through the dice’s opaque material exactly
as before, and so is dark glass (“A glossy table”), so the cheapest tray is
still the cheapest, and the dice’s
own materials are not touched at all: their source, their compiled packet and
every pixel of them are what they were.
TrayMesh lays the floor’s coordinates in millimetres from
the floor’s corner divided by the look’s floor_tile_mm: felt is 80 mm a
copy on every phone, and the 300 mm of oak covers the whole floor once. The
walls walk round the tray from the top edge down at wall_tile_mm, and the
rim carries on from the top of the wall, so an oak wall has an oak rim.u grows and its bitangent — the normal crossed with the tangent — the way
v grows; TrayMeshTest derives both from the triangles and checks them.
Every picture in the app counts its rows from the top, so v grows down
the picture, and the OpenGL-convention normal map (green up the picture) has
its green turned over in the shader.SurfaceMap). A die’s atlas is uploaded as it always was.color_mode = "average" has
its colour divided by the picture’s linear average before it reaches the
material (TableTint), so the felt averages out at the green the look
names. The average is taken once, from the pixels the picture is uploaded
from, and kept with the texture. The roughness map is averaged the same way
and moved, not scaled, by the difference (TableTint.roughnessShift, a
uniform of the material; the shader clamps to nought and one). A
photograph’s roughness map is the photograph’s finish: Poly Haven’s oak
boards average 0.44, a lacquered floor, and the key light comes down two in
one from the far side, so at the tilted view its highlight lies across the
middle of the floor. At 0.44 that was a white sheen of about 0.06 in linear
light over a brown whose blue is 0.013, and the oak drew pinkish grey; the
rim, darker still, drew as grey stone. Oiled oak at 0.75 keeps a sheen of
about 2.3 % of white looking straight down, and felt at 0.9 1.4 % — grey,
on top of the colour, and as big as green felt’s red. So the colour the
material is given is the one that draws as the look’s once that sheen is
added (SurfaceLight: Filament’s GGX for the key and its split-sum table
for the room, worked out on the JVM for a surface facing up seen straight
down); a channel darker than the sheen is lifted by the least grey that
reaches it, which keeps the colour’s hue. TableTextureDeviceTest holds the
floors to their colours (docs/tables.md, “What the floors draw as”).docs/dice-sets.md, “How an atlas
reaches the tray”). The bundled felt and oak are 1.5 MB of WebP in the APK
and about 50 MB on the GPU with their mip chains, the 2048-pixel oak most of
it.docs/tables.md, “Thumbnails”).Power-saving mode has no Filament at all, and nothing here reaches it.
A die is drawn with the edges the solver collides it with. The solver’s die was never sharp (“Dice bodies”): Jolt rounds every edge and corner by its convex radius, and for most of the app’s life the picture of that die was the sharp solid it was cut from. Where the two differ the sharp picture stood outside the die that collides — a cube balanced on a corner was drawn 0.35 mm into the felt and the tetrahedron d4 on its point 0.5 mm, and two dice touching at their corners were drawn inside each other.
RoundedEdges builds the same construction over the same faces, with the
same radius (decision 91):
RoundedEdgesTest checks every flat corner against its plane.Surface.normals), so the GPU blends the
light across each strip and an edge catches a highlight as a real one does.
A strip turns at most 30° between rows (MAX_SEGMENT_TURN); a corner patch
is a fan from the point where the sharp and the rounded corner are furthest
apart, so the corner is drawn exactly as far in as the arithmetic says.RoundedEdges.radiusFor): what HullMargin asks for, cut down by Jolt’s
own two limits over the same faces. So there is no gap between the drawn
surface and the colliding one to bound; how far either stands in from the
sharp hull is the same figure.| Shape | Radius, 16 mm die | Corner inside the sharp hull | Triangles, sharp → rounded |
|---|---|---|---|
| coin | 0.48 mm | 0.20 mm | 92 → 1,052 |
| d4 (long die) | 0.48 mm | 0.24 mm | 16 → 208 |
| d6 | 0.48 mm | 0.35 mm | 12 → 204 |
| d8 | 0.48 mm | 0.35 mm | 8 → 200 |
| d10 | 0.48 mm | 0.24 mm | 20 → 260 |
| d12 | 0.48 mm | 0.12 mm | 36 → 516 |
| d18 | 0.48 mm | 0.21 mm | 36 → 468 |
| d20 | 0.48 mm | 0.12 mm | 20 → 260 |
A hundred dice at the capacity limit are 20,000 triangles of d6 and at most
105,000 of coins, where the GPU’s 13.7 ms p99 at 100d6 was measured with
1,200 (“Performance”). Re-run on the rounded dice (Pixel 10a, 2026-10-04,
tools/harness.sh --rendered -n 20 -c 100 -s d6, 3,864 frames): GPU p50 / p99
6.5 / 14.8 ms against 6.2 / 13.7 ms, work p99 25.5 ms against 24.3 ms, 57.4 fps
against 57.3 — about a millisecond of GPU at the capacity limit, and the frame
budget still met.
What is printed stays where it was. A face’s texture coordinates are a
function of where a point sits on its face’s plane, and the flat part of a
rounded face is painted by the same function as the sharp face — so a number
or an author’s artwork is exactly where it was on the face and the rounding
only hides the outermost sliver of the cell under the bend. The bend is not
blank: each half of a strip and each share of a corner patch takes its face’s
cell at the point under it, so artwork that runs to the edge of a face runs
round the edge as paint would, and nothing is sampled from outside the face’s
polygon. The coin’s rim, and the rim’s half of every bend beside it, carry no
cell, as before.
Labels are still sized against the sharp face (FaceRoom), which is what the
face designer sizes them against too. Every solid’s printed numbers stay on
the flat with room to spare, the long d4’s included; the bare ends of a long d4,
like a coin’s rim, carry no cell at all.
A bend’s glint is spread over the pixels the bend covers. The first
gallery of rounded resin dice showed thin, broken white lines along some edges
— beside the 9. of a d12, between the 7 and the 6. of a d10. They looked
like white ink picked up by the bends, and are not: sampled as the GPU samples
it, the printed field on every bend of every shape stays below the half that
is a numeral’s edge (worst 0.09 on a d10, 0.30 on a d18, nought on a d12 and a
d20; RoundedEdgesTest), and a bone die with black numbers showed a light
line on the same edge. They were the lacquer’s glint. A bend turns the normal
through up to 90° in under 0.8 mm — two to four pixels of a gallery frame —
and a coat as polished as it then was (0.12; 0.4 since decision 103)
reflects a light from only a few of those degrees, so the glint is thinner than a pixel and each pixel either lands on
it or misses it: a dashed line, nearly white against a dark resin body. Both
dice materials are compiled with Filament’s geometric specular
anti-aliasing (DiceMaterial.SPECULAR_AA_VARIANCE, Filament’s defaults),
which raises the roughness — of the body and of the lacquer — by how fast the
normal changes between neighbouring pixels. On a bend that spreads the glint
into a soft, unbroken highlight; a flat face’s normal does not change, so the
faces, their numbers and the tray are drawn exactly as before. A floor drawn
in its colour alone has it too, being the dice’s own opaque material; the
table drawn from pictures does not (“The table’s surface”). The settings
are part of the material cache’s key (DiceMaterial.Variant.fingerprint), so a
packet compiled without them is not read back.
A rounder die is the same construction at a larger radius (decision 94).
RoundedEdges.radiusFor reads the die’s own edge_rounding and the error
Jolt is given for it, so the picture follows the solver at every value a set
may ask for. At the roundest, 6 % of a 16 mm die, every solid is rounded by
the 0.96 mm it asks for — the coin too, which is thick enough to take twice
that across, and the long d4, whose points are blunt enough. RoundedEdgesRoundnessTest
holds the mesh to the same promises at 1.5 %, 3 % and 6 % as
RoundedEdgesTest does at 3 %:
closed, every flat face on its own plane and inside the sharp one, a die on a
face on the felt, never outside its hull, its corners in by exactly what the
solver’s are. Meshes are cached per shape and radius, so a set of rounder
dice is one more mesh per shape, not one per die. What is printed is still
sized against the sharp face, so on a rounder die more of a numeral that runs
close to an edge is painted round the start of the bend.
It can be turned off: DieMesh.of(shape, rounding = 0.0) is the sharp
mesh, surface for surface, and DieMesh.of(die, scale) is the rounded one
the tray draws. Nothing of the physics reads any of this — the hull, the
radius it asks for and what Jolt makes of them are unchanged.
Dark glass is drawn like the plain table: one opaque surface, at its own
roughness (0.1) and colours, through the dice’s opaque material. What a
player sees in it is the room — the window and the lamps, sharp because the
surface is smooth — and nothing of the dice. Its physics, its sound and its
look in the picker are those of docs/tables.md, “Table looks”.
There is no reflection of the dice in it. One was built (decision 93): the dice drawn a second time, small, from the camera turned over in the floor, and the glass floor adding that picture at glass’s own strength. It was withdrawn (decision 104) because from this app’s camera there is nothing of it to see. The camera looks straight down — by default, and never more than the angled Table view’s 22° off it; lower, oblique shots are ruled out — so a die’s reflection lies directly beneath the die and is hidden by it; and glass looked at square on reflects about 4 % — 2.7 % at the strength dark glass’s roughness gave it. On the Pixel 10a the owner looked for it and saw none, and the render gallery shows none. So it went, with the second view of the dice, its target and a material to compile on first use.
It was not removed for speed, and it saved none. The floor of dark glass
is now drawn by the dice’s opaque material, as plain’s is, and that material
costs more over a whole floor than the glass one did: at 20d20 on the Pixel
10a, GPU p99 was 11.87 ms with the reflection (2026-10-19) and is
13.20 ms without it, against plain’s 13.0 ms and felt’s 11.34 ms
(tools/harness.sh --rendered, 2026-10-20). Every table still draws at 60
fps. A cheaper floor for the flat looks is the open question on the clear
coat they wear (docs/TODO.md, “Open questions”).
Why not Filament’s screen-space reflections either, read off Filament 1.76.1’s shaders before the planar one was built: they reflect everything on screen, so each wall would lay a dark band along its foot; their blur is computed in world units and this scene is in millimetres, so it comes out sharp; and they fade every ray that turns back towards the camera, which from straight above is every ray off the table. They would show nothing in the middle of the screen, for a depth pass and a full-screen ray march.
The bar is Step 5.7’s: 60 fps sustained at twenty dice, p99 frame under
16.6 ms, and 30 fps at the capacity limit (docs/TODO.md). Three
instruments read it, each answering what the others cannot:
| Instrument | What it times | Where |
|---|---|---|
tools/harness.sh --frames |
the simulation half of a frame, headless | HarnessTest, simulation/jolt |
tools/harness.sh --rendered |
a frame’s simulation and draw on the roll thread, the GPU’s time per frame, dropped steps and the frame rate, on a screen-sized surface through the shipping tray | RenderedHarnessTest, render/filament |
the debug overlay’s fps · p99 line |
the interval between frame callbacks on the real screen, by hand | TrayLoop, decision 72 |
The rendered harness scores the roll thread’s work and, as a row of its own,
the GPU’s: the GPU draws a frame behind the CPU, so either one running past a
sixtieth of a second is a frame the display waits for. The frame interval is
printed but not scored, because on a 60 Hz panel it is 16.7 ms by construction
(docs/architecture.md, decision 80; docs/build-setup.md, “Drawn frames:
the rendered harness”).
Measured on the Pixel 10a (2026-10-04, at #350; a 1080 × 2424 surface, the whole screen; the GPU row is measured on its driver):
| run | frames drawn | work p50 / p99 | GPU p50 / p99 | rate | steps dropped |
|---|---|---|---|---|---|
| 100 rolls of 20d20 | 13,886 | 3.6 / 8.3 ms | 6.6 / 12.6 ms | 60.3 fps | 0 |
| 10 rolls of 100d6, the capacity limit | 1,346 | 9.6 / 24.3 ms | 6.2 / 13.7 ms | 57.3 fps | 0 |
Both targets hold: sixty frames a second at twenty dice with p99 work at half the budget, and well over thirty at the capacity limit. At a hundred dice the p99 frame’s work passes 16.6 ms — the physics steps, not the GPU, which stays at 13.7 ms — so the display holds a frame now and then; the scorecard marks that row failed against the sixty-frame bar, which is not the bar for the capacity limit.
Those figures predate decision 89, which adds 4× MSAA (and drops FXAA), a variance shadow map with its mip chain for the soft shadow, and a 256-pixel reflection cube in place of a 32-pixel one. By estimate the shadow map is the dearest of the three, a millisecond or two of GPU at 2,048 pixels; the GPU p99 at twenty dice had 4 ms of room. The harness is re-run before it merges.
The prototype used to draw this screen as a column of bands with the tray as
one of them; the app draws one full-bleed table with the formula, the hint, the
picker and the buttons floating on it. The app’s shape won, and the design
of 2026-09-17 made it a design rather than an accident
(design/dInfinityPhone.dc.html, and docs/design-handover.md, “What was
answered”).
Every control over the table is a plate: opaque --color-bg, no radius, no
border, --shadow-sm, 7 / 11 / 8 dp of padding, hugging its content rather
than filling the width. A plate is what keeps a control legible over a lit 3D
table whose colour the player picked, and it is the answer to two of the faults
the first device session found — a formula whose dashed rule ran the full width
of the screen, so the text read as struck through rather than underlined, and a
bordered box of controls sitting on bare felt.
It is one component, ui/common’s Plate, because a plate is a token rather
than a layout: six of them on one screen, each drawing its own shadow and its
own padding, is six chances for the numbers to drift.
The top of the screen is a column of three things, and the bottom is two pull-ups and a plate. That is the layout the second device session asked for, and the whole of what it is for is the felt: with the straight-down table view, a plate over the tray is a place a die can land and not be seen. Everything that is not being used is therefore put away — behind a pull-down, behind a tab or below the bottom edge — rather than shrunk.
Along the top, 14 dp in and 12 dp down, in one column that pushes downwards as it opens:
Dice, the
count of dice the formula is asking for, and a chevron. Open, it is the
picker row and the set chooser on a plate under it. The row scrolls
sideways and has nothing under it — ten dice at a touch target worth
pressing do not fit across a 360 dp phone, and a row that reflowed to two
lines when a set defined one more die would be a row whose dice move
about.Formula and a chevron pointing inwards, mirroring Dice at the other
end of the corner — the formula itself is not drawn at all. Pressed, the
drawer slides in from the right-hand edge and is the field, the
squiggle, the one-tap fix and the keyboard, with a × at the end of the
field that empties it in one tap (docs/dice-notation.md, “Emptying the
field”); the chevron turns round and
pushes it back out.The formula used to be on the felt, as a line of type with a dashed rule under it, in every state whether or not anybody was editing it. The second device session asked for it to be out of the way entirely and to arrive from the side rather than dropping down, and both halves of that are the point: a formula is a thing somebody has already written, and a line of it over a table read straight down is one more block a die can land behind.
The shut tab is red when the formula does not read. That is the one thing it still says about a formula it no longer prints, and it has to say it — a mistake behind a door nobody has a reason to open is a mistake nobody finds. What is wrong is still said inside, under the squiggle, because that is where it can be acted on; red is not something a screen reader can say, so the tab’s state description says it in words.
Only one of the two can be open. The dice pull-down pushes what is under it down and the formula comes in over the table, so two open at once is the top half of the table covered — which is the thing this layout exists to stop. Opening either shuts the other, in the screen rather than in each control.
What is left along the bottom is one plate and two pull-ups: the plate that says what the roll is doing, the saved rolls, and the result. The picker has gone to the top, the two things to do with a result have gone onto the result itself (below), and the Roll button is gone altogether — a shake is the throw (“Starting a roll”).
Accent never touches felt. Accent appears only on a plate, which is how
an accent the player chooses freely and a shelf of tables stop being a pair
anybody has to check — a green accent on green felt cannot happen if the accent
is never on the felt, and with a colour picker there is no list of pairs to
check in the first place (question 10). So “See the odds”, a refusal and every
asking plate are each on one — and the result sheet, which is
not a plate but an opaque surface of its own, keeps the same rule for the same
reason, which is what lets “See the odds” and “Save as roll” sit on it. The pairing that has to be legible is accent-on---color-bg: one
pairing rather than a matrix.
Two things over the felt are accent-coloured and keep the rule by carrying
their own ground (docs/architecture.md, decision 84). The shake prompt is
an opaque accent fill with its words in onPrimary at 20 sp — a primary
button’s pairing, at a size where the accent’s 3:1 is enough — and its
smaller second line on --color-bg. The pick ring is a 4 dp accent ring
inside an 8 dp ring of --color-bg, so 2 dp of ground shows either side of
the accent whatever the felt is. Both are in the prototype under the
rollState tweak — unread, picked, earned and stuck
(design/dInfinityPhone.dc.html).
Where a plate wants the accent it wants its 700 step, because a kicker is
10 dp and a + is 13 and the accent as chosen only clears the contrast bar for
large text. That step is mixed from the accent in use rather than looked up —
ui/common’s Ink.accentDeep, over AccentRamp, which is the same rule the
filled tag mixes the ramp’s other two ends by (docs/architecture.md,
“Settings”).
| Plate | Where | What it carries |
|---|---|---|
| Dice | top left, 14 / 12 dp in | the word, the count and a chevron; the picker row and the set chooser behind it — and, after a throw, the formula’s tab too |
| Formula | top right, under the menu button | a tab: the word and a chevron, red when the formula does not read; the field and the squiggle slide in behind it. Folded into the shut dice pull-down after a throw |
| Ready | across the bottom | that a shake rolls, and what the throw is expected to come to |
| Counting | across the bottom | how far through the reading a roll is, and the range it can still come out in |
| Another throw earned | across the bottom | a chain that stopped, the shake it wants, and what is still to come |
| Throw again | across the bottom | how many dice landed where they cannot be read, how many were, that a shake throws them, and what is still to come |
| Could not settle | across the bottom | how many dice never stopped, what is still to come, and what to do about them |
| Shake prompt | centred under the controls along the top | what the next shake will throw and how many — dice to re-throw, earned dice, or picked dice and how to put one back; in the accent, until the shake |
There is no plate at all on an empty tray. Type a formula, or open Dice
at the top. stood there and the second device session asked for it to go: it
pointed at a formula that is no longer on the table and at a menu that says
Dice on its own head, and it was a block over the felt in the one state
where the felt is all there is. An empty tray is an empty tray, with the two
doors along the top — and, on a fresh install, the first-launch screen — to
say what to do. Shake the phone to roll. stays, on the ready plate: shaking
is the one thing nobody would guess at, and there is no button left to say
it.
Nothing is drawn behind the first-launch welcome, because nothing can
start there. The welcome is a takeover over the whole tray, and the result
sheet shares its bottom edge; a roll thrown while it was up — a formula
opened on the way back from an import, then a shake — drew the breakdown under
its buttons. While the welcome is up the shake is not listened to, so no
roll starts behind it and there is no result to hide (docs/architecture.md,
decision 74). What is typed meanwhile waits on the board, and the first shake
after the welcome is pressed past throws it.
The result is a pull-up sheet, not a plate in the stack
(design/dInfinity.dc.html, options 1e–1g; the prototype draws it as
position:absolute;bottom:0 with animation:dz-up). It comes up from the
bottom edge by itself once the dice have been read, can be pushed back down
until the whole table is visible, and can be pulled up again.
It used to be the first plate of the column of controls: a full-width band across the middle of the tray that nothing could move. That did not matter when the tray was a leaning shot with a small patch of felt; with the straight-down table view it is most of the table, and a die can land under it.
Three things are fixed about it:
See the odds and Save
as roll sit at the foot of the breakdown, which is what the second device
session asked for. They are in the sheet’s body and not in its grip,
and that is the whole of the difference between the two halves: the grip is
what survives a push down, so anything in it is a plate over the felt for as
long as a total lasts. See the odds used to be a plate of its own in the
column of controls, offered in Ready and in a refusal as well as after a
throw; it is now offered once the dice have landed and not before. Save as
roll is new here and is the pair the outcome graph already offers at the
foot of its bars — the two screens are about the same formula, so they end
the same way. Neither knows where it goes: the roll screen may not depend on
feature/saved, so both are callbacks :app fills in
(docs/architecture.md, “Modules”).What is dragged is the grip; what is tapped is the handle. The bar is 4 dp of ink and a thumb is not, so the whole band above the breakdown takes the drag. The tap is narrower on purpose — the total is the one number the screen exists to show, and a tap on it moving the sheet would be a control nobody asked for sitting on top of the result. So the handle alone is the button, 48 dp tall and the width of the sheet, and every rest is reachable from it without a drag: a gesture is not an interface. It carries its own name, the label of what a tap will do and which rest the sheet is in, because the bar looks identical up and down and a screen reader has nothing else to go on.
Where the sheet rests, what a drag does to that and what a flick settles to are
arithmetic in feature/roll’s SheetSlide, under JVM tests; Compose is left
with the gesture and the drawing. A flick wins over a position — a sheet thrown
at the bottom edge from an inch below the top means down — and “a flick” is
measured in sheet heights per second rather than pixels, so it is the same
gesture on every phone and for a one-line breakdown as for a twenty-die one.
While a dice material is being compiled, the tray says so. The first launch after an install or an update compiles the opaque material before the first frame can be drawn — about 2.4 s of black tray on the Pixel 10a — and a translucent die or a table drawn from pictures compiles one more material the first time one is shown, about two seconds in which the roll thread draws nothing and the dice stand still in the air. Nothing about either is broken, and without a word on the screen both look as if it were.
So a plate stands in the middle of the tray for exactly as long as a compile
runs (feature/roll’s PreparingTheDice; design/dInfinity.dc.html, option
1za):
The bar is an estimate, and it may only be wrong on the slow side.
libfilamat says nothing until it is done, so how far it has got is the time
since it started against how long the same material took on this phone last
time (ShaderWork.Compiling). The fill stops at 95 % until the compiler
finishes, and the plate goes when it does, so a phone slower than its estimate
sees a bar waiting near the end rather than a full bar that is still waiting.
The seconds are rounded up — the last one reads “1”, not “0”.
What this phone took is remembered (ShaderTimingStore), in
noBackupFilesDir rather than beside the materials: the material cache is in
codeCacheDir, which an update empties, and an update is exactly when the
figure is wanted. Every compile that finishes replaces its material’s figure.
Until a phone has compiled a material once, the estimate is the Pixel 10a’s:
opaque 2.4 s and resin 2 s, measured; table assumed at 2 s, the same as
resin, because it has not been timed alone; and the tone pass (decision 108),
a few lines of unlit shader compiled with the opaque material on the first
launch, assumed at 0.2 s and shown as the dice. A figure an earlier version kept
for the glass table’s material, which is gone (decision 104), is skipped like
any line the reader does not know. A missing,
unreadable or garbled file reads as those defaults, line by line, and a figure
under a tenth of a second or over two minutes is not a measurement of the
compiler and is not learnt.
A launch whose materials were on disk reports nothing, because nothing is
compiled — FilamentEngine tells ShaderProgress about a compile inside the
cache’s miss path and nowhere else — so a normal start shows no plate at all.
Power-saving mode never shows it, because it opens no engine.
It sits over the tray and under every control, so the menu and the formula are there to be used while it waits, and it takes no touch. For a screen reader it is three things: the title and the sentence as one polite live region, heard once when the plate appears; the rule, which is a progress bar, its value in whole per cent; and the time left. Only the first announces itself — a bar that spoke every time it moved would talk over the whole compile.
The table picker’s thumbnails are drawn with the same engine, so a phone that opens the picker before the tray compiles the material there, under the picker’s own placeholder swatches, with no plate; the tray afterwards finds it compiled.
The saved rolls are a pull-up as well (design/dInfinity.dc.html, option
1c). They were the last plate standing across the bottom of the felt, in
every state, whether or not anybody wanted one — so they are parked by
default, showing a grip with SAVED ROLLS on it, and a pull brings the strip
out. It is the same component as the result: one PullUpSheet over one
SheetSlide, with the two rests, the drag, the flick and the measuring in one
place rather than two. The only differences are that the saved rolls do not
arrive by themselves — they have nothing to announce — and that they start
parked.
Which leaves the question the two of them raise together, and it is settled in one place:
The rule is feature/roll’s BottomEdge: plain Kotlin, under JVM tests,
with the invariant asked of every order the two controls can be pressed in
rather than of the two the screen happens to reach first.
The counting plate is the home the running readout did not have. It was the
most important unstyled thing in the app: one line of text, sitting where
“Rolling…” used to be because there was nowhere else. It is now a plate across
the bottom carrying, in order, the kicker COUNTING at 10 dp / 600 with .1em
tracking at 65 % opacity; the count as 14 at 17 dp / 800 in tabular figures
followed by of 20 read; the range the finished roll can still come out in,
right-aligned, carrying the formula’s constant offset and ordered low to high;
and a 3 dp progress rule, --color-neutral-200 track and --color-text fill.
An open exploding chain puts a + at the top of the range in
--color-accent-700 — which answers the hand-over’s fifth question in passing:
the + is accent, at body size, on a plate, so it is the 700 step like every
other accent-coloured run of text at 10–14 dp.
It says the whole line once to a screen reader rather than four times. A
row of figures an eye takes in at a glance is four disconnected fragments read
aloud, so the plate carries one sentence of its own and merges what is under it
(docs/architecture.md, “Accessibility”).
Three states the prototype did not have live on that same plate, and only
one of them has a button to press. Another throw earned is a chain that has
stopped and is one shake short: an accent-700 kicker and a line of copy saying
how many dice it earned. Throw again is a throw that left dice nobody could
read — cocked, or standing on another — with the same accent-700 kicker and a
line saying how many, how many of the throw were read, that they stay where
they lie, and that a shake throws them (decision 70); the tray’s spoken
description says the same. Could not settle is the refusal: an alert icon, an
accent-700 kicker, copy naming how many dice never stopped, and Cancel the
roll — which is a way out rather than a way on, and is the only button left on
any of these plates. Another throw earned and Could not settle are
reachable in the prototype through its rollState tweak, which is the quickest
way to see them; Throw again is not drawn there yet and borrows the earned
plate’s layout (docs/design-handover.md, “The shake is the throw”).
Another throw earned and Could not settle are RollState.ShakeAgain and
RollState.Stalled — which the roll had reached all along, with one line of
text between them — and what was missing was the drawing. Throw again is
RollState.ThrowAgain, which is new: a throw used to throw its unread dice
again itself and never stopped to ask.
All three carry the range the roll can still come out in, under a STILL TO
COME kicker and drawn by the same component the counting plate draws it
with. That is the other half of the range fault: the figures went away the
moment the dice stopped, so the plate a chain waits on said nothing about the
numbers and a player deciding whether to shake had nothing to decide with. So
RollPresenter.progress now outlives the dice coming to rest when the roll
is not over — it is cleared by a roll that finished, by Cancel the roll
and by a fresh throw, and by nothing else. The range is the live one,
tightened by every die already read, rather than the formula’s pre-throw
ends.
All three wait for the same shake. The plates used to carry Throw 3 more and
Throw those 3 again, and both are gone with the Roll button: a throw is a
throw whether it is the first of a roll or the last, and a button that made
one was a button that made the shake optional. What is drawn instead is the
count, so a player who shakes knows how many dice are about to go up — on the
plate, and in the shake prompt over the tray, which is a polite live region,
so a screen reader is told rather than having to be swiped onto it, and
which stays until the shake (decision 84).
Stop the chain is gone too. It put the roll away with no total, which is
what Cancel the roll does and was not what the button said — a roll thrown
away rather than a roll finished. Scoring what is on the table instead would
need a reason a chain ended that is not the tray’s, and rather than invent one
the option was deleted: a chain that has earned a throw is finished by
throwing it.
A roll that comes back keeps what it already read — a stalled one and one
that left dice unread alike. The dice that were read are not thrown again —
throwing them would throw away answers the roll already has — so only the
others go back in the air, and their faces return to the dice they were thrown
for: the plan’s, or a chain’s (Passes). A throw that gives up reports no
outcome at all, so the faces read before it gave up are carried across
separately (RollMachine.gaveUp); in power-saving mode, where there are no
frames to pace a running readout, that one report is the only time the tray
says what it counted.
The total is drawn once. The result sheet keeps a subtotal per group,
because that is how its rows add up to the total — but a formula with one group
and nothing added to it has a subtotal that is the total, and printing it put
the roll’s number at the display size in the middle of the screen and again at
20 dp hard against the right edge, where the first device session read it as
clipped. So a subtotal is drawn unless it is the whole of the result
(Subtotals, and docs/design-handover.md).
A die from a later pass is not marked. A roll that had to throw something
again shows the dice of its last pass only, so a total counting twenty dice can
stand over a table holding three. The design outlines those dice in
--color-accent-700 with a pass 2 label; the app built that and took it out
again, because the table only ever holds the newest throw’s dice and an accent
mark there competed with the shake prompt and the pick ring for a meaning it
did not have (docs/architecture.md, decision 85). The result sheet is where
the earlier dice are.
6 and 9 carry a trailing dot. A die on a table lies at whatever angle it
landed at, and 6 and 9 are the same glyph turned over, so the ambiguous one
is marked — 6. and 9., on the felt and in the designer both. It replaces
the bar underneath this used to draw: a bar is a second horizontal in a system
whose dice already have edges. Which numbers are marked is unchanged and still
derived (docs/dice-sets.md, “Labels”), so a d% reads its units digit dotted
and its tens pair undotted, and a number set upright in the result sheet is not
dotted at all because nothing there is ambiguous.
It is what is printed on a die, so it is in core/glyphs rather than in a
Compose layout, and in the one solve the tray and the designer share: the mark
is a character of the built-in font that Typesetter writes after the label,
and LabelRoom measures the numeral with its dot on, because 6. is wider
than 6 and a 6 sized as though it were bare would hang its dot over the
edge of its face.
The dice are to arrive one at a time — not built yet. In the design each
falls from above where it lands, 85 ms after the one before it, and the result
sheet waits for the last of them: min(2400, 950 + (n − 1) × 85) ms
(docs/TODO.md, “Stagger the spawn”). What the app does today is release every
die at once and stagger them in height instead: SpawnLayout spreads the dice
over a grid of cells and cycles the cells through three height bands, so dice
that drift together arrive at different moments rather than in a heap. In the
prototype the shove a landing die gives the dice already down is an animation —
three passes along the collision normal, a randomised overshoot, a ±35° spin —
because the prototype has no solver. In the app it is not to be built at
all. A die landing among settled dice already moves them: they are rigid
bodies and it hit them. What the app takes from this is the stagger, dice
spawned across a beat rather than in one cluster; what it must not take is the
shove, which written in Kotlin would be precisely the invisible hand this
project refuses (.claude/CLAUDE.md). A settled die moved by another die is
physics. A settled die moved by code is a bug. When the stagger in time is built
it stays inside the seed — the schedule is part of the throw, so a replay
replays it — and it is still one world, so the capacity rule is unchanged.
Two asks from the Pixel 10a, both about seeing the dice
(docs/architecture.md, decision 83).
A throw clears the top. A shake that throws dice shuts whichever menu was
open and folds the formula’s tab into the shut dice pull-down: the tab slides
out through the right-hand edge and the Dice head is the only control left
along the top, so nothing there stands over the dice while they roll or after
they land. The menu button stays where it is. Opening the pull-down brings the
tab back beside it, and it stays out when the pull-down is shut again — until
the next throw. Nothing else brings it back: a tab that slid in when a total
landed would be one more thing moving over the dice the player is looking at.
To TalkBack the folded head offers “Open the dice and the formula”. A shake
that throws nothing — a formula that does not read, or dice already in the air
— leaves the top as it was, and the tab is where the mistake is marked.
A double tap clears the table. Two taps anywhere on the tray take every pull-down, pull-up and edge tab off it — the column along the top slides out through the top edge, the result and the saved rolls through the bottom one — and the next double tap brings each back exactly as it was: the same menu open or shut, the same sheet up or parked, the top folded or not. What stays is what is not a slide: the plate that says what the roll is waiting for (dropped to the bottom edge, since there is nothing left to ride above), the toast that announces it, the rings round picked dice and the debug overlay. A shake that throws gives the controls back, with the top folded, because a result that arrived behind a cleared table would be a total nobody saw.
A double tap is never a pick. One finger on a die picks it (“Picking a die up and throwing it again”), so the tray waits the platform’s double-tap timeout (300 ms) before it calls a lone tap a tap. Picking at once and taking the pick back on the second tap would ring a die, announce it and un-ring it inside a third of a second. A tap followed straight away by a pinch is a tap and a pinch; where the second tap of a double tap lands does not matter.
TalkBack takes a double tap for itself, so the tray carries one custom action, Hide the controls — Show the controls while they are hidden. It throws nothing (decision 66). Power-saving mode has no table to clear and takes no double tap.
stateDiagram-v2
[*] --> Out
Out --> Folded: a shake throws (menus shut)
Folded --> Out: the dice pull-down opened
Out --> Cleared: double tap
Folded --> ClearedFolded: double tap
Cleared --> Out: double tap
ClearedFolded --> Folded: double tap
Cleared --> Folded: a shake throws
ClearedFolded --> Folded: a shake throws
Which menu is open, whether the top is folded and whether the table is cleared
are one value, feature/roll’s Controls, plain Kotlin under JVM tests; the
gesture is DiceTray’s.
docs/tables.md, “Thumbnails”).headless renderer is used. The
screen goes further and puts no surface on the screen, rather than a
surface nothing draws to: a surface is a buffer the compositor keeps, and
what this mode claims is that none of it exists. PowerSavingTray is the
other implementation of Tray, and there is no Filament type in it.It says so, where the table would be. A grey panel with “Power-saving
mode” over “Same physics, no rendering. The result is identical to what the
tray would show.” — the prototype’s panel and the prototype’s words
(design/dInfinityPhone.dc.html, option 1z). It has to be said, because
the alternative was an empty screen with a total arriving on it, which is
indistinguishable from a renderer that has failed: the first device session
spent twenty minutes believing that was what it was looking at.
The sentence that matters is the second one. A player who thinks this mode is a cheaper kind of roll is a player who will not use it, and the whole claim of the mode is that the number is the same one the pictures would have shown.
It is not a plate. A plate is a ground for a control drawn over the table; this stands instead of it, which is why it fills the space the tray would and carries the tray’s own grey rather than the page’s colour.
RollPace exists so a player can
watch the dice land, and there is nothing here to watch. PowerSavingTray
asks for a fixed helping of simulated time rather than measuring a frame,
so it never crosses the line where real time becomes simulated time and
there is no factor in its path to set wrongly (“The simulation clock”). A
paced roll and this one take the same steps and come to the same faces; only
the wall clock over them differs, and here it is as short as the processor
can make it.Behind one setting — Developer tools, in Settings, off on every
install. With it off nothing about the app is different: no snapshot is
built, no menu row is drawn, and no seed exists anywhere a screen could show
one. With it on there are three things, and they are a separate surface
rather than fields unhidden on screens a player uses. The history still has no
replay and still never shows a seed (docs/architecture.md, decisions 13
and 56; docs/statistics.md).
There is no screen for it in the prototype and there is not meant to be:
design/dInfinity.dc.html is what a player sees, and this is a tool for
whoever is debugging the physics.
A panel over the tray, drawn while the roll screen is open. It shows, per frame:
FrameMeter), or “no frames
yet” before there are two in a row;FrameClock.droppedSteps, carried on the snapshot as
RollDiagnostics.droppedSteps), and how many every roll since the screen
opened has dropped (DroppedTally);Two things about it are decisions rather than details.
It is a view and cannot change the roll. The snapshot is a RollDiagnostics
built from state the loop already keeps, handed over through a DebugWatch
that returns nothing — the same promise Renderer makes and for the same
reason (docs/architecture.md, decision 38). RollDiagnosticsTest in
simulation/jolt runs one seed twice, taking a snapshot on every single step
of one run and none of the other, and asserts not only the same faces but the
same biases on the same steps. The snapshot is also built on demand:
DebugWatch.watching is asked before one is made, so a tray with the toggle
off walks no dice per frame.
It is a plan, not a wireframe over the dice. The picture is drawn by
Filament in perspective, from a camera that may lean 22° and can be pinched and
panned; the overlay is Compose, from straight above. Registering a wireframe to
the picture would mean reproducing the projection, the pinch and the pan on the
far side of Stage — new code behind the line no JVM test can reach, in order
to draw outlines over pictures that already show where the dice are. A plan says
what the pictures cannot: which die is standing on another, which is against a
wall, and which has not stopped yet. So nothing was added to Stage, and
TrayPlan — the arithmetic that turns a position in millimetres into a place on
the plan — is plain Kotlin with a JVM test (decision 56). The plan has the
picture’s left and right: the tray’s +y is the screen’s left (with +x
up the screen and +z out of it, nowhere else is right-handed), and the test
asks TrayCamera which side a point lands on rather than trusting that
sentence. The plan once drew +y on the right and showed the tray mirrored.
The frame rate is measured, not estimated, and only while it is shown.
TrayLoop.frame hands the overlay the time between two frame callbacks in a
row — Choreographer’s vsync timestamps, so a missed vsync shows as a 33 ms
frame on a 60 Hz panel — and nothing for the gap after a frame that asked for
no successor, which is the tray sitting still rather than stuttering. The
figures are FrameMeter’s, plain Kotlin with a JVM test; the relay keeps the
window on the roll thread and posts a reading to the screen every 30 frames.
With the toggle off DebugWatch.watching is false and no frame is timed. It
lives under the developer toggle rather than as a Settings row of its own: a
frame rate is a figure for whoever is checking Step 5.7’s targets, not a
choice a player makes (docs/architecture.md, decision 72). It reads what the
display did — the whole frame, physics and drawing — so it is the number the
headless harness cannot give (FrameTimes.drawn is false there).
The overlay is read when the roll screen opens and not watched, like power saving, the shake, the haptics and the sound, and for the same reason: an overlay appearing over a roll in progress is not a setting taking effect (decision 16). There is no overlay in power-saving mode, because there is no tray to draw it over.
On the Developer screen in the menu, which is listed only while the toggle is on. Both actions replay the last throw made this run, and the only difference between them is whose seed it carries:
ThrowSpec again — the dice, the
table, the scale, the seed and the shake that drove it — and says whether
it came to the same faces. It should, and a screen saying it did not is a
release blocker (docs/architecture.md, goal 4).What is replayed is FinishedThrow.thrown — the spec the roll actually ran,
with the shake written back into it — rather than a spec rebuilt from the plan
afterwards. A replay is run headlessly through the same DiceSimulator a
power-saving roll takes; there is no second path to a number here either. It
is not written to the history, the statistics or anywhere else.
Forced settles and post-rest corrections, with the seed and the counters of the throw that produced them. Both are supposed to be impossible, so the log is evidence rather than a statistic: there is no rate on it, no average and no chart, and the empty state reads “No anomalies. This is what a working build looks like.” A line in it is a bug to report, and the screen says that too.
It is kept in memory, bounded to the most recent fifty, and goes when the app does. It is not in the database, because it carries seeds and a stored seed is a replay waiting to be written into a screen a player can reach — which is what decision 13 exists to prevent. It is filled whatever the toggle says, so an anomaly from the throw before somebody went looking is still there; it is only ever read from the developer screen.
It is shared as plain text from that screen only, and deliberately not with
the statistics export: that file is built from HistoryEntry, which has no
seed on it and cannot grow one, and a single share path that could carry either
would be the place the two got mixed up (docs/statistics.md).
tools/harness.sh