Why the sum of dice is not flat
Two six-sided dice have 36 equally likely outcomes. Only one of them, 1 + 1, gives a sum of 2, but six give 7: 1 + 6, 2 + 5, 3 + 4 and their reverses. Middle sums can be made in more ways, so they come up more often. With three dice the peak is flatter and wider — 10 and 11 each have 27 of 216 outcomes, 12.5% — and by five or six dice the histogram looks like a bell curve. That is the central limit theorem at work on a very small scale.
Try one die against ten dice with the same number of sides, or a single d20 against 2d10: the ranges overlap, the shapes do not.
The exact distribution by convolution
The probability of a sum t with k dice follows from the distribution for k − 1 dice: the last die shows each face f = 1…s with probability 1/s.
The model runs this recursion on integer counts of outcomes and divides by sk at the end. The largest case, ten d20s, has 2010 ≈ 1.02 × 1013 outcomes, well inside the range where floating-point numbers hold integers exactly, so the “exact” line has no rounding error beyond the final division.
One die has mean (s + 1)/2 and variance (s² − 1)/12. Means and variances of independent dice add, so the sum has
For two six-sided dice that is 7 and 35/6 ≈ 5.833.
How close is the histogram?
The total variation (TV) distance is half the sum of the gaps between simulated and exact probabilities. It equals the largest difference the two distributions assign to any event, such as “the sum is at least 10”. It is never exactly zero for a finite number of rolls: sampling noise alone leaves each frequency off by about √(2p(1 − p)/(πn)) on average. Adding that over all sums gives the TV from sampling noise alone readout. In repeated runs at the default settings, the average TV distance agrees with it to within a few percent. Both shrink like 1/√n: 100 times more rolls make them 10 times smaller.
A TV distance far above the noise level would mean biased dice or a flawed random-number generator.
Example result
With seed 42, 10,000 rolls of two six-sided dice averaged 6.981 against an exact mean of 7.000, with variance 5.844 against 5.833. The total variation distance to the exact distribution was 0.0117, close to the 0.0121 expected from sampling noise alone.
| Rolls | 10,000 |
|---|---|
| Mean of the sum | 6.981 |
| Exact mean k(s+1)/2 | 7.000 |
| Variance of the sum | 5.844 |
| Exact variance k(s²−1)/12 | 5.833 |
| Total variation distance | 0.0117 |
| TV from sampling noise alone (approx.) | 0.0121 |
Implementation
The page runs simulation.js from the package below; the Python file is a line-by-line port with the same random-number generator, so a given seed produces the same result in both languages. The excerpts show the core of each.
// Exact distribution of the sum of k fair s-sided dice by repeated convolution:
// ways[j] counts the s^k equally likely outcomes whose sum is k + j.
// Integer counts stay exact up to 20^10 ≈ 10^13, well below 2^53.
export function exactWays(k, s) {
let ways = [1];
for (let d = 0; d < k; d++) {
const next = new Array(ways.length + s - 1).fill(0);
for (let i = 0; i < ways.length; i++) {
for (let f = 0; f < s; f++) next[i + f] += ways[i];
}
ways = next;
}
return ways;
}
// One roll: k independent dice, each face 1…s equally likely.
export function rollDice(k, s, rng, faces) {
let sum = 0;
for (let d = 0; d < k; d++) {
const face = 1 + rng.int(s);
if (faces) faces[d] = face;
sum += face;
}
return sum;
}
// Total variation distance: half the summed absolute gaps between the
// empirical frequencies and the exact probabilities.
export function tvDistance(counts, n, probs) {
let tv = 0;
for (let j = 0; j < probs.length; j++) tv += Math.abs(counts[j] / n - probs[j]);
return tv / 2;
}def exact_ways(k, s):
"""ways[j] = number of the s**k equally likely outcomes whose sum is k + j."""
ways = [1]
for _ in range(k):
nxt = [0] * (len(ways) + s - 1)
for i, w in enumerate(ways):
for f in range(s):
nxt[i + f] += w
ways = nxt
return ways
def roll_dice(k, s, rng):
"""Sum of k independent fair dice with faces 1..s."""
total = 0
for _ in range(k):
total += 1 + rng.int(s)
return total
def tv_distance(counts, n, probs):
"""Half the summed absolute gaps between empirical and exact probabilities."""
tv = 0
for c, q in zip(counts, probs):
tv += abs(c / n - q)
return tv / 2Run the full model from a terminal:
node simulation.js --dice 2 --sides 6 --seed 42
python simulation.py --dice 2 --sides 6 --seed 42Download the model
| File | Contents | Size |
|---|---|---|
README.md | How to run it, parameters, example result | 2.2 KB |
model.json | Parameters with ranges and defaults, outputs, the seed-42 example | 2.3 KB |
simulation.js | The model used on this page plus a command-line runner (Node 18+) | 8.0 KB |
simulation.py | Python 3.9+ port, standard library only | 6.2 KB |
example.csv | Result table from the example run | 405 B |
LICENSE.txt | MIT License | 1.0 KB |
Assumptions & limitations
- Fair, independent dice. Every face has probability exactly 1/s and dice do not influence each other. Physical dice are manufactured objects and can be slightly unbalanced.
- Faces from 32-bit random numbers. A face is ⌊u · s⌋ + 1 for a uniform u with 232 possible values, which biases a face by at most s/232 — invisible at a million rolls.
- The noise reference is approximate. It uses the normal approximation to the binomial for each sum, which is loose for sums with tiny probabilities.
- Sample variance. The simulated variance divides by n − 1.
References
- Grinstead, C. M. & Snell, J. L. (1997). Introduction to Probability, 2nd ed. American Mathematical Society. math.dartmouth.edu/~prob/prob/prob.pdf
- Ross, S. M. A First Course in Probability. Pearson.