dInfinity

Physics and rendering

Design: the tray, a settled roll and the power-saving result are options 1a and 1z of the clickable design (design/); “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.

Overview

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

Coordinates

One right-handed system, shared by the tray, the solver and the renderer: +z is up, the tray’s long side runs along +x and its short side along +y, and the origin is the middle of the floor. 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

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

Dice bodies

Every die is a convex rigid body:

Timestep and determinism

The simulation clock

A roll is a loop over fixed steps, and the only 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.

Starting a roll

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.

The hand lets go

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:

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.

No way in that is not a hand

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”).

What the screen says before the throw

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”).

Picking a die up and throwing it again

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.

It is the throw an explosion already makes

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.

What happens to the face the die already showed

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.

Which die, and whether it may be thrown again at all

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.

Can this be used to roll until you like the answer

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).

How it works on the screen

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.

What a shake’s spread currently rests on

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.)

Shake input

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.

How hard the dice are thrown, and how anyone can tell

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.

Settling and reading the result

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

The roll is finished when every die is at rest. 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:

Why a die could rock for ever

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.

Are the dice fair

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.

How round a die may be

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).

What it measured

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.

The d18 is not fair, and the shape is not why

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:

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:

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.

Where the d18’s bias actually sits

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.

The bar the d18 is held to

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.

Avoiding stacked and cocked dice

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

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

So the rule is: nothing touches a die that has come to rest. Everything below happens either before the dice are thrown or while they are still moving, except the last resort, which is 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”).

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

  2. 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.

  3. 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).

  4. 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.

Why a die was thrown again

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”).

Which Jolt, and when to look again

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.

The dice waiting to be thrown

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):

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:

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).

The dice an explosion or a reroll adds

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.

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.

Impacts, haptics and sound

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.

What an impact is, and where it is decided

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.

One list, two clocks

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.

Haptics

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.

Sound

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.

Rendering (normal mode)

Target: 60 fps with 20 dice on the Pixel 10a with headroom; the capacity rule caps a roll at what the table can hold, and never above a hundred dice (TableCapacity.MAX_DICE). Every die casts a shadow, whatever the count.

The table’s surface

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.

Power-saving mode has no Filament at all, and nothing here reaches it.

Rounded edges

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):

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.

A glossy table

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.

Performance, and how it is measured

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.

What is drawn over the table

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:

  1. Dice, a pull-down. Shut, it is one plate with the word 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.
  2. The menu button, in the same row, at the end of it. The room it takes is the row rather than a constant the formula had to remember to leave.
  3. The formula, under the menu button and aligned to the same edge, as a tab with a drawer behind it. Shut, it is a plate carrying the word 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:

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.

Preparing the dice

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.

Two pull-ups, one bottom edge

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.

Clearing the table

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.

Power-saving mode

Debug tooling

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.

The overlay

A panel over the tray, drawn while the roll screen is open. It shows, per frame:

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.

Replay

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:

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.

The anomaly log

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).