How can computers roll dice?
by Sam Estep, 2024-10-20
If you type "roll a die" into Google, it... well, it rolls a die:
But how does it do that?
To start: you may have heard that computers use binary numbers. That's true! This has been well-explained by many other people, so if you're unfamiliar, check out one of those explanations, e.g. Khan Academy.
(6 minutes later) Welcome back! So yeah, every digit of every number in your computer is either a 0 or a 1. Kind of like when you flip a coin, it's either heads or tails. (Or edge, but that's a different post entirely.) Let's talk about coin flips, then!
Coin flips
You can also Google "flip a coin":
This is much closer to the computer's native tongue. All it has to do is generate a single random bit. For the remainder of this post, we're going to take "generate a random bit" (or equivalently, "flip a coin") as a basic primitive, and then build everything else on top of that.
Let's say we want to simulate a die roll but all we have is a coin to flip. How can we give each number on the die an equal chance? Well, we can start by flipping the coin once. This at least lets us narrow down the possibilities:
- If we got heads, we'll say that the die roll is even: 2, 4, or 6.
- If we got tails, we'll say that the die roll is odd: 1, 3, or 5.
Either way we have 3 possibilities left. Now we're kinda stuck. If we flip the coin again, how do we choose what to do? Let's say our first flip was heads, so our choices are 2, 4, or 6. We could say:
- If we get heads again, we'll say the die roll is 2.
- If we get tails, we'll say the die roll is 4 or 6.
And then if we got tails, we need to flip a third time to choose between 4 and 6. But this is not a fair die roll! We're twice as likely to get 2 as we are to get 4, and similarly we're twice as likely to get 2 as 6. What are we to do?
Python!
Let's look at how real computers do it. If you have
the snake language installed,
it's pretty straightforward to use the
builtin random
module
to simulate rolling a die:
$ python
>>> import random
>>> random.randint(1, 6)
5
>>> random.randint(1, 6)
2
Now, you and I may have different ideas of fun, but I always enjoy
doing a little deep dive of the source code for other people's
software that I'm running.
How is randint
defined?
randint = _inst.randint
_inst = Random()
So that means:
def randint(self, a, b):
"""Return random integer in range [a, b], including both end points.
"""
return self.randrange(a, b+1)
Fair enough: to generate a random integer between 1 and 6 (inclusive),
that's the same as generating a random integer between 1 (inclusive)
and 7 (exclusive). If you look at the
source for randrange
, there are three different cases; we're only interested in one of
them, so I'll simplify the source code as if that were the only case:
def randrange(self, start, stop):
"""Choose a random item from range(start, stop).
"""
istart = _index(start)
istop = _index(stop)
width = istop - istart
istep = _index(step)
if width > 0:
return istart + self._randbelow(width)
raise ValueError(f"empty range in randrange({start}, {stop})")
So to generate a random integer between 1 (inclusive) and 7
(exclusive), that's the same as generating a random integer between 0
(inclusive) and 6 (exclusive) and then adding 1 at the end; but the
rabbit hole gets deeper.
What is _randbelow
?
_randbelow = _randbelow_with_getrandbits
def _randbelow_with_getrandbits(self, n):
"Return a random int in the range [0,n). Defined for n > 0."
getrandbits = self.getrandbits
k = n.bit_length()
r = getrandbits(k) # 0 <= r < 2**k
while r >= n:
r = getrandbits(k)
return r
Now that's what I'm talkin' about! See how it's defined entirely in
terms of
getrandbits
? Like we said before, in the end it all goes back to coin flips.
First we call
bit_length
on n
, which works like this:
>>> n = 6
>>> bin(n)
'0b110'
>>> n.bit_length()
3
So Python looks at the upper limit for the range of integers we care
about, and asks: how many bits do I need to represent that integer? In
the case of rolling a die, the answer is three bits, or three coin
flips. So far, nothing is different from what we did before, because
if you recall, in the worst case we did need to flip our coin three
times. But here instead of deciding what to do after each coin flip,
we simply flip the coin three times right at the start. This gives a
uniform random three-bit integer. The smallest integer we can
represent with three bits is 0, and the largest is 7. And indeed, if
you run random.getrandbits(3)
many times, you'll see this
uniform distribution!
OK... but that's not what we actually want. We wanted the generated
integer to be less than 6, so we can add 1 to it and get a uniform die
roll. And that's where the while
loop comes in: if the
generated integer is 6 or 7, we completely ignore it, flip the coin
three more times, and try again. We just keep doing this until we get
something in the correct range. In general this technique is called
rejection sampling. As we said before, we can do this until it works, then add 1.
And really, this works fine. If all you wanted to know is how your computer rolls a die then congratulations, now you know! But... isn't this a bit inefficient? Well, we can plot the chance that
- we don't have to retry at all,
- we have to retry only once,
- we have to retry twice,
- etc.,
and draw those in a different kind of histogram.
This shows that it's not horrible: the chance of having to retry times is exponential in . Actually, how well should we expect to be able to do? Well, in the case where is a power of two, uniformly sampling a nonnegative integer less than is equivalent to flipping a coin times; no need to retry. If is not a power of two then instead of directly giving the number of coin flips, that logarithm gives the entropy of the distribution; if we're using random bits to faithfully sampling from the distribution, we can't do better than the entropy on average. So for varying values of on the -axis, we can plot the entropy (in blue) and Python's expected number of coin flips (in red) on the -axis.
For some integers, Python will on average sample roughly the theoretical minimum possible number of bits. But for others, it samples twice as many bits as should be needed!
If you've been reading carefully, you may have noticed that there's some low-hanging fruit here: in the case where is a power of two, its binary representation is just a 1 followed by some number of 0s. In that case, we should never need to retry, but Python samples from a range exactly twice as big as it should be, so retries are possible. But that case only affects powers of two, and it turns out that we can do better even for all those other numbers that aren't powers of two.
A more clever approach
I say "more clever" instead of "more smarter"
because there's probably a good reason people don't do this in
practice. But we're gonna do it anyway! It turns out that when we
reject a sample, we can save some of the randomness we've already
accumulated; there's a discussion about this on
Stack Overflow, which links to a paper about the
Fast Dice Roller algorithm. Here's a Python implementation of that algorithm (modified slightly
for the n == 1
case); again, we assume that we have a
flip
function that returns 0
half the time
and 1
the other half of the time:
def fast_dice_roller(n):
v = 1
c = 0
while True:
while v < n:
v = 2 * v
c = 2 * c + flip()
if c < n:
return c
else:
v -= n
c -= n
Let's walk through how this works for our 6-sided die example! So,
n == 6
. We'll visualize this process as a flow chart that
goes from top to bottom and left to right (same as reading English
prose). Each circle in the flow chart contains the value of
v
at that point in the process.
As you can see from the code, we start with v == 1
. We
flip
the coin (doubling v
each time) until
we have v >= 6
, which in this case takes three coin
flips. At this point we have v == 8
, and we check to see
whether c < 6
. If so, we're all done! Otherwise, we
subtract 6
from v
, leaving
v == 2
. Now we only need to flip the coin
twice to get back to v >= 6
. We again check;
if c < 6
then we return, and if not then we subtract
6
from v
again. But we've seen this before:
8 - 6 == 2
, so we've hit a cycle! This cycle is indicated
in the diagram by a big red asterisk.
We saw that once we've entered this cycle, we now only need to flip
the coin twice for each attempt, instead of three times. OK, but does
that actually help us? Turns out, yes, it does! Here's its expected
number of coin flips for a given n
, plotted in
green.
As you can see, while the red curve can be up to double the height of the blue curve at a given point, the green curve is never more than 2 flips above the blue curve.
Now you know not only how your computer rolls dice, but also how it could roll dice even better. But... look at the shape of that graph. Kinda weird, right? Doesn't it just tug at your brain and make you want to understand it a bit more?
More math
The key invariant in this algorithm is that, at the start every
iteration of the while True
loop, c
is
always a uniformly random nonnegative integer less than
v
. At the start we have v == 1
, so the only
such integer is 0
, and indeed that is c
's
value! Now, think about what we do inside the inner
while
loop. We double v
, so now
c
needs to be uniformly distributed across twice as many
possible values. We start by doubling c
. This always
produces a value that is even! So we're missing all the odd values
less than v
. Then, the flip
function always
returns either 0
or 1
, so when we add its
result after doubling c
, we have a 50% chance to stay on
an even value, and a 50% to go to the odd value immediately following
it. We've restored the invariant that c
is uniformly
sampled from all nonnegative integers less than v
.
But that's all the same as in our earlier rejection sampling approach.
The trick is what we do after we check if c < n
. When
this is True
, remember that c
was uniformly
sampled, so all the nonnegative integers less than n
were
equally likely, and so we just return c
. But if it's
False
, now we know for a fact that
c >= n
. And again, all those nonnegative integers at
least n
but less than v
were equally likely,
so if we subtract n
from both v
and
c
, we maintain our invariant! Now v
is a
smaller value, but it's often still greater than 1
, so we
can use some of the randomness we've already gotten to avoid flipping
our coin quite as many times. You can see this in the diagram above:
if we were just doing rejection sampling then we'd have to flip the
coin three times every time we failed, but in this case we flip three
times only at first, and then after that we only flip twice on every
subsequent iteration.
That was but a simple example. Actually, there are even simpler ones:
if we have a power of two like n == 32
, there is no cycle
at all; we just flip
our coin a few times and then
return
.
But these cycles can also get much longer: here's
n == 11
.
This is a lot more interesting! In our n == 6
example the
cycle only went back to the previous node in the flow chart, but here
it went all the way back to the beginning. So the cycles can be
different lengths. Here are the first few of those cycle lengths, for
n == 1
through n == 40
, and for powers of
two where there's no cycle, I've inserted 1 as a placeholder value.
(Try using 0 instead for a different rabbit hole!)
1, 1, 1, 1, 2, 1, 1, 1, 3, 2, 5, 1, 6, 1, 1, 1, 4, 3, 9, 2, 2, 5, 4, 1, 10, 6, 9, 1, 14, 1, 1, 1, 5, 4, 5, 3, 18, 9, 4, 2, ...
Turns out, if you look up this sequence, it already has a name! It's called A136043:
Period-lengths of the base- -expansions of the reciprocals of the positive integers.
Neat! The bit about "reciprocals of the positive integers" makes sense, because if you're trying to uniformly sample a nonnegative integer less than , the chance of getting any specific possibility should be equal to . What is an -expansion, though? Turns out it's defined in OEIS A136042 (that page actually has a couple errors, which I've corrected here):
The base- -expansion of a positive real number , denoted by , is the integer sequence , where is the smallest exponent such that and where , with the initialization . The base- -expansion of is periodic with period length . Further computational results (see A136043) suggest that if is a prime with as a primitive root, then the base- -expansion of is periodic with period . This has been confirmed for primes up to . The base- -expansion of is given in A136044.
This is a bit dense and I'm not going to work through it in detail
here, but if you think about it, it roughly matches up with the
algorithm we've been examining. See how it mentions "the smallest
exponent
"? That's the same as our number of coin flips in each row of
the diagrams we've been looking at! Each flip doubles v
,
so that's exponentiation right there. The process of defining
in terms of
and
(where
in our case) is essentially the same as our process of going from one
row of the diagram to the next.
That's all I'll say about this sequence for now, but by all means dig further into this definition if you're curious! And if you think about it and look back at the diagrams we've been drawing, there are a couple other sequences we could define instead... can you find any of those in the OEIS?
Conclusion
And there you have it: we learned
- how your computer probably rolls dice,
- a cleverer way your computer could roll dice, and
- some fun math about patterns that emerge from that cleverer way.
One question you may still have is, how do computers flip coins in the first place? We kind of swept that under the rug at the beginning. Others have already written more about this, but simplifying a lot, your computer looks at something unpredictable in the real world, e.g. lava lamps, and uses some math to slice and dice what it sees into a sequence of random bits. Then your computer hands out those random bits to programs that ask for them, like the functions we've been looking at in this post.
Hopefully you had fun reading this! I've posted it on Hacker News and on Twitter, so feel free to reply there with any questions or comments!