Johannes' blog

Yahtzee and Monte Carlo

Using code to find optimal strategies for everyday issues? I got interested in Monte Carlo simulations when I read the book "Fooled by Randomness: The Hidden Role of Chance in Life and in the Markets" by Nassim Taleb.

“Monte Carlo mathematics, I think of it as a happy combination of the two: The Monte Carlo man’s realism without the shallowness, combined with the mathematician’s intuitions without the excessive abstraction. Indeed, this branch of mathematics has immense practical use — it doesn't present the same dryness commonly associated with mathematics. I became addicted to it the minute I became a trader. It shaped my thinking in matters related to randomness. Most of the examples used in this book were created with my Monte Carlo generator, which I introduce in this chapter. Yet it is far more a way of thinking than a computational method. Mathematics is primarily a tool to meditate, rather than to compute.”

Aside from my interest in finding optimal investment cases, I wanted to experiment a bit, and the use case that I picked for my first exercise was the game of Yahtzee. I often play it with my family and wanted to use Monte Carlo simulations to find out how well my default strategy is performing. According to Wikipedia, the optimal average score for Yahtzee is 254.59. I put together a small Python script to represent the game, which helped me to formalize my strategy.

My strategy is not super sophisticated, and I think there are still many areas that could be optimized. However, through Monte Carlo simulations, I was able to calculate that my current simple strategy returns an average of 202.82 points. The worst strategy I could conceive would score 72 points, basically only throwing once and going through the fields from top to bottom.

Conclusion

Simulation Code

I first wrote a class that would represent the game state for one player. This game state is for one game only and it keeps track of the different scores with the correct number of points, but without validation.

from collections import Counter

class Yahtzee:
    def __init__(self):
        self.upper_scores = {i: None for i in range(1, 7)}
        self.lower_scores = {
            'three_kind': None, 'four_kind': None, 'full_house': None,
            'small_straight': None, 'large_straight': None,
            'yahtzee': None, 'chance': None
        }

    def print_scores(self):
        ...

    def _hand_score(self, hand, key):
        ...

    def get_available_fields(self, hand):
        available_fields = []

        counts = [hand.count(i) for i in range(1, 7)]
        for i in range(1, 7):
            if counts[i-1] > 0 and self.upper_scores[i] is None:
                available_fields.append((i, 'upper'))

        def consecutive(l):
            return sorted(l) == list(range(min(l), max(l)+1))

        if 5 in counts and self.lower_scores['yahtzee'] is None:
            available_fields.append(('yahtzee', 'lower'))
        elif sorted(Counter(hand).values()) == [2, 3]: 
            available_fields.append(('full_house', 'lower'))
        elif 4 in counts and self.lower_scores['four_kind'] is None:
            available_fields.append(('four_kind', 'lower'))
        elif 3 in counts and self.lower_scores['three_kind'] is None:
            available_fields.append(('three_kind', 'lower'))
        elif consecutive(hand) and len(set(hand)) == 5 and self.lower_scores['large_straight'] is None:
            available_fields.append(('large_straight', 'lower'))
        elif consecutive(hand) and len(set(hand)) >= 4 and self.lower_scores['small_straight'] is None:
            available_fields.append(('small_straight', 'lower'))
        elif self.lower_scores['chance'] is None:
            available_fields.append(('chance', 'lower'))

        return available_fields

    def get_empty_fields(self):
        ...

    def eliminate_field(self, key):
        ...
        
    def set_field(self, key, hand):
        field, section = key
        if section == 'upper':
            self.upper_scores[field] = self._hand_score(hand, key)
        elif section == 'lower':
            self.lower_scores[field] = self._hand_score(hand, key)

    def get_score(self):
        us = sum(value for value in self.upper_scores.values() if value is not None and value is not False)
        ls = sum(value for value in self.lower_scores.values() if value is not None and value is not False)

        us_bonus = 35 if us >= 63 else 0        

        return us + ls + us_bonus

Based on this class I wrote a small script that would allow me to test various strategies.

import random
from yahtzee import Yahtzee

# Strategies
from stupidStrategy import StrategyStupid

# debug = True 
debug = False


def roll_dice(n):
    return [random.randint(1, 6) for _ in range(n)]

def simulate_game(strategy, rolls=3, turns=13):
    game = Yahtzee()
    for turn in range(turns):
        if debug: print('----Turn ', turn)
        hand = []
        for roll in range(rolls):
            hand += roll_dice(5 - len(hand))
            if debug: print("- roll", roll, hand)
            hand = strategy.execute(game, hand, turn, roll)

        if debug: game.print_scores()
        if debug: print('')

        
    return game.get_score()

def monte_carlo_simulation(strategy, n=100000):
    total = 0
    for _ in range(n):
        total += simulate_game(strategy)
    return total / n

print(monte_carlo_simulation(StrategyStupid(), 1))

Example of a very stupid strategy that would on average score only 73pts.

class StrategyStupid:
    def __init__(self):
        self.name = 'StrategyStupid'
        self.description = """
            Per Roll: Only roll once
            Scoring: Always fills from top to bottom all the fields
        """

    def execute(self, game, hand, turn, roll):
        # print('thinking...', hand, turn, roll)
        if roll == 2: 
            afields = game.get_available_fields(hand)
            if len(afields) > 0:
                game.set_field(afields[0], hand)
                # print('set:\t\t',afields[0], hand)
            else: 
                efields = game.get_empty_fields()
                if len(efields) > 0:
                    game.eliminate_field(efields[0])
                    # print('eliminate:\t',efields[0])
        return hand