rollatom
v1.1.0
Published
A dependency-free dice-notation parser and roller for the web platform.
Maintainers
Readme
rollatom
A dependency-free dice-notation engine for the web platform. It uses one grammar for rolling dice, represents each rolled face with a record (the "RollAtom"), and leaves interpretation to the table. The engine reports what came up; the table decides what it means.
Programmers may recognize a map/filter/reduce-style pipeline.
npm install rollatomimport { rollDice } from "rollatom";
rollDice("2d20kh1 + 5"); // advantage, +5
rollDice("6d10ko8c"); // count the 8-or-better successes
rollDice("10d[-1,0x6,1x3]"); // net successes: 8+ counts one, 1s subtract one
rollDice("(2d8+3)s/2"); // resistance: half the total, rounded down
rollDice("(1d12{'hope'} + 1d12{'fear'})i"); // two named d12s, listed individuallyTry formulas in the playground. A link can carry
one: #f=4d6dl1.
RollAtom is ESM-only and runs in current browsers, web workers, and Node 20 or later. The default
RNG needs crypto.getRandomValues; where that is missing, pass your own random. No package
manager is required: see Installing without npm.
rollDice(notation, options?) returns a NotationResult with the total, surviving values,
per-face records (dropped dice included, marked), and name-grouped subtotals. RollOptions
configures the random source (a CSPRNG by default), the color palette for #name tokens,
and autoColor for named subtotals. For server-authoritative rolls, call rollDice in the
trusted environment and send its result to clients.
validateDice(notation, options?) checks a formula without rolling it and returns a DiceError
or null.
Contents
- Why RollAtom?
- Coming from Roll20 or Foundry
- Grammar at a glance
- Installing without npm
- Calling the engine
- Reading the result
- Configuration
- Validating without rolling
- Rolling one formula many times
- Explaining a formula
- Versioning
Why RollAtom?
A roll is an array of faces: operators transform it, filters narrow it, and one reduction turns it into a number. Every part of the grammar follows from that model instead of being a feature added beside it.
- One scheme rather than a list of features.
k/d/rkeep, drop, or reroll;h/lselect by rank;o/uselect by value. Theothat names an explosion trigger is theothat names a value filter, so combinations you have not seen before work. - The whole roll survives into the result. Every face reports its raw roll, its sign, the die it
came from, the chain an explosion added, and the rolls a reroll threw away. Faces a filter removed
stay in the result marked as dropped, so an interface can show the die that was set aside
(Reading the result). The exception is one the formula asks for: an
sorcinside a larger formula seals its dice into a single number, so(2d6)s + 1reports one sealed face and the constant, not the two d6. - Unusual dice are data, not engine support. A weighted die, a narrative die, and a tens die are
face lists (
d[-1,0x6,1x3],d['cat','dog'],d[10..60:10]). A system with strange dice needs nothing added to the roller. - A fixed draw order. One formula and one generator always consume randomness in the same sequence, so a roll is reproducible and auditable. The generator is injectable and defaults to a CSPRNG, letting a server own the outcome while clients render it.
- Impossible rolls fail before the dice. An explosion that can never trigger, or one that could never stop, is rejected when the formula is read rather than discovered at the table.
The costs are deliberate. There is no arithmetic beyond the reduction, which may be multiplied or
divided once at the point the notation names it (sx2, s/2, s/3u); anything further belongs to
a macro layer. Nothing here knows what a success, a critical, or a degree of failure is. Word order
is fixed, so 6d6kh3! is an error rather than a guess and exploding after a keep is written
(6d6kh3)!. And familiar spellings brought from another roller can parse cleanly while meaning
something else, which the next section lists.
Coming from Roll20 or Foundry
Most notation carries over unchanged: 2d20kh1, 4d6dl1, 3d6!, and 4dF mean here what they
mean there. Four spellings look familiar but are not:
| Spelling | Elsewhere | In RollAtom |
|---|---|---|
| !! | compounding explosion, unbounded (Roll20, rpg-dice-roller) | one extra layer, appended as its own face. The compounding one is * |
| * | multiplication (1d8*2) | vertical explosion, unbounded. To scale a total, write 1d8sx2 (Final operators) |
| ro | reroll once (Roll20, Foundry, rpg-dice-roller) | reroll dice over a threshold. Reroll-once-under is ru (Operators) |
| s | sort (rpg-dice-roller) | sum, the default reduction. RollAtom does not sort; i keeps roll order |
Common rolls side by side:
| Roll | Elsewhere | Here |
|---|---|---|
| exploding pool | 3d6! (Roll20), 3d6x (Foundry) | 3d6! |
| explode one layer only | 3d6xo (Foundry) | 3d6!! |
| compounding explosion | 3d6!! (Roll20) | 3d6* |
| reroll 1s and 2s, once each | 4d6ro<3 | 4d6ru2 |
| reroll 1s and 2s until they clear | 4d6r<3 | 4d6rru2 |
| success pool at 5 or better | 6d6>5 (Roll20), 6d6cs>=5 (Foundry) | 6d6ko5c |
| half a modified total, rounded down | floor((2d8+3)/2) | (2d8+3)s/2 |
| percentile | d% | d100 |
The letter scheme behind the second table is uniform: k/d/r keep, drop, or reroll; h/l
select by rank; o/u select by value, over or under (Filters).
There is no general arithmetic and no comparison operator: scaling a total is the reducer scale
sx2 or s/2 (Final operators), and success counting is a
value filter plus c (Filters).
The grammar follows. It is executable as src/dice-notation.test.ts, with property-based contract
tests in src/notation-contract.test.ts. The API reference resumes after
Worked examples.
Grammar at a glance
The full specification lives in docs/GRAMMAR.md: the EBNF, every rule, the limits, and worked examples. This is the shape of it.
A formula is one or more blocks joined by + and -. A block is a die, then optional
operators, then optional filters, then an optional final operator. That order is fixed
and enforced, so 6d6kh3! is an error; write (6d6kh3)!.
| Part | Spelling | Examples |
|---|---|---|
| Dice | NdM, d[…], dF | 4d6, d[3..6], d[10..60:10], 2d['cat','dog'], 4dF |
| Appearance | {#color,'name'} | 2d6{#red}, 2d6{#red,'fire'} |
| Clamp | minN, maxN | 4d6min2 |
| Explode | ! !! * **, optional oN/uN trigger | 4d6!, 1d6*, 4d6!o5 |
| Reroll | r + h/l/o/u, rr for until | 4d6ru2, 4d6rru1, 4d6rl1 |
| Filters | k/d + h/l/m/o/u + N | 4d6dl1, 2d20kh1, 6d6ko4 |
| Final | s, c, i, with optional scale | (2d8+3)s/2, 6d6ko4c, (1d6+1d8)i |
The letters compose rather than enumerate:
kkeep,ddrop,rreroll.h/l/mselect by rank (highest, lowest, middle);o/uselect by value (over, under).rrrerolls until the condition no longer holds.!explodes horizontally, appending a new face;*explodes vertically, summing into the exploding die's own seat. Doubling either (!!,**) limits it to one extra layer.ssums,ccounts the survivors,ilists them. A scale rides onsandc:xNmultiplies,/Ndivides rounding down,/Nudivides rounding up.
rollDice("4d6dl1"); // drop the lowest of four
rollDice("2d20kh1 + 5"); // advantage, +5
rollDice("6d10ko8c"); // count the 8-or-better successes
rollDice("(2d8+3)s/2"); // half the modified total, rounded down
rollDice("10d[-1,0x6,1x3]"); // a weighted narrative dieComing from another roller, read Coming from Roll20 or Foundry first: several spellings parse cleanly here and mean something different.
Installing without npm
The published package is one ES module that imports nothing, so it also runs straight from a URL or from a copy inside your own tree. Every route below loads the same file.
That file is 43.2 kB, 12.3 kB gzipped. Minified, it is 16.5 kB, 6.0 kB gzipped.
From a CDN. Any npm-backed CDN serves the built module. Pin the version, so a later release cannot change what the page loads:
<script type="module">
import { rollDice } from "https://esm.sh/[email protected]";
console.log(rollDice("2d20kh1 + 5").total);
</script>https://cdn.jsdelivr.net/npm/[email protected]/dist/index.js and
https://unpkg.com/[email protected]/dist/index.js serve the same module.
Through an import map, when application code should keep writing the bare name:
<script type="importmap">
{ "imports": { "rollatom": "https://esm.sh/[email protected]" } }
</script>
<script type="module">
import { rollDice } from "rollatom";
</script>Vendored. Copy dist/index.js into your project, with dist/index.d.ts beside it for types.
It has no imports, so there is nothing to resolve. A TypeScript project can copy src/index.ts
instead.
In Deno, npm:[email protected] and the esm.sh URL both resolve:
import { rollDice } from "npm:[email protected]";Whatever the route, the runtime must provide crypto.getRandomValues, or a random function has
to be supplied per call (see Configuration). Nothing else is required.
Calling the engine
import { rollDice, validateDice, DiceError } from "rollatom";
import type { NotationResult, NotationFace, RollOptions, RandomInt } from "rollatom";
const result = rollDice("4d6dl1");rollDice(notation, options?) parses, rolls, and reduces in one call. It either returns a
complete NotationResult or throws DiceError. There is no partial result
and no error field to check:
try {
render(rollDice(notation));
} catch (error) {
if (error instanceof DiceError) showInvalid(notation);
else throw error;
}Branch on instanceof DiceError to separate a rejected formula from a real failure, and on
error.code to tell one rejection from another. Never on the message text
(Versioning).
Errors fall into two groups, and the split decides where a caller has to handle them:
| Group | Raised | Covers | Caught by validateDice? |
|---|---|---|---|
| Static | at parse time, before a die is drawn | syntax, unknown #color, formula length, operand count, i placement, impossible and never-halting triggers, and the base dice a formula is certain to roll | Yes |
| Roll-time | mid-roll, as dice are drawn and reduced | the total draw cap, the 50-roll chain cap on a single face, and a result too large to hold exactly | No |
An input field can therefore call validateDice on every keystroke
and still needs a try around rollDice (Limits and safety).
The same formula gives a different result every call. The default source is a CSPRNG. Pass
random to replay a roll (Custom random source); for a given formula the
draw order is fixed (Determinism), so a recorded seed reproduces the roll exactly.
For server-authoritative rolls, call rollDice in the trusted environment and send the result
to clients. A NotationResult is plain data: it survives JSON.stringify and JSON.parse
unchanged, and carries everything a renderer needs, so a client never re-parses or re-rolls.
Where the error is
A DiceError raised while reading the formula carries index, a 0-based offset into the
notation exactly as passed in, so an input field can point at the character that failed:
const error = validateDice(notation);
if (error) {
const caret = error.index === undefined ? "" : " ".repeat(error.index) + "^ ";
console.log(notation + "\n" + caret + error.message);
}6d6kh3!
^ Invalid notation
(2d6)sx3/2
^ Invalid notation
d6!o7
^ Explosion can never trigger
1d6 + (2d6i) + 3
^ `i` must be the outermost reductionindex marks the start of the offending token, not wherever scanning stopped: 2000d6 points
at the count and 2d6{'unclosed} at the opening quote, not at the character that finally gave up.
When the input simply runs out, it is one past the end, so (2d6 gives 4.
It is undefined for the failures no single character causes: the formula-wide draw cap
(60d6 + 60d6 counts base dice across every block), the length cap, and the caps only a roll can
reach. Treat it as absent-by-default and the caret as an enhancement.
Error codes
Every DiceError carries a code, of type DiceErrorCode, naming the rule the formula
broke. Branch on code; display message:
const error = validateDice(notation);
if (error?.code === "limit-draws") showDiceCounter();| Code | Raised when | Example |
|---|---|---|
| syntax | the formula is not well-formed | 6d6kh3! |
| unknown-color | a #name the palette does not define | 2d6{#chartreuse} |
| dead-trigger | an explosion or reroll threshold no face can meet | d6!o7 |
| endless-trigger | an unbounded explosion or reroll every face meets | d6!o1 |
| limit-draws | more dice than LIMITS.draws | 60d6 + 60d6 |
| limit-chain | one face chained past LIMITS.chain | 1d6! |
| limit-length | a formula longer than LIMITS.length | -- |
| limit-operands | more operands than LIMITS.operands | -- |
| limit-faces | a face list outside 2 to LIMITS.faces faces | d[1..102] |
| limit-value | a magnitude past LIMITS.value | 1d6 + 1001 |
| result-too-large | a sum past the integers a number holds exactly | (((((1000)sx1000)sx1000)sx1000)sx1000)sx1000 |
| invalid-roll | a custom random returned outside [1, faceCount] | -- |
Each limit- code names the LIMITS key it enforces. 1d6!
reaches the chain cap only when every die comes up 6.
Two formulas can share a message and differ in code: 0d6 and 101d6 both say Too many dice,
but the first is syntax and the second limit-draws. A minor release may add codes, so handle
an unfamiliar one as a plain rejection rather than switching exhaustively.
Reading the result
A result is the array of faces from The one idea, plus the reduction over it.
Rolling 4d6dl1 against a source that yields 5, 2, 6, 3:
{
"notation": "4d6dl1",
"total": 14,
"values": [5, 6, 3],
"mode": "sum",
"faces": [
{ "value": 5, "sign": 1, "raw": 5, "history": [5], "faces": 6 },
{ "value": 2, "sign": 1, "raw": 2, "history": [2], "faces": 6, "dropped": true },
{ "value": 6, "sign": 1, "raw": 6, "history": [6], "faces": 6 },
{ "value": 3, "sign": 1, "raw": 3, "history": [3], "faces": 6 }
]
}values and faces answer different questions. values is the arithmetic: the surviving
faces, signed, in roll order, and exactly what total reduces. faces is the picture: every
face in the final array, the ones filters removed included, marked dropped and contributing
nothing. A renderer that reads only values has no way to dim the die that lost. Dice inside a
seal are the exception: they are consumed by it and appear as its one face.
| Field | Type | Present | Holds |
|---|---|---|---|
| notation | string | always | the formula as passed in |
| total | number | always | the reduction, with any scale already applied |
| values | number[] | always | surviving face values, signed, in roll order |
| faces | NotationFace[] | always | every face, dropped ones included |
| mode | "sum", "count", "individual" | always | the outermost reduction |
| labels | string[] | when any face carries a name or is a string face, and always under i | per-face display text, positionally parallel to values |
| subtotals | NotationSubtotal[] | when any face carries a name | per-name totals, in first-appearance order |
| scale | NotationScale | when the outermost reducer carries one | the xN or /N that total already includes |
scale is the one trap. total has it applied and values do not, so a renderer that sums
values itself will disagree with total. (2d6)sx2 on 3 and 4 gives values: [3, 4],
scale: { "op": "x", "by": 2 }, and total: 14. Show total, or apply scale yourself.
A face
| Field | Type | Present | Holds |
|---|---|---|---|
| value | number | always | sign times raw: this face's contribution to total |
| sign | 1 or -1 | always | -1 only for a face joined by - |
| raw | number | always | the number the die shows, before sign |
| history | number[] | always | the raw rolls behind raw. Empty for constants and seals |
| rerolls | number[] | when the face rerolled | the raw rolls a reroll operator discarded, in order. A discarded vertical chain is recorded as its sum, not its rolls: 1d6*rl1 on 6, 2, 3 gives rerolls: [8] |
| faces | number | rolled dice only | the source die's face count, for picking an icon |
| label | string | labeled faces | the face's own text: a Fate +, a d['cat','dog'] side |
| name | string | named dice | the name the die carries, which drives subtotals |
| color | string | colored dice | the resolved hex color |
| sealed | true | sealed faces | produced by an s or c seal: sourceless, but not a constant |
| dropped | true | filtered faces | kept in faces, absent from values and total |
Three things the types do not tell you:
raw is not "the unsigned value". sign comes only from a - in the formula, so a face list
holding negative numbers puts them in raw: one dF reading minus has raw: -1 and sign: 1,
while the d4 in 2d6 - 1d4 has raw: 3 and sign: -1. To show the die as it landed, read raw;
to do arithmetic, read value.
history distinguishes the two explosions. A horizontal explosion adds faces to the array, so
1d6! on 6, 6, 2 returns three faces, each with its own single-entry history. A vertical
explosion sums the chain into one face, so 1d6* on the same rolls returns one face with
raw: 14 and history: [6, 6, 2]. Rendering history when it holds more than one entry is what
shows the chain that built the number.
A face with no faces field came from no die. A constant has history: [] and nothing else; a
seal has history: [] and sealed: true. Both still occupy a slot in values. A seal is a
collapse, not a summary: (2d6)s + 1 on 6 and 2 returns a sealed face with raw: 8 and the
constant, and the two d6, along with anything a filter dropped inside the parentheses, are not in
the result. Only a seal inside a larger formula does this. A reducer that ends the whole formula
((2d6)s, 6d6ko4c) is the formula's reduction, and its faces stay open.
Names, subtotals, and modes
A named die groups: every face sharing a name contributes to one entry in subtotals, colored by
the die's own color or, without one, by autoColor (Colors).
2d6{#red,'fire'} + 1d8{'ice'} on 3, 4, 7 produces subtotals of fire: 7 (#cc3333, the
palette's red) and ice: 7 (an auto color), with total: 14 unchanged. Subtotals describe the
roll; they never change the arithmetic.
mode says how to present total:
| mode | From | total is | Typical rendering |
|---|---|---|---|
| sum | implicit, or s | the sum of values | the number |
| count | c | how many faces survived | "3 hits" |
| individual | i | still the sum, usually ignored | each face listed separately |
Under i, labels is always present, and an unnamed face's label is simply its value as a string.
A label is therefore worth showing only when it differs from the value beside it.
A worked renderer
Everything above, as one function over a result:
function describe(result: NotationResult): string {
const lines: string[] = [];
for (const face of result.faces) {
const source = face.faces ? `d${face.faces}` : face.sealed ? "seal" : "const";
const chain = face.history.length > 1 ? ` = ${face.history.join(" + ")}` : "";
const rerolled = face.rerolls ? ` (rerolled ${face.rerolls.join(", ")})` : "";
const shown = face.label ?? String(face.value);
lines.push(
` ${face.dropped ? "-" : "*"} ${source} ${shown}${chain}${rerolled}` +
`${face.name ? ` [${face.name}]` : ""}${face.color ? ` ${face.color}` : ""}`
);
}
for (const sub of result.subtotals ?? []) lines.push(` ${sub.label}: ${sub.total}`);
if (result.mode === "count") lines.push(` ${result.total} hits`);
else if (result.mode === "individual") {
// Under `i`, an unnamed face's label is just its value, so only add a label that says more.
const parts = result.values.map((value, i) => {
const label = result.labels?.[i];
return label && label !== String(value) ? `${label} ${value}` : String(value);
});
lines.push(` ${parts.join(", ")}`);
} else lines.push(` total ${result.total}`);
return lines.join("\n");
}Against seeded rolls it prints:
4d6dl1 1d6*
* d6 5 * d6 14 = 6 + 6 + 2
- d6 2 total 14
* d6 6
* d6 3 4d6ru2
total 14 * d6 6 (rerolled 1)
* d6 5
6d6ko4c * d6 3 (rerolled 2)
* d6 5 * d6 4
- d6 2 total 18
* d6 6
- d6 3 (1d12{'hope'} + 1d12{'fear'})i
* d6 6 * d12 9 [hope]
- d6 1 * d12 4 [fear]
3 hits hope: 9
fear: 4
2d6{#red,'fire'} + 1d8{'ice'} hope 9, fear 4
* d6 3 [fire] #cc3333
* d6 4 [fire] #cc3333
* d8 7 [ice]
fire: 7
ice: 7
total 14A real renderer swaps the * and - markers for styling and faces for a die icon, but the
shape is this: walk faces to draw, read subtotals to group, and switch on mode for the
headline.
Configuration
Options are supplied per call. RollAtom has no mutable global configuration.
| Option | Type | Default | Purpose |
|---|---|---|---|
| random | (faceCount: number) => number | Web Crypto CSPRNG | Selects a face for every base roll, explosion, and reroll |
| palette | Record<string, string> | DEFAULT_PALETTE | Resolves named color tokens such as #red |
| autoColor | (label: string) => string | Stable color derived from the label | Colors named subtotals that have no explicit color |
Custom random source
The default generator uses crypto.getRandomValues() with rejection sampling, so there is no
modulo bias. It never uses Math.random().
A custom generator can wrap another cryptographic library, a seeded generator, hardware, or prefetched remote randomness:
import { rollDice, type RollOptions } from "rollatom";
const options: RollOptions = {
random(faceCount) {
return myRandom.uniformInteger(1, faceCount);
},
};
const result = rollDice("4d6!", options);random is synchronous and must return an integer from 1 through faceCount, inclusive. The
number is a 1-based face index, not necessarily the value printed on that face. For
d[10,20,30], indices 1, 2, and 3 select values 10, 20, and 30. RollAtom throws DiceError if
the callback returns zero, a fraction, or an out-of-range value. The custom generator is
responsible for producing an unbiased distribution. An asynchronous or remote source must
prefetch its values and expose them through the synchronous callback.
The callback is invoked once for every random draw, including explosion and reroll draws. Draw order is deterministic for a given formula, as specified in Limits and safety.
Colors
palette maps named tokens to the color metadata returned on faces and subtotals. Palette keys
should be lowercase because color tokens are case-insensitive. A 3- or 6-digit hex token such
as #f0f or #ff00aa does not use the palette.
Supplying palette replaces the default palette. Extend DEFAULT_PALETTE when the standard
names should remain available:
import { DEFAULT_PALETTE, rollDice, type RollOptions } from "rollatom";
const options: RollOptions = {
palette: {
...DEFAULT_PALETTE,
brand: "#7950f2",
danger: "#e03131",
},
autoColor(label) {
return label === "fire" ? "#e8590c" : "#495057";
},
};
const result = rollDice("2d6{#brand,'attack'} + 1d8{'fire'}", options);An explicit color is returned as NotationFace.color and is shared with colorless dice that have
the same name (Appearance). autoColor applies only to a named subtotal with no
explicit color; it does not assign a color to the individual faces. The default autoColor returns
the same color for the same label.
The limits are not options. They are fixed (Limits and safety)
and readable through the LIMITS export.
Validating without rolling
validateDice runs the parse phase and draws no dice. It returns the DiceError the same input
would throw, or null. Use it for an input field that should reject a bad formula as it is
typed, or to check macros before storing them:
import { validateDice } from "rollatom";
validateDice("2d20kh1 + 5"); // null
validateDice("6d6kh3!"); // DiceError: Invalid notation (operators may not follow filters)
validateDice("d6!o7"); // DiceError: Explosion can never trigger
validateDice("60d6 + 60d6"); // DiceError: Too many dice (more base dice than the draw cap allows)It makes every static check rollDice makes, and reads only palette from the options.
A validated formula can still fail on the roll-time limits
(Calling the engine), so rollDice still needs its try.
Rolling one formula many times
compileDice parses a formula once and hands back something you can roll repeatedly. Use it for a
macro a character sheet rolls every turn, or a loop sampling the same notation:
import { compileDice } from "rollatom";
const attack = compileDice("2d20kh1 + 5");
attack.roll().total; // a fresh roll
attack.roll().total; // another, from the same parse
attack.notation; // "2d20kh1 + 5"compileDice(notation, options?).roll() returns what rollDice(notation, options?) returns. A
compiled formula keeps no dice between rolls, so one instance can be stored and rolled for as long
as it lives.
Static errors throw from compileDice, not from roll. Roll-time ones still throw from each
roll:
compileDice("6d6kh3!"); // throws DiceError: Invalid notationpalette is read when the formula is compiled. random and autoColor given to compileDice
are defaults each roll may override:
const macro = compileDice("2d6{'pip', #brand}", { palette: { brand: "#7950f2" } });
macro.roll(); // the default CSPRNG
macro.roll({ random: (faces) => myRandom.uniformInteger(1, faces) }); // a source of your ownroll takes CompiledRollOptions: RollOptions without palette. For a formula rolled once,
rollDice costs the same.
Explaining a formula
explainDice returns what a formula does in English, one line per step in the order the roll
performs them. Use it for a tooltip or a help panel:
import { explainDice } from "rollatom";
explainDice("4d6dl1");
// Roll 4d6, drop the lowest.
// The total is the sum of the faces kept.Defaults the notation leaves unwritten are spelled out: a bare kh keeps one, an explosion
triggers on the highest face, a formula with no final operator is summed. A group's steps are
indented two spaces:
explainDice("(1d8* + 1d6*)kh1");
// Roll a group:
// Roll 1d8, explode on 8, the highest face (extra rolls add into the same die while they keep triggering).
// Add 1d6, explode on 6, the highest face (extra rolls add into the same die while they keep triggering).
// Across the group, keep the highest.
// The total is the sum of the faces kept.A final operator on the whole formula reads as the total; anywhere else it is a seal (Open vs sealed operands):
explainDice("2d8sx2 + 3");
// Roll 2d8, collapse to one face worth the sum, times 2.
// Add 3.
// The total is the sum of the faces.It draws no dice, reads only palette from its options, and throws the DiceError
validateDice would return. The wording is English only and may change in any release, like
DiceError.message; do not match against it.
Versioning
RollAtom follows semantic versioning from 1.0.0. The contract is the set of exports this document
describes -- rollDice, compileDice, validateDice, explainDice, DiceError, LIMITS, DEFAULT_PALETTE,
and the NotationResult, NotationFace, NotationSubtotal, NotationScale, RollOptions,
CompiledDice, CompiledRollOptions, Limits, DiceErrorCode, and RandomInt types --
together with the grammar itself. A formula that rolls
under 1.0 keeps rolling under every later 1.x, and keeps the same shape of result.
What that covers:
- The caps in
LIMITSmay rise in a minor release, never fall. - Rolled numbers are not covered; the draw order is. A given formula draws from
randomin the same order (Determinism), so a recorded seed replays the same roll. - ESM-only is part of the promise. 1.x will not add a CommonJS build.
DiceErrormessages are not covered; thecodeis. Which formulas are rejected, and with which code, will not change within 1.x. The wording may, and so may the exactindex: it is for drawing a caret, not comparing.
Additions are minor releases: new grammar, new optional result fields, new optional RollOptions.
A formula that stops rolling, or a result that changes shape, is a bug.
