alt text

Skeleton vs. Zombie

A fight breaks out between a zombie and a skeleton. Can we predict who is going to win?

Lets start with a simple scenario: the skeleton and zombie are standing next to one another when the hostilities begin.

First, lets figure out who goes first.

Each creature rolls initiative (a d20 plus dexterity modifier).

Roll Initiative

In [1]:
import numpy as np

zombie_initiative = np.random.choice(20) + 1 + 2
skeleton_initiative = np.random.choice(20) + 1 - 2

# re-roll if there is a tie
while zombie_initiative == skeleton_initiative:
    zombie_initiative = np.random.choice(20) + 1 + 2
    skeleton_initiative = np.random.choice(20) + 1 - 2

# determine who goes first
if skeleton_initiative > zombie_initiative:
    print("Skeleton goes first")
elif skeleton_initiative < zombie_initiative:
    print("Zombie goes first")
else:
    raise ValueError("we shouldn't be here!")
Zombie goes first
In [2]:
'''
TODO: add spatial constraints

At 0 ft: the zombie wins about 77% of the time
At infinite feet: the skeleton wins 100% of the time if it keeps moving away

'''

# simulates a fight between a skeleton and a zombie
import numpy as np
import time

class Monster:
    
    def __init__(self, position_x=0, position_y=0):
        self.position_x = position_x
        self.position_y = position_y
    
    def initiative(self):
        # roll to hit
        d20 = np.random.choice(20) + self.dexterity_mod + 1
        return d20
    
    def melee(self, target):
        # roll to hit
        d20 = np.random.choice(20) + self.melee_attack_bonus + 1
        if d20 < target.ac:
            # print('%s missed %s' % (self.name, target.name))
            return
        
        # roll damage
        damage = np.random.choice(self.melee_damage_dice) + self.melee_damage_bonus + 1
        
        if self.melee_damage_type == target.damage_vulnerability:
            damage *= 2
            # print('target is vulnerable to %s' % self.melee_damage_type)
        
        target.hp -= damage
        # print('%s dealt %d damage to %s' % (self.name, damage, target.name))
        # print('%s has %d hit points left' % (target.name, target.hp))
        return damage
    
    def ranged(self, target):
        # roll to hit
        d20 = np.random.choice(20) + self.ranged_attack_bonus + 1
        if d20 < target.ac:
            # print('%s missed %s' % (self.name, target.name))
            return
        
        # roll damage
        damage = np.random.choice(self.ranged_damage_dice) + self.ranged_damage_bonus + 1
        
        if self.ranged_damage_type == target.damage_vulnerability:
            damage *= 2
            # print('target is vulnerable to %s' % self.melee_damage_type)
        
        target.hp -= damage
        # print('%s dealt %d damage to %s' % (self.name, damage, target.name))
        # print('%s has %d hit points left' % (target.name, target.hp))
        return damage

class Skeleton(Monster):
    
    def __init__(self, position_x=0, position_y=0):
        super(Skeleton, self).__init__(position_x=0, position_y=0)
        self.name = 'skeleton'
        self.dexterity = 14
        self.dexterity_mod = 2
        self.constitution = 15
        self.constitution_mod = 2
        self.ac = 13
        self.hp = 13
        self.speed = 30
        self.melee_attack_bonus = 4
        self.melee_damage_bonus = 2
        self.melee_damage_dice = 6
        self.melee_damage_type = 'piercing'
        self.ranged_attack_bonus = 4
        self.ranged_damage_bonus = 2
        self.ranged_damage_dice = 6
        self.ranged_damage_type = 'piercing'
        self.ranged_distance = 80
        self.ranged_long_distance = 320
        self.damage_vulnerability = 'bludgeoning'
    
class Zombie(Monster):
    
    def __init__(self, position_x=0, position_y=0):
        super(Zombie, self).__init__(position_x=0, position_y=0)
        self.name = 'zombie'
        self.dexterity = 6
        self.dexterity_mod = -2
        self.constitution = 16
        self.constitution_mod = 3
        self.ac = 8
        self.hp = 22
        self.speed = 20
        self.melee_attack_bonus = 3
        self.melee_damage_bonus = 1
        self.melee_damage_dice = 6
        self.melee_damage_type = 'bludgeoning'
        self.ranged_attack_bonus = None
        self.ranged_damage_bonus = None
        self.ranged_damage_dice = None
        self.ranged_damage_type = None
        self.ranged_distance = None
        self.ranged_long_distance = None
        self.damage_vulnerability = None
    
    def undead_fortitude(self, damage):
        if self.hp != 0:
            raise ValueError('only use undead fortitute when you have zero hit points')
        # roll a constitution save
        d20 = np.random.choice(20) + self.constitution_mod + 1
        if d20 >= damage + 5:
            self.hp = 1
            return True
        else:
            return False
        

t0 = time.time()
starting_distance = 320
skeleton_wins = 0
zombie_wins = 0

n_perm = int(1e5)

for p in range(n_perm):
    # initialize monsters
    s = Skeleton()
    z = Zombie(position_x=starting_distance)

    # roll initiative
    si = s.initiative()
    zi = z.initiative()
    # print('skeleton initiative: ', si)
    # print('zombie initiative: ', zi)

    # re-reroll in case of tie
    while (si == zi):
        # print('a tie!')
        si = s.initiative()
        zi = z.initiative()
        # print(si, zi)

    # check to see who goes first
    if si > zi:
        first = 'skeleton'
        # print('skeleton goes first')
    elif zi > si:
        first = 'zombie'
        # print('zombie goes first')
    else:
        raise ValueError('how did we get a tie!?')

    # loop through 10000 rounds of combat
    for n in range(1, 10000):

        # compute distance
        r = np.sqrt((s.position_x - z.position_x)**2 + (s.position_y - z.position_y)**2)
        
        if first == 'skeleton':

            # skeleton moves away
            
            # skeleton attacks zombie
            d = s.melee(z)
            if z.hp < 0:
                # print('skeleton wins!!!')
                skeleton_wins += 1
                break
            elif z.hp == 0:
                save = z.undead_fortitude(d)
                if save:
                    # print('undead fortitude save the zombie!')
                    pass
                else:
                    # print('skeleton wins!!!')
                    skeleton_wins += 1
                    break

            # zombie attacks skeleton
            d = z.melee(s)
            if s.hp <= 0:
                # print('zombie wins!!!')
                zombie_wins += 1
                break

        elif first == 'zombie':

            # zombie moves toward skeleton
            
            # zombie attacks skeleton
            d = z.melee(s)
            if s.hp <= 0:
                # print('zombie wins!!!')
                zombie_wins += 1
                break

            # skeleton attacks zombie
            d = s.melee(z)
            if z.hp < 0:
                # print('skeleton wins!!!')
                skeleton_wins += 1
                break
            elif z.hp == 0:
                save = z.undead_fortitude(d)
                if save:
                    # print('undead fortitude save the zombie!')
                    pass
                else:
                    # print('skeleton wins!!!')
                    skeleton_wins += 1
                    break

        else:
            raise ValueError('how did we get here!?')

print('%d permutations in %.2f seconds' % (n_perm, time.time() - t0))
print('%d skeleton wins' % skeleton_wins)
print('%d zombie wins' % zombie_wins)
100000 permutations in 6.37 seconds
22694 skeleton wins
77306 zombie wins