1.7. Example: A Game of Chance
Hazard is a game of chance that was popular in medieval England. To play, a designated player named the caster throws a pair of six-sided dice and any number of other players can place bets on the caster’s rolls. In this chapter, we will first describe the game and then write code to simulate it.
The game proceeds in rounds. At the start of a round, the caster chooses an integer, known as the main, in the range 5 to 9 inclusive. The caster then rolls both dice in the come out roll. The terminology for this part of Hazard is complex and does not add a lot to our understanding of the game. Without affecting the accuracy of our simulation, we will simply refer to the the sum of the dice in the come out roll as the chance.
Here are the rules for the come out roll:
If the chance is equal to the caster’s chosen main, the caster immediately wins the round.
If the chance is 2 or 3, the caster immediately loses the round.
If the chance is 11 or 12, then:
If the caster’s main is 5 or 9, the caster loses the round.
If the caster’s main is 6 or 8, the caster wins the round if the chance is 12 and loses the round otherwise.
If the caster’s main is 7, the caster wins the round if the chance is 11 and loses the round otherwise.
If none of these conditions is met, the caster will continue to roll the dice until a throw matches the main or the chance. The caster wins the round if their last throw matches the chance and loses the round if it matches the main.
Here is a table that summarizes these rules:
Come out roll |
|||
---|---|---|---|
Main |
Caster wins immediately on a chance of |
Caster loses immediately on a chance of |
Caster continues to throw on chance of |
5 |
5 |
2, 3, 11, 12 |
Any other value |
6 |
6, 12 |
2, 3, 11 |
Any other value |
7 |
7, 11 |
2, 3, 12 |
Any other value |
8 |
8, 12 |
2, 3, 11 |
Any other value |
9 |
9 |
2, 3, 11, 12 |
Any other value |
The caster continues playing rounds until they lose two consecutive rounds, at which point another player becomes the caster.
To make this description more concrete, let’s play a few rounds with a chosen main of 5.
Round One |
|||
---|---|---|---|
Main |
Roll |
Result |
Notes |
5 |
9 |
Continues |
Come out roll, chance is 9 |
5 |
7 |
Continues |
Does not match main or chance |
5 |
7 |
Continues |
Does not match main or chance |
5 |
9 |
Wins |
Roll matches chance |
In this round, because the caster rolls a 9 in the come out roll, the chance is 9. This value does not match any of the special cases for the first roll, so the caster continues to roll the dice. Neither of the next two rolls (7 and 7) match either the chance or the main, and so the caster rolls once more. In their final roll, the caster throws a 9, which matches the chance, and wins.
Round Two |
|||
---|---|---|---|
Main |
Roll |
Result |
Notes |
5 |
4 |
Continues |
Come out roll, chance is 4 |
5 |
6 |
Continues |
Does not match main or chance |
5 |
11 |
Continues |
Does not match main or chance |
5 |
12 |
Continues |
Does not match main or chance |
5 |
5 |
Loses |
Roll matches main |
In the second round, because the caster rolls a 4 in the come out roll, the chance is 4. This value does not match any of the special cases, so the caster continues to roll the dice. The next three rolls (6, 11, and 12) do not match the chance or the main, so the caster rolls the dice a fifth time. The final roll is a 5, which matches the main, and the caster loses the round.
Round Three |
|||
---|---|---|---|
Main |
Roll |
Result |
Notes |
5 |
11 |
Loses |
Come out roll |
In the third round, the caster rolls an 11 in the come out roll and immediately loses. Since the caster has lost two rounds in a row, they pass the dice on to another player.
Given that brief explanation, let’s look at some code for simulating this game. Our implementation needs to handle at least three tasks:
rolling the dice,
playing a round, and
simulating a game.
We’ll start by writing a function to roll the dice.
1.7.1. Rolling dice
In Hazard, the caster throws of pair of dice and the face values are added together. Because we will use this computation often in our simulation, it makes sense to write a very short function to do it:
def throw_dice():
"""
Throw a pair of six-sided dice
Returns (int): the sum of the dice
"""
num_sides = 6
return random.randint(1, num_sides) + random.randint(1, num_sides)
We chose to use a constant for number of sides for the die, but we could have easily written this function to take the number of sides as an argument.
It may seem silly to write a function for a couple of function calls
and an addition. There are two advantages to encapsulating this work
into a function: first, encapsulating this work allows us to think
about rolling dice rather than calling random.randint
, etc when
writing later functions and, second, we can make changes, such as
using dice with a different number of sides or using another method
for simulating the rolling of the dice, simply by changing this
function rather than having to find every place in the code that needs
to roll the dice.
1.7.2. Playing a round
The work needed to play a round is sufficiently complex that it makes
sense to encapsulate it in a function. Since the caster chooses the main,
this function will need to take the main as a parameter.
The only detail that matters for the
rest of the game is whether the caster won the round.
We can return this information as a boolean: True
for a win and
False
for a loss. Putting these design decisions together gives
us the following function header:
def play_one_round(chosen_main):
"""
Play one round of Hazard.
Args:
chosen_main (int): a value between 5 and 9 inclusive.
Returns (bool): True, if player wins the round and False, otherwise.
"""
To simulate the come out roll, we first call our
throw_dice
function and save the result in a variable named
chance
.
chance = throw_dice()
Next, we need to translate the rules for the come out roll. At the
top-level, this computation requires a conditional with four branches.
The first two branches are straightforward: if each condition holds, the caster
wins in the first case (return True
), and loses in the second
(return False
).
if chance == chosen_main:
return True
elif (chance == 2) or (chance == 3):
return False
To determine the outcome of the third case, we need a nested
conditional that branches on the value of chosen_main
.
elif (chance == 11) or (chance == 12):
if (chosen_main == 5) or (chosen_main == 9):
return False
elif (chosen_main == 6) or (chosen_main == 8):
return (chance == 12)
else:
# chosen_main is 7
return (chance == 11)
Unlike the previous returns, the outcome of the second branch of the
inner conditional is not hard-coded to be the constant True
or the
constant False
instead it depends on whether the value of
chance
is 11 or 12. Since chance
can only have one of these
two values, we can determine the result by evaluating a simple boolean
expression: chance == 12
. The result of the third branch is
computed similarly, using the boolean expression chance == 11
.
Finally, if none of the first three conditions of the outer
conditional statement hold, then Python executes the body of the else
branch.
else:
roll = throw_dice()
while (roll != chance) and (roll != chosen_main):
roll = throw_dice()
return (roll == chance)
In this case, the caster continues to throw the dice until a roll
matches either the chance or the main. Let’s look at how to encode
this condition in two parts: The expression (roll == chance) or
(roll == chosen_main)
will be true when the roll matches either the
chance or the main. Since our loop should continue only when that
condition does not hold, we can simply negate this expression to get
an appropriate test for the loop: (not ((roll == chance) or (roll ==
chosen_main))
).
While that expression correct reflects the logic of the game, it is a
bit hard to understand at first glance. It can be rewritten as:
(roll != chance) and (roll != chosen_main)
, which is easier to
read.
After the loop finishes, we again return the result of evaluating a boolean expression.
A note about using parenthesis in expressions: because not
has
higher precedence than or
, the parenthesis surrounding the
expression ((roll == chance) or (roll == main))
in our first version
of the test are necessary for correctness. The parenthesis
surrounding the equality (and inequality) tests, in contrast, are not
necessary for correctness. Some programmers include parenthesis to
increase clarity, while others consider them unnecessary distractions.
You can decide for yourself.
We can put these pieces together to get the following complete function:
def play_one_round(chosen_main):
"""
Play one round of Hazard.
Args:
chosen_main (int): a value between 5 and 9 inclusive.
Returns (bool): True, if player wins the round and False, otherwise.
"""
chance = throw_dice()
if chance == chosen_main:
return True
elif (chance == 2) or (chance == 3):
return False
elif (chance == 11) or (chance == 12):
if (chosen_main == 5) or (chosen_main == 9):
return False
elif (chosen_main == 6) or (chosen_main == 8):
return (chance == 12)
else:
# chosen_main is 7
return (chance == 11)
else:
roll = throw_dice()
while (roll != chance) and (roll != chosen_main):
roll = throw_dice()
return (roll == chance)
While this version correctly implements the rules for one round, it
does not follow standard Python style. In particular, since all the
branches contain return statements, the elif
branches can be
converted into if
statements and the final else
can be dropped
altogether. The original version also uses more parentheses than
strictly necessary. Here is a version that is closer to standard
Python style:
def play_one_round(chosen_main):
"""
Play one round of Hazard.
Args:
chosen_main (int): a value between 5 and 9 inclusive.
Returns (bool): True, if player wins the round and False, otherwise.
"""
chance = throw_dice()
if chance == chosen_main:
return True
if chance == 2 or chance == 3:
return False
if chance == 11 or chance == 12:
if chosen_main == 5 or chosen_main == 9:
return False
if chosen_main == 6 or chosen_main == 8:
return chance == 12
# chosen_main is 7
return chance == 11
roll = throw_dice()
while roll != chance and roll != chosen_main:
roll = throw_dice()
return roll == chance
1.7.3. Simulating a player
We will also encapsulate the code for simulating an individual caster’s turn
in a function. Like play_one_round
, this function will take
the chosen main as a parameter. The betting rules for Hazard are
complex and so, our simulation will simply count the number of
rounds that the caster wins.
Recall that the caster plays rounds until they lose two in a row.
Keeping track of the number of consecutive losses is the trickiest
part of this function. We’ll use a variable, consecutive_losses
,
that we initialize to zero because the caster has not yet played or
lost any rounds. We will call play_one_round
to simulate a round
and update consecutive_losses
as appropriate. On a loss, we will
increment its value and on a win, we will reset it to zero.
As soon as the caster has lost two consecutive rounds, the test for the while loop will be false and the function will return the number of rounds won.
def simulate_caster(chosen_main):
"""
Simulate rounds until the caster loses two rounds in a row.
Args:
chosen_main (int): a value between 5 and 9 inclusive.
Returns (int): the number of rounds won
"""
num_wins = 0
consecutive_losses = 0
while consecutive_losses < 2:
if play_one_round(chosen_main):
consecutive_losses = 0
num_wins = num_wins + 1
else:
consecutive_losses = consecutive_losses + 1
return num_wins
1.7.4. Estimating win rate
We can easily reuse our function play_one_round
to print a table
of estimated per-round win rates for the different possible choices
for main. There is a tradeoff between accuracy and computation time:
simulating more rounds takes more time to compute but produces a more
accurate result. Rather than hard-coding a specific number of rounds,
we’ll write our function to take the number of rounds as a parameter
and let the user make this tradeoff.
We will use a pair of nested loops to generate the table. The outer
loop will iterate over the possible values for main (range(5,
10)
). Why 10
? Recall that range
generates a sequence of
values from the specified lower bound up to, but not including, the
specified upper bound. The inner loop will both simulate the desired
number of rounds and keep track of the number of rounds won for the
current value of chosen_main
.
def print_win_rate_table(num_rounds):
"""
Print a table with the win rates for the possible choices for main
Args:
num_rounds (int): the number of rounds to simulate
Returns: None
"""
for chosen_main in range(5, 10):
num_wins = 0
for _ in range(num_rounds):
if play_one_round(chosen_main):
num_wins = num_wins + 1
print(chosen_main, num_wins/num_rounds)
This computation needs to start with a fresh win count for each
possible value of chosen_main
, so the value of win
gets reset
to zero for each iteration of the outer loop. As we noted in our
earlier discussion of loops, initializing variables in the wrong place
is a very common source of bugs.
Also, notice that we used a sigle underscore character (that is,
_
) as the name for the loop variable. A single underscore
is often used in situations, such as this one, where Python’s syntax
requires a name, but the value will not be used.
1.7.5. Main block
Finally, we add a main block to complete our program. The main block needs to prompt the user for the number of rounds to use in the simulations and print the win rate table.
Here’s the code for the main block:
if __name__ == "__main__":
error_msg = "The number of rounds needs to be a positive integer. Goodbye."
n = input("Number of rounds: ")
try:
num_rounds = int(n)
except valueError:
print(error_msg)
sys.exit(1)
if num_rounds > 0:
print_win_rate_table(num_rounds)
else:
print(error_msg)
sys.exit(1)
We chose to use input
, a built-in function, to prompt the user for
a value. This function takes a string, prints it, waits for the user
to enter a value, and then returns that value as a string.
The user-supplied string can be converted to an integer using the
built-in int
function, which takes a string and returns the
cooresponding integer, if possible. This function will raise a
ValueError
if the string cannot be converted into an integer.
A try
block is often used when dealing with user input. If the
string supplied by the user can be converted to an integer, then the
variable num_rounds
will set to that integer and the main block
will continue with conditional that follows the try
block. If the
string cannot be converted to an integer, then int
will raise a
ValueError
. The try
block’s exception handler catches this
exception, prints a simple error message, and then uses the exit
function from the sys
standard library to exit the program.
Finally, the program verifies that the value supplied for the number
of rounds is a positive integer and then call
print_win_rate_table
. If the value is not a positive integer, the
program prints an error message and exits.
Technical Details
The parameter passed to sys.exit
is used as the exit status
or exit code for the program. By convention, programs that exit
normally use zero as their exit code. Programs that encounter an
error typically use a value other than zero as their exit
code.
By default, Python generates an exit code of zero unless the
program calls sys.exit
with a different value.
1.7.6. Final Program
In addition to the functions described earlier, the final program
contains a module docstring and a pair of import statements. The
module docstring is the triply-quoted string at the top of the file,
which in this case simply describes the purpose of the program and
shows a sample use. The import statements give the program access to
random.randint
, which is used in throw_dice
, and sys.exit
,
which is used in the main block.
Here is the full program:
"""
A program to estimate the success rates of different mains in the game Hazard.
Sample use:
$ python3 hazard.py
Number of rounds: 1000
5 0.498
6 0.499
7 0.483
8 0.504
9 0.493
"""
import random
import sys
def throw_dice():
"""
Throw a pair of six-sided dice
Returns (int): the sum of the dice
"""
num_sides = 6
return random.randint(1, num_sides) + random.randint(1, num_sides)
def play_one_round(chosen_main):
"""
Play one round of Hazard.
Args:
chosen_main (int): a value between 5 and 9 inclusive.
Returns (bool): True, if player wins the round and False, otherwise.
"""
chance = throw_dice()
if chance == chosen_main:
return True
if chance == 2 or chance == 3:
return False
if chance == 11 or chance == 12:
if chosen_main == 5 or chosen_main == 9:
return False
if chosen_main == 6 or chosen_main == 8:
return chance == 12
# chosen_main is 7
return chance == 11
roll = throw_dice()
while roll != chance and roll != chosen_main:
roll = throw_dice()
return roll == chance
def simulate_caster(chosen_main):
"""
Simulate rounds until the caster loses two rounds in a row.
Args:
chosen_main (int): a value between 5 and 9 inclusive.
Returns (int): the number of rounds won
"""
num_wins = 0
consecutive_losses = 0
while consecutive_losses < 2:
if play_one_round(chosen_main):
consecutive_losses = 0
num_wins = num_wins + 1
else:
consecutive_losses = consecutive_losses + 1
return num_wins
def print_win_rate_table(num_rounds):
"""
Print a table with the win rates for the possible choices for main
Args:
num_rounds (int): the number of rounds to simulate
Returns: None
"""
for chosen_main in range(5, 10):
num_wins = 0
for _ in range(num_rounds):
if play_one_round(chosen_main):
num_wins = num_wins + 1
print(chosen_main, num_wins/num_rounds)
if __name__ == "__main__":
error_msg = "The number of rounds needs to be a positive integer. Goodbye."
n = input("Number of rounds: ")
try:
num_rounds = int(n)
except valueError:
print(error_msg)
sys.exit(1)
if num_rounds > 0:
print_win_rate_table(num_rounds)
else:
print(error_msg)
sys.exit(1)