Skip to content

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

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.

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.

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 3
roll('2d6max5') // a die over 5 counts as 5

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

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.

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.

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.

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 // 6

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