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

const STATS = ["maxHp", "strength", "agility", "defense", "evasion"];
const SLOTS = ["armor", "ring"];
const 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
`;
const EFFECTS_TEXT = `
# id|name|mods
rage|rage|strength:3,defense:-2
shield|shield|defense:3
weak|weakness|strength:-4
`;
const 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
`;
function rows(table) {
const result = [];
for (const line of table.split("\n")) {
const text = line.trim();
if (text === "" || text.startsWith("#")) continue;
result.push(text.split("|"));
}
return result;
}
function parseMods(text) {
const mods = {};
if (text === "-") return mods;
for (const part of text.split(",")) {
const [stat, value] = part.split(":");
mods[stat] = Number(value);
}
return mods;
}
const ITEMS = {};
for (const [id, name, slot, mods] of rows(ITEMS_TEXT)) {
ITEMS[id] = { id, name, slot, mods: parseMods(mods) };
}
const EFFECTS = {};
for (const [id, name, mods] of rows(EFFECTS_TEXT)) {
EFFECTS[id] = { id, name, mods: parseMods(mods) };
}
const KINDS = {};
for (const [id, name, hp, strength, agility, defense, immune] of rows(ENEMIES_TEXT)) {
KINDS[id] = { id, name, hp: Number(hp), strength: Number(strength), agility: Number(agility), defense: Number(defense), immune: immune === "-" ? [] : immune.split(",") };
}
The records are plain objects, and the modifiers are an object "stat → number". Object.values(KINDS) returns the enemies in the order they were added.

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 {
constructor(name) {
this.name = name;
this.base = {};
for (const stat of STATS) this.base[stat] = 0;
this.effects = [];
this.hp = 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 {
constructor(kind) {
super(kind.name);
this.kind = kind;
this.base.maxHp = kind.hp;
this.base.strength = kind.strength;
this.base.agility = kind.agility;
this.base.defense = kind.defense;
this.hp = kind.hp;
}
// Immunity is data: a list from the table row.
isImmune(effectId) {
return this.kind.immune.includes(effectId);
}
}

And the hero carries something more: equipment.

class Hero extends Creature {
constructor(name, maxHp, strength, agility, defense) {
super(name);
this.base.maxHp = maxHp;
this.base.strength = strength;
this.base.agility = agility;
this.base.defense = defense;
this.hp = maxHp;
this.equipment = {};
for (const slot of SLOTS) this.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:

constructor(id, kind) {
super(id, nm(kind));
this.kind = kind;
this.base.set('maxHp', kind.hp);
this.base.set('strength', kind.strength);
this.base.set('agility', kind.agility);
this.base.set('defense', kind.defense);
this.base.set('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;
}
isImmune(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.

const everyone = [new Hero("barbarian", 48, 7, 4, 3)];
for (const kind of Object.values(KINDS)) everyone.push(new Enemy(kind));
for (const c of everyone) {
let 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.log(line);
}
console.log("Weakness for everyone:");
for (const c of everyone) {
const before = c.stat("strength");
c.addEffect("weak");
console.log(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.
effectBonus(stat) {
let total = 0;
for (const effect of this.effects) total += effect.mods[stat] ?? 0;
return total;
}
// The raw value (may be negative): base + equipment + effects.
rawStat(stat) {
return this.base[stat] + this.gearBonus(stat) + this.effectBonus(stat);
}
// A stat never drops below zero.
stat(stat) {
const value = this.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:

addEffect(id) {
for (const effect of this.effects) {
if (effect.id === id) return;
}
this.effects.push(EFFECTS[id]);
}
// Effects add up: two effects on one stat are added to each other.
effectBonus(stat) {
let total = 0;
for (const effect of this.effects) total += effect.mods[stat] ?? 0;
return total;
}
// The raw value (may be negative): base + equipment + effects.
rawStat(stat) {
return this.base[stat] + this.gearBonus(stat) + this.effectBonus(stat);
}
// A stat never drops below zero.
stat(stat) {
const value = this.rawStat(stat);
return value < 0 ? 0 : value;
}
gearBonus(stat) {
let total = 0;
for (const slot of SLOTS) {
const id = this.equipment[slot];
if (id === "") continue;
total += ITEMS[id].mods[stat] ?? 0;
}
return total;
}
// Health never exceeds the new maximum.
equip(id) {
this.equipment[ITEMS[id].slot] = id;
if (this.hp > this.maxHp()) this.hp = this.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.
const hero = new Hero("barbarian", 48, 7, 4, 3);
console.log("-- At the start --");
report(hero);
hero.equip("chain");
hero.equip("ring_strength");
console.log("-- With chain mail and the ring of strength --");
report(hero);
hero.addEffect("rage");
console.log("-- 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.
stat(stat) {
const value = this.rawStat(stat);
return value < 0 ? 0 : value;
}
maxHp() {
const value = this.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).
evasion() {
return this.stat("agility") * 2 + this.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.
rawStat(stat) {
let value = this.base.get(stat) + this.gearBonus(stat);
for (const effect of this.effects) value += effect.modifier(stat);
return value;
}
stat(stat) {
const value = this.rawStat(stat);
return value < 0 ? 0 : value;
}
maxHp() {
const value = this.stat('maxHp');
return value < 1 ? 1 : value;
}
maxMana() {
return this.stat('maxMana');
}
// Below 25% life the wound is grave: hits become less accurate.
isHeavilyWounded() {
return this.hp * 4 < this.maxHp();
}
accuracy() {
return 75 + this.stat('agility') * 2 + this.rawStat('accuracy') - (this.isHeavilyWounded() ? 15 : 0);
}
evasion() {
return this.stat('agility') * 2 + this.rawStat('evasion');
}
critChance() {
return 5 + Math.floor(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:

gearBonus(stat) {
let total = 0;
for (const slot of SLOTS) {
const id = this.equipment[slot];
if (id === "") continue;
total += ITEMS[id].mods[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.
equip(id) {
this.equipment[ITEMS[id].slot] = id;
if (this.hp > this.maxHp()) this.hp = this.maxHp();
}

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

gearBonus(stat) {
let total = 0;
for (const slot of this.equipment.keys()) {
const itemId = this.equipment.get(slot);
if (itemId === '') continue;
for (const mod of ITEMS.get(itemId).mods) {
if (mod.stat === stat) total += mod.value;
}
}
return total;
}
weapon() {
const id = this.equipment.get('weapon');
return ITEMS.get(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?

const hero = new Hero("barbarian", 48, 7, 4, 3);
hero.equip("ring_life");
hero.hp = hero.maxHp();
hero.addEffect("weak");
hero.unequip("ring");
console.log(hero.hp + "/" + hero.maxHp());
console.log(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:

const 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 ·