- Fri 24 January 2020
- Games
- Michael Lehotay
- #simulation, #probability, #object-oriented-programming
I have been thinking about combat in RPG games lately and wondering if I could come up with a way to model combat that I could use for machine learning. I'd like to be able to make decisions about the best weapon to wield and the best armor to wear, and to be able to predict the odds of surviving a particular battle. The obvious approach is to simulate the battle many times and use statistics!
Our combat system will be pretty basic. For simplicity it will only use melee weapons and not have any missile weapons or magic. Other than that it will work like many tabletop role-playing games: fighters take turns attacking each other and the outcome of each attack is randomly determined by rolling dice. A regular set of dice contains dice with 4, 6, 8, 10, 12, and 20 sides.
Combatants have three variables that describe their toughness and their state of health:
- Level is a measure of a fighter's skill and experience.
- Health or (hit points) is the amount of injury the fighter can withstand.
- Armor class describes how easy or difficult it is to hit a fighter. The lower their AC, the harder they are to hit.
To attack, roll a 20-sided die to see if you hit your opponent. You need to roll a certain number or higher to hit. The formula is 22 - level - armor_class
. For example, to hit an opponent with armor class 10, a level 1 fighter needs to roll an 11 or higher (22 - 1 - 10 = 11).
If you hit, then roll to see how much damage you do. The number of dice and what kind of dice is determined by the weapon you are wielding.
Let's start by implementing rolls of the dice. We'll use this function any time we need to generate a random number.
import random
def roll(dice, sides):
total = 0
for _ in range(0, dice):
total += random.randint(1, sides)
return total
And now we define the weapons and armor as dictionaries.
The amount of damage a weapon can do is represented as a tuple with three elements: the number of dice, the number of sides per die, and an addend. For example, a flail does 2-7 points of damage by rolling a 6-sided die and adding 1 (1d6+1).
weapon_list = {
None: (1,2,0), # 1d2
'axe': (1,6,0), # 1d6
'battle axe': (1,8,0), # 1d8
'club': (1,6,0),
'dagger': (1,4,0),
'flail': (1,6,1), # 1d6+1
'hammer': (1,4,1),
'mace': (1,6,1),
'morning star': (2,4,0), # 2d4
'scimitar': (1,8,0),
'spear': (1,6,0),
'quarterstaff': (1,6,0),
'broad sword': (2,4,0),
'long sword': (1,8,0),
'short sword': (1,6,0),
'trident': (1,6,1),
'two-handed sword': (1,10,0)
}
The value for a type of armor is the amount of protection it provides. The values correspond to the difference in the die roll needed to hit an opponent. So if you need to roll a 13 to hit an enemy wearing ring mail, you'd need to roll a 15 to hit an opponent in chain mail.
armor_list = {
None: 0,
'shield': 1,
'padded armor': 2,
'leather armor': 2,
'studded leather': 3,
'ring mail': 3,
'scale mail': 4,
'chain mail': 5,
'splint mail': 6,
'banded mail': 6,
'plate mail': 7
}
Fighter
Here is the Fighter
class to represent a combatant.
The constructor takes the name
, level
, faction
, weapon
, and armor
for the fighter. Name
is the fighter's name (e.g., Frodo) and faction
is the name of the "side" they are on. Fighters from the same faction will not attack each other. Values for faction
could be 'good' and 'evil' or anything else you want. The battle
member variable will be assigned a value when the fighter enters the arena. For now, set it to None.
class Fighter:
def __init__(self, name, level, faction, weapon, armor):
self.name = name
self.level = level
self.max_health = sum(roll(1,10) for _ in range(0,level))
self.health = self.max_health
self.faction = faction
self.weapon = weapon
self.armor = armor
self.armor_class = 10 - armor_list[self.armor]
self.battle = None
def __repr__(self):
return f'{self.name} ({self.health}/{self.max_health}) \
[Level {self.level} {self.__class__.__name__}, {self.weapon}, {self.armor}, {self.faction}]'
On each turn the fighter will make a list all the opponents in the battle (anybody not in their faction), and then randomly select one to attack.
def take_turn(self):
opponents = [f for f in self.battle.fighters if f.faction!=self.faction]
if(opponents != []):
target = random.choice(opponents)
self.attack(target)
The attack hits its target if the fighter rolls a high enough number. That number is 22 - level - armor_class
.
If the attack hits then roll again to calculate the amount of damage and call the target's take_damage
method.
def attack(self, opponent):
if (roll(1,20) >= (22 - self.level - opponent.armor_class)):
(dice, sides, plus) = weapon_list[self.weapon]
damage = roll(dice, sides) + plus
opponent.take_damage(damage, self)
elif(self.battle.verbose):
print(f' {self.name} swings at {opponent.name} and misses')
Note here that when the target object's take_damage
method is called, the program flow transfers from the attacker to the opponent. Now self
no longer refers to the attacking fighter and instead it refers to the target.
When a fighter's health is reduced to zero they die.
def take_damage(self, damage, attacker):
if self.battle.verbose:
print(f' {attacker.name} attacks {self.name} for {damage} damage')
self.health -= damage
if(self.health < 1):
self.die()
def die(self):
if self.battle.verbose:
print(f' {self.name} dies!')
self.battle.fighters.remove(self)
self.battle = None
Battle
Here we have a class to represent a battle between two or more fighters. The battle has a title
, a list of fighters
, a turn
counter, and a winner
. The constructor gets called with a list of roles
that describe how each fighter object should be instantiated, and a boolean verbosity flag. When verbose
is set to true then the play-by-play action of the battle is printed.
A Fighter
object is created for each role
passed to the Battle
constructor. The fighters are then added to the battle by calling its add_fighter
method.
The roles
parameter is a list of dictionaries with the following keys (and example values):
{
'name': 'Boromir',
'faction': 'Gondor',
'level': 9,
'class': Fighter,
'weapon': 'long sword',
'armor': 'chain mail'
}
class Battle:
def __init__(self, title, roles, verbose):
self.title = title
self.verbose = verbose
self.fighters = []
self.winner = None
self.turn = 0
for role in roles:
fighter = role['class'](role['name'], role['level'], role['faction'], role['weapon'], role['armor'])
self.add_fighter(fighter)
def __repr__(self):
return f'{self.title} turn {self.turn}'
def add_fighter(self, fighter):
self.fighters.append(fighter)
fighter.battle = self
The battle proceeds in rounds. During a round each fighter is given their turn to attack. The battle ends when all the surviving fighters are from the same faction.
def fight_battle(self):
if self.verbose:
print(f'{self.title} fighters:')
for fighter in self.fighters:
print(fighter)
while self.winner == None:
self.play_round()
if self.verbose:
print(f'{self.winner} wins {self.title}!')
return self.winner
def play_round(self):
self.turn += 1
if self.verbose:
print(f'{self}:')
for f in self.fighters:
f.take_turn()
if len({f.faction for f in self.fighters}) == 1:
self.winner = self.fighters[0].faction
We've got enough code written now to simulate a full battle. Let's give it a go.
roles = [
{'name': 'Bob', 'faction': 'Green Banner', 'level': 2,
'class':Fighter, 'weapon':'scimitar', 'armor':'chain mail'},
{'name': 'Alice', 'faction': 'Mithril Order', 'level': 1,
'class':Fighter, 'weapon':'short sword', 'armor':'leather armor'},
{'name': 'Eve', 'faction': 'Mithril Order', 'level': 1,
'class':Fighter, 'weapon':'flail', 'armor':'shield'}
]
Battle('Test Battle', roles, True).fight_battle()
Test Battle fighters:
Bob (11/11) [Level 2 Fighter, scimitar, chain mail, Green Banner]
Alice (8/8) [Level 1 Fighter, short sword, leather armor, Mithril Order]
Eve (9/9) [Level 1 Fighter, flail, shield, Mithril Order]
Test Battle turn 1:
Bob swings at Alice and misses
Alice swings at Bob and misses
Eve attacks Bob for 6 damage
Test Battle turn 2:
Bob swings at Eve and misses
Alice swings at Bob and misses
Eve attacks Bob for 5 damage
Bob dies!
Mithril Order wins Test Battle!
'Mithril Order'
Arena
Now we need to repeat many iterations
of the battle so that we can estimate each faction's probability of winning. 1000 times should be plenty.
We create a set of factions
by adding the faction of each role
. Using a set ensures that we only count each faction once. Then we start fighting battles and counting wins
.
The print_probabilities
method prints the probability of each faction winning based on their observed win frequencies.
class Arena:
def __init__(self, roles, iterations=1000, verbose=False):
self.roles = roles
self.iterations = iterations
self.verbose = verbose
self.factions = {role['faction'] for role in roles}
self.wins = {faction:0 for faction in self.factions}
self.winner = None
def simulate_battle(self):
for i in range(0, self.iterations):
winner = Battle(f'Battle {i+1}', self.roles, self.verbose).fight_battle()
self.wins[winner] += 1
def print_probabilities(self):
print('Estimated Probabilities of Victory:')
for faction in sorted(self.factions):
print(f'{faction}: {self.wins[faction]/self.iterations}')
That's it! We are done! Let's see how it works with more than one iteration.
arena = Arena(roles, iterations=2, verbose=True)
arena.simulate_battle()
arena.print_probabilities()
Battle 1 fighters:
Bob (11/11) [Level 2 Fighter, scimitar, chain mail, Green Banner]
Alice (10/10) [Level 1 Fighter, short sword, leather armor, Mithril Order]
Eve (1/1) [Level 1 Fighter, flail, shield, Mithril Order]
Battle 1 turn 1:
Bob attacks Eve for 6 damage
Eve dies!
Alice swings at Bob and misses
Battle 1 turn 2:
Bob attacks Alice for 8 damage
Alice swings at Bob and misses
Battle 1 turn 3:
Bob swings at Alice and misses
Alice swings at Bob and misses
Battle 1 turn 4:
Bob attacks Alice for 1 damage
Alice swings at Bob and misses
Battle 1 turn 5:
Bob swings at Alice and misses
Alice swings at Bob and misses
Battle 1 turn 6:
Bob swings at Alice and misses
Alice swings at Bob and misses
Battle 1 turn 7:
Bob attacks Alice for 7 damage
Alice dies!
Green Banner wins Battle 1!
Battle 2 fighters:
Bob (6/6) [Level 2 Fighter, scimitar, chain mail, Green Banner]
Alice (6/6) [Level 1 Fighter, short sword, leather armor, Mithril Order]
Eve (2/2) [Level 1 Fighter, flail, shield, Mithril Order]
Battle 2 turn 1:
Bob swings at Alice and misses
Alice swings at Bob and misses
Eve swings at Bob and misses
Battle 2 turn 2:
Bob attacks Alice for 2 damage
Alice attacks Bob for 6 damage
Bob dies!
Mithril Order wins Battle 2!
Estimated Probabilities of Victory:
Green Banner: 0.5
Mithril Order: 0.5
That works pretty well. I am happy with that! Let's do it again with 1000 iterations.
arena = Arena(roles)
arena.simulate_battle()
arena.print_probabilities()
Estimated Probabilities of Victory:
Green Banner: 0.606
Mithril Order: 0.394
It looks like the Green Banner has the edge in this battle. They are predicted to win about 60% of the time.
arena = Arena(roles)
arena.simulate_battle()
arena.print_probabilities()
Estimated Probabilities of Victory:
Green Banner: 0.617
Mithril Order: 0.383
And the predictions are pretty repeatable. Awesome! I wonder what happens if we give one of the Mithril Order fighters some better equipment.
roles[2] = {'name': 'Eve', 'faction': 'Mithril Order', 'level': 1,
'class':Fighter, 'weapon':'broad sword', 'armor':'splint mail'}
arena = Arena(roles)
arena.simulate_battle()
arena.print_probabilities()
Estimated Probabilities of Victory:
Green Banner: 0.423
Mithril Order: 0.577
Way to go Mithril Order!
I put the code for this blog post on GitHub: https://github.com/mlehotay/arena