“Roll-and-keep” is a concept popularized by the Legend of the Five Rings RPG. From 1st to 4th Edition, L5R used the following system: the player rolls a pool of (exploding) d10s, keeping a number of the highest dice rolled. This is thus a generalization of keep-highest dice pools (= keep one) and success-counting dice pools (= keep all).
A similar concept can be applied to many schemes that have been proposed for D&D-style character generation. For example, roll 5d6, keeping the highest three dice; and/or rolling seven ability scores, keeping the highest six.
However, computing the probabilities for this type of dice pool is not as easy as it is for keep-highest or success-counting. In this article, we’ll look at systems where the player rolls a homogeneous pool of dice and keeps a number of the highest dice. Each die’s outcomes have a finite range . Without loss of generality, we’ll say that the lowest roll on a die is 0.
Existing calculators
Popular existing dice probability calculators are not capable of efficiently computing the probabilities for this type of system in closed form. By “closed form” I mean “not Monte Carlo” ; while Monte Carlo methods are very flexible, they can take a long time to converge, especially compared to the precision of a direct calculation.
Here are some examples:
AnyDice
AnyDice appears to take exponential time in . For example,
output [highest 5 of 10d[explode d10]]
times out. (n.b. At default, AnyDice explodes dice up to two times, not counting the base roll. In this example, the maximum outcome on each die is or triple that of a single die.) Neither is exploding the only issue, though it exacerbates it;
output [highest 8 of 16d10]
times out as well, as does
output [highest 2 of 20d10]
Troll
The conference paper for the Troll dice roller and probability calculator says:
For example, the game “Legend of Five Rings” [18] uses a roll mechanism that can be described in Troll as
sum (largest M N#(sum accumulate x := d10 until x<10))
where M and N depend on the situation. With M = 3, N = 5 and the maximum number of iterations for accumulate set to 5, Troll takes nearly 500 seconds to calculate the result, and it gets much worse if any of the numbers increase.
To be fair, this paper was presented more than a decade ago, and hardware has advanced considerably since then. cpubenchmark.net shows a factor of about 5× in single-threaded performance and 33× in overall performance from the CPU used in the paper to my current desktop CPU. However, 500 seconds is a long time, and even this advancement in hardware is not nearly enough to overcome even modest increases in the number of dice due to the exponential time of the algorithm.
Troll does do better than AnyDice on the last of the three.
SnakeEyes
A Lua-language calculator that runs locally in the browser. (Hence why I don’t feel bad linking a futile L5R script in this case, as it’s not putting load on someone else’s server. I don’t recommend clicking on that if your computer is prone to hanging though.) Unfortunately, this too cannot complete the first two calculations in a reasonable amount of time, though it also does better than AnyDice on the third. The author also did come up with an optimized script for L5R in particular.
I believe the difference is that AnyDice enumerates all possible rolls when doing roll-and-keep, whereas SnakeEyes and possibly Troll only keep track of the highest dice, which has only possibilities (times some polynomial factor when looking at the net time). More on this later.
lynks.se
There does exist a website with L5R probabilities: lynks.se. However, if you look at that webpage’s source code, you’ll see that the probabilities have been precomputed and stored in static arrays. Furthermore, there is a small but noticeable amount of noise in the data, indicating that it was computed via Monte Carlo rather than a closed-form method.
pyPBE
Another Monte Carlo application, this time for D&D-style character generation. Web version.
I don’t mean this as harsh criticism of any of these resources. They are very accessible and are a great service to the RPG community. It also may not be simple to integrate semi-specialized algorithms into a generalist online dice calculator.
But, like the archetypical adventurer, we always want more.
Convolution
How can we compute these probabilities more efficiently? Let’s look at a simpler problem first: finding the probability mass function (PMF; “Normal” data on AnyDice) of a sum of dice without dropping any of them.
The brute-force method is to enumerate all possible rolls for the entire pool and sum the dice for each, which would take time, which is exponential.
A much faster method to to do the addition via iterated convolutions. Instead of the brute force method, we could iteratively take the PMF of the first dice, which can take on a range of possible outcomes, and convolve it with the PMF of a single additional die to get the PMF of the first dice. I would guess that most of the popular dice calculators do this, whether their creators conceptualized it as a convolution or not. The time to compute the PMF of the sum of dice is then the sum of the time taken by the convolutions, each of which takes time, for a net time of
which is nicely polynomial compared to the exponential time of the brute-force algorithm.1
Unfortunately, the roll-and-keep system makes things more difficult. If we keep only the PMF of the sum from iteration to iteration, we have no way of correctly dropping the new lowest die as we consider each die in turn. On the other hand, if we track the probability distribution of all dice kept so far, this requires possible sets of kept dice to be processed during each of iterations. Without even counting the time it takes to process each set, this is time, which is exponential again. (Though this is still better than the brute-force method, since if is constant this is polynomial.) It’s possible to do better by keeping the kept dice in sorted order, though this is still exponential.
Cutting up the probability space
Instead, what we’re going to do is cut up the probability space into a polynomial number of disjoint pieces, where the probability of landing in each is the product of a number of factors which we can compute using iterated convolutions just like the sum of dice above. We’re going to parameterize the pieces using the following parameters:
- The lowest single roll among the kept dice.
- The number of kept dice that rolled strictly greater than .
- The sum of those dice.
The total result of each piece is then .
The probability of landing in one of these pieces is equal to the product of the following:
- The lo factor: The probability that, out of dice that were less than or equal to the lowest kept die, all of them will roll or less, and at least of them roll exactly equal to (in order to be consistent with being in fact the lowest kept die).
- The sum factor: The probability that, out of dice, all of them will roll strictly greater than and have sum exactly .
- The binomial factor: The number of ways of assigning dice to the above two categories.
The lo factor
To compute the chance that at least dice will roll exactly equal to , we can compute the chance that exactly dice will roll exactly equal to and then take a cumulative sum in reverse order. We’ll compute a full array of these values. Given indexes , the array will tell us the chance that dice will all roll less than or equal to , with of those dice rolling exactly equal to .
Consider each vector corresponding to . The elements of the vector are indexed by . The vector for just consists of two elements , where the elements are the chance of a single die rolling strictly less than and exactly equal to respectively. To compute the vectors for , we can just convolve the vector for with the vector for a single die .
can range up to , and has possible values. Computing this array for all possible values of therefore requires convolutions and cumulative sums, each of which takes time, for a net time of for this step.
Here’s a pictorial representation for on d6s:
⚂ × 0 | ⚂ × 1 | ⚂ × 2 | ⚂ × 3 | |
1 die | ⚀, ⚁ = 2/6 | ⚂ = 1/6 | ||
2 dice | ⚀⚀, ⚀⚁, ⚁⚀, ⚁⚁ = 4/36 | ⚀⚂, ⚁⚂, ⚂⚀, ⚂⚁ = 4/36 | ⚂⚂ = 1/36 | |
3 dice | ⚀⚀⚀, ⚀⚀⚁, ⚀⚁⚀, ⚀⚁⚁, ⚁⚀⚀, ⚁⚀⚁, ⚁⚁⚀, ⚁⚁⚁ = 8/216 | ⚀⚀⚂, ⚀⚁⚂, ⚀⚂⚀, ⚀⚂⚁, ⚁⚀⚂, ⚁⚁⚂, ⚁⚂⚀, ⚁⚂⚁, ⚂⚀⚀, ⚂⚀⚁, ⚂⚁⚀, ⚂⚁⚁ = 12/216 | ⚀⚂⚂, ⚁⚂⚂, ⚂⚀⚂, ⚂⚁⚂, ⚂⚂⚀, ⚂⚂⚁ = 6/216 | ⚂⚂⚂ = 1/216 |
Each row/vector is the convolution of the previous vector and the first vector. Note how instead of counting all the individual possibilities that fall into each cell, we can convolve the previous vector and the first vector.
The sum factor
Again, we’ll compute an array, this time indexed by . The vector for with is just the PMF for a single die with all outcomes with index less than or equal to removed. Just like the lo factor, we can compute the vector for by convolving the vector with the vector .
can be up to , and once again has possible values. Therefore, this requires convolutions, each of which takes time , for a net time of .
The binomial factor
This is just the binomial coefficient , which can be computed for all in time, which is less than that of the the lo factor.
The outer loop
Now we just have to loop, multiply, and accumulate the three factors over all values of , which is iterations. The net time is thus
On my desktop (Intel i5-8400), my NumPy-based implementation can compute the PMF of any of the examples above in under 5 milliseconds.
Similar strategies can be used to compute the PMF of:
- The sum of the lowest dice.
- The th lowest or highest single die.
- The sum of the th to the th lowest dice, i.e. dropping dice from both the top and the bottom, with the binomial factor becoming a multinomial factor. Though this is getting sketchy from both a computational and from a game design perspective.
Source code
GitHub. At time of writing the API is far from stable, so I wouldn’t advise building anything serious around this just yet.
1 It’s possible to do even better asymptotically using a fast Fourier transform. However, according to scipy this is estimated to only be faster than the quadratic direct algorithm past about a range of 500, which is greater than pretty much any tabletop RPG will make use of. There’s also the option of doing binary splits but a) this only improves the time by a constant factor, and b) our final algorithm needs the intermediate PMFs anyways.
It is conceivable will lose less to floating-point precision, but I haven’t worked this out yet.