Heroes, enemies, stats
In short: One creature for everyone: the hero and the enemies differ in data (rows of a table) and in two methods, and every stat is a sum of layers, base + equipment + effects, computed each time it is asked for.
Problem
Ten enemies, three classes, one question
The game has ten kinds of enemies and three hero classes. All of them have health, strength, agility and defense, and a hit asks the same numbers of each. How do you write that?
The first idea of anyone who has learned inheritance is a class for every kind: Enemy is the parent, Goblin and Skeleton are the children. Soon you see the tree:
Enemy├── Rat├── Goblin│ ├── GoblinSpearman│ └── GoblinShaman├── Undead│ ├── Skeleton immune to bleeding and poison│ └── StoneServant immune to bleeding and poison (again!)├── Golem immune to bleeding and poison (again!!)└── ...The skeleton, the stone servant and the golem do not bleed and cannot be poisoned, but they are not of one family: one is undead, the others are stone. Either the immunity is copied into three classes, or you need a "Bloodless" class between them that crosses the "Undead" family. And a second trait (weak to fire) would be another crossing family. A deep tree is fine as long as every case fits one branch.
There is another problem: a new enemy means a new class, new compiled code and a programmer. A designer who wants a goblin with 30 health cannot make it alone.
In RPG 1 the five races inherit one Character. There inheritance is in the right place, because every race does something different (its own ability). Here the enemies differ in numbers. Different behavior goes into a method. Different numbers are data.
The idea
An enemy is a row in a table
The first rule of the game: if two things differ only in numbers, they are one class and two rows of data. The enemies of this lesson are four lines of text, with cells separated by |:
# id|name|health|strength|agility|defense|immunerat|giant rat|9|1|6|0|-goblin|goblin spearman|16|2|5|1|-skeleton|skeleton|28|4|4|2|bleed,poisongolem|ore golem|46|6|1|4|bleed,poisonThe game works exactly like this, only with ten enemies. Here is the real table from the canon. The columns this lesson does not use (glyph, color, damage, experience, gold, loot, skill, depth) are left out, and the rows are the same:
# id|name|health|strength|agility|defense|speed|resists|weak to|immune|moodrat|giant rat|9|1|6|0|12|-|-|-|wandergoblin|goblin spearman|16|2|5|1|10|-|-|-|wandershaman|goblin shaman|13|1|4|0|9|-|-|-|sleepwolf|blood wolf|20|3|8|1|14|-|-|-|sleepwolf_leader|wolf leader|36|4|9|2|14|-|-|-|sleepskeleton|skeleton|28|4|4|2|10|slash|blunt|bleed,poison|sleepcultist|cultist|26|3|6|1|11|-|-|-|wandergolem|ore golem|46|6|1|4|7|pierce,fire|blunt|bleed,poison|sleepservant|stone servant|14|2|3|1|9|-|blunt|bleed,poison|sleepwarden|Warden of the Depths|120|5|4|3|10|-|-|bleed,poison|guardThe code reads the table at startup and makes a record for every row. A new enemy is a new row: not a single line of code changes. This is called data-driven design: the game is an engine, and the content lives in tables that can be edited without a programmer.
More precisely, if you are curious
This is how the tables of the lesson (the enemies, the items and the effects) are read. The text is a constant, and rows splits it into lines and cells, skipping empty lines and notes that start with #:
@dataclass(frozen=True)class Item: id: str name: str slot: str mods: dict
@dataclass(frozen=True)class Effect: id: str name: str mods: dict
@dataclass(frozen=True)class Kind: id: str name: str hp: int strength: int agility: int defense: int immune: list
STATS = ["maxHp", "strength", "agility", "defense", "evasion"]SLOTS = ["armor", "ring"]
ITEMS_TEXT = """# id|name|slot|modsleather|leather armor|armor|defense:2chain|chain mail|armor|defense:4,evasion:-5ring_strength|ring of strength|ring|strength:2ring_life|ring of life|ring|maxHp:10"""
EFFECTS_TEXT = """# id|name|modsrage|rage|strength:3,defense:-2shield|shield|defense:3weak|weakness|strength:-4"""
ENEMIES_TEXT = """# id|name|health|strength|agility|defense|immunerat|giant rat|9|1|6|0|-goblin|goblin spearman|16|2|5|1|-skeleton|skeleton|28|4|4|2|bleed,poisongolem|ore golem|46|6|1|4|bleed,poison"""
def rows(table): result = [] for line in table.split("\n"): text = line.strip() if text == "" or text.startswith("#"): continue result.append(text.split("|")) return result
def parse_mods(text): mods = {} if text == "-": return mods for part in text.split(","): stat, value = part.split(":") mods[stat] = int(value) return mods
ITEMS = {}for row in rows(ITEMS_TEXT): ITEMS[row[0]] = Item(row[0], row[1], row[2], parse_mods(row[3]))
EFFECTS = {}for row in rows(EFFECTS_TEXT): EFFECTS[row[0]] = Effect(row[0], row[1], parse_mods(row[2]))
KINDS = {}for row in rows(ENEMIES_TEXT): immune = [] if row[6] == "-" else row[6].split(",") KINDS[row[0]] = Kind(row[0], row[1], int(row[2]), int(row[3]), int(row[4]), int(row[5]), immune)@dataclass(frozen=True) makes the record: the fields are given in the constructor and never change. A dict holds the modifiers.The idea
One creature for everyone
The differences between the hero and the enemies are small. So there is one class Creature with everything shared: a name, health, a base (a number for every stat) and a list of effects. Here is its beginning:
class Creature: def __init__(self, name): self.name = name self.base = {} for stat in STATS: self.base[stat] = 0 self.effects = [] self.hp = 0Creature has a map of stats and a list of effects; it is not a goblin or a skeleton. This is composition: you assemble a creature from parts instead of inheriting it from a more general creature. An enemy from the table simply fills the base from its row:
class Enemy(Creature): def __init__(self, kind): super().__init__(kind.name) self.kind = kind self.base["maxHp"] = kind.hp self.base["strength"] = kind.strength self.base["agility"] = kind.agility self.base["defense"] = kind.defense self.hp = kind.hp
# Immunity is data: a list from the table row. def is_immune(self, effect_id): return effect_id in self.kind.immuneAnd the hero carries something more: equipment.
class Hero(Creature): def __init__(self, name, max_hp, strength, agility, defense): super().__init__(name) self.base["maxHp"] = max_hp self.base["strength"] = strength self.base["agility"] = agility self.base["defense"] = defense self.hp = max_hp self.equipment = {} for slot in SLOTS: self.equipment[slot] = ""One level of inheritance is left: Creature → Hero and Enemy. The two subclasses replace one method each, because that is the only place where the behavior differs: the hero has gearBonus (equipment), the enemy has isImmune (immunity from its row). Everything else, including everything in the combat system, asks a Creature and does not know whom it asks.
The canon does the same. Here are the constructor of Enemy and its isImmune:
def __init__(self, creature_id, kind): super().__init__(creature_id, nm(kind)) self.kind = kind self.base['maxHp'] = kind.hp self.base['strength'] = kind.strength self.base['agility'] = kind.agility self.base['defense'] = kind.defense self.base['speed'] = kind.speed self.hp = kind.hp self.state = kind.mood self.calm = 0 self.lost_turns = 0 self.phase = 1 self.dead = False self.summoned = False self.noticed = Falsedef is_immune(self, effect_id): return effect_id in self.kind.immuneAn enemy from the table copies its numbers into base and keeps a reference to its row (kind). Immunity is not a class but a list in that row.
More precisely, if you are curious
The rule "composition over inheritance" does not say "never inherit". It says: inherit for behavior (one method differs), assemble for make-up (the parts differ). Here Creature holds the shared skeleton, and the subclasses are two thin ones. If the heroes were five races with five abilities, as in RPG 1, inheritance would be the right tool.
In the canon the effects are small classes too (PoisonEffect, RageEffect), because poison and burning do something every turn. An effect that only changes numbers can also be a table row, as it is in the lesson.
See it
Everyone through one piece of code
The hero and the four enemies go into one list of Creature. The loop does not ask who is who: it calls maxHp, stat and evasion, and for an enemy immune to bleeding it adds a note. Then everyone gets the same effect.
everyone = [Hero("barbarian", 48, 7, 4, 3)]for kind in KINDS.values(): everyone.append(Enemy(kind))
for c in everyone: line = c.name + ": health " + str(c.max_hp()) + ", strength " + str(c.stat("strength")) + ", defense " + str(c.stat("defense")) + ", dodge " + str(c.evasion()) if c.is_immune("bleed"): line += ", immune to bleeding and poison" print(line)
print("Weakness for everyone:")for c in everyone: before = c.stat("strength") c.add_effect("weak") print(c.name + ": strength " + str(before) + " -> " + str(c.stat("strength")))barbarian: health 48, strength 7, defense 3, dodge 8giant rat: health 9, strength 1, defense 0, dodge 12goblin spearman: health 16, strength 2, defense 1, dodge 10skeleton: health 28, strength 4, defense 2, dodge 8, immune to bleeding and poisonore golem: health 46, strength 6, defense 4, dodge 2, immune to bleeding and poisonWeakness for everyone:barbarian: strength 7 -> 3giant rat: strength 1 -> 0goblin spearman: strength 2 -> 0skeleton: strength 4 -> 0ore golem: strength 6 -> 2Weakness takes 4 from everyone's strength. The rat, the goblin and the skeleton fall below zero and stay at 0: they cannot hit with negative strength. The golem has 6, so it keeps 2. The next card says why.
The idea
A stat is a sum of layers
The hero's strength is not one number. It is a sum:
- base: what the hero is born with (the barbarian's 7 strength). It is stored in the creature;
- equipment: the bonuses of the items worn right now (+2 from the ring). It is not stored but collected from the slots;
- effects: the bonuses and penalties of the effects active right now (+3 from rage). It is not stored but collected from the list of effects.
# Effects add up: two effects on one stat are added to each other.def effect_bonus(self, stat): total = 0 for effect in self.effects: total += effect.mods.get(stat, 0) return total
# The raw value (may be negative): base + equipment + effects.def raw_stat(self, stat): return self.base[stat] + self.gear_bonus(stat) + self.effect_bonus(stat)
# A stat never drops below zero.def stat(self, stat): value = self.raw_stat(stat) return 0 if value < 0 else valueThe total is computed on every request and stored nowhere. So it cannot get out of sync. You take the mail off and defense drops, because gearBonus does not find the item. An effect runs out and strength comes back, because effectBonus does not find the effect. Nothing "puts the number back". If the creature stored its total defense, you would have to remember to change it on every event: equip, unequip, an effect starts, an effect ends, the hero levels up. One forgotten place and defense "goes mad".
The hero in the program is a barbarian (48 health, 7 strength, 4 agility, 3 defense) with chain mail, a ring of strength and rage. Every line is base +equipment +effects = total:
def add_effect(self, effect_id): for effect in self.effects: if effect.id == effect_id: return self.effects.append(EFFECTS[effect_id])
# Effects add up: two effects on one stat are added to each other.def effect_bonus(self, stat): total = 0 for effect in self.effects: total += effect.mods.get(stat, 0) return total
# The raw value (may be negative): base + equipment + effects.def raw_stat(self, stat): return self.base[stat] + self.gear_bonus(stat) + self.effect_bonus(stat)
# A stat never drops below zero.def stat(self, stat): value = self.raw_stat(stat) return 0 if value < 0 else value
def gear_bonus(self, stat): total = 0 for slot in SLOTS: item_id = self.equipment[slot] if item_id == "": continue total += ITEMS[item_id].mods.get(stat, 0) return total
# Health never exceeds the new maximum.def equip(self, item_id): self.equipment[ITEMS[item_id].slot] = item_id if self.hp > self.max_hp(): self.hp = self.max_hp()Step by step, in words
- Only the base is stored: strength 7, agility 4, defense 3. Dodge is derived: agility × 2 = 8.
- Chain mail: defense is 3 + 4 = 7. The dodge bonus is -5, but stat() is 0; the raw number stays and dodge falls to 3.
- The ring is in another slot and does not push out the mail: slots are summed. Strength 7 + 2 = 9.
- Rage: +3 to strength, −2 to defense. effectBonus adds up the effects: strength 12, defense 5. When it ends, the layer disappears.
- The total is computed fresh: stat() = max(0, base + equipment + effects). It is not stored, so it cannot get out of sync.
hero = Hero("barbarian", 48, 7, 4, 3)print("-- At the start --")report(hero)hero.equip("chain")hero.equip("ring_strength")print("-- With chain mail and the ring of strength --")report(hero)hero.add_effect("rage")print("-- And with rage --")report(hero)-- At the start --barbarian: health 48/48 maxHp: 48 +0 +0 = 48 strength: 7 +0 +0 = 7 agility: 4 +0 +0 = 4 defense: 3 +0 +0 = 3 evasion: 0 +0 +0 = 0 dodge: 8-- With chain mail and the ring of strength --barbarian: health 48/48 maxHp: 48 +0 +0 = 48 strength: 7 +2 +0 = 9 agility: 4 +0 +0 = 4 defense: 3 +4 +0 = 7 evasion: 0 -5 +0 = 0 dodge: 3-- And with rage --barbarian: health 48/48 maxHp: 48 +0 +0 = 48 strength: 7 +2 +3 = 12 agility: 4 +0 +0 = 4 defense: 3 +4 -2 = 5 evasion: 0 -5 +0 = 0 dodge: 3Look at the last line: evasion: 0 -5 +0 = 0, yet the dodge is 3. The next card says why.
The idea
The raw number and the stat
The mail is heavy: it takes 5 from the dodge. But a stat with a minus sign makes no sense: strength −3 would turn a hit into healing. That is why stat does not drop below zero:
# A stat never drops below zero.def stat(self, stat): value = self.raw_stat(stat) return 0 if value < 0 else value
def max_hp(self): value = self.stat("maxHp") return 1 if value < 1 else valueThe dodge, however, needs to know that the mail weighs something. It does not use stat but the raw number (rawStat), which may be negative:
# Dodge comes from agility and from the raw bonus (which may be negative).def evasion(self): return self.stat("agility") * 2 + self.raw_stat("evasion")With agility 4 the dodge is 4 × 2 = 8. The mail takes 5, but stat("evasion") is 0, while the dodge is 8 − 5 = 3. If the dodge used stat("evasion"), the mail would cost nothing.
maxHp has its own rule: it does not drop below 1. A creature with 0 maximum health is dead the moment it is born, and dividing by the maximum health (for example a bar in the interface) would break.
return if value < 0 else valueA stat does not drop below zero: if the sum is negative, stat returns 0. The raw number is kept for the dodge, so that the fact that the mail is heavy does not get lost.
The game does the same, with a few more derived numbers: accuracy, the chance of a critical hit and "heavily wounded" (below 25% health the hits are less accurate):
# The raw value (it can be negative): base + gear + effects.def raw_stat(self, stat): value = self.base[stat] + self.gear_bonus(stat) for effect in self.effects: value += effect.modifier(stat) return value
def stat(self, stat): value = self.raw_stat(stat) return 0 if value < 0 else value
def max_hp(self): value = self.stat('maxHp') return 1 if value < 1 else value
def max_mana(self): return self.stat('maxMana')
# Below 25% life the wound is grave: hits become less accurate.def is_heavily_wounded(self): return self.hp * 4 < self.max_hp()
def accuracy(self): return 75 + self.stat('agility') * 2 + self.raw_stat('accuracy') - (15 if self.is_heavily_wounded() else 0)
def evasion(self): return self.stat('agility') * 2 + self.raw_stat('evasion')
def crit_chance(self): return 5 + self.stat('agility') // 10That is where rawStat in the canon comes from: it adds the base, gearBonus and every effect with effect.modifier(stat). The lesson keeps effectBonus separate so that you can see the three layers one by one.
The idea
The hero carries, the enemy does not
The base Creature.gearBonus returns 0, because an enemy has no equipment. The hero replaces it:
def gear_bonus(self, stat): total = 0 for slot in SLOTS: item_id = self.equipment[slot] if item_id == "": continue total += ITEMS[item_id].mods.get(stat, 0) return totalThe hero stores only which item is in which slot (name → id). The numbers themselves are in the table of items. So one piece of mail is one row of data, not a copy in every hero, and changing the balance means changing a number in one place.
When an item goes on or off, the maximum health can change. The ring of life gives +10. Take it off while the hero is at 58 of 58, and the current health stays 58 with a maximum of 48: something that must not happen. That is why equip and unequip bring the health back within the limit:
# Health never exceeds the new maximum.def equip(self, item_id): self.equipment[ITEMS[item_id].slot] = item_id if self.hp > self.max_hp(): self.hp = self.max_hp()The canon does the same (gearBonus walks the slots and sums the modifiers):
def gear_bonus(self, stat): total = 0 for slot in self.equipment.keys(): item_id = self.equipment[slot] if item_id == '': continue for mod in ITEMS[item_id].mods: if mod.stat == stat: total += mod.value return total
def weapon(self): weapon_id = self.equipment['weapon'] return ITEMS['fists' if weapon_id == '' else weapon_id]More precisely, if you are curious
In the canon the hero also has a third slot, a weapon, and weapon() returns fists when the slot is empty, so there is no special case "no weapon". That way the hero always has something to hit with. In the lesson there are two slots, so that the numbers stay visible.
Items are kept by id (a string), not by reference. So the game's save file is simple (it stores "ring_life", not an object) and the same item can be in many places.
Try it
Predict the output
The hero puts on the ring of life (+10 maximum health) and heals fully. Then weakness hits (−4 strength) and the hero takes the ring off. What does the program print?
hero = Hero("barbarian", 48, 7, 4, 3)hero.equip("ring_life")hero.hp = hero.max_hp()hero.add_effect("weak")hero.unequip("ring")print(str(hero.hp) + "/" + str(hero.max_hp()))print(hero.stat("strength"))The maximum falls from 58 to 48, and unequip brings the health back within the limit: 48 / 48. Strength is 7 − 4 = 3: the effect is a layer that does not depend on the ring. The option 58 / 48 forgets to bring the health back, 7 forgets the effect, and 58 / 58 forgets both.
Try it
Find the bug
A colleague "simplified" one line, and now a hero with rage and a shield has strange numbers. The two effects are added one after the other, and the program prints this:
hero = Hero("barbarian", 48, 7, 4, 3)hero.add_effect("rage")hero.add_effect("shield")report(hero)barbarian: health 48/48 maxHp: 48 +0 +0 = 48 strength: 7 +0 +0 = 7 agility: 4 +0 +0 = 4 defense: 3 +0 +3 = 6 evasion: 0 +0 +0 = 0 dodge: 8Rage does not help strength (there is no +3), and defense is 3 + 3, not 3 − 2 + 3. Here are the three methods that compute the layers. Which line is to blame?
Pick the line with the bug.
In effectBonus the assignment = keeps only the last effect: every new one wipes the previous. The shield has no bonus for strength and resets it, and for defense only the shield's +3 is left, without the −2 of rage. It must be +=: the "effects" layer is the sum of all the effects, not the last one.
More precisely
When a class, when a row
When something new appears in the game, ask: does it differ in numbers or in behavior?
- A new enemy with other health, strength and defense, an immunity to fire, another mood: a row in the table;
- a new item with modifiers: a row in the table, and a new kind of slot is a new name in the list of slots;
- an effect that only changes numbers (rage, shield, weakness): a row in the table;
- an effect that does something every turn (poison, burning, healing): a method, which is why it is a small class in the canon;
- an enemy that decides how to move (the shaman summons, the wolf flees): behavior, so code. In the canon these are skills and state machines, not subclasses of
Creature.
A class (or a replaced method) is needed when the different behavior does not fit into a number. Everything else grows by rows in a table.
Exercise
The amulet
Solve the task in your editor.
Amulet. Add a third slot amulet and an item amulet_bear ("bear amulet": +6 maximum health, +1 strength, −1 agility) only as a new row in the table of items and a new name in the list of slots. No new class. The program reads commands from the input (one per line): equip <item>, remove <slot>, buff <effect> and heal (health becomes the maximum). After every command it prints > command and one line: health/maximum | strength | defense | agility | dodge. The hero is the same barbarian (48, 7, 4, 3).
With the input equip chain / equip amulet_bear / heal / buff rage / remove amulet / remove armor (one per line) the output is:
> equip chain48/48 | strength 7 | defense 7 | agility 4 | dodge 3> equip amulet_bear48/54 | strength 8 | defense 7 | agility 3 | dodge 1> heal54/54 | strength 8 | defense 7 | agility 3 | dodge 1> buff rage54/54 | strength 11 | defense 5 | agility 3 | dodge 1> remove amulet48/48 | strength 10 | defense 5 | agility 4 | dodge 3> remove armor48/48 | strength 10 | defense 1 | agility 4 | dodge 8Look at heal and at remove amulet: with the amulet the maximum is 54 and heal fills up to 54, while remove amulet brings the health back to the limit of 48.
More tasks
- A new enemy without code. Add the blood wolf from the canon (20 health, 3 strength, 8 agility, 1 defense, no immunity) only as a row in the enemy table. What is its dodge?
- A second ring. Make the slots
ring1andring2. What happens toring_strengthif you put it in both? Is that right? - An effect with a duration. Give the effect a number of turns and a method
tick()that decreases it and removes the effect at zero. What muststat()do after it runs out?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Polymorphism · Lists: append, remove and sort · dict and set · dataclass, match and patterns
- Tasks on this topic: Chronicles of Arcanum