npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 dicerollerts

Quick 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 0

There 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` with d20 + $mod. Run stored programs through migrateSource(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 1 and 4d6 + 2d8 behave identically - the dice modifiers still bind tighter than +/-, so 4 + 4d6 drop 1 parses as 4 + ((4d6) drop 1).
  • Public DiceParser is gone; use ProgramParser.parse for everything. Roller.roll and DiceStats.{distribution,mean,...} now accept the unified Expression AST emitted by the parser.
  • The DiceExpr wrapper node is gone. Dice atoms (Die, NDice, CustomDie, DiceReduce, StructuredDiceRoll) appear directly in Expression. The source: string cosmetic field is removed.
  • Evaluator's onDiceExpr + onStructuredDiceRoll callbacks merged into a single onDiceTerm: (event: DiceTermResult) => void. The event.kind discriminator is 'arithmetic' | 'structured'.
  • RollResult tag renames: binary-op-resultbinary-expr-result, literal-resultnumber-literal-result, unary-op-resultunary-expr-result. Constructors renamed to match (binaryExprResult, numberLiteralResult, unaryExprResult). Op fields use the program vocabulary (add/subtract/multiply/divide).
  • ProgramParameters.list shape: defaultExpr + defaultSource → single defaultExpression: Expression.
  • partsing runtime 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_invisible

The 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 0

They 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 / max is 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` - unaffected

Because 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, so 2d6 max - 1 keeps meaning "reduce by max, then subtract 1". Write $a max (-1).
  • There is no line continuation after min / max. Unlike + or and, a trailing max at end of line does not continue on the next one - a max with 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) // -2

Expression 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 pool is 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 M base dice matching the trigger and K base 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.
  • times keeps its per-die meaning. In 10d10 explode twice on 10 cancel on 1 each 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 / drop selection - filters already run after functors, and a die you dropped still got rolled. All four dice can cancel in 4d6 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 + filter

Note 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 10

Cancelling 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 1 can 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 with explode ... 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 in 4d6 keep 3 count >= 5 cancel on 1.
  • Only base dice cancel. In 10d10 explode on 10 count >= 8 cancel on 1 the 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 cancel in the reducer and a cancel on the functor are independent and may both appear: 10d10 explode on 10 cancel on 1 count >= 8 cancel on 1 spends 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 + $mod

Count position (parametric count) - roll a variable number of dice:

$rolls = 3
$damage = $rolls D6     # rolls 3d6

The variable may itself be a dice expression:

$rolls = d4            # rolls a d4 (1-4)
$damage = $rolls D6     # rolls between 1d6 and 4d6, summed

Sides position (parametric sides):

$sides = 8
$roll = 1d$sides       # rolls a d8

Both positions:

$n = 3
$s = 6
$roll = $n D$s          # rolls 3d6

Parametric 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 dice

Modifier 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 >= $difficulty

Naming 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 result

Roller 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 elimination

DE.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 _ -> default is 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             # 3

Aggregator body rules:

  • count: body is forbidden (use count 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 + desc

argmin 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 copy

Slices 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 it

Bare-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 @attack

Numeric-faced dice are also supported and sum numerically:

dice @loaded [ 6, 6, 6, 6, 6, 1 ]
100 @loaded               # heavily biased toward 6

Rules:

  • 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 @d and N @d field-set-consistent, and means a mixed pool built with sum for $d in [@a, @b] { $d } can be read per symbol ($pool.x) without a default for any symbol whose source die is in the pool. (A count == 0 roll 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 @name may 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.failure

Because 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 source is covered; tokens.map(t => t.text).join('') always equals source. Whitespace and comments (# ...) are first-class tokens.
  • Context-aware classification. The same word lands in different kinds depending on grammatical role:
    • max in [a, b] maxreducer;
    • max in max for $x in $xs { $x }aggregator;
    • max in { max: 5 } (plain record) → identifier;
    • max in $max → part of variable;
    • max in $x is { max: 10 }param-field.
  • Error recovery. tokenize never throws and never returns an empty array for non-empty input - unrecognized runs are emitted as error tokens so half-typed editor buffers keep producing useful output.
  • Shorthand splitting. Dice modifier shorthands split into a role token plus a number: 4d6k3dice("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.result is a RollResult tree.
  • event.kind === 'structured': structured-dice-roll expression. event.result is a StructuredDiceRollResult carrying name, per-die draws, 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 wins

Fires 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 over sum and sort, 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