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 #:
record Item(String id, String name, String slot, Map<String, Integer> mods) {}
record Effect(String id, String name, Map<String, Integer> mods) {}
record Kind(String id, String name, int hp, int strength, int agility, int defense, List<String> immune) {}
final class Data { static final String[] STATS = { "maxHp", "strength", "agility", "defense", "evasion" }; static final String[] SLOTS = { "armor", "ring" };
static final String ITEMS_TEXT = """ # 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""";
static final String EFFECTS_TEXT = """ # id|name|mods rage|rage|strength:3,defense:-2 shield|shield|defense:3 weak|weakness|strength:-4""";
static final String ENEMIES_TEXT = """ # 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""";
static final Map<String, Item> ITEMS = new LinkedHashMap<>(); static final Map<String, Effect> EFFECTS = new LinkedHashMap<>(); static final Map<String, Kind> KINDS = new LinkedHashMap<>();
static { for (String[] row : rows(ITEMS_TEXT)) { ITEMS.put(row[0], new Item(row[0], row[1], row[2], parseMods(row[3]))); } for (String[] row : rows(EFFECTS_TEXT)) { EFFECTS.put(row[0], new Effect(row[0], row[1], parseMods(row[2]))); } for (String[] row : rows(ENEMIES_TEXT)) { List<String> immune = row[6].equals("-") ? List.of() : List.of(row[6].split(",")); KINDS.put(row[0], new Kind(row[0], row[1], Integer.parseInt(row[2]), Integer.parseInt(row[3]), Integer.parseInt(row[4]), Integer.parseInt(row[5]), immune)); } }
static List<String[]> rows(String table) { List<String[]> result = new ArrayList<>(); for (String line : table.split("\n")) { String text = line.trim(); if (text.isEmpty() || text.startsWith("#")) continue; result.add(text.split("\\|")); } return result; }
static Map<String, Integer> parseMods(String text) { Map<String, Integer> mods = new HashMap<>(); if (text.equals("-")) return mods; for (String part : text.split(",")) { String[] pair = part.split(":"); mods.put(pair[0], Integer.parseInt(pair[1])); } return mods; }}record is a record with fields that do not change. LinkedHashMap keeps the order of insertion, so the enemies come out in the order of the table.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 { final String name; final Map<String, Integer> base = new HashMap<>(); final List<Effect> effects = new ArrayList<>(); int hp;
Creature(String name) { this.name = name; for (String stat : Data.STATS) base.put(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 extends Creature { final Kind kind;
Enemy(Kind kind) { super(kind.name()); this.kind = kind; base.put("maxHp", kind.hp()); base.put("strength", kind.strength()); base.put("agility", kind.agility()); base.put("defense", kind.defense()); hp = kind.hp(); }
// Immunity is data: a list from the table row. @Override boolean isImmune(String effectId) { return kind.immune().contains(effectId); }}And the hero carries something more: equipment.
class Hero extends Creature { final Map<String, String> equipment = new HashMap<>();
Hero(String name, int maxHp, int strength, int agility, int defense) { super(name); base.put("maxHp", maxHp); base.put("strength", strength); base.put("agility", agility); base.put("defense", defense); hp = maxHp; for (String slot : Data.SLOTS) equipment.put(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:
Enemy(int id, EnemyKind kind) { super(id, nm(kind)); this.kind = kind; this.base.put("maxHp", kind.hp); this.base.put("strength", kind.strength); this.base.put("agility", kind.agility); this.base.put("defense", kind.defense); this.base.put("speed", kind.speed); this.hp = kind.hp; this.state = kind.mood; this.calm = 0; this.lostTurns = 0; this.phase = 1; this.dead = false; this.summoned = false; this.noticed = false;}@Overrideboolean isImmune(String effectId) { return this.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.
List<Creature> all = new ArrayList<>();all.add(new Hero("barbarian", 48, 7, 4, 3));for (Kind kind : Data.KINDS.values()) all.add(new Enemy(kind));
for (Creature c : 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"; System.out.println(line);}
System.out.println("Weakness for everyone:");for (Creature c : all) { int before = c.stat("strength"); c.addEffect("weak"); System.out.println(c.name + ": strength " + before + " -> " + 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.int effectBonus(String stat) { int total = 0; for (Effect effect : effects) total += effect.mods().getOrDefault(stat, 0); return total;}
// The raw value (may be negative): base + equipment + effects.int rawStat(String stat) { return base.get(stat) + gearBonus(stat) + effectBonus(stat);}
// A stat never drops below zero.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:
void addEffect(String id) { for (Effect effect : effects) { if (effect.id().equals(id)) return; } effects.add(Data.EFFECTS.get(id));}
// Effects add up: two effects on one stat are added to each other.int effectBonus(String stat) { int total = 0; for (Effect effect : effects) total += effect.mods().getOrDefault(stat, 0); return total;}
// The raw value (may be negative): base + equipment + effects.int rawStat(String stat) { return base.get(stat) + gearBonus(stat) + effectBonus(stat);}
// A stat never drops below zero.int stat(String stat) { int value = rawStat(stat); return value < 0 ? 0 : value;}
@Overrideint gearBonus(String stat) { int total = 0; for (String slot : Data.SLOTS) { String id = equipment.get(slot); if (id.isEmpty()) continue; total += Data.ITEMS.get(id).mods().getOrDefault(stat, 0); } return total;}
// Health never exceeds the new maximum.void equip(String id) { equipment.put(Data.ITEMS.get(id).slot(), id); if (hp > maxHp()) hp = maxHp();}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 = new Hero("barbarian", 48, 7, 4, 3);System.out.println("-- At the start --");report(hero);hero.equip("chain");hero.equip("ring_strength");System.out.println("-- With chain mail and the ring of strength --");report(hero);hero.addEffect("rage");System.out.println("-- 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.int stat(String stat) { int value = rawStat(stat); return value < 0 ? 0 : value;}
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).int evasion() { return 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;A 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.int rawStat(String stat) { int value = this.base.get(stat) + this.gearBonus(stat); for (Effect effect : this.effects) value += effect.modifier(stat); return value;}
int stat(String stat) { int value = this.rawStat(stat); return value < 0 ? 0 : value;}
int maxHp() { int value = this.stat("maxHp"); return value < 1 ? 1 : value;}
int maxMana() { return this.stat("maxMana");}
// Below 25% life the wound is grave: hits become less accurate.boolean isHeavilyWounded() { return this.hp * 4 < this.maxHp();}
int accuracy() { return 75 + this.stat("agility") * 2 + this.rawStat("accuracy") - (this.isHeavilyWounded() ? 15 : 0);}
int evasion() { return this.stat("agility") * 2 + this.rawStat("evasion");}
int critChance() { return 5 + this.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:
@Overrideint gearBonus(String stat) { int total = 0; for (String slot : Data.SLOTS) { String id = equipment.get(slot); if (id.isEmpty()) continue; total += Data.ITEMS.get(id).mods().getOrDefault(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.void equip(String id) { equipment.put(Data.ITEMS.get(id).slot(), id); if (hp > maxHp()) hp = maxHp();}The canon does the same (gearBonus walks the slots and sums the modifiers):
@Overrideint gearBonus(String stat) { int total = 0; for (String slot : this.equipment.keySet()) { String itemId = this.equipment.get(slot); if (itemId.equals("")) continue; for (Mod mod : ITEMS.get(itemId).mods) { if (mod.stat.equals(stat)) total += mod.value; } } return total;}
Item weapon() { String id = this.equipment.get("weapon"); return ITEMS.get(id.equals("") ? "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?
Hero hero = new Hero("barbarian", 48, 7, 4, 3);hero.equip("ring_life");hero.hp = hero.maxHp();hero.addEffect("weak");hero.unequip("ring");System.out.println(hero.hp + "/" + hero.maxHp());System.out.println(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 = new Hero("barbarian", 48, 7, 4, 3);hero.addEffect("rage");hero.addEffect("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 · HashMap and HashSet · record, switch and patterns
- Tasks on this topic: Chronicles of Arcanum