A small language for asking exact questions about dice, and for simulating the fights that dice alone cannot answer
Every rule in a role-playing game is a claim about a probability distribution, and almost every such claim is made by feel. A designer adds two to an attack bonus and calls it a small buff. A referee swaps three six-sided dice for one twelve-sided die and calls it about the same, but swingier. Both claims are testable. Neither is usually tested, because testing them means either an afternoon of algebra or ten thousand hand-rolled dice.The second claim is true, and the size of the effect is worth knowing: 3d6 and 1d12+4 share a mean of 10.5, but their standard deviations are 2.96 and 3.45, and their extremes differ enormously. Three sixes in a row happens once in 216 rolls; a twelve happens once in twelve.
This page is a language for making those claims precisely, and an interpreter that answers them. It does two different things, and the difference matters more than any syntax on this page.
For a single roll, however tangled, the interpreter computes the answer exactly. It does not roll dice. It enumerates every outcome and adds up the probabilities, so 4d6 keep highest 3 comes back with a mean of 12.2446, correct to the last digit, and a shape you can read off the plot rather than squint at through sampling noise.Exactly 15869⁄1296. Enumerating all 1296 orderings is cheap; the interpreter instead walks the six face values and tracks how many dice have been placed and how many kept, which stays cheap even for twenty dice.
For anything that unfolds over time, exactness stops being available. Once a goblin’s hit points depend on what happened last round, and this round’s attacks depend on who is still standing, the space of outcomes stops being something you can enumerate. So the interpreter switches honestly to simulation: it plays the fight out many thousands of times and reports what happened, with the uncertainty that sampling implies printed next to the answer rather than hidden behind it.
The language is small. There are eleven statements and one genuinely subtle idea, which is the subject of the third section below.
Everything below is live. Edit the program and press Run, or ⌘/Ctrl + Enter. Tab indents; Escape leaves the editor.
A die is written the way it is written at the table. d20 is one twenty-sided die, 3d6 is three six-sided dice added together, d% is percentile, and dF is a Fudge die reading −1, 0 or +1. Dice combine with ordinary arithmetic, so 2d6 + 3 means what it looks like.
After a handful of dice you may write a modifier, and the modifiers read as English: 4d6 keep highest 3, 2d6 drop lowest 1, d6 exploding, d10 reroll 1, 6d10 count >= 7. Any expression at all may be rolled twice and resolved with with advantage or with disadvantage.Advantage on a d20 is worth about +3.3 to the average, but that average conceals its real behaviour: it helps most when you needed a middling number and barely at all when you needed a 2 or a 20. Sweep the target number and the effect draws itself.
show gives a one-line summary with a sparkline; plot draws the whole distribution, and hovering it reports the chance of any particular result or better.
Given exactly two things, compare also answers the question the picture cannot: which one is better. There are two answers, and they disagree more often than anyone expects.
Roll 4d6 keep highest 3 against 3d6 + 3 and the first comes out higher 34.3 per cent of the time. Ask instead which is likelier to reach a particular number — 15 or more, then 16 or more, and so on through the whole range — and 3d6 + 3 wins at every single threshold, without exception.This is first-order stochastic dominance, and it is a much stronger statement than a higher average. It says that for any target number a designer might pick, the dominating roll is the one you would rather have. The interpreter checks it across the whole support, so it is a fact rather than an impression.
Both are true. The first is a fact about a duel; the second is a fact about target numbers, and a game is nearly always about target numbers. A tool that reported only the averages — 12.24 against 13.5 — would have told you which was bigger and nothing about why the argument keeps happening.
Give something a name with let, and each time you mention that name it rolls again. This is almost always what you want: let attack = d20 + 5 names a kind of roll, not a particular one, and two attacks in a round are two separate rolls.
But it means attack + attack is two swings, not one swing counted twice. If you want a single roll used in more than one place — and you do, constantly, because that is what a critical hit is — you need given, which fixes one roll and then works out the consequences for every value it could have taken.The interpreter enumerates the twenty faces of the die, evaluates the body once for each, and mixes the twenty answers in proportion to their probability. The result is exact, not sampled. This is conditioning, done the pedestrian way.
The difference is not small. Two independent d20 rolls added together have a standard deviation of 8.16; one d20 roll counted twice has a standard deviation of 11.53. Same average, different game.
When you mention a random name twice in one expression, the interpreter says so in a note underneath the answer, and works out the other reading so you can see what you would be choosing between. It does not stop you; sometimes two swings is exactly what you meant.
Here is the question a designer actually has: what does this attack do per round, and how much does the bonus matter? It needs a to-hit roll, a critical hit rule that depends on that same roll, damage that only happens on a hit, and a sweep across the parameter you are thinking of changing.
The sweep is the part worth keeping. A single number tells you what the damage is; the curve tells you what the bonus is worth, and whether the rule you are considering is linear, which most of them are not.Against a fixed armour class a flat bonus is worth a constant amount per point, right up until the roll can no longer fail, at which point further bonuses are worth nothing at all. The kink in the curve is where your bonus stops buying anything.
A fight is not a roll. Its outcome depends on its own history, and the number of histories is not something worth enumerating. For that there is sim, which plays a block of ordinary imperative code many times over and collects whatever you record.
Inside a sim block the rules change, deliberately: every die is actually rolled, var holds a number rather than a distribution, and a name mentioned twice is the same number both times. It is the same language read the way a program is read rather than the way an equation is read.
Everything a sim reports carries a ± figure, which is the ninety-five per cent interval for the sampling error and nothing else.It says how sure you can be that more trials would not move the number. It says nothing about whether the model is a good model of your game, which is a question no amount of trials can answer. Quadrupling the trials halves it.
For the common case of two sides hitting each other until one falls over, describing the creatures and writing fight is quicker than writing the loop.
A table maps ranges on a die to outcomes, which may be numbers, dice, or text. Roll it by naming it. If the ranges leave a gap, the interpreter says so rather than quietly guessing.
| Statement | What it does |
|---|---|
let name = expr | Names a kind of roll. Each mention rolls again. |
show expr | One line: mean, spread, range, and a sparkline. |
plot expr | The whole distribution, drawn. Hover for the odds of any result. |
compare a, b, … | Several distributions on one shared scale. |
sweep name from a to b { expr } | Evaluates the expression at each value and draws the curve. step sets the interval. |
define name(args) = expr | A reusable rule. |
table name { 1-3: … } | A roll table. Entries may be numbers, dice or text. |
creature name { hp … ac … attack … } | Something that can be put in a fight. |
fight a vs b trials n | Simulates the two sides until one is down. |
sim n { … } | Runs a block n times and summarises what it recorded. |
check expr | Asserts the expression is certainly true. Used for the self-test below. |
sim block| Statement | What it does |
|---|---|
var name = expr | Rolls once and keeps the number. |
name = expr | Assigns to a name already introduced. |
if cond { … } else { … } | A branch. |
while cond { … } | A loop. Stops complaining loudly after 5,000 turns. |
repeat n { … } | A loop with a count. |
record "name" expr | Collects a value from this trial. Yes/no values are reported as percentages. |
stop | Ends this trial early. |
| Written | Meaning |
|---|---|
d20 3d6 d% dF | One die, three dice, percentile, a Fudge die (−1, 0, +1). |
4d6 keep highest 3 | Also keep lowest, drop highest, drop lowest; or kh3, kl1, dh1, dl1. |
d6 exploding | A maximum result rolls again and adds. penetrating subtracts one from each extra die. |
d10 reroll 1 | Rerolls a 1 or less, once. Add forever to reroll until it is not. |
6d10 count >= 7 | A dice pool: counts how many dice pass the test. |
expr with advantage | Rolls the whole expression twice and keeps the better. Also with disadvantage. |
| Operators | Notes |
|---|---|
if … then … else … | else may be left off; it means zero. |
given name = expr { … } | Fixes one roll, exactly, for the whole block. Several may be listed with commas. |
or and not | Anything other than zero counts as true. |
< <= > >= == != | Produce 1 or 0, so chance(d20 >= 15) is 0.3 and comparisons can be added up. |
+ - * / // % ^ d | // divides and rounds down, which is what “half, rounded down” means. |
| Function | Returns |
|---|---|
mean(x) sd(x) variance(x) | Exact average, spread, variance. |
chance(x) | The probability that x is not zero. Feed it a comparison. |
atleast(x, n) atmost(x, n) | The probability of rolling n or more, or n or less. |
median(x) quantile(x, q) | The middle, and any other quantile. |
lowest(x) highest(x) outcomes(x) | The smallest and largest possible results, and how many there are. |
min(a, b) max(a, b) clamp(x, lo, hi) | Taken over the distributions, not over their averages. |
floor ceil round abs half sqrt | Applied to every outcome. half rounds down. |
best(k, n, x) worst(k, n, x) | The sum of the best or worst k of n copies of anything, exactly. |
copies(n, x) dice(n, faces) | n independent copies added up. |
roll(x) | Actually rolls it, once, and gives a number. |
The exact engine works with finite distributions held as a list of outcomes and their probabilities. Adding two of them means considering every pair, so the cost grows with the number of distinct results rather than the number of dice: 100d6 is instant because it has only 501 possible totals, while given over a large distribution is expensive because the body is evaluated once per outcome. When a calculation would grow past what is sensible, the interpreter stops and says so rather than grinding.
Exploding dice are unbounded in principle, so they are computed shell by shell until the remaining probability falls below 10−15 and the remainder is placed on the last outcome. The error this introduces is far below the fourth decimal place that anything here reports.
Everything sampled uses a seeded generator, printed in the workbench, so a result you share can be reproduced exactly by anyone who types the same seed. Change the seed to convince yourself an answer is not an artefact of one lucky stream.The generator is mulberry32, which is not cryptographic and does not need to be. For a hundred thousand trials of a fight it is indistinguishable from a good source.
fight makes assumptions, and they are worth knowing before quoting its numbers: sides act in alternating turns, who goes first is a coin flip, each attacker swings once at the first enemy still standing, an attack hits when its roll reaches the target’s armour, and there are no critical hits, no saving throws, no morale and no retreat. For anything more specific, write the loop yourself in a sim block, where none of those assumptions are made for you.
A probability engine that is quietly wrong is worse than no engine, so the interpreter carries a suite of checks with known answers — values computed independently with exact rational arithmetic. They run in the page, against the same code that ran your program.