A few years ago I wrote a maze-crawler game in C++ for a school project, but it was arguably the most stimulating work I’ve done to date. Statistics, data exploration/visualization, and math have their appeal for me, but there was something about coding a game into existence that brought real joy every time I brought the game closer to working. Don’t get me wrong: there were plenty of parts of my code that went wrong when I tried to turn my outline/pseudo-code into an actual game, but the first time my character moved (and the enemies on the same floor moved towards them) was exhilarating! There was a part of me that wanted to keep going down this route for a future career, but I’d already invested a lot of time/effort into training myself as a statistician, and eventually enough time passed without me thinking about this kind of work. But now, in this part of my blog, I’m going to work my way through OOP with Python by making a game where people have ships, belong to factions, and face off against each other.

The first goal was to make some ship classes and do some testing of how they interact. As a step one, I made a ship class with a set of core attributes: a ship’s attack strength, armor value, and hitpoints. Then, a couple of core methods: an ability to attack (and record “dice” roles), and an ability to update hitpoints based on enemy attacks (“dice” roles). The ship parent class looks like this:

# Ships will have a number of hitpoints, a number of dice to attack with, and an armor value that the...
# ... attacking ship must cross in order to land a hit.

class Ship:

    def __init__(self, armor, num_attacks, hitpoints):
        self.armor = armor
        self.num_attacks = num_attacks
        self.hitpoints = hitpoints

    # How many attacks pierced the armor?
    def take_damage(self, dice_values):
        # For each dice roll:
        for attack in range(len(dice_values)):
            if dice_values[attack] > self.armor:
                self.hitpoints -= 1
    # Generate random dice rolls. Number of dice rolls will be dependent on what subclass of...
    # ... ship is being used (and later what faction/bonuses are involved).
    def roll_dice(self):
        dice_values = []
        for dice in range(self.num_attacks):
            temp_dice_roll = random.randint(1,6)
            dice_values.append(temp_dice_roll)
        return dice_values

From this parent class, I made two types (sub-classes) of ships: destroyers and cruisers. The destroyer has weaker attribute values than the cruiser, but the cruiser will have a higher cost, where the latter feature will start to matter as I fill out the details of the game/game board:

# Each subtype of ship will have a cost attached to it. 

class Destroyer(Ship):

    # Here we add the cost of our sub-class
    cost = 2

    def __init__(self):
        # Use super to call ship's init, but then specify the destroyer's instance of...
        # ... armor, attacks, and hit points.
        super().__init__(armor=2, num_attacks=1, hitpoints=1)
    

class Cruiser(Ship):
    # Here we add the cost of our sub-class
    cost = 5



    def __init__(self):
        # Use super to call ship's init, but then specify the destroyer's instance of...
        # ... armor, attacks, and hit points.
        super().__init__(armor=3, num_attacks=1, hitpoints=2)

Then I would simulate a simple “battle” of these two ship types against each other and record the fractions of times that, say, the destroyer won in a head-to-head fight with the cruiser. These tests help to sanity check that my code is working. For example, the destroyer is weaker than the cruiser, so I’d expect (on average) that the fraction of times that the destroyer wins should be less than 0.5 (and its fun to simulate things, so why not??):

# Test 1: does 1 destroyer beat 1 cruiser? Simulate many times and record fraction...
# ... if times where the destroyer wins. Use this (and number of sims) to run a simple t-test...
# ... I will also let the destroyer go first (later tests can vary this!).

# More simulations to make the test results more conclusive.
sims = 1000

num_destroyer_wins = 0

for sim in range(sims):
    first_destroyer = Destroyer()
    #second_destroyer = Destroyer()

    first_cruiser = Cruiser()

    # Each round continues until one side is sunk
    while((first_destroyer.hitpoints > 0) and
          first_cruiser.hitpoints > 0 ):
        # Destroyer attacks:
        destroyer_roll1 = first_destroyer.roll_dice()
        #destroyer_roll2 = second_destroyer.roll_dice()
        # Just use "+" and no "[]" to concatenate!
        # all_destroyer_dice = destroyer_roll1 + destroyer_roll2
        all_destroyer_dice = destroyer_roll1

        # Check for a hit to cruiser:
        first_cruiser.take_damage(all_destroyer_dice)

        # If the cruiser is still alive, the cruiser attacks.
        if(first_cruiser.hitpoints > 0):
            cruiser_roll1 = first_cruiser.roll_dice()
            all_cruiser_dice = cruiser_roll1
            # check for hits to destroyer:
            first_destroyer.take_damage(all_cruiser_dice)
    
    # Who won?
    if first_destroyer.hitpoints > 0:
        num_destroyer_wins += 1

# What fraction of sims did the destroyer win?
fraction_destroyer_won = num_destroyer_wins/sims


# Perform binomial test
result = binomtest(num_destroyer_wins, sims, p=0.5, alternative='two-sided')

# Print results
print(f"Destroyer wins: {num_destroyer_wins} out of {sims}")
print(f"p-value: {result.pvalue:.4f}")

The results show that the destroyer wins about 10% of all battles, but my cost ratio between these two ships is 2.5. If I change nothing else, this would suggest to me that cruisers are a better deal than destroyers, so future work will require some tinker of this. A couple future concerns are about how to assign hits when there are multiple ships in each group and/or ships can do more than one hit (e.g. does the defender/attack choose this, or can I set some rules about ships that automatically assign this?).

Posted in

Leave a comment