dInfinity

Dice notation

Design: the notation field (option 2a), the three dice pickers (1h–1j), the parse-error state (9c) and the saved-roll screens (1n–1p, editor 1r, import 9f–9g) are in the clickable design (design/).

dInfinity accepts the tabletop notation most players already know, e.g. 3d6 + 1d20 - 4. This document defines exactly what is accepted and how it is evaluated.

Examples

Input Meaning
d20 one twenty-sided die
3d6 three six-sided dice, summed
3d6 + 1d20 - 4 sum of both groups minus 4
2d20kh1 roll two d20, keep the highest (advantage)
2d20kl1 keep the lowest (disadvantage)
4d6dl1 roll four d6, drop the lowest (stat generation)
d100 or d% percentile: two d10 as tens and units (d% is an alias for d100)
8d6! exploding d6: each 6 rolls an additional d6
2 * (1d8 + 3) arithmetic and grouping
1d6 + 1d4 [Fire] trailing label, ignored for math, shown in breakdown
brass:1d20 use the d20 from the installed dice set with id brass
3{skull-d6}kh1 three of the die whose own id is skull-d6, keep the highest
3{brass:skull-d6}kh1 the same, from the set brass
3{skull:d6}kh1 three of the d6 of the set skull

The app carries this reference too. Menu → Notation lists everything below in the same words, with an example on every line that you can tap to put in the tray’s field — because somebody with a phone in their hand at a table is not reading a README. The screen is built from NotationReference in core/notation, beside the parser, and a test puts every one of its examples through that parser: an example the app would refuse fails the build rather than the player (docs/architecture.md).

Grammar

formula   := expr label?
expr      := term (("+" | "-") term)*
term      := factor (("*" | "/") factor)*
factor    := ("-")? atom
atom      := dice | integer | "(" expr ")"
dice      := (setref ":")? count? "d" sides modifier*
           | count? braced modifier*     ; a die named by its own id
count     := integer                     ; default 1, max 1000 (see Limits)
sides     := integer | "%" | "F"          ; "%" is an alias for 100 (as 2d10), "F" = fudge/fate die
setref    := identifier                  ; installed dice set id
braced    := "{" (slug ":")? slug "}"    ; {die} or {set:die}, no spaces inside
slug      := [a-z0-9][a-z0-9-]*          ; at most 40 characters
modifier  := "kh" integer                ; keep highest n
           | "kl" integer                ; keep lowest n
           | "dh" integer                ; drop highest n
           | "dl" integer                ; drop lowest n
           | "!"                         ; explode on max face
           | "r" integer                 ; reroll once on value <= n
           | "min" integer               ; clamp each die to at least n
label     := "[" text "]"
integer   := [0-9]+
identifier:= [a-z][a-z0-9_-]*

Whitespace is ignored between tokens. Most of the notation is case-insensitive: D, F, kh, kl, dh, dl and min may be written in either case, so 4D6KH3 and 4d6kh3 are one formula. Two things are not:

A set’s own dice

A dice set may define dice under any id — skull-d6, a d100 that really has a hundred faces (docs/dice-sets.md). Plain notation cannot name them: a die id and a modifier are made of the same characters, so brass:skull-d6kh1 has no reading that does not ask the installed sets where the id stops (docs/architecture.md, decision 31). Braces close the id before the modifiers start, so a set’s own die is written braced:

The set goes inside the braces and only there: brass:3{skull-d6} is an error that says where to put it, so each die has one spelling and the picker’s badges can read it back. A braced id is the die with that id and nothing else: {d100} is a set’s real hundred-face die, never the percentile pair, and {d10-tens} is the tens die thrown on its own. Everything else is as for any group — the count, the modifiers, the limits, the breakdown, which quotes the group as written.

The parser lexes what is between the braces without looking at any set, which is what lets the field keep validating on every keystroke; whether a set has the die is decided when the formula is planned (DieResolver), and a die nobody has is the usual “no skull-d6 in set “builtin”” under the group. A formula without braces means exactly what it always did, so every saved roll, history entry and collection file written before is read the same way.

Limits

To keep the simulation and the probability graph tractable:

Limit Value Behaviour when exceeded
Dice per formula (parse) 1,000 Parse error, shown inline. This bound exists so the outcome graph stays cheap.
Dice per roll table capacity (docs/tables.md), hard cap 100 A shake throws nothing and the screen says how many would fit; the graph still works
Sides per die must exist in a set Parse error naming the missing die
Explosion depth 20 Further explosions ignored, noted in breakdown
Dice in the tray, including the ones explosions add table capacity, hard cap 100 The chain stops there, noted in the breakdown. An added die is dropped into clear floor, and a tray with none left cannot take one (docs/tables.md)
Nested parentheses 8 Parse error
Characters in a braced set or die id 40 Parse error; no dice set can define a longer id
Result magnitude fits in 64-bit Overflow is a parse-time error via the PMF bound

A formula like 500d6 can be typed and graphed but cannot be rolled: the dice would not fit on the table with room to move, and a physics engine with that many bodies crammed in a box produces garbage rather than a roll. The capacity check happens before any body is created and the UI explains it (“500 dice don’t fit on the table; up to 72 do”).

Evaluation

  1. Parse into an AST.
  2. Resolve each dice node to concrete Die definitions: setref if given, otherwise the default set — chosen on that set’s own details screen (docs/dice-sets.md, design 6a) and remembered with the settings — falling back to the built-in set per die when the default set lacks that one (so a set with no d12 still rolls 1d20 + 1d12). The breakdown names the set each die came from and says when it fell back.

    A default that is not installed, switched off, or no longer valid is not a default: the built-in set stands in for the whole catalogue rather than per die, because a catalogue pointing at a set nobody has cannot resolve a plain d20 at all. The setting is left as it was — a set switched off for an evening is still the one the player chose.

  3. Run the table capacity check on the total die count (including the dice that a first explosion could add). Refuse with a message if it fails.
  4. All dice from all groups go into one physics throw. The breakdown attributes each physical die back to its group. A die that comes to rest where it cannot be read — cocked, or standing on another — has no face yet: it waits where it lies for the player’s next shake, which throws it again, and nothing below happens until every die of the throw has a face (docs/physics-and-rendering.md, “Avoiding stacked and cocked dice”). So an explosion is never decided, and a chain never asks for its shake, while a die of the throw is still unread.
  5. Exploding and re-rolled dice: each extra die is a throw of its own, made once the last one has come to rest, into the same tray. It drops into the clear floor the settled dice leave, among the dice that set it off, and the player watches it land — the dice already down are drawn where they stopped and nothing moves them, because nothing in the new throw can reach them (docs/physics-and-rendering.md, “The dice an explosion or a reroll adds”). A chain stops at the depth limit, or sooner if the tray has no room left for another die. In power-saving mode the same throws happen with nothing drawn.
  6. Apply the group’s modifiers in the fixed order below, then the arithmetic. See Division rounding below.
  7. Produce a RollResult with the total and a per-die breakdown including dropped dice (shown struck through). The result screen shows the total large, then each group’s subtotal and the individual dice, then the modifiers — the user never has to add anything up.

    A chain that stopped says so, under its group. Everything else a die has to say about itself is already legible on the row above — a dropped die is struck through, a rerolled one stands beside its replacement, an exploded one is simply another die in the row. The two ways a chain ends are the exception, because what they describe is a die that is not there: an 8d6! that reached the depth limit and an 8d6! that ran out of table look identical on the sheet, and both read as an explosion that never happened. So the group carries a line saying which it was — “Exploding stopped at 20 dice.” or “The tray had no room for another die.” A reroll the tray had no room for reaches the second line too, because it is the same fact about the tray. Each line appears at most once per group, whatever the number of dice that hit the limit, and in a fixed order rather than the order they happened in.

    The modifiers are the plain numbers the formula adds or takes away, each on a row of its own and in the order they were written. They are the top-level sum only: the 3 in (2d6 + 3) * 2 is multiplied along with the dice, so listing it as “+ 3” would be adding up to the wrong number in front of the player. A formula like that gets no such rows, and its total is the formula’s own arithmetic — RollResult.itemised is what says which of the two a result is, and it is checked rather than assumed, because “the rows add up” is the one claim on that screen a reader cannot verify at a glance. It is the same rule the picker’s badges follow (see “Picking dice without typing”): edit or itemise only what can be read back.

The order modifiers are applied in

Modifiers take effect in this order whatever order they were written in, so 4d6r1dl1 and 4d6dl1r1 mean the same thing:

  1. r n — a die showing n or less is thrown once more. Once: the replacement stands however low it is. Both dice stay in the breakdown, the first struck through — which is also how a die a player picked up and threw again is shown, every earlier face struck through in front of the one that replaced it (physics-and-rendering.md, “Picking a die up and throwing it again”). No die of a group with r n or ! can be picked up: in a chain, which die counts depends on what was thrown because of what. A reroll the tray has no room for does not happen and the die stands as it fell, marked in the breakdown — the alternative being a die dropped onto dice that have already been read.
  2. ! — a die showing its highest face earns another throw of the same die. The new die joins that die’s chain rather than the group at large, so 2d6!kh1 keeps the better of two chains, which is what a player means by it. A chain stops after the explosion depth limit, or when the tray has no clear floor left for another die; either way the die that would have exploded again is marked in the breakdown.

    The app does not throw the earned dice. The player shakes again. An exploding six earns a throw, and a throw is something a hand does — so the dice that are down stay down, the screen says how many a shake is owed, and the next shake throws them. There is no button for it: a shake is the only way anything is thrown (docs/physics-and-rendering.md, “Starting a roll”).

    A chain cannot be stopped short. There was a Stop the chain option beside it, and it put the roll away with no total — a roll thrown away rather than a roll finished, which is not what the words promised. Scoring what is on the table instead would need a reason a chain ended that is not the tray’s, so rather than invent one the option was deleted. A chain that has earned a throw is finished by throwing it.

    Advantage earns nothing. 2d20kh1 is one throw of two dice followed by a selection, so there is no second throw to wait for and no shake is owed. Only ! and r n earn a later throw.

    Every chain that earned a die is owed one at the same moment, and one shake throws the lot. Three sixes in 8d6! are three dice, thrown together, because that is what a hand does with three dice; asking for a shake each would be asking three times for one act. Depth is the part that stays sequential — a chain cannot know it needs a third die until the second has landed — so a long chain is several rounds and a wide one is a single handful. A chain that threw itself finished a roll the player had not finished asking for, and it made the one moment worth watching happen without them. Everything else about it is unchanged: the earned die is thrown into the same tray, among the dice still down, on a seed derived from the throw that started the chain — so a chain still replays to itself however long the player takes between shakes. Still down, because a throw that had to throw one of its own dice again took the dice it had already read off the table to make the room, and those have left: they keep their faces and the next link of the chain is neither drawn over them nor aimed around them (docs/physics-and-rendering.md, “The dice an explosion or a reroll adds”).

  3. min n — a die below n counts as n, per die. The face it actually landed on is still what the breakdown shows; only its contribution changes.
  4. kh / kl / dh / dl — whole chains are kept or dropped, ranked by what each chain came to together. A percentile pair counts as one unit, so 2d%kh1 keeps the better of two 1–100 results.

What the screen says before the dice are thrown

Every roll shows what it is expected to come to: the lowest, the highest and the average. It is on the plate over the tray before the shake, where the Roll button’s label used to be the only thing saying what a throw was worth, and again under the breakdown on the result sheet, so the total is a number in a range rather than a number on its own.

The ends are the same reckoning the live range uses, asked of a throw that has read no dice at all — so the figures a player reads before the throw and the figures they watch close during it are one calculation seen twice. The average is the exact mean of the distribution (docs/probability.md), and a formula too large to graph exactly keeps its range and loses only its average.

What the screen says while the dice are still landing

A roll counts each die and takes it off the table the moment it can be read, so by the time the last one lands most of the answer has been known for a while and the dice that carried it are gone (docs/physics-and-rendering.md). What the screen follows instead is how many dice have been read and how high and low the finished roll can still come out, which is one line whether the throw was four dice or a hundred.

The floor is exact: the lowest faces set off no explosion, so it is a total the roll could really come to. kh, kl, dh, dl, min, arithmetic and percentile pairs are exact at both ends — forcing the dice that have not landed to their lowest or highest faces is a real assignment, and a group’s subtotal only goes up as its dice do. Subtraction turns a group’s range over rather than carrying it through, so 20 - 1d6 reads 14 to 19 and not the other way about.

A formula that can explode shows its ceiling with a +. A maximum face earns another throw, so there is no useful highest number — counting every die a forced chain could earn gave 8d6! a ceiling of 1008, attainable and useless.

The ceiling is instead every die at its highest, and every throw they earn at its lowest. 3d6! is three sixes, and the three throws those earn coming up one apiece: 3 to 21+. The + is the honest part — those three could have been sixes too, and earned three more.

A lowest face explodes nowhere, so the chain stops itself rather than being cut off, and the number is a total the roll could really come to.

The mark comes from the ceiling’s reckoning alone. The floor draws extra dice as well — a die forced low is what sets off r n — and a reroll is not an explosion: its replacement stands however it lands, so 1d6r3 really does top out at six and marking it would promise a number that cannot come.

A group may carry each modifier at most once, and may keep or drop, not both: 4d6dl1dl1 and 4d6kh1dl1 are refused rather than quietly meaning something. Keeping or dropping more dice than the group rolls is refused too.

What is settled before the dice are thrown

Everything that can be known without rolling is checked while the formula is still text, so a roll in front of a player never fails halfway through:

Division rounding

Division rounds down by default (7/2 = 3), which is what most game rules say. The default is a setting, and the result sheet offers Down / Nearest / Up for the throw in front of you — the total is recomputed from the same dice, which stay as they landed. Nearest rounds .5 up and anything below .5 down.

The per-throw override is not remembered: the next roll uses the setting again. The outcome graph always uses the setting, since it is computed before the throw exists.

Picking dice without typing

The roll screen also has a dice picker: tap a d6 three times and a d20 once and you have 3d6 + 1d20. A tap writes into the formula field, so what comes out is a formula somebody could have typed — which is what makes the outcome graph, the breakdown and statistics identical either way. A picked roll can be turned into a saved roll with one tap.

It is a pull-down at the top of the table, headed Dice and carrying the count of dice the formula asks for, so a menu that is shut still says what is in the throw. The row inside scrolls sideways: ten dice at a touch target worth pressing do not fit across a 360 dp phone (docs/physics-and-rendering.md, “What is drawn over the table”).

Which set the row offers is chosen inside that same pull-down, under the row, once there is a second set installed — one entry is furniture, so the chooser is not drawn until it has something to choose between. It is not the same question as the default set: which set a bare d20 means is a preference chosen where the sets are (docs/dice-sets.md, design 6a), and somebody whose default is their own set still reaches for a borrowed d20. A die taken from a set that is not the default is written with it in front — brass:1d20 — so the row can only write a formula that rolls what it showed; taken from the default set it is written bare, because that is what a person would type. Changing the chooser leaves the formula exactly as it is: what is already written was written on purpose.

The rules the picker follows, all of which fall out of “a tap writes a formula and never throws one away”:

The row offers every die the selected set defines: the standard dice first — dN, d% and dF, in their usual order — then the set’s own dice in the order its file lists them, each captioned with its id and drawn as the solid it is. A tap on a set’s own die writes it braced (see “A set’s own dice”): 1{skull-d6} from the default set, 1{brass:skull-d6} from another, counted up to 2{brass:skull-d6} by the next tap like any group. A set’s own die whose id plain notation already names exactly — a d3 — is written plainly, 1d3, because that is what somebody would type; a d100 of its own is braced, because plain d100 is the percentile pair (docs/architecture.md, decision 75). The badges follow the same rule as ever: 2{d6} is not what the d6 button writes, so it is not the d6 button’s to count.

d100 and d%

d% is an alias for d100 everywhere: in typed formulas, saved rolls, imported collections and the breakdown (which always prints d100). Both are always resolved to a tens d10 (faces 00–90) and a units d10 (0–9) from the same set, marked as a pair. Result = tens + units, with 00+0 = 100. If the set has a d10-tens die it is used; otherwise the normal d10 is used with its face values multiplied by ten in the breakdown. Sets can also define a true 100-face die but it is never chosen by d100 implicitly.

d2

d2 is an ordinary die id: it uses the set’s d2 (a coin) if it has one. Without a setref, a set that lacks one falls back to the built-in set’s coin, like any other missing die, and the breakdown says which set it came from. With a setref, a missing d2 is an error. Nothing stands in for a coin — no d6 is relabelled 1,2,1,2,1,2.

dF, and the sign a Fudge total carries

A Fudge die’s faces are a minus, a blank and a plus — worth −1, 0 and +1 — and the bundled df is labelled − − 0 + 0 + (docs/dice-sets.md, “Numbering”). The faces and the breakdown print those symbols, and so does the statistics histogram, which names each bar by the die’s own label (docs/statistics.md, “Screens”).

A total of Fudge dice is written with its sign: +2, −1, 0. That is how a Fate result is written, and it is the only spelling that says the same thing about one die as about four. The minus is U+2212, the one the faces carry, rather than a hyphen — a total set beside the dice that made it should use the same glyph they do.

It applies when every die in the roll is a Fudge die, and to a group’s subtotal on the same rule. 1dF + 1d6 is a count, because a d6 is a count and a count plus a sign is a count. Exports are unaffected: a file is read by a machine (docs/statistics.md).

The alternative was to print the die’s own label — −, +, 0 — for a roll of exactly one die. It was refused because it only works for one: 4dF has no single face to quote, and a d10, whose tenth face is printed 0 and is worth ten, would read 0 for its best roll.

Saved rolls

A saved roll is a named formula with an icon, living in a group:

SavedRollGroup {
  id, name ("Curse of Strahd" / "Thorin"), icon,
  parentId?              // one level of nesting: game → character
  sortOrder
}

SavedRoll {
  id, groupId,
  name ("Fireball"), icon (from icon pack or emoji),
  formula ("8d6 [Fire]"),
  colour tag,
  sortOrder               // the order the player dragged it into
  createdAt, lastUsedAt, useCount
}

Export and import

Saved rolls travel as a collection: a JSON file describing groups and rolls.

{
  "format": 1,
  "name": "Thorin, level 5 fighter",
  "groups": [
    { "id": "thorin", "name": "Thorin", "icon": "⚔️", "parent": null }
  ],
  "rolls": [
    { "group": "thorin", "name": "Longsword", "icon": "🗡️", "formula": "1d20 + 7 [Attack]" },
    { "group": "thorin", "name": "Longsword damage", "icon": "💥", "formula": "1d8 + 4 [Slashing]" }
  ]
}

Emptying the field

Every formula field — the tray’s drawer, the saved-roll editor, the outcome graph — carries a × at its end while it has anything in it. One tap empties it and leaves the cursor in it, so the keyboard stays up for the next formula (design/dInfinity.dc.html, options 2a, 6f and 9c). A phone has no select-all worth the name, and the alternative was holding backspace through 8d6 [Fire] + 2d4kh1. An empty field has no ×, because there is nothing for it to do. To a screen reader it is one button, “Clear the formula”, with the 48 dp target of every other control.

It is not a second way to empty a formula: the × hands the screen the same empty text the last backspace would, so each screen does exactly what it already does with an empty field. On the tray that is the Empty state — the waiting dice come off the board and nothing is left to throw (docs/architecture.md, “The roll screen”, the clear the field edges); the editor has a roll with no formula, and no odds under it, until one is typed; the graph goes back to the note it shows with nothing to draw. It throws nothing, and nothing that has been saved is changed until the editor saves.

Error messages

Parse errors point at the offending character range and are shown inline under the formula field: the formula is printed again with a squiggle under the characters that are wrong, and the message after it (design/dInfinity.dc.html, options 6f and 9c).

3d6 + 1d7 - 4 — this set has no d7
       ~~~

Printed again rather than marked up in the field itself. The field is where somebody is typing, and a squiggle that moves under the cursor as they type is a squiggle that fights them. A wave rather than a straight underline, because a straight underline under text reads as a link.

The blame is clamped to a character that exists: a formula that stops in the middle of something (3d6 +) is blamed on a position one past its end, and there is nothing there to underline.

Suggestions are offered when the intent is obvious (d7 → nearest available, 3 d 6 → 3d6), as one tap under the message. A braced {d7} is offered the nearest dN still braced; a braced {skull-d7} is offered nothing, because there is no honest nearest to a skull. Empty braces ({}), a { with no }, and a character that cannot be in an id ({Skull}, a space) are each an error of their own, pointing at the brace or at the character. Where there is no honest reading there is no suggestion: a guess that is wrong is one tap away from replacing a formula somebody meant.