Skip to content
wannacode

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|immune
rat|giant rat|9|1|6|0|-
goblin|goblin spearman|16|2|5|1|-
skeleton|skeleton|28|4|4|2|bleed,poison
golem|ore golem|46|6|1|4|bleed,poison

The 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|mood
rat|giant rat|9|1|6|0|12|-|-|-|wander
goblin|goblin spearman|16|2|5|1|10|-|-|-|wander
shaman|goblin shaman|13|1|4|0|9|-|-|-|sleep
wolf|blood wolf|20|3|8|1|14|-|-|-|sleep
wolf_leader|wolf leader|36|4|9|2|14|-|-|-|sleep
skeleton|skeleton|28|4|4|2|10|slash|blunt|bleed,poison|sleep
cultist|cultist|26|3|6|1|11|-|-|-|wander
golem|ore golem|46|6|1|4|7|pierce,fire|blunt|bleed,poison|sleep
servant|stone servant|14|2|3|1|9|-|blunt|bleed,poison|sleep
warden|Warden of the Depths|120|5|4|3|10|-|-|bleed,poison|guard

The 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 #:

record Item(string Id, string Name, string Slot, Dictionary<string, int> Mods);
record Effect(string Id, string Name, Dictionary<string, int> Mods);
record Kind(string Id, string Name, int Hp, int Strength, int Agility, int Defense, string[] Immune);
static class Data
{
public static readonly string[] Stats = ["maxHp", "strength", "agility", "defense", "evasion"];
public static readonly string[] Slots = ["armor", "ring"];
public const string ItemsText = """
# id|name|slot|mods
leather|leather armor|armor|defense:2
chain|chain mail|armor|defense:4,evasion:-5
ring_strength|ring of strength|ring|strength:2
ring_life|ring of life|ring|maxHp:10
""";
public const string EffectsText = """
# id|name|mods
rage|rage|strength:3,defense:-2
shield|shield|defense:3
weak|weakness|strength:-4
""";
public const string EnemiesText = """
# id|name|health|strength|agility|defense|immune
rat|giant rat|9|1|6|0|-
goblin|goblin spearman|16|2|5|1|-
skeleton|skeleton|28|4|4|2|bleed,poison
golem|ore golem|46|6|1|4|bleed,poison
""";
public static readonly Dictionary<string, Item> Items = new();
public static readonly Dictionary<string, Effect> Effects = new();
public static readonly Dictionary<string, Kind> Kinds = new();
static Data()
{
foreach (string[] row in Rows(ItemsText))
{
Items[row[0]] = new Item(row[0], row[1], row[2], ParseMods(row[3]));
}
foreach (string[] row in Rows(EffectsText))
{
Effects[row[0]] = new Effect(row[0], row[1], ParseMods(row[2]));
}
foreach (string[] row in Rows(EnemiesText))
{
string[] immune = row[6] == "-" ? Array.Empty<string>() : row[6].Split(',');
Kinds[row[0]] = new Kind(row[0], row[1], int.Parse(row[2]), int.Parse(row[3]), int.Parse(row[4]), int.Parse(row[5]), immune);
}
}
static List<string[]> Rows(string table)
{
var result = new List<string[]>();
foreach (string line in table.Split('\n'))
{
string text = line.Trim();
if (text == "" || text.StartsWith('#')) continue;
result.Add(text.Split('|'));
}
return result;
}
static Dictionary<string, int> ParseMods(string text)
{
var mods = new Dictionary<string, int>();
if (text == "-") return mods;
foreach (string part in text.Split(','))
{
string[] pair = part.Split(':');
mods[pair[0]] = int.Parse(pair[1]);
}
return mods;
}
}
A record is a record with fields that do not change. A Dictionary returns its items in the order of insertion as long as nothing is removed, and the tables here are never removed from.

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
{
public readonly string Name;
public readonly Dictionary<string, int> Base = new();
public readonly List<Effect> Effects = new();
public int Hp;
public Creature(string name)
{
Name = name;
foreach (string stat in Data.Stats) Base[stat] = 0;
}

Creature 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
{
public readonly Kind Kind;
public Enemy(Kind kind) : base(kind.Name)
{
Kind = kind;
Base["maxHp"] = kind.Hp;
Base["strength"] = kind.Strength;
Base["agility"] = kind.Agility;
Base["defense"] = kind.Defense;
Hp = kind.Hp;
}
// Immunity is data: a list from the table row.
public override bool IsImmune(string effectId) => Array.IndexOf(Kind.Immune, effectId) >= 0;
}

And the hero carries something more: equipment.

class Hero : Creature
{
public readonly Dictionary<string, string> Equipment = new();
public Hero(string name, int maxHp, int strength, int agility, int defense) : base(name)
{
Base["maxHp"] = maxHp;
Base["strength"] = strength;
Base["agility"] = agility;
Base["defense"] = defense;
Hp = maxHp;
foreach (string slot in Data.Slots) 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:

public Enemy(int id, EnemyKind kind) : base(id, Nm(kind))
{
Kind = kind;
Base["maxHp"] = kind.Hp;
Base["strength"] = kind.Strength;
Base["agility"] = kind.Agility;
Base["defense"] = kind.Defense;
Base["speed"] = kind.Speed;
Hp = kind.Hp;
State = kind.Mood;
Calm = 0;
LostTurns = 0;
Phase = 1;
Dead = false;
Summoned = false;
Noticed = false;
}
public override bool IsImmune(string effectId)
{
return Kind.Immune.IndexOf(effectId) >= 0;
}

An 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.

var all = new List<Creature> { new Hero("barbarian", 48, 7, 4, 3) };
foreach (Kind kind in Data.Kinds.Values) all.Add(new Enemy(kind));
foreach (Creature c in all)
{
string line = c.Name + ": health " + c.MaxHp() + ", strength " + c.Stat("strength") + ", defense " + c.Stat("defense") + ", dodge " + c.Evasion();
if (c.IsImmune("bleed")) line += ", immune to bleeding and poison";
Console.WriteLine(line);
}
Console.WriteLine("Weakness for everyone:");
foreach (Creature c in all)
{
int before = c.Stat("strength");
c.AddEffect("weak");
Console.WriteLine(c.Name + ": strength " + before + " -> " + c.Stat("strength"));
}
Output
barbarian: health 48, strength 7, defense 3, dodge 8
giant rat: health 9, strength 1, defense 0, dodge 12
goblin spearman: health 16, strength 2, defense 1, dodge 10
skeleton: health 28, strength 4, defense 2, dodge 8, immune to bleeding and poison
ore golem: health 46, strength 6, defense 4, dodge 2, immune to bleeding and poison
Weakness for everyone:
barbarian: strength 7 -> 3
giant rat: strength 1 -> 0
goblin spearman: strength 2 -> 0
skeleton: strength 4 -> 0
ore golem: strength 6 -> 2

Weakness 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.
public int EffectBonus(string stat)
{
int total = 0;
foreach (Effect effect in Effects) total += effect.Mods.GetValueOrDefault(stat, 0);
return total;
}
// The raw value (may be negative): base + equipment + effects.
public int RawStat(string stat) => Base[stat] + GearBonus(stat) + EffectBonus(stat);
// A stat never drops below zero.
public int Stat(string stat)
{
int value = RawStat(stat);
return value < 0 ? 0 : value;
}

The 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:

public void AddEffect(string id)
{
foreach (Effect effect in Effects)
{
if (effect.Id == id) return;
}
Effects.Add(Data.Effects[id]);
}
// Effects add up: two effects on one stat are added to each other.
public int EffectBonus(string stat)
{
int total = 0;
foreach (Effect effect in Effects) total += effect.Mods.GetValueOrDefault(stat, 0);
return total;
}
// The raw value (may be negative): base + equipment + effects.
public int RawStat(string stat) => Base[stat] + GearBonus(stat) + EffectBonus(stat);
// A stat never drops below zero.
public int Stat(string stat)
{
int value = RawStat(stat);
return value < 0 ? 0 : value;
}
public override int GearBonus(string stat)
{
int total = 0;
foreach (string slot in Data.Slots)
{
string id = Equipment[slot];
if (id == "") continue;
total += Data.Items[id].Mods.GetValueOrDefault(stat, 0);
}
return total;
}
// Health never exceeds the new maximum.
public void Equip(string id)
{
Equipment[Data.Items[id].Slot] = id;
if (Hp > MaxHp()) Hp = MaxHp();
}
Step by step, in words
  1. Only the base is stored: strength 7, agility 4, defense 3. Dodge is derived: agility × 2 = 8.
  2. 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.
  3. The ring is in another slot and does not push out the mail: slots are summed. Strength 7 + 2 = 9.
  4. Rage: +3 to strength, −2 to defense. effectBonus adds up the effects: strength 12, defense 5. When it ends, the layer disappears.
  5. The total is computed fresh: stat() = max(0, base + equipment + effects). It is not stored, so it cannot get out of sync.
var hero = new Hero("barbarian", 48, 7, 4, 3);
Console.WriteLine("-- At the start --");
Report(hero);
hero.Equip("chain");
hero.Equip("ring_strength");
Console.WriteLine("-- With chain mail and the ring of strength --");
Report(hero);
hero.AddEffect("rage");
Console.WriteLine("-- And with rage --");
Report(hero);
Output
-- 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: 3

Look 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.
public int Stat(string stat)
{
int value = RawStat(stat);
return value < 0 ? 0 : value;
}
public int MaxHp()
{
int value = Stat("maxHp");
return value < 1 ? 1 : value;
}

The 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).
public int Evasion() => Stat("agility") * 2 + RawStat("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 value < 0 ?  : value;

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.
public int RawStat(string stat)
{
int value = Base[stat] + GearBonus(stat);
foreach (Effect effect in Effects) value += effect.Modifier(stat);
return value;
}
public int Stat(string stat)
{
int value = RawStat(stat);
return value < 0 ? 0 : value;
}
public int MaxHp()
{
int value = Stat("maxHp");
return value < 1 ? 1 : value;
}
public int MaxMana()
{
return Stat("maxMana");
}
// Below 25% life the wound is grave: hits become less accurate.
public bool IsHeavilyWounded()
{
return Hp * 4 < MaxHp();
}
public int Accuracy()
{
return 75 + Stat("agility") * 2 + RawStat("accuracy") - (IsHeavilyWounded() ? 15 : 0);
}
public int Evasion()
{
return Stat("agility") * 2 + RawStat("evasion");
}
public int CritChance()
{
return 5 + Stat("agility") / 10;
}

That 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:

public override int GearBonus(string stat)
{
int total = 0;
foreach (string slot in Data.Slots)
{
string id = Equipment[slot];
if (id == "") continue;
total += Data.Items[id].Mods.GetValueOrDefault(stat, 0);
}
return total;
}

The 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.
public void Equip(string id)
{
Equipment[Data.Items[id].Slot] = id;
if (Hp > MaxHp()) Hp = MaxHp();
}

The canon does the same (gearBonus walks the slots and sums the modifiers):

public override int GearBonus(string stat)
{
int total = 0;
foreach (string slot in Equipment.Keys)
{
string itemId = Equipment.Get(slot);
if (itemId == "") continue;
foreach (Mod mod in ITEMS[itemId].Mods)
{
if (mod.Stat == stat) total += mod.Value;
}
}
return total;
}
public Item Weapon()
{
string id = Equipment.Get("weapon");
return ITEMS[id == "" ? "fists" : 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?

var hero = new Hero("barbarian", 48, 7, 4, 3);
hero.Equip("ring_life");
hero.Hp = hero.MaxHp();
hero.AddEffect("weak");
hero.Unequip("ring");
Console.WriteLine(hero.Hp + "/" + hero.MaxHp());
Console.WriteLine(hero.Stat("strength"));
Answer

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:

var hero = new Hero("barbarian", 48, 7, 4, 3);
hero.AddEffect("rage");
hero.AddEffect("shield");
Report(hero);
Output
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: 8

Rage 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.

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:

Output
> equip chain
48/48 | strength 7 | defense 7 | agility 4 | dodge 3
> equip amulet_bear
48/54 | strength 8 | defense 7 | agility 3 | dodge 1
> heal
54/54 | strength 8 | defense 7 | agility 3 | dodge 1
> buff rage
54/54 | strength 11 | defense 5 | agility 3 | dodge 1
> remove amulet
48/48 | strength 10 | defense 5 | agility 4 | dodge 3
> remove armor
48/48 | strength 10 | defense 1 | agility 4 | dodge 8

Look 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 ring1 and ring2. What happens to ring_strength if 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 must stat() do after it runs out?

Test

Check yourself

1. The game gets a new enemy: the same as the goblin, but with 30 health instead of 16. What do you do?
2. A hero with 3 defense puts on chain mail (+4 defense), and then gets rage (−2 defense). What is stat("defense")?
3. The hero has agility 4 and chain mail with a dodge bonus of −5. What is evasion()?
4. Why does Creature have no field "defense with equipment" that is updated when the hero puts something on?
5. Why does Hero.gearBonus override the method of Creature instead of just having a field of its own?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·