π² An exact-results dice-rolling library, for answering dice-related stat questions.
I'm an avid D&D player, as is my brother, and we both like working on homebrew design as well. Part of this requires understanding dice results very well, so you can tell if something is likely to be balanced against something else.
Previously, I'd often answer dice questions by quickly writing up some simulation code-- run 10k trials and take the average, and your result will be good enough. But it bugged me having to rewrite that same code over and over, handling multiple dice was trickier than I thought it could be, and ultimately it just kinda bugged me that I was using approximations when I knew the exact result wasn't tricky to calculate, it just required a bit of work.
So, I wrote this library for myself, which offers a fairly convenient JS API for calculating dice results precisely, even for complicated operations involving multiple dice, rerolls, and complex reactions to dice results.
See the dingus for a script playground, along with several pieces of example code showing off the functionality.
The Roll
class represents the result of a die roll (or several).
It maintains a list of possible results (usually, die faces)
paired with the chance of each occurring.
For example, Roll.d6
has a list of six such pairs,
each containing a value from 1-6 and a chance of 1/6 (approximately .16666).
There are several ways to construct rolls:
-
Roll.d4
/.d6
/.d8
/.d10
/.d12
/.d20
These readonly properties return fresh
Roll
objects of the given number of sides. Exactly equivalent toRoll.d(4)
/etc, just slightly easier to type for such common cases. -
Roll.d(int D)
Returns a
Roll
representing a die with D sides; that is, whose pairs contain the values 1-D, and all have the same 1/D chance. -
Roll.nd(int N, int|Roll D)
Returns a
Roll
representing a roll of NdD dice. The pairs are arrays of N values, each 1-D, and all have an equal 1/(D^N) chance.For example,
Roll.nd(3,6)
represents a 3d6 roll. Each result pair is a value like[1, 2, 4]
, and has a 1/216 (6^3) chance.You can alternately pass another Roll as the D, and it will duplicate that roll N times, making a composite die. For example,
Roll.nd(2, Roll.d6.explode())
creates a roll with a pair of individually-exploding d6s (very distinct fromRoll.nd(2,6).explode()
, which only explodes when the combined roll is 12). (In AnyDice notation, these would be written2d[explode d6]
vsexplode 2d6
.) -
Roll.and(...(Role | any) values)
Combines multiple
Rolls
(and non-Roll
values) into a singleRoll
whose results are arrays of values, taken from every possible combination of the inputs.For example,
Roll.and(Roll.d4, Roll.d6)
produces aRoll
with 24 results, each an array of two values ranging from 1-4 and 1-6. (Akad4 d6
.)Non-
Roll
values can be passed, and are taken as constants; e.g.Roll.and(Roll.d4, 3)
is effectively1d4 3
.(See also
r.and()
, which is the same function but as a method on existing rolls, if that's more convenient.) -
Roll.parse(str diceExpr)
Parses a string like
2d6 1d4 - 5
into a Roll (in this case, identical toRoll.and(Roll.nd(2,6), Roll.d4, -5)
).This syntax is under active development, but will likely be a close analog (or possibly an exact duplicate) of Roll20 Syntax, as it's generally pretty reasonable.
Currently supports:
- Addition and subtraction of terms
- Plain numbers
- Dice expressions, with:
k
/d
/kh
/kl
/dh
/dl
suffixes, like4d6d1
for "4d6, drop lowest 1". Number is required.adv
/dis
suffixes, like1d20adv
for "1d20, rolled with advantage". A following number is optional to indicate how many rolls should be made and then the highest/lowest selected from. Elven advantage, for example, would be1d20adv3
.
-
flat([Roll | any] values)
The same as
Roll.and()
, but as a free-standing function in the module, and takes a single input that's an array of theRoll
values; that is,flat([d1, d2, d3])
is identical toRoll.any(d1, d2, d3)
.Especially useful when processing a multi-die roll in a
.flatMap()
callback, when you want to replace one of the die rolls in an array:// Reroll any 6s that come up, once Roll.nd(3,6).flatMap(faces=> flat(faces.map(x=> x==6 ? Roll.d6 : x)) ); // Tho note this can be done much easier as Roll.nd(3,6).replace(6, Roll.d6)
-
Roll.fromFaces([any] faces)
Returns a
Roll
whose pairs have the values given in the array, and whose chances are all equally 1/(length of the array).Useful when constructing "odd" dice, like a "high variance" d6:
Roll.fromFaces([1, 1, 2, 5, 6, 6])
. -
Roll.fromPairs([[value, chance]] pairs>)
The most manual construction method. Takes the exact value/chance pairs that the
Roll
should represent. Useful when you're doing something extremely custom. -
new Roll(value)
A trivial constructor. Takes a single value, and returns a
Roll
with one pair, containing that value and a 100% chance.This exists so that the class is a pointed monad; it's not generally useful.
Once a Roll
has been constructed,
there are several methods for altering the result.
-
r.and(...[Roll | any] values)
Combines the current roll with one or more additional
Roll
s (or non-Roll
values, treated as constants). For example,Roll.2d6.and(Roll.d4)
produces a2d6 1d4
result.This is identical to the static
Roll.and()
function;d1.and(d2)
does the exact same thing asRoll.and(d1, d2)
. -
r.sum()
Replaces each roll result by the sum of its faces.
For example,
Roll.nd(3,6).sum()
returns a Roll with results between 3 and 18, rather than more than 200 individual die-face triples.(Uses the
sumFaces(faces)
convenience function, also exported.) -
r.count((function|any) target)
Replaces each result with a count of how many times a particular value appears among the result's faces, then combines identical counts together.
If
target
is not a function, just counts how many times thetarget
value appears among the faces.If
target
is a function, calls it on each face, and counts how many times the function returns a truthy value.For example,
Roll.nd(5, 10).count(x=>x>=8)
counts how many faces on each possible roll of a 5d10 are 8 or higher (matching how to count successes on a World of Darkness dice pool), returning a roll with the chance of 0-5 successes.Roll.nd(8, 6).count(6)
will count how many times a 6 shows up among 8d6.(Uses the
countFaces(faces, pred)
convenience function, also exported.) -
r.replace((function|any) target, (function|any) repl)
Replaces some of the faces on each result. If
target
is not a function, it replaces any faces that match the target; if it is, it callstarget(face)
and replaces any that return a truthy value.If
repl
is not a function, it's used as the replacement for any valid targets; if it is,repl(face)
is called, and the return value is used as the replacement.For example,
Roll.nd(2,6).replace(x=>x<=2, Roll.d6)
will replace any 1s and 2s in the 2d6 results with a fresh d6 roll.(Uses the
replaceFaces(faces, target, repl)
convenience function, also exported.) -
r.keepHighest(int n=1, function key=sumFaces, function compareFn=(a,b)=>key(b)-key(a))
-
r.dropHighest(...)
-
r.keepLowest(...)
-
r.dropLowest(...)
Keeps or drops the N highest or lowest faces of each result, then buckets and sorts the result.
For example,
Roll.nd(4,6).keepHighest(2)
will give a roll with outcomes from 2-12 (as you'd expect for keeping 2 d6s), but with high numbers being vastly more common than low ones.If your faces are non-numeric,
key
andcompareFn
can be used to control what are considered "highest" or "lowest" faces. ThecompareFn
must sort "highest" first to work correctly. -
r.advantage(int n=2, function key=sumFaces, function compareFn=(a,b)=>key(b)-key(a))
-
r.disadvantage(...)
Repeats the roll N times, taking the highest (or lowest) result (according to the key function), and returns a new
Roll
representing the outcome.For example,
Roll.d20.advantage()
will return a roll representing "roll 2d20 and take the highest": it will have the same 20 outcomes (1-20) as it did originally, but the chances will have shifted upwards, with 1 now having a 1/400 chance and 20 having a 39/400 chance.By default, sums the faces to determine what is "highest", but this can be controlled with
key
andcompareFn
, as perkeepHighest()
/etc. (These functions are implemented on top ofkeepHighest(1)
andkeepLowest(1)
.) -
r.explode({(int or function)? threshold, function? pred, function sum=sumFaces, int times=Infinity})
Creates an "exploding" die, rerolling the die when it satisfies some condition and adding the reroll to the original value.
By default, rerolls based on max value;
Roll.nd(2, 6)
will reroll a[6,6]
result, with the reroll results adding to the 12 sum. This is distinct fromflat([Roll.d6.explode(), Roll.d6.explode()])
, which is a pair of exploding d6s which each individually explode when they roll a 6.The optional
threshold
argument can be a number, in which case it rerolls whenever the roll's sum is >= the threshold; or it can be a function, which is passed a list of the Roll values and must return a threshold number (this is how the default works, by calculating the maximum sum value). If omitted, the threshold is the maximum value of the Roll.The optional
pred
argument must be a boolean function, returning true when the roll should explode. It's passed both the summed value and the original value, in case your explosion logic is complicated and based on exact face results. If omitted, the predicate just checks if the value is the maximum possible for the Roll.The
sum
function is called on each value before passing it to thepred
function, and is expected to return a number. It defaults tosumFaces()
.Finally, you can control how many times a die is allowed to explode. By default, this will use the normal logic for
.reroll()
, stopping the explosions when the pending rerolls are less than .01% in total.
If the convenience methods above aren't enough, you can get low-level and manipulate Rolls more directly:
-
r.map(function cb)
Returns a new
Roll
where all the values from the original roll have been replaced with the result of passing them tocb
. The chance of each outcome is maintained.For example, to represent a
1d10 3
, one could writeRoll.d10.map(x=>x 3)
, giving aRoll
with the values 4-13, each still with a 10% chance. -
r.flatMap(function cb)
Similar to
.map()
, except that ifcb
returns anotherRoll
, it's "folded in" to the returnedRoll
-- the returnedRoll
's values are added to the parent roll, with its chances multiplied by the chance of the original result.For example, to roll a d6, but reroll a 1 result once, one could write:
Roll.d6.flatMap(x=>x > 1 ? x : Roll.d6)
, giving a roll with 11 values: the values 2-6 each with a 1/6 chance, and then then values 1-6 each with a 1/36 chance. -
r.join()
The method that actually does the "fold
Roll
results into the parentRoll
" behavior;r.flatMap(cb)
is in fact justr.map(cb).join()
. -
r.bucket(function key=String, function join=x=>x[0])
Simplifies a roll by merging similar results into a single result, combining their chances.
The
key
function is passed each result's value, and values that produce the same return value are merged (using the same logic asMap
, so return primitives like numbers or strings, not objects). By default it stringifies, which works well on numbers or arrays.Then the list of grouped values is passed to the
join
function to produce the new value; by default it just selects the first one. (This is fine if all the results grouped by thekey
are in fact identical, but if you want to do some more complicated combination of the values, you can.)For example, in the description of
.flatMap()
the reroll produced multiple faces with the same value. This is fine numerically, but it will create unnecessary work if additional transformations are done on the value. Calling.bucket()
on the the result will group the results by their value, resulting in aRoll
that again has only 6 results (1-6), and the appropriate combined chances for each (1/36 for 1, 7/36 for 2-6). -
r.reroll({function summarize, function map, function key=defaultRerollKey, function join, function cleanup, number threshold=.0001, int rollMax=1000}={})
This is a toughy.
.reroll()
is a powerful function that allows you to simulate the effects of arbitrary rerolls on the die, even theoretically infinite ones. In its simplest form, you can think of it as ".flatMap()
, but call.flatMap()
some more on any returnedRolls
before.join()
ing them all together".As a simple example, in real life you can simulate a d5 by just rolling a d6, and rerolling any 6s until you get a non-6 result. If you tried to do this with
.flatMap()
, as in the preceding examples, you'd get aRoll
where 6s are less likely, but still possible. On the other hand, this can be easily done with.reroll()
, using essentially the exact same code:Roll.d6.reroll({ map: x => x==6 ? Roll.d6 : x });
Now, whenever you replace a 6 with fresh
Roll.d6
, themap
callback will be called on those results as well, rerolling any 6s produced by the second roll. And thenmap
will be called on the third d6's results, and the fourth, etc. This process eventually cuts off; by default it won't repeat this process more than 1000 times, and it'll stop early if, after a mapping pass, the newRoll
results sum to a total chance of less than .0001 (1 in 10 thousand). These unmapped results are dropped, so in the above code there will not be any 6 values in the finalRoll
at all.In addition to the value being mapped, the
map()
callback is passed the reroll # as its second argument.If you want to return a
Roll
and just have it flattened into the finalRoll
, rather than going thru another mapping pass with its values, you can return it as the "done" property on an object.For example, say you want to reroll 1s on an 8d6, but at most twice; after that they're stuck with the 1s:
Roll.nd(8, 6).reroll({ map(faces, rollNum) { // reroll 1s on any dice const newFaces = flat(faces.map(x => x==1 ? Roll.d6 : x)); if(rollNum == 1) { // first reroll, allowed to go again if needed return newFaces; } else { // second reroll, no more! return {done:newFaces}; } } })
The rest of
.reroll()
's arguments let you control its behavior more finely.-
key
andjoin
: identical to the same arguments in.bucket()
, because the results are bucketed after each mapping pass to reduce the amount of wasted work. The defaultkey
argument will stringify the value, unless the value has a.key
property itself, in which case that property's value is used. (See thesummarize
argument for how this can be useful.)(The default
key
function is also exported in the module asdefaultRerollKey
.) -
cleanup
: If there are leftoverRoll
results whose chances are too small, by default they're thrown out. If you pass acleanup
function, it's called with the results. It should return an array of results to add to the finalRoll
. -
summarize
: When doing complex dice operations, you might need to track some state across rerolls (more than just the dice results themselves). If passed,summarize
is called on every value before it's passed tomap
, and its return value replaces the original value.summarize
is passed three arguments: the value to be summarized, the previous summarized value that was rerolled (if this is a reroll pass;undefined
if this is an original value), and the roll # likemap
.For example, in D&D "death saves" are represented by rolling a d20 over several rounds; after a sufficient number of successes or failures, you finally either stabilize or die. The chances of either outcome can be determined by:
Roll.d20.reroll({ summarize(roll, oldSummary={}) { return { successes:(roll>=10?1:0) (oldSummary.successes || 0), failures:(roll<10?1:0) (roll==1?1:0) (oldSummary.failures || 0), nat20: (roll==20), get key() { return `${this.successes}/${this.failures}/${this.nat20}`; }, } }, map(summary) { if(summary.nat20) return "revive"; if(summary.successes >= 3) return "stabilize"; if(summary.failures >= 3) return "die"; return Roll.d20; } })
In other words:
- start with a d20 roll, and reroll it repeatedly.
- summarize each result by digesting it into a count of total successes, total failures, and whether a "nat 20" was rolled. Add a key getter to allow the results to be bucketed, since the exact number rolled doesn't matter, just the counts.
- map the summarized results, checking for termination conditions, and otherwise returning another d20 to continue rolling.
This produces a
Roll
with three results (the values "revive", "stabilize", and "die"), each with their correct chance of occurring (approximately 18%, 41%, and 41%).
-
-
r.normalize()
If your
Roll
's results' chances don't sum to 1, this will rescale them so they do. -
r.normalizeFaces()
Ensures that your
Roll
's values are all flat arrays; e.g.1
becomes[1]
,[[1, 2], [3, 4]]
becomes[1, 2, 3, 4]
etc.This does potentially change the semantics of the
Roll
; for example,Roll.nd(2, Roll.nd(2,6))
is a pair of 2d6s, not 4d6. Calling.keepHighest()
on the roll will give you the higher of the two 2d6 values; calling.normalizeFaces.keepHighest()
will instead give you the highest d6 of the 4d6.
Once you've gotten a Roll
,
you probably want to know its results.
-
r.roll(int? n)
Rolls the dice! Returns one of the possible values, weighted appropriately based on the chances.
If
n
is passed, returns an array ofn
roll results. -
r.average(function fn=sumFaces)
Returns the average value of the roll, weighting by chance. Each value is passed thru
fn
first to render it numerical. The default function will flatten and sum arrays, or return values that are already numeric.The
sumFaces()
function is also exported by this module, for convenience. (It's used by.sum()
as well.) -
r.text({string sep="", function fn=String, bool average}={})
Returns the results as a string of text, with each result looking like
<[value] [chance]%>
(like<1 16.7%>
for a result from a d6). Ifaverage
is truthy, additionally appends an<average [value]>
to the end. (If not passed, it's automatically added if the average isn't NaN.)Each value is mapped by
fn
before being added to string, and all the results are joined by thesep
string (defaulting to""
so they're all on one line, but passing"\n"
to put one per line can be useful). -
r.table({fn=String, average=false}={})
Returns an HTML
<table>
element listing the results, with the first column being the value (first mapped thrufn
) and the second column being the chance. Ifaverage
is truthy, appends a final average row to the table. (If not passed, it's automatically added if the average isn't NaN.)