The Riddler: Turning America's Pastime Into A Game Of Yahtzee

24 Mar 2019

My friend Ricky Martinez and I tackled The Riddler Express for this weekend1, which reads as follows:

Over the years, people have invented many games that simulate baseball using two standard dice. In these games, each dice roll corresponds with a baseball event. Two players take turns rolling dice and tracking what happens on the field. Suppose you happen to be an ardent devotee of one of these simulated games from the late 19th century, called Our National Ball Game, which assigns rolls to baseball outcomes like so:

1, 1: double

1, 2: single

1, 3: single

1, 4: single

1, 5: base on error

1, 6: base on balls

2, 2: strike

2, 3: strike

2, 4: strike

2, 5: strike

2, 6: foul out

3, 3: out at 1st

3, 4: out at 1st

3, 5: out at 1st

3, 6: out at 1st

4, 4: fly out

4, 5: fly out

4, 6: fly out

5, 5: double play

5, 6: triple

6, 6: home run

Given those rules, what’s the average number of runs that would be scored in nine innings of this dice game? What’s the distribution of the number of runs scored? (Histograms welcome.) You can assume some standard baseball things, like runners scoring from second on singles and runners scoring from third on fly outs.

These are some high-scoring baseball games! In 10,000 simulated sets of 9 innings, each team scored, on average,2 approximately 14.61 runs, which means that each game had an average of about 29 runs. The full distribution is slightly right skewed (since a team could get “lucky” and gets outcomes that are not associated with strikes or outs many times in a row, with theoretically no upper bound) and shown here:

This supposes a few assumptions that we made that were ambiguous in the instructions that could affect the scoring that are described in the following section of the blog post which runs through our code. We also do not consider the possibility of extra innings, though there were only 249 games that ended tied out of our 5,000 simulations (assuming each team “played” their nine innings one after the other). Either way, it’s clear that the list of outcomes described above does not match the frequency of baseball outcomes (the triple is the least likely hit in baseball and it’s equally as likely to pop up as a double in our dice rolls; a homerun is equally as likely as a double), which explains the odd results. The Riddler Classic this weekend challenges readers to tweak the dice rolls so that the probabilities are more realistic, but we stuck with the Riddler Express here. The rest of the blog post is an annotated version of our code, so that if you’re interested, you can edit whatever line you’d like or use it as a basis to answer The Riddler Classic.


First, we important a random integer generator, define first base as 0, define second base as 1, and define third base as 2 (true to the spirit of Python, our indices begin at 0!). Then, we define the set of outcomes as outlined above, and assign strings to those outcomes that we will use in functions later.

from random import randint

first = 0
second = 1
third = 2

outcomes = {
  (1,1): 'double',
  (1,2): 'single',
  (1,3): 'single',
  (1,4): 'single',
  (1,5): 'base_on_error',
  (1,6): 'base_on_balls',
  (2,2): 'strike',
  (2,3): 'strike',
  (2,4): 'strike',
  (2,5): 'strike',
  (2,6): 'foul_out',
  (3,3): 'out_at_1st',
  (3,4): 'out_at_1st',
  (3,5): 'out_at_1st',
  (3,6): 'out_at_1st',
  (4,4): 'fly_out',
  (4,5): 'fly_out',
  (4,6): 'fly_out',
  (5,5): 'double_play',
  (5,6): 'triple',
  (6,6): 'home_run',
}

Here are our rolls, two sets of randomly generated integers from 1 to 6, inclusive.

def roll():
  result = [randint(1,6), randint(1,6)]
  return tuple(sorted(result))

Then we define Game, where all of our “counters” are set to their initial values. The score is reset to 0, the inning is set to 1, the number of outs and strikes are 0, and there is no one on base. reset_game simply resets these values to their initial state whenever a game is completed in the simulations. More accurately, these are not “games” per se, but rather a single teams simulated score across 9 innings. In that sense, we only simulated 5,000 “games” of baseball.

class Game:
  def __init__(self):
    self.score = 0
    self.inning = 1
    self.num_outs = 0
    self.num_strikes = 0
    self.bases = [False, False, False]

  def reset_game(self):
    self.score = 0
    self.inning = 1
    self.num_outs = 0
    self.num_strikes = 0
    self.bases = [False, False, False]

is_in_legal_game ensures that when the number of strikes hits 3, the number of strikes is reset to 0, and the number of outs is increased by 1. It also ensures that when the number of outs is greater than or equal to three (it could be 4 because of double plays, we’ll get to that later), the bases and the number of outs are reset, and the inning ticker increases by 1. It also ensures that the game ends after 9 innings.

def is_in_legal_game(self):
    if self.num_strikes == 3:
      self.num_strikes = 0
      self.num_outs += 1
    
    if self.num_outs >= 3:
      self.bases = [False, False, False]
      self.num_outs = 0
      self.inning += 1
    
    return True if self.inning <= 9 else False

Now we get into the outcomes. We tackle each in the order they are listed in the problem. Here, when a double is rolled, if there’s a player on third, that person scores, and third base becomes empty. If there’s a player on second, that person also scores, and second base becomes empty. If there’s a player on first, that player leaves first base and moves to third base, and the batter moves to second base. Notice how the strike count is reset to 0, as this occurs in all of the outcomes except for strikes.

def double(self):
    if self.bases[third]:
      self.bases[third] = False
      self.score += 1
    
    if self.bases[second]:
      self.bases[second] = False
      self.score += 1
    
    if self.bases[first]:
      self.bases[first] = False
      self.bases[third] = True
    
    self.bases[second] = True
    self.num_strikes = 0

When a single is rolled, runners on third and second would score, a runner on first would move to second base, and the batter moves onto first base.

 def single(self):
    if self.bases[third]:
      self.bases[third] = False
      self.score += 1
    
    if self.bases[second]:
      self.bases[second] = False
      self.score += 1
    
    if self.bases[first]:
      self.bases[first] = False
      self.bases[second] = True
    
    self.bases[first] = True
    self.num_strikes = 0

We assume that if a player reaches a base on error, it is equivalent to a walk. That is, everyone keeps their base, unless they have to move up. For example, if the bases are loaded, the runner on third would score and everyone moves up one base. The rest of the logic is self-explanatory. Because of this assumption, we can also define reaching base on balls in the same way.

def base_on_error(self):
    if self.bases[third] and self.bases[second] and self.bases[first]:
      self.bases[third] = False
      self.score += 1
    
    if self.bases[second] and self.bases[first]:
      self.bases[second] = False
      self.bases[third] = True
    
    if self.bases[first]:
      self.bases[first] = False
      self.bases[second] = True

    self.bases[first] = True
    self.num_strikes = 0

 def base_on_balls(self):
    self.base_on_error()

Next, a strike simply increases the strike count by 1. Recall that is_in_legal_game ensures that the batter is out if he or she reaches three strikes.

def strike(self):
    self.num_strikes += 1

We assume that a foul out is identical to a fly out but without the possibility of scoring: the number of outs is increased by 1, and the strike count is reset.

def foul_out(self):
    self.num_outs += 1
    self.num_strikes = 0

In an out at first, everyone stays on base unless they have to move, the number of outs is increased by 1 unless that’s the third out, in which case a runner on third (who would normally score if the bases were loaded) does not score.

def out_at_1st(self):
    self.num_outs += 1

    if self.num_outs == 3:
      return

    if self.bases[third] and self.bases[second] and self.bases[first]:
      self.bases[third] = False
      self.score += 1
    
    if self.bases[second] and self.bases[first]:
      self.bases[second] = False
      self.bases[third] = True
    
    if self.bases[first]:
      self.bases[first] = False
      self.bases[second] = True
    
    self.num_strikes = 0

In a fly out, the logic is somewhat similar: the number of outs is increased by 1 and if it’s not the third out, then a runner on third would score.

def fly_out(self):
    self.num_outs += 1

    if self.num_outs < 3 and self.bases[third]:
      self.bases[third] = False
      self.score += 1
    
    self.num_strikes = 0

We assume that in a double play, the two outs are a runner who was initially on first base, and the batter. According to a cursory Wikipedia search, this is the most common type of double play. As a result, in our simulations, you can’t have a double play without a runner on first. If the number of outs is not 0 and a double play is rolled, the double play ends the inning, but otherwise, a runner on third will score, and a runner on second would move up to third.

def double_play(self):
    if not self.bases[first]:
      return
    
    self.num_outs += 2
    self.num_strikes = 0

    if self.num_outs >= 3:
      return

    if self.bases[third]:
      self.bases[third] = False
      self.score += 1
    
    if self.bases[second]:
      self.bases[second] = False
      self.bases[third] = True
    
    self.bases[first] = False
    self.num_strikes = 0

Triples and home runs are fairly straightforward. In a triple, runners on third, second, and first all score, while the batter ends up at third base. In a homerun, everyone who is on base scores and the bases are cleared.

def triple(self):
    if self.bases[third]:
      self.bases[third] = False
      self.score += 1
    
    if self.bases[second]:
      self.bases[second] = False
      self.score += 1
    
    if self.bases[first]:
      self.bases[first] = False
      self.score += 1
    
    self.bases[third] = True
    self.num_strikes = 0
  
def home_run(self):
    if self.bases[third]:
      self.bases[third] = False
      self.score += 1
    
    if self.bases[second]:
      self.bases[second] = False
      self.score += 1
    
    if self.bases[first]:
      self.bases[first] = False
      self.score += 1
    
    self.score += 1
    self.num_strikes = 0

Now we’re ready to play ball! As long as we are in a legal game, the dice are rolled and the functions run smoothly.

def play_ball(self):
  	while self.is_in_legal_game():
      outcome = outcomes[roll()]
      method_to_call = getattr(self, outcome)
      method_to_call()

If we’d like we can provide information for each outcome, such as what inning it is, the number of outs, the number of strikes, the score, and which runners are on base. Scrolling through that wall of text is almost like watching a real baseball game (just much faster and high-scoring)!

inning = self.inning
      num_outs = self.num_outs
      num_strikes = self.num_strikes
      score = self.score
      fst = '1' if self.bases[first] else ''
      snd = '2' if self.bases[second] else ''
      thd = '3' if self.bases[third] else ''
      print(f'inning: {inning}, num_outs: {num_outs}, num_strikes: {num_strikes}, score: {score}, bases=[{fst}, {snd}, {thd}] -- {outcome}')

return self.score

This final set of code sets the number of simulations and prints out a list of all the scores of each set of 9 innings.


num_games_to_play = 10000
baseball_game = Game()
scores = []

for _ in range(num_games_to_play):
  result = baseball_game.play_ball()
  scores.append(result)
  baseball_game.reset_game()

end = time()

avg_score = sum(scores) / len(scores)
print(f'avg_score is: {avg_score}')

print(scores)

Turns out you can (kind of) simulate baseball using dice rolls!

  1. As I’ve previously mentioned on this blog, The Riddler is a weekly set of two puzzles published by FiveThirtyEight on math, logic, and probability. 

  2. Erratum (March 28, 2019): an earlier version of this post did not include a reset of the strikes for a double play that ends an inning. Fixing this bug leads to an increase in the average score per nine innings by 0.005. The distribution of scores will have the same shape. We regret the error. 

comments powered by Disqus