How To Beat Your Dad At Camel Up (Interlude – Code Review)

Welcome back! This is a small technical interlude in our series on Camel Up. If you’re not up to date, you can find parts 1 & 2 here: How to Beat Your Dad at Camel Up / How to Beat Your Dad At Camel Up (Part 2).

Camel Up is a board game about betting on camels racing around a track in Egypt. I’ve spent the last couple of articles showing you some pretty graphs about the game. We also saw that you can dominate family games night by sitting on the left of your dad! Today I want to give an overview of the code generating those glamorous graphs! If you want to follow along, the GitHub repository can be found here.

Language & Aims

I’m most familiar with Python so it was a natural choice for this project. I wanted development to be fairly quick and best practice. My overall aim was to have a small project that showed off use of tests on my public GitHub. I’ve made extensive use of testing in my professional career, but public proof of this was somewhat lacking. I also believe that the complexity of development is greatly reduced with tests, even without 100% code coverage. With tests, I have confidence that I’m considering edge cases, my code is working, and I can refactor without fear.

I’ve also used type-hints throughout the codebase as I believe these enhance the readability of the code. I think it also encourages simplicity as complex code makes pyright sad, and writing the right type hints becomes tricky. It’s often easier to simplify the code than get the right type hint.

Ease of development and clarity of code were prioritized over runtime speed. The performance is not terrible, however, simulating ~100,000 games takes approximately 10 minutes on my desktop PC. I re-visited this simulation while learning CUDA. Writing it in C++ with GPU acceleration shot performance to the moon! I simulated 2.5 billion legs of Camel Up within 4 seconds, which is far too much Camel Up for one lifetime!

In the rest of this article I want to take you through the main components of the code. We’ll look at the objects defined in camel_up/game.py starting from the most low-level and see how these combine together to simulate the game.

Structure

Fundamental Objects

We’ll first look at the class representing the dice for the camels running in the race:

class Dice:
    color: str
    possible_values: list[int] = [1, 2, 3]

    def __init__(self, color: str) -> None:
        self.color = color

    def __str__(self) -> str:
        return f"Dice: {self.color}"

    def __repr__(self):
        return self.__str__()

    def roll(self) -> int:
        return random.sample(self.possible_values, k=1)[0]

When rolling we simply sample from the set of possible values. These values are hard-coded into the Dice class. With the modularity of the code, there is no reason that we couldn’t change this in the future. For instance, if we wanted to ask questions like “What would change if Camel Up had a 4-sided die?”

This dice has the ‘color’ attribute which links it to a specific camel. We also link to the Dice object from the Camel object. An alternative is to make the link in both directions, but this complicates initialization. Therefore, I made one side of the relationship have a ‘color’ string that can be used for matching.

class Camel:
    dice: Dice
    color: str

    def __init__(self, color: str) -> None:
        self.color = color
        self.dice = Dice(color)

    def roll_dice(self) -> int:
        return self.dice.roll()

The Camel class automates the creation of our Dice object in the constructor. It also allows a public API to get a dice roll for that specific camel. I also like that if you squint and turn your head, the code kind of looks like a camel too!

We’re on a roll with our Dice objects – but we need somewhere to store them! It’s time to look at the Pyramid class which controls the rolling in the game. The Pyramid needs to store the Dice objects and whether or not they have been rolled this leg. A die is randomly selected when a roll action is taken from those that have not been rolled this leg. The selected die is rolled, and then recorded as having been rolled this leg.

class Pyramid:
    dice: list[Dice]
    dice_already_rolled: list[Dice]
    dice_still_to_roll: list[Dice]

    def __init__(self, dice: list[Dice]) -> None:
        self.dice = dice
        self.reset()

    def roll_dice(self) -> tuple[str, int]:
        dice_to_roll = self.dice_still_to_roll.pop()
        self.dice_already_rolled.append(dice_to_roll)
        return (dice_to_roll.color, dice_to_roll.roll())

    def reset(self) -> None:
        self.dice_still_to_roll = copy.copy(self.dice)
        self.dice_already_rolled = []
        random.shuffle(self.dice_still_to_roll)

The randomness in the pyramid is introduced in the reset() method. This method shuffles the list of dice when they are reloaded into the pyramid. This means that we just need to pop the top die from the list when a roll action is taken.

The last fundamental piece of the game we’ll consider are the betting tiles. These are related to a particular camel and have a payoff if that camel wins the leg. This payoff is either 2, 3, or 5 coins depending on the tile. All tiles pay 1 coin for second place and lose 1 coin for finishes outside the top two.

@dataclass
class BettingSlip:
    color: str
    winnings_if_true: int

I’ve chosen a dataclass here to simplify the implementation as it will automatically create the __init__ method for me.

Context is key!

We’ll now see how these building blocks come together elegantly to create something more complex. The GameContext class keeps track of the current board state. This is used to store things like the position of the camels, and the availability of betting slips. Below is a cut-down representation of its public API. There is a mix of helper functions to carry out actions like rolling dice, moving camels, taking betting slips. There are also query functions about the game state like “Is the leg over?”, or “Is the game over?”.

class GameContext:
    camels: list[Camel]
    betting_slips: dict[str, list[BettingSlip]]
    track: dict[int, list[str]]
    current_space: dict[str, int]
    finishing_space: int
    action_number: int
    _pyramid: Pyramid

    def take_action(self, player_action: Action, player: Player, **kwargs) -> None:
        ...

    def is_leg_finished(self) -> bool:
        ...

    def is_game_finished(self) -> bool:
        ...

    def roll_dice_and_move_camel(self):
        ...

    def get_leg_winner(self) -> str:
        ...

    def get_leg_runner_up(self) -> str:
        ...
         
    def get_top_betting_slip(self, color: str) -> BettingSlip:
        ...

    def reset_for_next_leg(self) -> None:
        ...

    def get_current_occupied_spaces(self) -> list[int]:
        ...

The ‘track’ dictionary has a key for each space on the board. Its values are lists representing the stack of camels on that space. The ‘current_space’ attribute is mainly a helper to speed up finding the correct camel on the track. This can be removed if we’re happy to search the track dictionary each time we want to move a camel. I think this is a helpful addition to the code to simplify some algorithms.

Players and their Strategies

Now that we’ve created objects for the fundamental objects of the game, let’s consider the humans! We bring together the objects seen so far in the Player class. This is an important class that’s responsible for:

  1. Tracking player score,
  2. Tracking which betting slips the player holds,
  3. Choosing an action for the player to take.

The player strategy has been abstracted into a PlayerStrategy object to make it more modular. The PlayerStrategy object is assigned to a particular Player. It takes in the current game state (the GameContext) and returns an Action for the player to carry out. This is a normal application of the Strategy design pattern.

As we’ve seen in the previous articles, the currently implemented strategies are the AlwaysRollStrategy and the TakeLeaderBetSlipStrategy:

class AlwaysRollStrategy(PlayerStrategy):
    """This strategy will always roll the dice, no matter what
    the game context.

    This is intended to be a baseline strategy that other
    strategies can be compared to.
    """

    def choose_action(self, context: GameContext) -> Action:
        logger.info("Player is rolling dice...")
        return RollDiceAction()

class TakeLeaderBetSlipStrategy(PlayerStrategy):
    """This strategy will take the current leader's top bet tile if possible.
    Otherwise will roll the dice.

    This is intended to be a baseline strategy that other
    strategies can be compared to.
    """

    def choose_action(self, context: GameContext) -> Action:
        current_leader = context.get_leg_winner()
        if len(context.betting_slips[current_leader]) > 0:
            logger.info(
                f"Taking betting slip of current leader which is {current_leader}"
            )
            return TakeBettingSlipAction(current_leader)
        logger.info("rolling dice...")
        return RollDiceAction()

When asked to choose an action, the Player class delegates this to its PlayerStrategy. This ensures modularity so we can easily create new strategies and switch out player strategies for simulations at will.

class Player:
    strategy: PlayerStrategy
    coins: int
    betting_slips: list[BettingSlip]
    automated: bool
    player_number: int

    def __init__(
        self, strategy: PlayerStrategy, player_number=1, automated: bool = True
    ) -> None:
        self.coins = 3
        self.strategy = strategy
        self.betting_slips = []
        self.automated = automated
        self.player_number = player_number

    def gain_coins(self, coins_to_gain: int) -> None:
        self.coins += coins_to_gain

    def lose_coins(self, coins_to_lose: int) -> None:
        self.coins = max(0, self.coins - coins_to_lose)

    def choose_action(self, context: GameContext) -> Action:
        return self.strategy.choose_action(context)

    def take_betting_slip(self, betting_slip: BettingSlip) -> None:
        self.betting_slips.append(betting_slip)

    def return_all_betting_slips(self) -> list[BettingSlip]:
        betting_slips = [a for a in self.betting_slips]
        self.betting_slips = []
        return betting_slips

    def reset(self) -> None:
        self.coins = 3
        self.betting_slips = []

The Player class is also where the rule that a player cannot go into negative coins is enforced. The lose_coins method will have a hard-stop at zero coins. If only it worked this way in real life!

CI/CD

Let’s round out the article by talking about the current CI pipeline. Right now there’s no “deployment” for this code, so I just run the tests in the cloud. This ensures I’m not accidentally introducing regressions into the code. I will add in type-checking into the pipeline soon.

Summary

This repository has been created to show off some best practices like use of testing, CI/CD, and type hints. It has the side-effect of also being quite useful for finding out more about Camel Up! We’ve gone through the structure of the objects in the repository and the trade-offs made during development. I’ve also provided some tantalizing sneak-peeks of some upcoming articles where I used CUDA to simulated billions of legs! Let’s finish this article by seeing what else is coming up!

Where Next?

I’ve been working down a few different paths to expand this repository:

  • Addition of extra game components:
    • Boost tiles and end-of-game betting slips are still to be added to the code. This should be fairly straightforward additions to the GameContext class. It will track where tiles have been placed and who has bet on the overall winner and loser.
    • PlayerStrategy objects that take these actions into account will need to be created as well.
  • Uncovering strategies through machine learning:
    • Manually creating PlayerStrategy objects is limited by my understanding of the game and creativity. Creating a reinforcement learning agent to learn strategies and analyzing them will uncover unexpected insights.
    • Work has begun on this, using the gymnasium package.

PRs are always welcome if you want to help out! Otherwise stay tuned for the next article in the series!


Comments

One response to “How To Beat Your Dad At Camel Up (Interlude – Code Review)”

  1. […] Welcome back to another article in our series on winning Camel Up! If you’ve missed part 1, part 2, or our code review, you can find them here: How to Beat Your Dad at Camel Up / How to Beat Your Dad At Camel Up (Part 2) / How To Beat Your Dad At Camel Up (Interlude – Code Review) […]

Leave a Reply to How to Beat Your Dad At Camel Up (Part 3 – CUDA Baby!) – Mr. GrumpyKitten Cancel reply

Your email address will not be published. Required fields are marked *