Prototyping a simple battle system in Python
Today I prototyped a very simple turn-based battle system I had invented some time earlier. To be precise it is a duelling system, since only two characters can participate in the battle. It was created for an adventure game, focused on the narrative rather than on the combat. The actual code is also included (scroll down the page to see it).
Each character has the following base stats:
- endurance;
- accuracy;
- reaction;
- luck.
Each of the base stats can be either 0 or 1, with 0 representing "normal" and 1 representing "good". Also each character has a health value: 2 means that the character is healthy, 1 that he is wounded and 0 means dead. That's it, there are no hit points or other stats.
Initiating battle
When the character starts a duel, initial chance to attack first calculated as following:
0.5 + attacker's reaction * 0.25 + attacker's luck * 0.25 - target's reaction * 0.25 - target's luck * 0.25
If the character who initiated the duel succeeds, he attacks first. If not, opponets get the first turn. That makes impossible for a character with zero reaction and zero luck to attack the opponent with reaction and luck equal to 1. At the same time, it gives 100% chance to start the duel for a good reaction and luck character. Probably I'll have to rebalance this and correct by a small ammount, e.g. 5%.
Calculating hit chance
The chance to hit the target is calculated the following way:
0.5 + attacker's accuracy * 0.25 + attacker's luck * 0.2 - target's luck * 0.2 - distance * 0.025
Luck affects aim a little bit less than it affects initiative. Also, each unit of distance (a step) takes away 2.5% chance to hit. This makes the hit chance negative on higher distances. And in the best case (1 accuracy and 1 luck character against a 0 accuracy and 0 luck opponent at point blank), the maximum hit chance is 95%. I don't take into consideration weapon type or attack range - all my characters are equipped with firearms by deafault.
Scoring a hit doesn't make the target wounded, we need to apply additional calculations.
Damage calculation
If the target is hit, there is a chance that it will die or it will be wounded. First, we check the chance to be killed:
1 - target's endurance * 0.25 - target's health * 0.25
In simple words, a target character who is wounded has 75% chance to die if his endurance is 0 and 50% chance to die if his endurance is 1. A healthy character has 50% and 25% chance respectively.
If the chance to be killed is failed, we calculate the chance to be wounded. This is the most simple formula:
0.75 - target's endurance * 0.25
There are several issues there.
First, when calculating the damage, the luck of the characters is not taken into the consideration. That is not consistend how luck has been used before. On the other hand, luck has been already applied when we've calculated the hit chance, so I may leave it as is.
Second, why not calculate both death chance and wound chance together with the hit chance? Technically it is the same, but could be a good idea to show the player the chance he has to wound or to kill the opponent.
The third issue is the most prominent: as you noticed, if the wounded character is hit again but stays alive, he still remains "wounded". This not only doesn't make sense, but also can make duels tediously long. Probably, I should calculate the wound/death chance only for the first hit, and the next hit should be instakill.
Anyway, this system will be changed a lot when I'll plug it in the actual game. But I tested it in the console and was satisfied with the combat flow. Here is the code if you want to test it yourself:
from weakref import WeakKeyDictionary import random class BaseStat(int): def __str__(self): if self == 0: return "normal" else: return "good" class BaseStatField(object): def __init__(self): self.default = 0 self.values = WeakKeyDictionary() def __get__(self, instance, owner): return self.values.get(instance, self.default) def __set__(self, instance, value): if value < 0 or value > 1: raise ValueError("BaseStat must be either 0 or 1.") self.values[instance] = BaseStat(value) def __delete__(self, instance): del self.values[instance] class Health(int): def __str__(self): if self == 2: return "healthy" elif self == 1: return "wounded" else: return "dead" class HealthField(object): def __init__(self): self.default = 2 self.values = WeakKeyDictionary() def __get__(self, instance, owner): return self.values.get(instance, self.default) def __set__(self, instance, value): if value not in (0, 1, 2): raise ValueError("Health must be either 0, 1 or 2.") self.values[instance] = Health(value) def __delete__(self, instance): del self.values[instance] class Character(): health = HealthField() endurance = BaseStatField() accuracy = BaseStatField() reaction = BaseStatField() luck = BaseStatField() stats = ["health", "endurance", "accuracy", "reaction", "luck"] def __init__(self, name, health, endurance, accuracy, reaction, luck): self.name = name self.health = health self.endurance = endurance self.accuracy = accuracy self.reaction = reaction self.luck = luck def print_stats(self): print(self.name) print("-----------------") for stat in self.stats: print("{} is {}.".format(stat, getattr(self, stat))) class Attack(object): def __init__(self, attacker, target, distance): self.attacker = attacker self.target = target self.distance = distance @property def hit_chance(self): return 0.5 + (self.attacker.accuracy * 0.25) - (self.distance * 0.025) + (self.attacker.luck * 0.2) - (self.target.luck * 0.2) @property def wound_chance(self): return 0.75 - (self.target.endurance * 0.25) @property def death_chance(self): return 1 - (self.target.endurance * 0.25) - (self.target.health * 0.25) def execute(self): if random.random() <= self.hit_chance: print("{} hits {}.".format(self.attacker.name, self.target.name)) print("Death chance is {}".format(self.death_chance)) if random.random() < self.death_chance: self.target.health = 0 print("{} dies.".format(self.target.name)) elif random.random() < self.wound_chance: self.target.health = 1 print("{} is wounded.".format(self.target.name)) else: print("{} is not damaged by the attack.".format(self.target.name)) else: print("{} misses.".format(self.attacker.name)) class Duel(object): turn_number = 1 def __init__(self, actor1, actor2, distance): self.actors = [actor1, actor2] self.distance = distance @property def initiative(self): return 0.5 + (self.actors[0].reaction * 0.25) - (self.actors[1].reaction * 0.25) + (self.actors[0].luck * 0.25) - (self.actors[1].luck * 0.25) def start(self): print("Duel starts.") if random.random() >= self.initiative: print("{} failed to attack first.".format(self.actors[0].name)) self.swap() print("\n") self.make_turn() def swap(self): temp = self.actors[0] self.actors[0] = self.actors[1] self.actors[1] = temp def make_turn(self): print("Turn {}".format(self.turn_number)) attack = Attack(self.actors[0], self.actors[1], self.distance) attack.execute() if all(a.health > 0 for a in self.actors): print("\n") self.swap() self.turn_number += 1 self.make_turn() else: print("Duel finished in {} turns.".format(self.turn_number)) print("\n") player = Character("Player", 2, 1, 1, 1, 1) enemy = Character("Enemy", 2, 1, 1, 1, 1) Duel(player, enemy, 0).start()