dicerollerts
v0.59.1
Published
A TypeScript library for parsing, rolling, and analyzing dice expressions, plus a small program language for tabletop-RPG-flavoured automation. Supports standard dice notation, custom dice, dice pools, exploding/rerolling/compound mechanics, structured-fa
Readme
dicerollerts
A TypeScript library for parsing, rolling, and analyzing dice expressions, plus a small program language for tabletop-RPG-flavoured automation. Supports standard dice notation, custom dice, dice pools, exploding/rerolling/compound mechanics, structured-face dice, parametric dice, and full probability analysis (exact distributions, joint distributions, and adaptive Monte Carlo).
Install
npm install dicerollertsQuick Start
import { ProgramParser, Evaluator, RR } from 'dicerollerts'
const result = ProgramParser.parse('4d6 drop 1')
if (result.success) {
const evaluator = new Evaluator(
(sides) => Math.floor(Math.random() * sides) + 1,
)
console.log(evaluator.run(result.program)) // e.g. 14
}One language
This library is a single language. Dice atoms (d6, 4d6, dF, d%,
d{1,2,3}, @name, 3 @name) are first-class expressions and compose
freely with variables, arithmetic, control flow, records, arrays, and
comprehensions:
$str = 5
$attack = d20 + $str
if $attack >= 15 then 2d6 + $str else 0There is no separate dice-expression "mode" and no backtick wrapping -
write the same notation everywhere. The single public parser entry point
is ProgramParser.parse(source).
Migrating from 0.x:
- Backticks are no longer accepted. Replace
`d20 + $mod`withd20 + $mod. Run stored programs throughmigrateSource(text)(a one-line regex that strips backticks) for a quick mechanical pass.- Arithmetic that previously lived inside a backtick block is now program-level, so
4d6 drop 1and4d6 + 2d8behave identically - the dice modifiers still bind tighter than+/-, so4 + 4d6 drop 1parses as4 + ((4d6) drop 1).- Public
DiceParseris gone; useProgramParser.parsefor everything.Roller.rollandDiceStats.{distribution,mean,...}now accept the unifiedExpressionAST emitted by the parser.- The
DiceExprwrapper node is gone. Dice atoms (Die,NDice,CustomDie,DiceReduce,StructuredDiceRoll) appear directly inExpression. Thesource: stringcosmetic field is removed.Evaluator'sonDiceExpr+onStructuredDiceRollcallbacks merged into a singleonDiceTerm: (event: DiceTermResult) => void. Theevent.kinddiscriminator is'arithmetic'|'structured'.RollResulttag renames:binary-op-result→binary-expr-result,literal-result→number-literal-result,unary-op-result→unary-expr-result. Constructors renamed to match (binaryExprResult,numberLiteralResult,unaryExprResult). Op fields use the program vocabulary (add/subtract/multiply/divide).ProgramParameters.listshape:defaultExpr+defaultSource→ singledefaultExpression: Expression.partsingruntime dependency is gone - the package now ships with zero runtime deps.
1. Dice Expression Language
Pass any of the following to ProgramParser.parse(...). Whitespace is
generally free; _ is treated as whitespace.
Multiline expressions
A newline normally ends a statement. An expression may still span several lines
when a line ends with a pending binary operator (+ - * / and or and the
comparison operators) - the trailing operator signals that the right-hand side
continues on the next line:
$dmg = 2d6 + $str +
$weapon_bonus +
$crit_dice
$hit = $attack >= $ac and
not $defender_invisibleThe operator must be the last token on the line; a newline placed before an
operator ends the statement instead. A # comment is allowed between a trailing
operator and the newline.
Basic dice and literals
| Notation | Description |
| -------------- | ---------------------------- |
| d6 | One six-sided die |
| 3d6 | Three six-sided dice, summed |
| d20 | Twenty-sided die |
| d% or d100 | Percent die |
| 42 | Literal number |
| -d6 | Negated die |
Custom dice
| Notation | Description |
| ---------------- | --------------------------- |
| d{1,1,2,2,3,4} | Die with custom face values |
| dF | Fate / Fudge die (-1, 0, 1) |
| 4dF | Four Fate dice |
Custom face values may be negative or zero.
Arithmetic
Standard precedence (multiplication / division before addition / subtraction), left-to-right within each tier. Parentheses group sub-expressions.
| Notation | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 3d6 + 4 | Addition |
| 2d8 - 1 | Subtraction |
| d6 * 2 | Multiplication |
| d100 / 10 | Division (truncates to integer; throws UndefinedOutcomeError on zero divisor - the analyser turns this into partial-number / undefined FieldStats rather than crashing) |
| $a max 0 | Larger of two numbers - see Scalar min / max |
| $a min 10 | Smaller of two numbers - see Scalar min / max |
| (2d6+1)*3 | Parenthesized |
Unicode aliases: × and ⋅ are accepted for multiplication; ÷ for division.
≤ for <=, ≥ for >=, ≠ for !=. → for the -> match-arm / fold
lambda arrow. The letter x and the colon : are not accepted as
operators - they collide with identifiers (and 0x… literals) and with
record-field / array-slice syntax respectively.
Scalar min / max
max and min are also infix operators over two numbers: $a max $b is
the larger of the two, $a min $b the smaller. They give the "floor this at
zero" and "clamp to a range" idioms a direct spelling instead of a duplicated
if or a two-element comprehension.
$tens = 4d10 count >= 8
$ones = 4d10 count == 1
($tens - $ones) max 0They sit between comparison and addition in precedence and associate left,
and max and min share one level - so the clamp idiom reads in order:
| Written | Groups as |
| ------------------ | --------------------------- |
| $a - $b max 0 | ($a - $b) max 0 |
| $a max 0 + 1 | $a max (0 + 1) |
| $a * 2 max $b | ($a * 2) max $b |
| $a max 0 > 3 | ($a max 0) > 3 |
| $x max 0 min 10 | ($x max 0) min 10 (clamp) |
| $a max $b max $c | ($a max $b) max $c |
Disambiguation from the reducer, aggregator and threshold roles
The same two words already name a dice reducer
(2d6 max), a comprehension aggregator
(max for $x in $xs { $x }) and an
explode / reroll threshold (3d6 explode on max). One rule keeps
them apart, and it has no exceptions:
min/maxis the infix operator when - and only when - an operand follows it on the same line, except inside a dice-modifier threshold slot, where the threshold reading always wins.
2d6 max # reducer: the higher of the two dice
2d6 max 3 # operator: max(sum of 2d6, 3)
2d6 max max 3 # reducer, then operator: max(higher die, 3)
3d6 explode on max # threshold: explode on a natural 6
3d6 explode on max max 4 # threshold, then operator: max(sum, 4)
max for $x in [1, 2] { $x } # aggregator - `for` is not an operand
{ max: 5, min: 1 } # record keys, `$max` / `$r.max` - unaffectedBecause the threshold reading wins outright, a threshold max is never also
the operator: write the threshold and the operator out in full
(3d6 explode on max max 4). 3d6 explode max 4 is a syntax error, exactly as
it was before the operators existed.
Two consequences worth memorising:
- A negative right operand needs parentheses. A bare
-is deliberately not an operand start, so2d6 max - 1keeps meaning "reduce by max, then subtract 1". Write$a max (-1). - There is no line continuation after
min/max. Unlike+orand, a trailingmaxat end of line does not continue on the next one - amaxwith the next line's operand below it is two statements (a reducer, then a reference). Keep both operands on one line, or parenthesise.
The infix reading also covers only the exact words min and max; the reducer
aliases minimum / maximum stay reducer-only.
Rounding
Five postfix operators turn a numeric expression into a rounded integer. They
bind tighter than arithmetic - 2d6 / 3 round rounds the quotient, not the
operand.
| Form | Mode | 1.5 | -1.5 | 2.5 |
| ------------------- | -------------------------------- | ----- | ------ | ----- |
| … round | Half away from zero | 2 | -2 | 3 |
| … round up | Ceiling - toward +∞ | 2 | -1 | 3 |
| … round down | Floor - toward -∞ | 1 | -2 | 2 |
| … truncate | Toward zero | 1 | -1 | 2 |
| … round half even | Banker's rounding (nearest even) | 2 | -2 | 2 |
round is not JavaScript's Math.round - -1.5 round is -2, not -1.
This matches the convention used by spreadsheet ROUND, Python's
decimal.ROUND_HALF_UP, and most tabletop rulebooks.
The keywords are not lexer-level reserved - $round, $truncate, etc. remain
valid identifiers outside the postfix slot.
The runtime helpers are also exported for consumers that fold RoundExpr
nodes into a normalised number themselves (UIs that display the rounded value
alongside the source, serializers that pre-compute the result):
import {
applyRoundMode,
roundHalfAwayFromZero,
roundHalfEven,
} from 'dicerollerts'
applyRoundMode(1.5, 'round-half-even') // 2
roundHalfAwayFromZero(-1.5) // -2Expression sets and reducers
Group dice into a pool with square brackets. The bare bracket form
[d, d, …] is an array literal (list-typed); adding a reducer
suffix turns it into a scalar dice-pool expression.
| Notation | Description |
| --------------------- | --------------------------- |
| [2d6, 3d8, d10] | List of three rolled values |
| [2d6, 3d8, d10] sum | Sum of all (explicit sum) |
| [2d6, 3d8] min | Lowest result |
| [2d6, 3d8] max | Highest result |
| [2d6, 3d8] average | Arithmetic mean (rounded) |
| [2d6, 3d8] median | Median |
| [d20, d20] keep 1 | Filter + default sum |
Reducer aliases: min / minimum / take least; max / maximum / take best;
average / avg; median / med.
Migrating from older versions:
- Paren-list dice pools
(d, d, …)were removed; use[d, d, …].as poolis gone - bracket literals are already list-typed, so$rolls = [d20, d20]replaces the historic$rolls = 2d20 as pool.- Bare
(expr)parens still work for arithmetic grouping ((d6 + 1) * 2); only the multi-element comma-separated paren form is gone.
Drop and Keep
Drop or keep some of the dice in a pool. Filters can be chained - each filter operates on the surviving dice from the previous one.
| Notation | Shorthand | Description |
| ---------------------------- | --------- | --------------------------------------- |
| 4d6 drop 1 | 4d6d1 | Drop lowest 1 |
| 4d6 drop lowest 1 | 4d6dl1 | Same, explicit (low is also accepted) |
| 4d6 drop highest 1 | 4d6dh1 | Drop highest 1 (high also accepted) |
| 4d6 keep 3 | 4d6k3 | Keep highest 3 |
| 4d6 keep highest 3 | 4d6kh3 | Same, explicit (high also accepted) |
| 4d6 keep lowest 1 | 4d6kl1 | Keep lowest 1 |
| 5d6 keep middle 3 | | Keep the central band of 3 sorted dice |
| 5d6 drop middle 1 | | Drop the central die (keep the outer 4) |
| 5d6 drop high 1 drop low 1 | | Drop high 1 then low 1 (keep middle 3) |
The single-letter shorthands k<N> / d<N> keep highest / drop lowest; the
two-letter forms kh<N> / kl<N> / dh<N> / dl<N> name the direction
explicitly. Like the single-letter forms, they take a literal count only - use
the long keep lowest $n keyword form for a parametric count.
For middle, when the pool size minus N is odd, the extra dropped die is taken
from the upper end of the sorted pool. So 5d6 keep middle 2 keeps sorted
ranks {1, 2} (the lower-middle pair), and 5d6 drop middle 2 keeps the
complement {0, 3, 4}.
Heterogeneous filterable pools are also allowed: [d20, d12, d10] keep 2.
Exploding
Roll again when a trigger condition is met. Extra rolls are added as separate
dice and contribute to the reducer (typically sum).
| Notation | Shorthand | Description |
| -------------------------- | --------- | --------------------------------------- |
| 3d6 explode on 6 | | Explode on exact 6, no limit |
| 3d6 explode once on 6 | | Explode at most once |
| 3d6 explode twice on 6 | | At most twice |
| 3d6 explode thrice on 6 | | At most three times |
| 3d6 explode 3 times on 6 | | At most N explosions |
| 3d6 explode on 5 or more | 3d6e5 | Explode on 5+ |
| 3d6 explode on 2 or less | | Explode on 1 or 2 |
| 3d6 explode on 3..5 | | Explode on a value in [3, 5] |
| 3d6 explode 3..5 | | Same, on omitted |
| 3d6 explode >= 6 | | Operator spelling of on 6 or more |
| 3d6 explode <= 2 | | Operator spelling of on 2 or less |
| 3d6 explode == 6 | | Operator spelling of on 6 (= 6 too) |
| 3d6 explode exactly 6 | | Keyword spelling of on 6 |
| 3d6 explode on max | 3d6em | Explode on the die's maximum |
| 3d6 explode max | | Same |
| 3d6 explode always on 6 | | Same as explode on 6 |
Every one of those trigger spellings is accepted by explode, reroll and
compound alike, before or after a times qualifier
(3d6 explode twice >= 5, 3d6 reroll once on 1..2).
Cancelling explosions
cancel <range> after an explode or compound trigger takes explosions
away from the pool. Each die of the initial pool whose face matches the
cancel range removes one explosion from the pool's explosion budget - the
"tens explode, ones cancel" family of rules.
| Notation | Description |
| --------------------------------------- | ----------------------------------------- |
| 10d10 explode on 10 cancel on 1 | Each 1 rolled denies one 10 its explosion |
| 10d10 explode twice on 10 cancel on 1 | Pool budget and per-die cap both apply |
| 10d10 explode on 10 cancel on 1..2 | Any 1 or 2 cancels |
| 10d10 explode on 10 cancel >= 2 | Operator spelling of the cancel threshold |
| 10d10 compound on 10 cancel on 1 | Same rule for compound |
| 4d6 explode on 6 cancel on 1 keep 3 | Cancels are counted before keep selects |
The rules in full:
- Only the initial pool cancels. A face rolled inside an explode chain never cancels anything, however deep the chain goes. The budget is one up-front number computed from the base pool.
- The budget is per pool, not per die. With
Mbase dice matching the trigger andKbase dice matching the cancel range,max(M - K, 0)of the triggering dice begin a chain and the rest stay ordinary dice. Which of them loses is settled left to right - the earliest triggering dice are the ones denied. That choice cannot affect the distribution of a homogeneous pool, but it is deterministic, replay-stable, and visible in the roll trace. timeskeeps its per-die meaning. In10d10 explode twice on 10 cancel on 1each granted die may still explode at most twice, while the pool budget independently limits how many dice explode at all; whichever binds first wins.- Cancels are counted over the raw pool, before any
keep/dropselection - filters already run after functors, and a die you dropped still got rolled. All four dice can cancel in4d6 explode on 6 cancel on 1 keep 3. - The explosion budget is clamped at zero. A negative explosion count is
meaningless, so extra cancels are simply wasted. This is deliberately not
symmetric with
count ... cancel, which is never clamped so that a botch stays expressible - see Cancelling successes.
The argument takes the same threshold spellings as a trigger: cancel on 1,
the bare cancel 1, cancel on 2 or less, cancel on 5 or more,
cancel on $ones, and cancel on ($n + 1).
Which cancel forms get an exact distribution and which fall back to Monte Carlo is tabulated under Cancellation and exact analysis.
Rerolling
Roll again when triggered, but only the last roll counts.
| Notation | Shorthand | Description |
| ------------------------- | --------- | ---------------------------- |
| 3d6 reroll on 1 | | Reroll 1s, no limit |
| d6 reroll once on 1 | | Reroll at most once |
| 3d6 reroll on 2 or less | 3d6r2 | Reroll 1s and 2s |
| 3d6 reroll on 1..2 | | Same, range spelling |
| 3d6 reroll <= 2 | | Same, operator spelling |
| 3d6 reroll once on 1..2 | | Range spelling after times |
Compound exploding
Like exploding, but extra rolls are summed into the original die - one die, higher value.
| Notation | Shorthand | Description |
| --------------------------- | --------- | ------------------------- |
| d6 compound on 6 | | Compound on 6 |
| d6 compound once on 6 | | At most once |
| 3d6 compound on 6 or more | 3d6ce6 | Compound on 6+ |
| 3d6 compound on 4..6 | | Compound on 4, 5 or 6 |
| 3d6 compound >= 4 | | Same, operator spelling |
| 3d6 compound on max | 3d6cem | Compound on the die's max |
| 3d6 compound max | | Same |
Chaining functors with filters
Filters can chain after a functor - the filter ranks dice by their per-original-die total (sum of an explode chain, last reroll, compound total, or the kept-of-pair for emphasis):
4d6 explode on 6 keep 3 # keep top 3 post-explode totals
4d6 reroll on 1 drop lowest 1
4d6 explode on 6 keep 3 count >= 7 # successes ≥ 7 over the kept totals
5d6 explode max keep 3
4d6 reroll on 1 explode on 6 keep 3 # combined functor + filterNote the difference between a reducer that follows a filter and one
that follows a bare functor. A filter collapses each original die to a
single total first, so 4d6 explode on 6 keep 3 count >= 7 counts totals
and a die that exploded to 7+ scores. Without a filter the reducer sees the
flattened pool — an explode chain contributes one entry per rolled die,
exactly as it does at roll time — so 4d6 explode on 6 count >= 7 is
identically zero (no single d6 face reaches 7), while
10d10 explode on 10 count >= 8 counts every 8+ in the whole chain. That
flattened reading is what makes exploding success pools work; compound,
reroll and emphasis contribute one entry per die either way, and sum
is unaffected because both readings add up to the same total.
The filter must follow the functor, never precede it
(4d6 keep 3 explode on 6 is rejected with a migration hint that
suggests the swap). Bounded-times forms (once, twice,
N times, emphasis) get exact-tier distributions. Unbounded
always-times also goes exact via a probability-mass cutoff
(epsilon = 1e-12); only chains that exceed the joint-enumeration
cap (e.g. 4d6 explode on 6 keep 3, ~54M joint cells) fall back
to Monte Carlo. Combined functors (reroll once on 1 explode once
on 6) take the same exact path in their bounded form.
Emphasis
Roll two dice, keep the result furthest from a center point.
| Notation | Description |
| --------------------------- | ---------------------------------- |
| d20 emphasis | Furthest from average; reroll ties |
| d20 emphasis high | Tie-break to higher value |
| d20 emphasis low | Tie-break to lower value |
| d20 emphasis reroll | Reroll ties (explicit) |
| d20 furthest from 10 | Custom center point (reroll ties) |
| d20 furthest from 10 high | Custom center, high tie-break |
| d20 furthest from 10 low | Custom center, low tie-break |
Dice pools / success counting
Count how many dice meet one or more thresholds. Thresholds can be expressed
with operators, English on ... triggers, or the exactly keyword — but not
as a bare number: count 5 was ambiguous between == 5 and >= 5 and was
removed. (Functor triggers keep the bare form, where there is no such
ambiguity: 3d6 explode 6 is fine.) Multiple thresholds may be chained with
and so each die contributes one success per matching threshold; every link
of the chain must be an explicit threshold form too.
| Notation | Shorthand | Description |
| ----------------------------------- | --------- | ------------------------------------------------------- |
| 8d10 count >= 6 | 8d10c6 | Count successes >= 6 |
| 8d10 count on 6 or more | | Same, English trigger form |
| 3d6 count == 5 | | Count exact 5s (operator form) |
| 3d6 count exactly 5 | | Same, keyword form |
| 3d6 count on 5 | | Same, trigger form |
| 3d6 count on 3..5 | | Count dice between 3 and 5 inclusive |
| 4d6 count <= 2 | | Count values <= 2 |
| 4d6 count > 4 | | Count values > 4 (canonicalises to >= 5) |
| 4d6 count < 3 | | Count values < 3 (canonicalises to <= 2) |
| 5d10 count on 6 or more and on 10 | | Multi-step: each die counts once per matching threshold |
Threshold values may be a positive literal, a $variable, or a
parenthesised expression: 8d10 count >= (3d6) rolls 3d6 once and
counts how many of the eight dice land at or above that value. This
applies generally to dice-modifier arguments: triggers, counts, and
keep/drop amounts all accept literals, variables, and parenthesised
expressions (e.g. 4d6 explode on $threshold, 6d20 keep ($n + 1),
5d10 reroll once on (d4)).
The and separator only has this meaning inside a count reducer; outside it
remains the boolean operator.
Multi-step counts
Chain thresholds with and to give each die one success per matching
threshold. For example, in 5d10 count >= 6 and == 10, a 10 matches both
>= 6 and == 10, so that die contributes 2 successes; a 7 only matches >= 6
and contributes 1; a 3 matches neither and contributes 0. Each segment must be
an explicit threshold form (operator, exactly, or on):
5d10 count >= 6 and == 10
4d12 count >= 6 and >= 10
8d10 count on 6 or more and on 10Cancelling successes
cancel <range> after a count reducer subtracts one success for every die of
the raw pool whose face matches the range. This is the World of Darkness
"ones cancel successes" rule, and the reason the feature exists.
| Notation | Description |
| -------------------------------------------- | ---------------------------------------------------- |
| 6d10 count >= 6 cancel on 1 | oWoD 1st ed.: difficulty 6, each 1 removes a success |
| 10d10 count >= 8 cancel on 1 | Ten dice, difficulty 8 |
| 8d10 count >= 6 and >= 10 cancel on 1 | Binds to the whole and chain, not to one threshold |
| 10d10 explode on 10 count >= 8 cancel on 1 | oWoD 2nd ed. specialty: 10s explode, 1s still cancel |
| 2d6c5 cancel on 1 | The c<v> shorthand takes the same suffix |
The rules in full:
- The total is not clamped.
6d10 count >= 6 cancel on 1can evaluate to-2, so an oWoD botch or an Exalted-style failure stays expressible and "zero successes" stays distinguishable from "botched by two". This is deliberately not symmetric withexplode ... cancel, whose budget is clamped at zero because a negative explosion count is meaningless - see Cancelling explosions. - Cancels are read over the raw pool, before any
keep/drop: all four dice cancel in4d6 keep 3 count >= 5 cancel on 1. - Only base dice cancel. In
10d10 explode on 10 count >= 8 cancel on 1the count sees the whole flattened pool (base dice plus everything the chains rolled), but only the ten base faces can cancel - a 1 inside a chain does nothing. Exact mean:10/3 - 1, i.e. ten dice at one third of a success each, less one success for the expected number of 1s. - A
cancelin the reducer and acancelon the functor are independent and may both appear:10d10 explode on 10 cancel on 1 count >= 8 cancel on 1spends the 1s twice, once against explosions and once against successes.
The oWoD 1st-edition roll that motivated the mechanic, with its botch rule:
# Six dice at difficulty 6; each 1 cancels a success.
# Exact analysis: a mean of 2.4 net successes, support -6 .. 6.
$net = 6d10 count >= 6 cancel on 1
if $net < 0 then "botch" else if $net == 0 then "failure" else "success"Cancellation and exact analysis
Pool cancellation is always available to the Monte Carlo tier, and to the exact tier only where the pool still factorises. The exact tier declines rather than approximates: a form that is not listed as exact falls back to sampling and stays correct, it does not report a confident wrong distribution.
| Expression | Tier | Why |
| ----------------------------------------------- | ----------- | ------------------------------------------------------------------ |
| 10d10 count >= 8 cancel on 1 | exact | Each die contributes an additive hits(f) - 1{f in C} |
| 4d6 keep 3 count >= 5 cancel on 1 | exact | Enumerated by pool composition (roughly up to 14d10 keep 10) |
| 10d10 explode on 10 cancel on 1 count >= 8 | exact | Homogeneous pool, reducer is sum / count / min / max |
| 10d10 explode on 10 count >= 8 cancel on 1 | exact | Count over the flattened chain, cancel over the base faces |
| 3d6 compound once on 6 cancel on 1 sum | exact | compound ... cancel is exact for sum |
| 3d6 explode once on 6 cancel on 1 keep 2 | Monte Carlo | A keep/drop filter ranks per-die totals that the budget correlates |
| [d6, d8] explode on 6 cancel on 1 count >= 5 | Monte Carlo | Heterogeneous pool: which die is denied becomes observable |
| 3d6 compound once on 6 cancel on 1 count >= 6 | Monte Carlo | A granted compound die's entry depends on its own face |
| 3d6 reroll once on 1 count >= 5 cancel on 1 | Monte Carlo | Cancel reads the raw face, the reducer the settled one |
| 2d6 explode once on 6 cancel on 1 average | Monte Carlo | average / median / pool depend on the pool's random length |
ProgramStats.explainTier names which of these applied, in the same words -
the analyzer and the explanation read the same rule, so they cannot drift.
Variables in dice expressions
Inside a dice expression, $var references resolve to the variable's value
at roll time. Three positions are supported.
Additive position - modify a roll with a constant or computed value:
$mod = 5
$attack = d20 + $modCount position (parametric count) - roll a variable number of dice:
$rolls = 3
$damage = $rolls D6 # rolls 3d6The variable may itself be a dice expression:
$rolls = d4 # rolls a d4 (1-4)
$damage = $rolls D6 # rolls between 1d6 and 4d6, summedSides position (parametric sides):
$sides = 8
$roll = 1d$sides # rolls a d8Both positions:
$n = 3
$s = 6
$roll = $n D$s # rolls 3d6Parametric forms require whitespace between the variable name and the
d/D ($n d6, $n D6, $n DF, $n d{1,2}). The no-whitespace form
$nD6 is rejected - it's visually ambiguous with a long variable name. The
parser accepts either lowercase d or uppercase D after the whitespace.
The renderer emits uppercase D as the canonical form ($n D6) to
distinguish parametric heads from compact literal-count dice (3d6).
Counts of 0 or fewer return 0; the resolved count is capped at
MAX_DICE_COUNT = 10000 per expression - programs that try to roll more
(e.g., 100000d6) throw a clear runtime error. The AST holds count and
sides compactly regardless of size, so 100000d6 keep 1 parses instantly; the
runtime cap only fires when you actually roll.
Parametric counts compose with all modifiers:
$n = d6
$attack = $n D6 keep 1 # roll N d6, keep highest 1 (where N is itself a roll)
$pool = $n D10 count >= 6 # variable-size dice pool
$burst = $n D6 explode on 6 # variable-count exploding dice
$fate = $n DF # N Fate diceModifier position - a threshold, trigger, times qualifier or cancel
range may also be a variable:
$difficulty = 8
$ones = 1
$successes = 10d10 explode on 8 or more cancel on $ones count >= $difficultyNaming a constant does not cost the exact tier
Every variable slot in a dice atom is folded to its value before analysis
when that value is a compile-time constant, so the program above is analysed as
though you had written the literals. $difficulty = 8 is exactly as exact as
8, and that holds for a declared parameter and its overrides too - which is
what lets a UI drive a difficulty control and still get exact distributions.
The slot does not have to be a bare read: anything
constantIntegerInEnv resolves works, including arithmetic
(cancel on ($n + 1)) and chains of constant bindings. Note that / is
integer division, so $d = 8 / 3 folds to 2.
A slot that is not constant - $d = d6, a value that depends on a roll -
has no single value to substitute, so the atom keeps its variable and the
program falls back to Monte Carlo rather than picking one of the outcomes.
ProgramStats.explainTier says so in those terms.
Parse errors
import { ProgramParser, suggestKeyword } from 'dicerollerts'
const r = ProgramParser.parse('3d6 explod on 6')
if (!r.success) {
r.errors[0].message // error description
r.errors[0].position // character offset
r.errors[0].suggestion // e.g. "Did you mean 'explode'?"
r.errors[0].context // surrounding input text
}
suggestKeyword('explod') // "explode"2. Roller and roll results
import { Roller, RR, MAX_DICE_COUNT } from 'dicerollerts'
const roller = new Roller((sides) => Math.floor(Math.random() * sides) + 1)
// With explicit options:
const roller2 = new Roller(rollFn, {
maxExplodeIterations: 100, // default 100
maxRerollIterations: 100,
maxEmphasisIterations: 100,
})
const roll = roller.roll(expr) // RollResult tree
RR.getResult(roll) // numeric resultRoller operates on the unified Expression AST emitted by
ProgramParser. The roll result is a structured tree (RollResult)
describing every die rolled, kept, and discarded - useful for transcript
rendering and replay.
MAX_DICE_COUNT is 10_000. Roller enforces this for every n-dice
expression at roll time.
To roll structured-dice-roll expressions (@name references), use
Evaluator.run instead - Roller deals only with numeric results.
Expression utilities (DE)
import { DE } from 'dicerollerts'
DE.toString(expr) // canonical string representation
DE.validate(expr) // null if valid, ValidationMessage[] if not
DE.calculateBasicRolls(expr) // count of dice in expression
DE.simplify(expr) // constant folding & identity eliminationDE.simplify performs algebraic simplification on a DiceExpression:
literal+literal folding, unary negate folding, double-negation, identity
elimination (x + 0 → x, x * 1 → x, etc.).
3. Distribution analysis on dice expressions (DiceStats)
import { DiceStats } from 'dicerollerts'
// Exact distribution (for expressions whose support is enumerable):
const dist = DiceStats.distribution(expr) // Map<number, number>
DiceStats.mean(expr)
DiceStats.stddev(expr)
DiceStats.min(expr)
DiceStats.max(expr)
DiceStats.percentile(expr, 50)
// Monte Carlo (for any expression):
const mc = DiceStats.monteCarlo(expr, { trials: 50000 })
mc.mean
mc.stddev
mc.min
mc.max
mc.distribution // Map<number, number> - conditional on defined outcomes
mc.percentile(75)
mc.undefinedMass // fraction of trials that produced an undefined outcome
// (division by zero); 0 if every trial was defined
// Streaming Monte Carlo with progress:
for await (const progress of DiceStats.monteCarloAsync(expr, {
trials: 50_000,
chunkSize: 1000,
})) {
// progress.completed, progress.total, progress.result
}
// Summary: exact when possible (≤ 20 basic rolls), Monte Carlo fallback:
const s = DiceStats.summary(expr)
s.min
s.max
s.mean
s.stddev
s.distribution
s.percentiles // { 25, 50, 75 }Exact distribution is supported for: die, custom-die, literal, binary
and unary ops on those, every dice-reduce shape (including dice-list-with-filter,
chained drop/keep, sums, mins, max, average, median, count), explode / reroll /
compound (both bounded times - once, twice, thrice, N times - and the
unbounded always form via a probability-mass cutoff), combined functors
(reroll … explode …), emphasis, and literal-count structured-dice rolls
(dice @die [...]; 3 @die). Parametric-count dice ($n D6), variable-count
repeat $n { … }, and chains whose joint enumeration exceeds the
five-million-cell cap fall back to Monte Carlo (or ProgramStats, which
can handle them in context).
Pool cancellation (explode … cancel, count … cancel) is exactly analysed
only for the shapes listed under
Cancellation and exact analysis; every
other cancel form falls back to sampling rather than reporting an approximate
distribution as exact.
4. Program Language
A small scripting language for tabletop-RPG-flavoured automation. Variables, conditionals, records, arrays, comprehensions, and dice declarations.
import { ProgramParser, Evaluator, ProgramStats } from 'dicerollerts'
const source =
$str_mod = 5
$ac = 15
$attack = d20 + $str_mod
$hit = $attack >= $ac
$damage = if $hit then 2d6 + $str_mod else 0
{ attack: $attack, hit: $hit, damage: $damage }
const parsed = ProgramParser.parse(source)
if (!parsed.success) {
console.error(parsed.errors)
} else {
// Single roll:
const evaluator = new Evaluator(
(sides) => Math.floor(Math.random() * sides) + 1,
)
const result = evaluator.run(parsed.program)
// e.g. { attack: 18, hit: true, damage: 14 }
// Probability analysis (auto-detects strategy):
const analysis = ProgramStats.analyze(parsed.program)
// analysis.strategy.tier: 'constant' | 'exact' | 'monte-carlo'
// analysis.stats: per-field distributions
}Language reference
Statements
- Assignment:
$name = expr- variables are immutable; reassigning throws. Names match$[a-z_][a-z0-9_]*. - Parameter declaration:
$name is { default: ..., ... }- see Parameters. - Dice declaration:
dice @name [ ... ]- see Structured-face dice. - Expression statement: any expression. The program's final value is the value of the last statement.
Statements are separated by newlines. Lines starting with # are comments;
# to end-of-line is also a comment.
Expressions
| Form | Notes |
| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| 42, -3, true, false, "text" | Literals |
| d6, 4d6, 4d6 drop 1, @name, 3 @name | Dice atoms - see Dice Expression Language |
| $name | Variable reference |
| +, -, *, / | Arithmetic (/ is integer division) |
| a max b, a min b | Larger / smaller of two numbers - see Scalar min / max |
| … round, round up, round down, truncate, round half even | Postfix rounding - see Rounding |
| ==, !=, <, <=, >, >= | Comparisons |
| and, or, not | Booleans |
| if cond then a else b | Conditional (else required) |
| match { ... }, match VALUE { ... } | Pattern matching - see Match |
| { key: value, ... }, { $var } (shorthand) | Records |
| $rec.field | Field access |
| [1, 2, 3], [d6, d8] | Array literal (also acts as a dice pool with a trailing modifier) |
| [...$arr, d6], [...3d6] | Spread element - inline a list into an array literal |
| $arr[0], $arr[1:3], $arr[:-1], $arr[-3:] | Indexing and slicing - see Slicing |
| repeat N { body } | Returns array of N body evaluations (a rolled count stays exact-analyzable while its randomness is read nowhere else) |
| for $x in arr { body } | Comprehension - see Comprehensions |
| <aggregator> for ... | Aggregator-prefixed comprehension |
| argmin, argmax | Sugar for sort + slice (returns 1-element array) |
| fold arr from init ($acc, $x) -> body | Generic left fold |
+ is overloaded: when either operand is a string, it concatenates; otherwise
numeric.
Match expressions
Replace nested if-else chains with a flat form. Two modes are supported.
Guard mode (no value after match): picks the first arm whose pattern is
truthy.
$damage = match {
$crit -> 4d6 + 1d4 + 4
$hit -> 2d6 + 4
_ -> 0
}Value mode (value after match): compares the matched value against each
arm's pattern using ==.
$roll = match $roll_mode {
"advantage" -> 2d20 keep highest 1
"disadvantage" -> 2d20 keep lowest 1
_ -> d20
}Arms can carry an optional if guard clause, evaluated only when the pattern
matches:
$damage = match $weapon {
"sword" if $crit -> 4d6
"sword" -> 2d6
"dagger" if $crit -> 2d4
"dagger" -> 1d4
_ -> 0
}_is the wildcard (matches anything; use it as a catch-all).- Arms are separated by newlines or commas; trailing separators are allowed.
- The first matching arm wins; remaining arms are not evaluated.
- A trailing
_ -> defaultis recommended - non-matching trials throw a runtime error.
Comprehensions and fold
Iterate over arrays with optional filtering, optionally collapsing via an aggregator prefix.
# Map: returns an array.
for $x in [1, 2, 3] { $x * 2 } # [2, 4, 6]
# Map + filter.
for $x in [1, 2, 3, 4] if $x > 2 { $x } # [3, 4]
# Aggregator prefix collapses to a single value.
sum for $x in [1, 2, 3] { $x } # 6
product for $x in [2, 3, 4] { $x } # 24
max for $x in [3, 1, 5, 2] { $x } # 5
min for $x in [3, 1, 5, 2] { $x } # 1
average for $x in [2, 4, 6] { $x } # 4
count for $x in [1, 2, 3, 4, 5] if $x > 2 # 3 (no body - count only)
first for $x in [1, 2, 3] if $x > 1 # 2 (short-circuits)
last for $x in [1, 2, 3] if $x > 1 # 3Aggregator body rules:
count: body is forbidden (usecount for $x in arr if cond).first/last: body is optional (defaults to the element itself).- All others (and the no-aggregator form): body is required.
sum over records does field-wise sum (matching structured-face dice
semantics):
sum for $r in [{a: 1, b: 2}, {a: 3, c: 4}] { $r }
# {a: 4, b: 2, c: 4}The sort aggregator returns the elements in sorted order (ascending by
default) rather than collapsing them. Optional by (sort key) and explicit
asc / desc:
sort for $x in [3, 1, 2] { $x } # [1, 2, 3]
sort for $x in [3, 1, 2] desc { $x } # [3, 2, 1]
sort for $r in $records by $r.priority { $r } # records sorted by priority
sort for $r in $records by $r.priority desc { $r } # descending
sort for $r in $records if $r.active by $r.priority desc { $r } # filter + by + descargmin and argmax are sugar for sort + slice [0:1]. They return a
single-element array containing the element with the smallest / largest key:
argmin for $r in $records by $r.priority { $r } # [record_with_min_priority]
argmax for $r in $records by $r.priority { $r } # [record_with_max_priority]
argmin for $x in [3, 1, 2] { $x } # [1]Both forms accept a by clause and follow the same rules as sort. They
always sort in their natural direction (argmin ascending, argmax
descending) - explicit asc / desc is rejected.
For arbitrary combining logic, use fold:
fold [1, 2, 3, 4] from 0 ($acc, $x) -> $acc + $x # 10
fold [{a: 1}, {a: 2}, {a: 3}] from {total: 0}
($acc, $x) -> {total: $acc.total + $x.a} # {total: 6}Comprehension and fold introduce lexical scopes - inner $x, $acc, etc.
shadow outer bindings of the same name within the body.
Array slicing
Arrays support Python-style half-open slice notation with optional negative indices (counted from the end):
$arr[1:3] # elements at indices 1, 2 (not 3)
$arr[:-1] # all but the last element
$arr[-3:] # the last 3 elements
$arr[:] # full copySlices clamp out-of-range bounds rather than erroring. Slicing and indexing
can be chained: $arr[:3][0] returns the first element of the first three.
Step ([a:b:c]) is not supported. $arr[] is rejected.
Spread elements
[...expr] flattens a list-valued expression into the enclosing array
literal. Three operand shapes are lifted to lists automatically:
[...$arr, 99] # $arr is already a list; concat with 99
[...3d6] # 3d6 is normally a scalar sum - spread lifts
# it to the three individual rolls
[...3d6 sum] # explicit sum can also be lifted to its pool
[...repeat 4 { d6 }, 0] # repeat produces a list; spread inlines itBare-scalar values ([...5], [...d20 + 3]) are rejected - spread is
list-only. Inside dice-pool position ([...3d6] keep 3), the spread is
equivalent to writing the elements out by hand.
Structured-face dice
Define a die whose faces are records, then roll N of them with field-wise sum. Useful for game systems where each die face yields multiple symbols (Descent, Star Wars FFG, Genesys, etc.).
dice @attack [
{ damage: 1 },
{ damage: 2, range: 1 },
{ surge: 1 },
{ damage: 1, range: 1 },
{ damage: 0 },
{ damage: 2 }
]
# Bare reference rolls one face. The result carries the die's full declared
# field set, with fields the rolled face lacks defaulting to 0.
$one = @attack # e.g. {damage: 2, range: 1, surge: 0}
# With a count, rolls N and field-wise sums (missing fields default to 0).
$total = 3 @attack # e.g. {damage: 4, range: 1, surge: 1}
# Parametric count.
$n is { default: 5 }
$variable = $n @attackNumeric-faced dice are also supported and sum numerically:
dice @loaded [ 6, 6, 6, 6, 6, 1 ]
100 @loaded # heavily biased toward 6Rules:
- Faces must be all-numeric or all-record (no mixing).
- Record-face values must be integers (no decimals, strings, booleans, or nested records).
- Every record roll (any count
>= 1, including a bare@d) carries the field union of every field that appears in any declared face; a field missing from a particular rolled face contributes 0. This makes@dandN @dfield-set-consistent, and means a mixed pool built withsum for $d in [@a, @b] { $d }can be read per symbol ($pool.x) without adefaultfor any symbol whose source die is in the pool. (Acount == 0roll has no schema to project and stays the empty record{}.) - Structured-dice rolls cannot be combined with arithmetic or other dice in the same expression - keep them in their own statement (assign to a variable, or place inside a record) and combine at the program level.
- Each
dice @namemay be declared once per program; redeclaration throws.
See docs/structured-dice.md for the trace shape
(StructuredDiceRollResult), the flatStructuredFaces walker for
rendering each die independently, and end-to-end consumer patterns.
Reading symbols from a mixed pool
Different structured dice can't be combined in one expression, so a mixed pool (e.g. an FFG / Genesys narrative-dice roll) is built by summing a comprehension over the dice, then read per symbol:
$pool = sum for $d in [@proficiency, @difficulty, @difficulty] { $d }
$pool.success - $pool.failureBecause every roll carries its die's full declared schema, any symbol whose
source die is in the pool reads directly and defaults to 0 when it wasn't
shown — $pool.triumph is 0 on a roll where no triumph face came up, not an
error. A symbol whose source die is not in the pool is still absent and
throws Record has no field 'despair'. Use the default operator there
(and anywhere you want an absent field, out-of-range index, or undefined
outcome to fall back) to read it as 0:
{
net_success: $pool.success - $pool.failure, # both source dice in the pool
triumphs: $pool.triumph, # 0 when no triumph face came up
despairs: $pool.despair default 0, # 0 — no despair die in this pool
}primary default fallback yields primary, or fallback when primary
has no value — a missing record field, an out-of-range index
($xs[99] default 0), or an undefined outcome such as division by zero
((a / b) default 0). Every other error (type errors, undefined variables)
still propagates, so strict access remains the default everywhere else. It
binds tighter than arithmetic, so $a.x default 0 - $b.y default 0 groups as
($a.x default 0) - ($b.y default 0). default is exact in ProgramStats
analysis, not only at roll time.
Parameters
Declare a variable as a parameter with is { ... } to give it a default value
plus optional metadata. Tools can introspect the program to render UI inputs;
callers can override defaults at runtime.
$str_mod is {
default: 5,
min: 0,
max: 30,
label: "STR Modifier",
description: "Your strength bonus",
}
$weapon is {
default: "longsword",
enum: ["longsword", "dagger", "greataxe"],
}
$advantage is { default: false }
$attack_die is { default: d20 }
$attack = $attack_die + $str_mod
$hit = $attack >= 15
{ attack: $attack, hit: $hit }Field rules:
default(required): a number, boolean, string, or dice expression.label,description: string literals. Either"double"or'single'quotes work; both accept the usual\n,\t,\\escapes and let the other quote appear unescaped ("can't",'he said "hi"').min,max: only valid when the default is a number, or a dice expression.enum: only valid when the default is non-number; entries must match the default's primitive type and the default must be a member of the enum.- Dice-expression defaults may not reference structured-face dice (
@name)- use a numeric default.
- A parameter may not be reassigned with
=.
Override at runtime:
evaluator.run(program, { parameters: { str_mod: 7, weapon: 'dagger' } })
ProgramStats.analyze(program, { parameters: { str_mod: 7 } })Overrides are validated (unknown name, type mismatch, out of range, not in enum) and throw clear errors. Without overrides, literal defaults act as constants and dice-expression defaults are rolled per execution (or analysed exactly).
Introspect for UI:
import { ProgramParameters } from 'dicerollerts'
const params = ProgramParameters.list(program)
// Parameter[] = [{
// name, default?, defaultExpression?,
// label?, description?, min?, max?, enum?
// }, ...]The input kind is inferred by the consumer from the data: number with min
and max → bounded slider; string with enum → dropdown; boolean → toggle;
dice expression default → free-form expression input.
For consumers that want a canonical version of that dispatch rather than
re-implementing it, classifyParameter returns a tagged ParameterKind:
import { ProgramParameters, classifyParameter } from 'dicerollerts'
ProgramParameters.list(program).map(classifyParameter)
// ParameterKind[] - { kind: "boolean" } | { kind: "string-enum"; choices }
// | { kind: "number-enum"; choices }
// | { kind: "number-range"; min, max, default }
// | { kind: "number"; default } | { kind: "string"; default }
// | { kind: "expression" }Precedence: dice-expression default wins, then boolean, then enum (string
or number), then number-range (both min and max present), then plain
number, then string. The classifier never throws; ambiguous specs collapse
to the most informative kind.
Reserved words
Cannot be used as record keys or identifiers: if, then, else, true,
false, and, or, not, repeat, is, match, dice, for, in,
fold, from, by, asc, desc, argmin, argmax. The wildcard _ is
also reserved.
5. Evaluator
import { Evaluator } from 'dicerollerts'
const evaluator = new Evaluator(
(sides) => Math.floor(Math.random() * sides) + 1,
{
maxRepeatIterations: 10000, // default 10000
onDiceTerm: (event) => {
// event.kind is 'arithmetic' | 'structured'
// event.node is the lifted dice AST node (Die / NDice / DiceReduce / ...
// or StructuredDiceRoll for the structured case)
// event.result is a RollResult tree (arithmetic) or
// StructuredDiceRollResult (structured)
},
},
)
evaluator.run(program)
evaluator.run(program, { parameters: { str_mod: 7 } })Tokenization
For syntax highlighting and editor integration, the package exports a
tokenize(source) function that classifies a source string into a flat
array of position-tagged tokens.
import { tokenize } from 'dicerollerts'
const tokens = tokenize('[d20 + $mod, d20 + $mod] max')
// → [{ kind: 'punct', text: '[', start: 0, end: 1 },
// { kind: 'dice', text: 'd20', ... },
// { kind: 'whitespace', text: ' ', ... },
// { kind: 'operator', text: '+', ... },
// { kind: 'variable', text: '$mod', ... },
// ...
// { kind: 'reducer', text: 'max', ... }]Properties:
- Total coverage. Every character of
sourceis covered;tokens.map(t => t.text).join('')always equalssource. Whitespace and comments (# ...) are first-class tokens. - Context-aware classification. The same word lands in different
kinds depending on grammatical role:maxin[a, b] max→reducer;maxinmax for $x in $xs { $x }→aggregator;maxin{ max: 5 }(plain record) →identifier;maxin$max→ part ofvariable;maxin$x is { max: 10 }→param-field.
- Error recovery.
tokenizenever throws and never returns an empty array for non-empty input - unrecognized runs are emitted aserrortokens so half-typed editor buffers keep producing useful output. - Shorthand splitting. Dice modifier shorthands split into a role
token plus a number:
4d6k3→dice("4d6") filter("k") number("3").
The full set of token kinds:
| Kind | Description |
| ---------------- | ------------------------------------------------------ |
| whitespace | runs of spaces / tabs / newlines |
| comment | # to end of line |
| string | "..." or '...' (symmetric quotes) |
| number | integer or decimal, including unary-minus signed forms |
| dice | d6, 2d20, dF, d%, d{1,2,3} |
| variable | $name (the $ is part of the token) |
| identifier | bare identifier in non-keyword position |
| structured-die | @name (the @ is part of the token) |
| keyword | if, then, match, for, is, … |
| reducer | sum, max, … in [...] reducer position |
| aggregator | sum, count, sort, … in ... for $x in position |
| functor | explode, reroll, … and shorthands e, r, ce |
| filter | keep, drop, … and shorthands k, d, kh, kl |
| param-field | default, min, max, … inside $x is { ... } |
| operator | + - * / == != < <= > >= = -> and or not × ⋅ ÷ |
| punct | ( ) [ ] { } , . : ; % |
| error | unrecognized run; tokenizer skipped to keep going |
Token and TokenKind are exported as types. Adding a new TokenKind
is a minor-version bump; renaming or removing one is breaking. Consumers
can default-case unknown kinds to plain rendering.
Hooks
onDiceTerm fires once per dice term the evaluator executes, in evaluation
order. It fires:
- once per iteration inside
repeat; - once per passing element inside a comprehension or fold;
- only for the taken branch of an
if/match; - for parameter dice defaults (rolled when no override is provided).
The callback receives a tagged DiceTermResult event:
event.kind === 'arithmetic': ordinary dice term (die,n-dice,custom-die,dice-reduce).event.resultis aRollResulttree.event.kind === 'structured':structured-dice-rollexpression.event.resultis aStructuredDiceRollResultcarryingname, per-diedraws, and the field-wise sum.
event.node is the exact AST node from the parsed program - stable across
runs.
onScope is a separate callback that fires at scope boundaries -
iteration boundaries (comprehensions, repeat, fold) and conditional
branch boundaries (if, match). Consumers that walk the AST in
lockstep with execution - visualizers, debuggers, the dicerun2
inline-math walker - use it to know which iteration or branch they're
in without re-implementing the evaluator's dispatch semantics:
new Evaluator(rng, {
onDiceTerm: (e) => {
/* dice events */
},
onScope: (e) => {
switch (e.kind) {
case 'iteration-enter':
/* { node, index, total, element?, accumulator? } */ break
case 'iteration-exit':
/* { node, index } */ break
case 'filter-skip':
/* { node, index, element } - comprehension filter rejected */ break
case 'sort-reorder':
/* { node, sourceToSorted } - emitted once per sort */ break
case 'branch-enter':
/* { node, branch, scrutinee? } - if-then / if-else / match-arm */ break
case 'branch-exit':
/* { node } - pair with the matching branch-enter */ break
}
},
})element carries the bound element (comprehensions + fold), accumulator
the running fold state. For sort comprehensions, body events fire in
source order - sort-reorder.sourceToSorted is the permutation
(sorted[i] === source[sourceToSorted[i]]), so consumers render the rows
in sorted order without re-running the sort.
For branch-enter, branch is a tagged union: { kind: 'if-then' },
{ kind: 'if-else' }, or { kind: 'match-arm', armIndex, isWildcard }
(where armIndex is the source-order position of the matched arm and
isWildcard is true for _). scrutinee carries the matched value
for value-mode match VAL { … }; it is absent (not undefined) for
if and for guard-mode match { … }. Use 'scrutinee' in event as the
value-mode discriminator - the absence pattern survives JSON round-trips.
Branch events nest with iteration events on a shared depth stack. The
canonical consumer pattern is: push on every enter, pop on every exit,
and the stack's depth at any dice event is that die's "open conditional
depth" - useful for animation staggering, debugger frame indicators,
and conditional-frequency analysis. Condition / scrutinee dice fire on
onDiceTerm before the matching branch-enter; arm body dice fire
between branch-enter and branch-exit. Both callbacks fire
synchronously from the same evaluator, so push order is evaluation
order - subscribe to both and append to one buffer for a merged stream.
If an arm body throws, branch-exit does not fire (the event log is
left mid-scope, mirroring the iteration convention). A match whose
arms all fail raises No match arm fired before any branch-enter,
so the stack stays balanced.
recordRun includes scope events in log.events only when called with
{ captureScopeEvents: true } (default false, so existing replay
consumers stay unchanged).
onAssignment is a third callback that fires once per execution of an
Assignment statement, after the RHS resolves. Drives Monaco-style
inlay hints and any other "show me the resolved value of $name = …"
tooling that can't run the program client-side because the RHS contains
random dice:
const values = new Map<Assignment, Value>()
new Evaluator(rng, {
onAssignment: (e) => values.set(e.node, e.value),
}).run(program)
// values keyed by AST node identity - last write of each Assignment winsFires once per execution, so the same assignment inside repeat N
fires N times with iteration-distinct values. Consumers that want one
value per source location should key by event.node (or
event.node.loc.offset) and use last-write-wins - keying by
event.node.name is a footgun because different scopes can declare the
same name with distinct AST nodes. Dice events in the RHS fire on
onDiceTerm before the matching onAssignment event. Does NOT fire
for parameter declarations ($x is { … }) or comprehension / fold
binders.
Replay / audit log
import {
recordRun,
replayRun,
logToJSON,
logFromJSON,
ReplayDesyncError,
type ExecutionLog,
} from 'dicerollerts'
const { value, log } = recordRun(program, rollFn, {
parameters: { x: 5 },
maxRepeatIterations: 1000,
})
// log.draws: number[] - every value returned by rollFn, in evaluation order
// log.events: ExecutionEvent[] - one entry per dice-expr / structured-dice-roll
// Replay using only the recorded draws (rollFn is not called):
const { value: replayed } = replayRun(program, log)
// throws ReplayDesyncError if log under- or over-supplies draws
// JSON round-trip (Maps are not used in the log; standard JSON.stringify works):
const json = logToJSON(log)
const restored = logFromJSON(json)recordRun and replayRun accept the same parameters and
maxRepeatIterations options as Evaluator.run. Branching, comprehensions,
and repeat are all handled - only the draws actually consumed during the
recording are stored, and replaying takes the same branches deterministically.
6. Probability analysis on programs (ProgramStats)
ProgramStats.analyze() picks one of three strategies:
constant- no randomness, single evaluation.exact- covers single dice expressions, comparisons, conditionals, arithmetic on independent distributions, shared variables (joint enumeration), boolean ops, sort / slice / index over shared rolls, comprehensions oversumandsort, symmetric statistics of large homogeneous dice pools (see Large dice pools),if-driven discriminated outputs (per-variant conditional stats), and more.monte-carlo- adaptive batched simulation; stops when per-bin frequencies stabilise.
import { ProgramStats } from 'dicerollerts'
const result = ProgramStats.analyze(program, {
minTrials: 1000, // initial batch
maxTrials: 100000, // cap (default 100000)
batchSize: 1000, // batch increment (default 1000)
targetRelativeError: 0.01, // 1% convergence target
parameters: { x: 5 }, // optional overrides
signa