We want to make equal-probability choices using six-sided die. This means we start with a random value(s) in the domain \(D = \{1, 2, 3, 4, 5, 6\}\). We want to map the dice to values in some outcome domain \(R\).
We’ll write \(\lvert R \rvert = 5\) to mean there are 5 outcomes. This implies \(R = \{1, 2, 3, 4, 5\}\).
Using D6 is easy for making choices between 2, 3, and 6 items. We’ll beat these to death explicitly to introduce a little notation that can help with more complicated mechanics.
We can also note the overlaps between D6 and using polyhedral die with 4, 6, 8, 10, 12 or 20 faces.
\(\lvert R \rvert = 2\), tossing a coin.
To choose between two outcomes, we have two mechanics. One of these is really easy to explain, the other appears contrived to most folks.
- High-Low. \(R = \lfloor\frac{D-1}{3}\rfloor + 1\), which we can also write as \(R = 1 \textbf{ if } D \in \{1, 2, 3\}; 2 \textbf{ if } D \in \{4, 5, 6\}\). We’ll abbreviate this further to \(\bigwedge_D [\{1, 2, 3\}, \{4, 5, 6\}]\), relying on the order of subsets to provide the resulting value. The expression \(\bigwedge_D [S_0, S_1, ..., S_{n-1}]\) defines \(n\) subsets of the domain of \(D\). The resulting value is the subset number that contains \(D\)’s value.
- Even-Odd. \(D-1 \pmod 2 + 1\), which we can also write as \(\bigwedge_D [\{2, 4, 6\}, \{1, 3, 5\}]\).
The \(\bigwedge_D [\{1, 2, 3\}, \{4, 5, 6\}]\) seems easier to communicate to players as a way to toss a coin with a D6. The idea of lower 3 vs. upper 3 seems slightly more clear than the even-odd rule.
Especially if we write in something lass mathy. Maybe "D6 [1-3, 4-6]".
\(\lvert R \rvert = 3\)
To choose between three outcomes, we also have two mechanics, one simpler to explain than the other.
- Low-Mid-High. \(R = \lfloor\frac{D-1}{2}\rfloor + 1\). Or \(\bigwedge_D [\{1, 2\}, \{3, 4\}, \{5, 6\}]\).
- Mod 3. \(D-1 \pmod 3 + 1\). Or \(\bigwedge_D [\{3, 6\}, \{1, 5\}, \{2, 4\}]\).
The idea of lower 2 vs. middle 2 vs. upper 2 seems clear. The modulo-3 rule is not as clear.
\(\lvert R \rvert = 4\)
This is where things become a bit more complicated.
We have two mechanics to handle choosing among 4 outcomes.
- One roll, of 2D.
- Potential re-rolls using 1D.
The re-roll mechanic is pretty easy to explain.
| Die | Outcome |
|---|---|
| 1-4 | D |
| 5, 6 | Reroll |
The other choice uses 2D and a computation.
- Roll 2 die: \(D_2\), \(D_1\).
- Map each die to a result using the \(\lvert R \rvert=2\) rule: \(R_2=\bigwedge_{D_2} [\{1, 2, 3\}, \{4, 5, 6\}]\), \(R_1=\bigwedge_{D_1} [\{1, 2, 3\}, \{4, 5, 6\}]\).
- The subscript is the weight. \(R_2\) has a weight of \(2\). Compute \(R = R_2\times 2 + R_1\times 1\). Or \(\sum R_x\times x\).
We might explain this in the rules with:
Use two different-colored die, perhaps white and green. Assign a weight of 1 to the white die, and a weight of 2 to the green one. Roll. Discard all dice that show 1-3. Keep the remaining dice. Sum the weights based on the colors. No dice is a sum of 0. The white is 1. The green is 2. Both white and green is 3. Add one so the result is in the range 1-4.
This isn’t too onerous, and it provides equal probability results in a single roll of two dice.
It provides a precise mapping from D6 to D4.
\(\lvert R \rvert=5\)
As with \(\lvert R \rvert=4\), we have two mechanics: single roll of of using 4D or re-roll using 1D. The re-roll version is clear: Use 1D and reroll any 6’s to keep the range in 1 to 5.
The single roll alternative uses 4D.
| Total | Outcome |
|---|---|
| 4-10 | 1 |
| 11-12 | 2 |
| 13-14 | 3 |
| 15-16 | 4 |
| 17-24 | 5 |
The worst-case discrepancy between these outcomes and a 5-sided die is about 4.1%.
\(\lvert R \rvert=6\)
Doesn’t bear belaboring.
\(\lvert R \rvert=7\)
As with 5, there are two mechanics:
- Re-roll using the \(\lvert R \rvert=8\) outcomes (shown next), re-rolling on a result of 8 to keep the results in the range 1-7.
- For a single-roll, use 5D.
| Total | Outcome |
|---|---|
| 5-12 | 1 |
| 13-14 | 2 |
| 15-16 | 3 |
| 17-17 | 4 |
| 18-19 | 5 |
| 20-21 | 6 |
| 22-30 | 7 |
The worst-case discrepancy between these outcomes and a 7-sided die is about 5.2%.
\(\lvert R \rvert=8\)
For this we have two mechanics: a 3D computation, or a 4D table.
Similar to \(\lvert R \rvert=4\), we can do a computation. In this case, it’s 3D with distinct colors and dice weights of 1, 2, and 4. We can think of this as using a white, green, and red die. Roll all three, discard all dice showing 1-3. Add the weights for the dice which remain: 1 for the white die, 2 for the green die, and 4 for the red die.
This approach provides a precise mapping from D6 to D8.
The alternative is to roll 4D table.
| Total | Outcome |
|---|---|
| 4-9 | 1 |
| 10-11 | 2 |
| 12-12 | 3 |
| 13-13 | 4 |
| 14-14 | 5 |
| 15-15 | 6 |
| 16-17 | 7 |
| 18-24 | 8 |
The actual distribution has a number of places where it differs by about 5.2% from the expected distribution. It’s difficult to do better without rolling 9D.
\(\lvert R \rvert=9\)
We can leverage the \(\lvert R \rvert=3\) rules using 2D.
- Roll 2 die: \(D_3\), \(D_1\).
- Map each die to a result using the \(\lvert R \rvert=3\) rule: \(R_3=\bigwedge_{D_3} [\{1, 2\}, \{3, 4\}, \{5, 6\}]\), \(R_1=\bigwedge_{D_1} [\{1, 2\}, \{3, 4\}, \{5, 6\}]\).
- The subscript is the weight. \(R_3\) has a weight of \(3\). Compute \(R = R_3\times 3 + R_1\times 1 + 1\). Or \(\sum R_x\times x + 1\).
In English > Use two distinctly colored die: say white and green. The green die as a weight of 3. The white die has a weight of one. > We’ll transform each die’s value into the range 0, 1, 2, and then multiply by the weight. > This means 3 times the green die’s value (0, 1 or 2) plus the white die’s value (0, 1, or 2) plus 1 more to make a number from 1 to 9.
This isn’t quite as simple as using \(\lvert R \rvert=2\) rule, where we can keep or discard dice. We’re stuck with adding and multiplying to compute nine different values.
| D_3 | D_1 | Outcome |
|---|---|---|
| 1-2 | 1-2 | 1 |
| 3-4 | 2 | |
| 5-6 | 3 | |
| 3-4 | 1-2 | 4 |
| 3-4 | 5 | |
| 5-6 | 6 | |
| 5-6 | 1-2 | 7 |
| 3-4 | 8 | |
| 5-6 | 9 |
This is precisely the result of a 9-sided die.
Or. We can roll 5D and use this table.
| Total | Outcome |
|---|---|
| 5-12 | 1 |
| 13-14 | 2 |
| 15-15 | 3 |
| 16-16 | 4 |
| 17-17 | 5 |
| 18-18 | 6 |
| 19-19 | 7 |
| 20-21 | 8 |
| 22-30 | 9 |
This has a maximum discrepancy of 4.2% from an ideal 9-sided die.
\(\lvert R \rvert = 10\)
We have three mechanics to choose among 10 possible outcomes.
- Use \(\lvert R \rvert = 12\) outcomes and reroll on 11 or 12 to keep the result to the range 1 to 10.
- Use \(\lvert R \rvert = 5\) outcomes combined with \(\lvert R \rvert = 2\) outcomes. This is 4D and one more distinctly-colored die. The 4D provides 5 values from 0 to 4. When the odd die is 1-3, discard it. Otherwise it has a weight of 5, pushing the 4D values from 5 to 9. Then add 1 to make the resulting value have a range of 1 to 10.
- Use a 3D and the following table.
| Total | Outcome |
|---|---|
| 3-6 | 1 |
| 7-7 | 2 |
| 8-8 | 3 |
| 9-9 | 4 |
| 10-10 | 5 |
| 11-11 | 6 |
| 12-12 | 7 |
| 13-13 | 8 |
| 14-14 | 9 |
| 15-18 | 10 |
Interestingly the maximum error is only 3.1% with this.
\(\lvert R \rvert = 11\)
There are two mechanics for choosing among 11 outcomes:
- Use \(\lvert R \rvert = 12\) outcomes and reroll on 12 to keep the value between 1 and 11.
- Use 5D and the following table:
| Total | Outcome |
|---|---|
| 5-11 | 1 |
| 12-13 | 2 |
| 14-14 | 3 |
| 15-15 | 4 |
| 16-16 | 5 |
| 17-17 | 6 |
| 18-18 | 7 |
| 19-19 | 8 |
| 20-20 | 9 |
| 21-22 | 10 |
| 23-30 | 11 |
\(\lvert R \rvert = 12\)
While 6D and a table of roll ranges and outcomes provides pretty good results, it seems a lot simpler to use 2D.
One die is used to choose between the lower outcomes (1-6) or the upper outcomes (7-12). The second die chooses an outcome from the selected sub-group.
In effect, it decomposes the set of outcomes into two subsets.
This matches the results of a 12-side polyhedral die precisely.
\(\lvert R \rvert = 20\)
We’ll include 20 choices just to think about a D20 polyhedral die.
- We can combine \(\lvert R \rvert = 5\) choices and \(\lvert R \rvert = 4\) choices. This can be a lot of die rolling.
- We can use 24 choices with rerolls. This is \(\lvert R \rvert = 4\) choices multiplied 6, to which you’ll add an odd-colored die. Values above 20 get a re-roll.
This second choice isn’t too bad: it requires 3 distinctly-colored dice: red, green, and white. 1. The red and green are used to compute select a value from the set {0, 6, 12, 18}. The red die picks a subset: {0, 6} or {12, 18}. The green die picks the final value. 2. The white die is added to this, to make a number from 1 to 24. 3. Values from 21 to 24 get re-rolled.
This 3D rule is precisely a D20 roll.
\(\lvert R \rvert = 100\)
For total completeness, we can also get 100-sided die outcomes. It’s unpleasantly complicated.
With 3D – of distinct colors – the following can be done.
- Roll 3 dice, white, green, red.
- Use the red die to pick a value from {0, 36, 72}.
- Multiply the green die by the white die to get a value from 1 to 36.
- Add to get a value from 1 to 108.
- Reroll anything over 100.
More pragmatically, most tables using D100 have less than 100 distinct entries and wonderfully irregular probabilities. In some cases, the D100 probabilities mimic an nD6 distribution. This is because it can be easier to roll 2D10 than 5D6.
In other cases, the D100-based table can be decomposed into sub-tables, and a chain of dice rolls can recreate the original probability distribution.
TL;DR
Here are mechanics for up to 12 choices.
We’ve drawn the line at 12 because any game designer with more than 12 choices can subdivide the choices into multiple, smaller groups. It helps the players and GM’s to create a chain of smaller choice tables instead of one too-big-to-understand table of choices.
| Outcomes | D6 Mechanic |
|---|---|
| 2 | 1D [1-3, 4-6] |
| 3 | 1D [1-2, 3-4, 5-6] |
| 4 | 1D with reroll {5, 6} or 2D with weights of 2, 1 |
| 5 | 1D with reroll {6} or 4D [4-10, 11-12, 13-14, 15-16, 17-24] |
| 6 | 1D Do we even need to include this? |
| 7 | Use 8 outcomes with a reroll {7} or 5D [5-12, 13-14, 15-16, 17, 18-19, 20-21, 22-30] |
| 8 | 4D [4-9, 10-11, 12, 13, 14, 15, 16-17, 18-24] or 3D with weights of 4, 2, 1 |
| 9 | 5D [5-12, 13-14, 15, 16, 17, 18, 19, 20-21, 22-30] or 2D with weights of 3, 1 |
| 10 | 3D [3-6, 7, 8, 9, 10, 11, 12, 13, 14, 15-18] or 5 outcomes combined with 2 outcomes |
| 11 | Use 12 outcomes with reroll {11} or 5D [5-11, 12-13, 14, 15, 16, 17, 18, 19, 20, 21-22, 23-30] |
| 12 | Use 6 outcomes, combined with 2 outcomes |
| above 12 | Decompose into 2 or more tables; use a chain of rolls |
Implementation
This is a fun bit of programming to work out the proper distribution of values for nD6.
Given a distribution of nD6, we can then divide it up into equal-sized bins to work out the mapping from the dice distribution to a the desired uniform distribution.
The distribution is tricky, though.
Multinomial Distribution for dice
We have two ways to tackle a distribution of dice.
- Exhaustive Enumeration of the dice combinations.
- Direct computation of the results.
Exhaustive enumeration is easy to implement. We can use the itertools.product function to repeat the values from \(\{1, 2, 3, 4, 5, 6\}\) the right number of times.
For 2D, this is \(\{1, 2, 3, 4, 5, 6\} \times \{1, 2, 3, 4, 5, 6\}\). It has \(6^2 = 36\) distinct pairs of values. It has 11 distinct sums between 2 and 12.
For 10D, there are \(6^{10} = 60,466,176\) distinct 10-tuples of values, with sums from 10 to 60.
Why would anyone roll 10D to make a fair choice among alternatives? While it doesn’t seem terribly practical, it seems prudent to at least explore the possibility.
The problem is that enumerating over 60 million dice alternatives, computing over 60 million sums, and then accumulating the values into a Counter object takes a lot of time. It only gets worse for more dice.
My box of 6-sided die has a dozen dice. \(6^{12} = 2,176,782,336\). Ouch, that’s a lot of combinations.
from collections import Counter from collections.abc import Iterator import itertools def all_rolls(n: int, k: int = 6) -> Iterator[int]: """ Create *all* alternatives that will define a distribution with n samples of k categories. Sum the n-tuples to create totals that range in value between n and n*k. """ yield from ( sum(x) for x in itertools.product(range(1, k + 1), repeat=n) ) def distribution_hard(n: int, k: int=6) -> Counter[int]: """Create a counter to summarize """ return Counter(all_rolls(n, k))
sorted(distribution_hard(2).items())
[(2, 1), (3, 2), (4, 3), (5, 4), (6, 5), (7, 6), (8, 5), (9, 4), (10, 3), (11, 2), (12, 1)]
For direct computation of the distribution of dice, we need a lesson in multinomials.
See https://towardsdatascience.com/modelling-the-probability-distributions-of-dice-b6ecf87b24ea/. From this we learn the following (amongst other things). For \(n\) dice of \(s\) sides (6 in our case), the probability of getting the target value \(T\) is this:
equation GIF
(Above is the GIF from the original article.)
This is the formula, rewritten here in LaTeX:
\(P(n, s, T) = \Bigl(\sum\limits_{k=0}^{\lfloor \frac{T-n}{s} \rfloor}\bigl(-1\bigr)^k \frac{n!}{(n-k)!k!} \frac{(T-sk-1)!}{(T-sk-n)!(n-1)!}\Bigr)\Bigl(\frac{1}{s}\Bigr)^n\)
Yes, they’re the same.
We’ll use the following, with two optimizations. First, Python has the math.comb() function to compute binomials directly. We can leverage \(\frac{n!}{(n-k)!k!} = \binom{n}{k}\). Second, we’ll drop the \(\bigl(\frac{1}{s}\bigr)^n\) term because we don’t want a probability, we want the actual number of combinations of dice with the given total, \(T\).
\(P(n, s, T) = \sum\limits_{k=0}^{\lfloor \frac{T-n}{s} \rfloor}\bigl(-1\bigr)^k \binom{n}{k} \binom{T-sk-1}{n-1}\)
We can compute the distribution using \(P(n, s, T)\) instead of enumerating the product of \(n\) dice.
Also, as a Python implementation choice, we prefer to have the values of n and s last, since these don’t change as frequently as T. For our purposes, s has a default value of 6, which is not going to change for this analysis.
from math import comb, factorial from functools import cache @cache def dice_prob(T, n, s=6): upper = (T - n) // s series = ( (-1 if k % 2 == 1 else 1) * comb(n, k) * comb(T - s * k - 1, n - 1) for k in range(0, upper + 1) ) return int(sum(series)) def distribution_2(n, s=6): return dict((t, dice_prob(t, n)) for t in range(n, n*6+1))
sorted(distribution_2(2).items())
[(2, 1), (3, 2), (4, 3), (5, 4), (6, 5), (7, 6), (8, 5), (9, 4), (10, 3), (11, 2), (12, 1)]
def test_distribution(): """Takes over 3 minutes 40 seconds to demonstrate the two algorithms produce the same result.""" for dice in range(2, 13): closed_form = distribution_2(dice, 6) hard_way = distribution_hard(dice, 6) assert closed_form == hard_way, f"mismatch for {dice=} {closed_form=} {hard_way=}" print("Identical!") # test_distribution()
Mechanic Details
This data structure records the details of a dice-lookup table mechanic.
The Mechanic object will contain one or more DieRange instances.
A DieRange object lists the dice values from the source distribution used to create (approximately) equal-sized outcome bins. It also has the total number of dice combinations for the collection of values. When summarizing, the low and high attribute are important to summarize the range of values that define the outcome bin.
The Mechanic object is created with a list of DieRange instances. These define all the individual outcomes. The match with ideal bin-sizes isn’t going to be absolutely correct. The score and max_error are two ways to evaluate how will the mechanic fits the ideal.
from dataclasses import dataclass, field from statistics import mean, variance @dataclass class DieRange: map_to: int values: list[int] = field(default_factory=list) total: int = field(default=0) @property def low(self): return min(self.values) @property def high(self): return max(self.values) @dataclass class Mechanic: result_size: int n_dice: int total_size: int mapping: list[DieRange] = field(default_factory=list) _actuals: list[float] = field(default_factory=list, init=False) @property def actuals(self): if not self._actuals: self._actuals = [dr.total / self.total_size for dr in self.mapping] return self._actuals @property def total(self): return sum(self.mapping) @property def score(self): expected = 1 / self.result_size return sum( (actual - expected)**2 for actual in self.actuals ) @property def max_error(self): expected = 1 / self.result_size errors = ( actual - expected for actual in self.actuals ) return max(errors, key=lambda v: abs(v))
The goal is to produce Mechanic instances for a given number of equal-probability outcomes, and a selected number of dice.
We can vary the number of dice for a given number of outcomes, looking for one that has a minimal error.
from collections import defaultdict def make_mechanic(result_size: int, number_dice: int) -> Mechanic: """ Computes the multinomial distribution for nD6. Formally, n samples of k=6 categories. Then, breaks the distribution into `result_size` distinct buckets. Return a summary of the Mechanic to make it easier to compare alternatives. :param result_size: number of outcomes :param number_dice: number of dice to try and map to outcomes :returns: a Mechanic with details of a table to provide (approximately) equal probability outcomes """ distribution = distribution_2(number_dice) n_tile_width = sum(distribution.values()) / result_size # print(f"{n_tile_width:.1f} out of {sum(distribution.values())}") rolls = defaultdict(list) tile_num = 0 accum_fq = 0 for k in sorted(distribution): accum_fq = accum_fq + distribution[k] if accum_fq >= n_tile_width: tile_num += 0 if tile_num == result_size-1 else 1 accum_fq = accum_fq - n_tile_width rolls[tile_num].append(k) m = Mechanic( result_size, number_dice, sum(distribution.values()), [ DieRange(r, rolls[r], sum(distribution[n] for n in rolls[r])) for r in rolls ] ) return m
The report() function explores the alternative space. It creates a bunch of alternative mechanics, and then ranks them by the score computation.
In some cases, the best fit also involves a big handful of dice. A slightly worse fit may involve substantially fewer dice. It’s not a simple matter of picking the “best” fit; it also has to be reasonably useful by actual players.
def report(outcomes: int = 5, max_dice: int | None = None): """ Locate optimal Mechanics for a given number of outcomes. This prints a summary of each mechanic considered. :param outcomes: Number of distinct, equal-probability outcomes :param max_dice: Maximum number of dice to consider, the default is the number of outcomes. """ mechanics = [ make_mechanic(outcomes, n_dice) for n_dice in range(2, max_dice or outcomes+1) ] for m in sorted(mechanics, key=lambda m: m.score): print(f"{m.result_size} choices with {m.n_dice}D") print(f"| roll | result | frequency | error |") print(f"| ---- | ------ | --------- | ----- |") expected = 1 / outcomes for dr in m.mapping: actual = dr.total / m.total_size print(f"| {dr.low}-{dr.high} | {dr.map_to} | {actual:.1%} | {actual - expected:.1%} |") print(f"{m.score=:.3f} {m.max_error=:.1%}") print()
Let’s use this report() function to compute a number of different kinds of equal-probability mechanics.
report(5)
5 choices with 4D | roll | result | frequency | error | | ---- | ------ | --------- | ----- | | 4-10 | 0 | 15.9% | -4.1% | | 11-12 | 1 | 17.7% | -2.3% | | 13-14 | 2 | 22.1% | 2.1% | | 15-16 | 3 | 20.4% | 0.4% | | 17-24 | 4 | 23.9% | 3.9% | m.score=0.004 m.max_error=-4.1% 5 choices with 3D | roll | result | frequency | error | | ---- | ------ | --------- | ----- | | 3-7 | 0 | 16.2% | -3.8% | | 8-9 | 1 | 21.3% | 1.3% | | 10-10 | 2 | 12.5% | -7.5% | | 11-12 | 3 | 24.1% | 4.1% | | 13-18 | 4 | 25.9% | 5.9% | m.score=0.012 m.max_error=-7.5% 5 choices with 5D | roll | result | frequency | error | | ---- | ------ | --------- | ----- | | 5-13 | 0 | 15.2% | -4.8% | | 14-16 | 1 | 24.8% | 4.8% | | 17-17 | 2 | 10.0% | -10.0% | | 18-20 | 3 | 27.9% | 7.9% | | 21-30 | 4 | 22.1% | 2.1% | m.score=0.021 m.max_error=-10.0% 5 choices with 2D | roll | result | frequency | error | | ---- | ------ | --------- | ----- | | 2-4 | 0 | 16.7% | -3.3% | | 5-5 | 1 | 11.1% | -8.9% | | 6-7 | 2 | 30.6% | 10.6% | | 8-8 | 3 | 13.9% | -6.1% | | 9-12 | 4 | 27.8% | 7.8% | m.score=0.030 m.max_error=10.6%
We'll omit all the other experiments we ran to fill in the TL;DR table up front.