The formula grammar
Every term a formula can carry. The grammar is OpenDice’s, so it is the same whether you call
roll() or the API. The examples below are the package; over HTTP the same string goes in
formula.
A formula is read case-insensitively and whitespace is trimmed. Anything the parser does not recognise is an error rather than a guess, so a typo never quietly becomes a different roll.
| Formula | Meaning |
|---|---|
2d6 |
roll two six-sided dice |
1d20+7, 10-1d4 |
flat modifiers, added or subtracted |
2d20adv |
advantage: roll two, keep the highest |
2d20dis |
disadvantage: roll two, keep the lowest |
4d6kh3 |
keep the highest three |
4d6kl3 |
keep the lowest three |
4d6kh |
keep one: a blank count keeps a single die |
2d6min3, 2d6max5 |
a floor or a ceiling on every die in the group |
2d6totalmin3 |
the same bound against the group’s total; totalmax caps it |
1d6! |
exploding: a top face rolls again and adds |
1d6!p |
penetrating: like !, but each roll after the first counts 1 less |
1d6x10 |
multiply this group’s total by ten |
1d8+1d4+3 |
as many terms as you need |
2d6 fire |
a trailing tag, when the caller lists it |
Advantage and disadvantage
Section titled “Advantage and disadvantage”2d20adv rolls two d20 and keeps the highest. The count says how many dice are thrown and
the suffix says that one of them survives, so the two are read separately: 4d20adv throws
four d20 and keeps the best of the four. Every die comes back, not only the one that
counted, so an interface can show the dice that were dropped rather than hide them.
Write a count of at least two. 1d20adv still rolls, and still rolls two dice, but one die
leaves the suffix nothing to choose between — it is deprecated, and a future version will
refuse it. 2d20adv is the formula it means.
You can also ask for advantage without writing it into the formula, which is what you want when the formula came from somewhere else:
roll('2d20+7', { advantage: 'advantage' })It applies to the first d20 term that does not already carry adv, dis, a keep rule or a
bound, keeping one die out of however many that term throws. Net advantage and disadvantage yourself before asking:
what you pass is applied rather than reasoned about. Over HTTP the same value goes in the
request body’s advantage.
Keeping and dropping
Section titled “Keeping and dropping”4d6kh3 rolls four dice and keeps the highest three — the usual way to
roll an ability score. 4d6kl3 keeps the lowest three
instead.
Leave the count out and it keeps one: 3d100kh is 3d100kh1. Writing the number is not the
same as leaving it out, though — 4d6kh0 keeps no dice, and is refused.
A keep rule never keeps them all. If you ask to keep more dice than you rolled, you get the dice you rolled.
Floors and ceilings
Section titled “Floors and ceilings”min sets a value the dice have to beat and max one they may not pass. Both apply to each
die on its own:
roll('2d6min3') // a die under 3 counts as 3roll('2d6max5') // a die over 5 counts as 5Write totalmin or totalmax to bound the group’s total instead. The two are not the same
thing: 2d6min3 floors both dice, so the least it can come to is 6, while 2d6totalmin3
floors the pair once and can still come to 3.
A bound binds to its own group the way a multiplier does, never to the sum, so anything added
afterwards lands on top of it — 2d6totalmin8+1 is never below 9. On a subtracted group it
bounds the dice rather than what the group contributes, so a min raises what gets taken
away: 2d6+1-1d4min2 reaches 11 rather than 12.
Nothing rolled is lost
Section titled “Nothing rolled is lost”A bound is not a face rewritten. It joins results as a value the dice competed against, and
kept says which of them won:
{ "sides": 6, "results": [1, 3], "kept": [3], "keptFlags": [false, true], "total": 3}The die rolled a 1, met a bound of 3, and lost — the 1 stays on the record. Per die the pool runs face, bound, face, bound; per total the sum meets one copy at the end and keeping is all or nothing. A tie goes to the dice, so a die matching its bound is the entry that counts.
Because a bound is not a die, a group that kept one reports no natural face: 1d20min20
counts as 20 every roll, and none of those is a 20 anybody rolled.
What a bound will not do
Section titled “What a bound will not do”A bound must be at least 1. It combines with a multiplier, which still applies last —
2d6totalmin10x2 is 20 on a pair of ones — and with nothing else: kh, kl, adv, dis
and ! are alternatives to it rather than companions.
Exploding and penetrating
Section titled “Exploding and penetrating”1d6! rolls again every time a die lands on its top face, and adds the whole chain. A die
of fewer than two sides never explodes, since every roll would be a top face.
1d6!p penetrates instead, which HackMaster uses: it explodes the same way, but every roll
after the first counts one less. The deduction comes off what the roll is worth rather than
off the face it landed on, so it never shortens a chain — a second roll showing a 6 on a d6
is recorded as 5 and still rolls again.
That has one consequence worth knowing: a penetrated 1 is recorded as 0, which is the only
case results holds a number below 1.
Both are capped at 100 explosions per die, and neither combines with kh, kl, adv, dis
or a bound.
Multipliers
Section titled “Multipliers”1d6x10 multiplies that group’s total by ten. The multiplier binds to its own group, never
to the sum, so 1d6x10+5 is “ten times a d6, then add five” rather than “ten times
everything”.
A trailing word is a tag: 2d6 fire rolls two d6 and carries fire back on the result.
Tags are metadata and never affect the arithmetic.
A tag is one word of letters, and one per formula, always the last thing in it. Digits and
punctuation are not letters, so 2d6 fire2 is a stray token whatever you list — the parser
never reads it as a tag in the first place. Case does not matter in the formula, which is
lowercased before it is read: 2d6 Fire comes back tagged fire. The words you list must be
written in lowercase, though, since they are matched against the formula after that.
A tag only works if you list it first. Pass the words you recognise in tags:
const result = roll('2d6 fire', { tags: ['fire'] })
result.tag // 'fire'result.total // 6Without that list, a trailing word is a parse error like any other stray token —
roll('2d6 fire') on its own throws. That is deliberate: an unrecognised word is far more
often a typo than a tag, and swallowing it would hide the mistake.
Over HTTP the same list goes in the request body’s tags, which
the endpoint documents.