Blood, wounds and effects
In short: An effect is something that stays on a creature for a few turns: it has a moment when it is applied, a moment every turn and a moment when it expires, and the repeat rule says whether applying it again refreshes, stacks or is ignored.
Problem
One cut, three turns
A wolf scratches you and blood flows from the wound. It does not hurt once: it hurts every turn until the wound closes. A second cut does not start a new bleed, it adds a wound to the same one. And when life drops under a quarter, your hand shakes and your strikes become less accurate.
Here are three turns from the real game: Iva against the wolf leader.
=== Battle ===Iva, barbarian lv2 L [###########-] 62/67 M [########] 5/5 bldx3:21) Wolf leader [########] 36/36 Battle! Against you: wolf leader. Wolf leader lunges, teeth first. Wolf leader scratches Iva (2). Iva is bleeding. Iva bleeds harder (wounds: 3). Iva loses blood (3).1 attack 2 skill 3 item 4 fleeUnder the life bar you see bldx3:2: bleeding with three wounds (x3) and two turns left. The log tells what happens: the effect is applied, bleeds harder (wounds: 3), and life drops by 3. This is where those numbers come from.
This is an effect: something that stays on a creature for a few turns and acts during them. Poison, burning, stun, regeneration, rage and shield are of the same kind. To have all of them, an effect has to do five things: three moments (when applied, every turn, when it expires), a repeat rule, changes to stats, a trail on the map and a heavy wound. The lesson builds them one by one in small programs, and then shows the same in the game.
More precisely, if you are curious
Blood in the game is a mechanic, not a picture. Bleeding is an effect with numbers: wounds, turns, damage. The battle log describes the strength of a hit with words ("scratches", "slashes") and bleeding with one sentence, with no descriptions of violence for its own sake. The game is for students and adults, and it stays that way. Blood is there because it gives the player a decision: bandage or run, wait or strike again.
The idea
An effect has three moments
What does poison do? Three different things at three different moments: when it is applied ("Poison seeps into the blood"), every turn (damage) and when it expires ("Its effect wears off"). So an effect is a class with three methods that do nothing by default: onApply, onTurn and onExpire. Each kind of effect changes only what it needs.
class Effect { constructor(id, turns, power) { this.id = id; this.turns = turns; this.power = power; this.stacks = 1; }
stackRule() { return "refresh"; }
maxStacks() { return 1; }
modifier(stat) { return 0; }
onApply(target, ctx) { ctx.say(t("fx.on." + this.id, target.name)); }
onTurn(target, ctx) {}
onExpire(target, ctx) { ctx.say(t("fx.off." + this.id, target.name)); }
label() { const name = t("fxs." + this.id); return this.stacks > 1 ? name + "x" + this.stacks + ":" + this.turns : name + ":" + this.turns; }}
class PoisonEffect extends Effect { onTurn(target, ctx) { ctx.say(t("fx.tick.poison", target.name, this.power)); hurt(ctx, target, this.power); }}
class RegenEffect extends Effect { onTurn(target, ctx) { const healed = target.heal(this.power); if (healed > 0) ctx.say(t("fx.tick.regen", target.name, healed)); }}The class also keeps an id (it finds the text and the effect in the list), turns (how many turns are left), power (the strength) and stacks (wounds, layers). label() makes a short tag like psn:2 for the bar in battle.
A creature keeps its effects in a list effects. Every turn tickEffects walks the list: the effect acts (onTurn), then loses one turn, and when the turns run out it is removed and says onExpire. The program puts poison for 3 turns with power 2 and regeneration for 2 turns with power 1 on the same Iva:
const hero = new Creature("Iva", 20, stats(10, 4, 10));const log = new Log();applyEffect(hero, makeEffect("poison", 3, 2), log);applyEffect(hero, makeEffect("regen", 2, 1), log);for (let turn = 1; turn <= 3; turn++) { console.log("Turn " + turn); tickEffects(hero, log); console.log(status(hero));}Poison seeps into Iva.Iva begins to recover.Turn 1Poison burns Iva (2).Iva recovers (+1).Life 19/20 [psn:2 rgn:1]Turn 2Poison burns Iva (2).Iva recovers (+1).Regeneration wears off Iva.Life 18/20 [psn:1]Turn 3Poison burns Iva (2).Poison wears off Iva.Life 16/20Poison acts three times (turns 1, 2 and 3) and expires on the third. Regeneration acts twice and expires on the second. The two effects run at the same time, each with its own turns. This is why turns = 3 means "three actions", not "until the third turn is over".
ctx is the log: it has say(text) and addBlood(amount). In battle it is the battle log, on the map it is the game itself. So the same effect works in both places, and the log decides where the text goes. The two classes and the creature itself are in the block below.
More precisely, if you are curious
The log, the creature and the texts. The texts of the effects are data, not code: a key and a template, as in the canon (t('fx.on.' + this.id, …)). This way the same engine writes in Bulgarian, English, German and Spanish.
class Log { constructor() { this.blood = 0; }
say(text) { console.log(text); }
addBlood(amount) { this.blood += amount; }}
// The quiet log does not write, but it counts the blood.class Quiet { constructor() { this.blood = 0; }
say(text) {}
addBlood(amount) { this.blood += amount; }}class Creature { constructor(name, maxHp, base) { this.name = name; this.maxHp = maxHp; this.hp = maxHp; this.base = base; this.effects = []; }
isAlive() { return this.hp > 0; }
stat(stat) { let value = this.base[stat]; for (const effect of this.effects) value += effect.modifier(stat); return value < 0 ? 0 : value; }
// Under 25% life the wound is heavy: strikes become less accurate. isHeavilyWounded() { return this.hp * 4 < this.maxHp; }
accuracy() { return 75 + this.stat("agility") * 2 - (this.isHeavilyWounded() ? 15 : 0); }
heal(amount) { const before = this.hp; this.hp = Math.min(this.maxHp, this.hp + amount); return this.hp - before; }
findEffect(id) { for (const effect of this.effects) { if (effect.id === id) return effect; } return null; }
removeEffect(id) { const kept = []; for (const effect of this.effects) { if (effect.id !== id) kept.push(effect); } this.effects = kept; }}
function stats(strength, defense, agility) { return { strength: strength, defense: defense, agility: agility };}// The texts are data: a key and a template. {name} is the name, {n} is the number.const TEXT = { "fx.on.bleed": "{name} is bleeding.", "fx.on.poison": "Poison seeps into {name}.", "fx.on.burn": "{name} catches fire.", "fx.on.stun": "{name} is knocked off balance.", "fx.on.regen": "{name} begins to recover.", "fx.on.rage": "{name} flies into a rage!", "fx.on.shield": "A shield guards {name}.", "fx.more.bleed": "{name} bleeds harder (wounds: {n}).", "fx.tick.bleed": "{name} loses blood ({n}).", "fx.tick.poison": "Poison burns {name} ({n}).", "fx.tick.burn": "Flames bite {name} ({n}).", "fx.tick.regen": "{name} recovers (+{n}).", "fx.off.bleed": "The wounds of {name} stop bleeding.", "fx.off.poison": "Poison wears off {name}.", "fx.off.burn": "Burning wears off {name}.", "fx.off.regen": "Regeneration wears off {name}.", "fx.off.rage": "Rage wears off {name}.", "fx.off.shield": "Shield wears off {name}.", "fxs.bleed": "bld", "fxs.poison": "psn", "fxs.burn": "fire", "fxs.stun": "stun", "fxs.regen": "rgn", "fxs.rage": "rage", "fxs.shield": "shld", "combat.wounded": "{name} is badly wounded!",};
function t(key, name = "", n = 0) { return TEXT[key].replace("{name}", name).replace("{n}", String(n));}Quiet is a log that stays silent but counts the blood. In the game it is for enemies off screen: they bleed and die without writing to the player. The creature and its stats are from the lesson on creatures; here we need stat, heal, findEffect and removeEffect.
In the game it is the same. The only difference is in the texts: the canon has one common template The effect of {effect} on {name} wears off and takes the name of the effect from the table, while our table has one line per effect.
// #region effects// An effect has three moments: when applied (onApply), every turn (onTurn) and when it expires (onExpire),// and a rule for a repeat: refresh (refresh), add up (stack) or skip it (ignore).// ctx is the battle log or the map: both have say(text) and addBlood(amount).class Effect { constructor(id, turns, power) { this.id = id; this.turns = turns; this.power = power; this.stacks = 1; }
stackRule() { return 'refresh'; }
maxStacks() { return 1; }
modifier(stat) { return 0; }
onApply(target, ctx) { ctx.say(t('fx.on.' + this.id, { name: target.name })); }
onTurn(target, ctx) {}
onExpire(target, ctx) { ctx.say(t('fx.off', { name: target.name, effect: t('fx.' + this.id) })); }
label() { const name = t('fxs.' + this.id); return this.stacks > 1 ? name + 'x' + this.stacks + ':' + this.turns : name + ':' + this.turns; }}class PoisonEffect extends Effect { onTurn(target, ctx) { ctx.say(t('fx.tick.poison', { name: target.name, damage: this.power })); hurt(ctx, target, this.power); }}The idea
What happens when an effect is applied a second time
The wolf is already poisoned and gets hit with more poison. Or it is already bleeding and is scratched again. Or it is already stunned and somebody hits it with a club. The three cases are not the same, so every effect carries a repeat rule:
- refresh (
refresh, the default): the longer of the two durations and the greater power stay. Poison, burning, regeneration, rage and shield work like this; - stack (
stack): one more layer is added, up tomaxStacks(). Bleeding works like this; - ignore (
ignore): applying it again does nothing. Stun works like this: otherwise three enemies with clubs would lock the hero forever.
The rule is read from the effect that is already on the creature (existing) and applied by applyEffect. The function returns true if something changed:
// Applies an effect by its rule; returns whether anything changed.function applyEffect(target, effect, ctx) { if (!target.isAlive()) return false; const existing = target.findEffect(effect.id); if (existing === null) { target.effects.push(effect); effect.onApply(target, ctx); return true; } const rule = existing.stackRule(); if (rule === "ignore") return false; if (rule === "stack") { existing.stacks = Math.min(existing.maxStacks(), existing.stacks + effect.stacks); ctx.say(t("fx.more." + effect.id, target.name, existing.stacks)); } existing.turns = Math.max(existing.turns, effect.turns); existing.power = Math.max(existing.power, effect.power); return true;}
// Every effect acts, then loses a turn; expired ones are removed.function tickEffects(creature, ctx) { for (const effect of creature.effects.slice()) { if (!creature.isAlive()) return; effect.onTurn(creature, ctx); effect.turns -= 1; if (effect.turns <= 0) { creature.removeEffect(effect.id); effect.onExpire(creature, ctx); } }}The program puts six effects on a goblin and for each prints whether anything changed and what the goblin has afterwards:
const goblin = new Creature("Goblin", 40, stats(8, 2, 6));const log = new Log();const incoming = [ makeEffect("poison", 3, 2), makeEffect("poison", 5, 1), makeEffect("bleed", 3, 1), makeEffect("bleed", 3, 1), makeEffect("stun", 1, 0), makeEffect("stun", 2, 0),];for (const effect of incoming) { const label = effect.label(); const changed = applyEffect(goblin, effect, log); console.log(label + " -> " + (changed ? "yes" : "no") + " " + status(goblin));}Poison seeps into Goblin.psn:3 -> yes Life 40/40 [psn:3]psn:5 -> yes Life 40/40 [psn:5]Goblin is bleeding.bld:3 -> yes Life 40/40 [psn:5 bld:3]Goblin bleeds harder (wounds: 2).bld:3 -> yes Life 40/40 [psn:5 bldx2:3]Goblin is knocked off balance.stun:1 -> yes Life 40/40 [psn:5 bldx2:3 stun:1]stun:2 -> no Life 40/40 [psn:5 bldx2:3 stun:1]The second poison extends the first (psn:3 becomes psn:5), with no new message. The second bleed is a new wound: bldx2:3. The second stun returns no and nothing changes.
// Applies an effect by its rule; returns whether anything changed.
function applyEffect(target, effect, ctx) {
if (!target.isAlive()) return false;
const existing = target.findEffect(effect.id);
if (existing === null) {
target.effects.push(effect);
effect.onApply(target, ctx);
return true;
}
const rule = existing.stackRule();
if (rule === "ignore") return false;
if (rule === "stack") {
existing.stacks = Math.min(, existing.stacks + effect.stacks);
ctx.say(t("fx.more." + effect.id, target.name, existing.stacks));
}
existing.turns = Math.max(existing.turns, effect.turns);
existing.power = Math.max(existing.power, effect.power);
return true;
}Wounds do not grow without limit. The effect itself says the maximum number of layers with maxStacks(): for bleeding it is 5, for all the others it is 1. So the new count is the smaller of maxStacks() and stacks + effect.stacks.
In the game the function has two more lines at the top. An effect is not applied to a dead creature, and some creatures are immune (the bone skeleton has no blood to bleed). The second line protects against endless stunning: after a stun the next turn is guaranteed.
// Applies an effect by its rule; returns whether anything changed.function applyEffect(target, effect, ctx) { if (!target.isAlive() || target.isImmune(effect.id)) return false; if (effect.id === 'stun' && target.stunProof) return false; const existing = target.findEffect(effect.id); if (existing === null) { target.effects.push(effect); effect.onApply(target, ctx); return true; } const rule = existing.stackRule(); if (rule === 'ignore') return false; if (rule === 'stack') { existing.stacks = Math.min(existing.maxStacks(), existing.stacks + effect.stacks); ctx.say(t('fx.more.' + effect.id, { name: target.name, stacks: existing.stacks })); } existing.turns = Math.max(existing.turns, effect.turns); existing.power = Math.max(existing.power, effect.power); return true;}// The effects' turn: each one acts, then loses a turn; expired ones are removed.function tickEffects(creature, ctx) { for (const effect of creature.effects.slice()) { if (!creature.isAlive()) return; effect.onTurn(creature, ctx); effect.turns -= 1; if (effect.turns <= 0) { creature.removeEffect(effect.id); effect.onExpire(creature, ctx); } }}More precisely, if you are curious
The rule is a method of the effect, not a field: a new effect with stackRule() { return "stack" } and maxStacks() { return 3 } is the whole difference. This idea is called polymorphism: applyEffect does not know what the effect is, it asks it. The alternative is a switch (effect.id) in applyEffect, which grows with every new effect and keeps the rules away from the effect they belong to.
See it
Bleeding: wounds that stack
Bleeding is the effect the rule stack exists for. Every new cut is one more wound (5 at most), each wound loses 1 life per turn, and the turns go back to 3. The damage of a turn is the number of wounds times the power of one wound:
// Each wound loses 1 life per turn, up to 5 wounds.class BleedEffect extends Effect { constructor(turns, stacks) { super("bleed", turns, 1); this.stacks = stacks; }
stackRule() { return "stack"; }
maxStacks() { return 5; }
onTurn(target, ctx) { const damage = this.stacks * this.power; ctx.say(t("fx.tick.bleed", target.name, damage)); hurt(ctx, target, damage); ctx.addBlood(damage); }
onExpire(target, ctx) { ctx.say(t("fx.off.bleed", target.name)); }}The constructor takes turns and wounds, not power: the power of one wound is always 1. So makeEffect("bleed", 3, 2) means "bleeding for three turns with two wounds": the third number for bleeding is the wounds. This is how an enemy with a "rend" attack puts two wounds at once.
// Each wound loses 1 life per turn, up to 5 wounds.class BleedEffect extends Effect { constructor(turns, stacks) { super("bleed", turns, 1); this.stacks = stacks; }
stackRule() { return "stack"; }
maxStacks() { return 5; }
onTurn(target, ctx) { const damage = this.stacks * this.power; ctx.say(t("fx.tick.bleed", target.name, damage)); hurt(ctx, target, damage); ctx.addBlood(damage); }
onExpire(target, ctx) { ctx.say(t("fx.off.bleed", target.name)); }}
// Applies an effect by its rule; returns whether anything changed.function applyEffect(target, effect, ctx) { if (!target.isAlive()) return false; const existing = target.findEffect(effect.id); if (existing === null) { target.effects.push(effect); effect.onApply(target, ctx); return true; } const rule = existing.stackRule(); if (rule === "ignore") return false; if (rule === "stack") { existing.stacks = Math.min(existing.maxStacks(), existing.stacks + effect.stacks); ctx.say(t("fx.more." + effect.id, target.name, existing.stacks)); } existing.turns = Math.max(existing.turns, effect.turns); existing.power = Math.max(existing.power, effect.power); return true;}
// Every effect acts, then loses a turn; expired ones are removed.function tickEffects(creature, ctx) { for (const effect of creature.effects.slice()) { if (!creature.isAlive()) return; effect.onTurn(creature, ctx); effect.turns -= 1; if (effect.turns <= 0) { creature.removeEffect(effect.id); effect.onExpire(creature, ctx); } }}
// Damage outside a strike: never below zero, and it says when the wound became heavy.function hurt(ctx, target, amount) { const wasHeavy = target.isHeavilyWounded(); target.hp = Math.max(0, target.hp - amount); if (target.isAlive() && !wasHeavy && target.isHeavilyWounded()) ctx.say(t("combat.wounded", target.name));}
for (let turn = 1; turn <= 5; turn++) { console.log("Turn " + turn); if (turn <= 3) applyEffect(wolf, makeEffect("bleed", 3, 1), log); tickEffects(wolf, log); console.log(status(wolf));}Step by step, in words
- The wolf has 15 life. Bleeding is an effect with the rule stack: every new cut is one more wound (up to 5), and the turns go back to 3. Three strikes, five turns of blood.
- Turn 1: the first strike. The wolf is not bleeding yet, so applyEffect adds a new effect (onApply). Then the effect turn takes 1 × 1 = 1: life 14, 2 turns left.
- Turn 2: the second strike. The effect already exists and its rule is stack: the wounds become 2, the turns go back to 3. The damage is 2 × 1 = 2: life 12.
- Turn 3: the third strike, 3 wounds. One wound costs 1 life per turn, three wounds cost 3. Life 9. There are no more strikes, but the blood stays.
- Turn 4: no strike. tickEffects again takes 3 × 1 = 3 and removes one turn: 1 left. Life 6.
- Turn 5: the last blood, 3. Life drops to 3, under 25% of 15: hurt says "heavy wound". The turns run out and the effect is removed (onExpire).
- The same three strikes with the rule refresh (no stacking) would have taken only 5 life; with stack they took 12 of 15. Blood spilled: 12.
The program strikes the wolf on three turns in a row (turns 1–3), and on the other two it only ticks. The wolf has 15 life:
const wolf = new Creature("Wolf", 15, stats(8, 2, 6));const log = new Log();for (let turn = 1; turn <= 5; turn++) { console.log("Turn " + turn); if (turn <= 3) applyEffect(wolf, makeEffect("bleed", 3, 1), log); tickEffects(wolf, log); console.log(status(wolf));}console.log("Blood spilled: " + log.blood);Turn 1Wolf is bleeding.Wolf loses blood (1).Life 14/15 [bld:2]Turn 2Wolf bleeds harder (wounds: 2).Wolf loses blood (2).Life 12/15 [bldx2:2]Turn 3Wolf bleeds harder (wounds: 3).Wolf loses blood (3).Life 9/15 [bldx3:2]Turn 4Wolf loses blood (3).Life 6/15 [bldx3:1]Turn 5Wolf loses blood (3).Wolf is badly wounded!The wounds of Wolf stop bleeding.Life 3/15Blood spilled: 12The damage per turn grows 1, 2, 3 without the strikes being stronger: the wounds add up. On turn 3 there are three wounds and the turns are 3 again, so the bleeding goes on for two more turns at 3 each. In total 1 + 2 + 3 + 3 + 3 = 12. The spilled blood is exactly the life taken (15 − 3 = 12), because addBlood counts the damage of the bleeding. On turn 5 life drops under a quarter and "heavy wound" appears (the last cards).
If bleeding did not stack but only refreshed, the same three strikes would take 1 per turn and 5 life in total (5 turns of blood at 1). With stacking it is 12. A small change in the rule makes bladed weapons dangerous.
Where do the wounds come from in the game? From the combat system: a strike with a bladed weapon puts a wound on a critical hit or in 35% of cases, and an enemy's "rend" puts two at once. The class in the game is this:
// #region bleeding// Bleeding stacks: every new cut is one more wound (up to 5), each one costs 1 life per turn, for three// turns. Spilled blood counts for the stats, and a bleeding creature that moves leaves a trail.class BleedEffect extends Effect { constructor(turns, stacks) { super('bleed', turns, 1); this.stacks = stacks; }
stackRule() { return 'stack'; }
maxStacks() { return 5; }
onTurn(target, ctx) { const damage = this.stacks * this.power; ctx.say(t('fx.tick.bleed', { name: target.name, damage: damage })); hurt(ctx, target, damage); ctx.addBlood(damage); }
onExpire(target, ctx) { ctx.say(t('fx.off.bleed', { name: target.name })); }}More precisely, if you are curious
In the canon, the bleeding of the hero and of the enemies is the same class. This is why the antidote removes poison, bleeding and burning with removeEffect, and the hero's slow recovery (+1 life every third turn) does not work while the hero is bleeding or poisoned: worldTurn asks hasEffect.
Try it
Predict the output
The hero bleeds with one wound. After the first turn the hero gets another cut with two wounds. The effects work with Quiet, so only the life is printed. What does the program print?
const hero = new Creature("Iva", 20, stats(10, 4, 10));const quiet = new Quiet();applyEffect(hero, makeEffect("bleed", 3, 1), quiet);for (let turn = 1; turn <= 4; turn++) { if (turn === 2) applyEffect(hero, makeEffect("bleed", 3, 2), quiet); tickEffects(hero, quiet); console.log("Life " + hero.hp);}Bleeding has the rule stack: after the first turn there is one wound (life 19), then two are added and the wounds become three, and the turns go back to 3. Three turns of 3 follow: 16, 13 and 10. The answer 19, 18, 17, 16 is refreshing (the wound stays one, the turns go back to 3), and 19, 18, 17, 17 is ignore (the second application is thrown away).
Try it
Find the bug
A colleague touched onTurn of bleeding. The program with the wolf prints this, and life drops by 1 per turn although there are three wounds:
// Each wound loses 1 life per turn, up to 5 wounds.class BleedEffect extends Effect { constructor(turns, stacks) { super("bleed", turns, 1); this.stacks = stacks; }
stackRule() { return "stack"; }
maxStacks() { return 5; }
onTurn(target, ctx) { const damage = this.power; ctx.say(t("fx.tick.bleed", target.name, damage)); hurt(ctx, target, damage); ctx.addBlood(damage); }
onExpire(target, ctx) { ctx.say(t("fx.off.bleed", target.name)); }}Turn 1Wolf is bleeding.Wolf loses blood (1).Life 14/15 [bld:2]Turn 2Wolf bleeds harder (wounds: 2).Wolf loses blood (1).Life 13/15 [bldx2:2]Turn 3Wolf bleeds harder (wounds: 3).Wolf loses blood (1).Life 12/15 [bldx3:2]Turn 4Wolf loses blood (1).Life 11/15 [bldx3:1]Turn 5Wolf loses blood (1).The wounds of Wolf stop bleeding.Life 10/15Blood spilled: 5The label says wounds: 3, and "loses blood" stays at 1. Which line computes the damage?
Pick the line with the bug.
The damage is power, not stacks × power. The wounds are counted and the label shows them, but the life does not see them: three wounds take as much as one. The correct calculation is damage = stacks × power. The bug is sneaky: nothing breaks, applyEffect stacks, label() shows the wounds, and the difference shows only in the numbers.
The idea
Effects that change stats
Not every effect acts every turn. Rage gives strength and takes defense while it lasts, and a shield gives defense. They do nothing in onTurn. Instead, the stat of a creature is computed from a base and the effects:
stat = the creature's base + equipment + the changes from effectsEvery effect has a method modifier(stat) that returns how much it adds to a given stat (0 by default). The creature adds up the answers every time a stat is asked for and does not keep the result. So when an effect is removed from the list, the stat comes back by itself:
class RageEffect extends Effect { modifier(stat) { if (stat === "strength") return this.power; if (stat === "defense") return -2; return 0; }}
class ShieldEffect extends Effect { modifier(stat) { return stat === "defense" ? this.power : 0; }}The program gives Iva rage for 3 turns with power 4 and a shield for 2 turns with power 5, and then prints the stats:
const hero = new Creature("Iva", 30, stats(10, 4, 10));const log = new Log();applyEffect(hero, makeEffect("rage", 3, 4), log);applyEffect(hero, makeEffect("shield", 2, 5), log);console.log(statLine(hero));for (let turn = 1; turn <= 3; turn++) { console.log("Turn " + turn); tickEffects(hero, log); console.log(statLine(hero));}Iva flies into a rage!A shield guards Iva.strength 14, defense 7Turn 1strength 14, defense 7Turn 2Shield wears off Iva.strength 14, defense 2Turn 3Rage wears off Iva.strength 10, defense 4Strength 14 is 10 + 4. Defense is 4 − 2 (rage) + 5 (shield) = 7. Once the shield expires, 4 − 2 = 2 remains, and when the rage expires too, everything returns to the base. Nobody "gives back" anything: the stat is computed again every time it is asked for.
Two details. stat does not go below 0: with weakness and no armor defense is 0, not negative. And an effect that stacks and gives a stat has to multiply by stacks in modifier, as bleeding multiplies in onTurn: that is the exercise at the end of the lesson.
The game has the same two effects:
class RageEffect extends Effect { modifier(stat) { if (stat === 'strength') return this.power; if (stat === 'defense') return -2; return 0; }}class ShieldEffect extends Effect { modifier(stat) { return stat === 'defense' ? this.power : 0; }}More precisely, if you are curious
The game has other stat changes too: DodgeEffect (evasion), WeakEffect (weakness from an enemy's cry) and FuryEffect (the Warden's rage, the second phase). All are about ten lines. What is different about FuryEffect is that it lasts 999 turns: to the end of the battle.
The idea
The blood trail
A bleeding wolf leaves a trail on the map. It is part of the game: the quest "The blood trail" leads you along it to the den of the wolf leader.
Who puts the drop? Not the effect. The effect knows how much blood is flowing (ctx.addBlood(damage)), but not where the creature stands. The one who moves the creature knows. So the movement asks hasEffect("bleed") and puts a drop on the cell the creature leaves. The level keeps the drops in a separate layer over the floor (decor) and puts blood only on a clean floor: a corpse and items stay visible.
class Level { constructor(floor) { this.floor = floor; this.decor = new Array(floor.length).fill(" "); }
// Blood goes only on a clean floor. addBlood(x) { if (this.decor[x] === " " && this.floor[x] === ".") this.decor[x] = ","; }
draw(walkerX) { let row = ""; for (let x = 0; x < this.floor.length; x++) { let glyph = this.floor[x]; if (this.decor[x] !== " ") glyph = this.decor[x]; if (x === walkerX) glyph = "w"; row += glyph; } return row; }}The program sends a wounded wolf along a corridor. The wound lasts three turns, so there are three drops, and then the wolf walks clean:
const level = new Level("#...........#");const wolf = new Creature("Wolf", 15, stats(8, 2, 6));const quiet = new Quiet();let x = 1;applyEffect(wolf, makeEffect("bleed", 3, 1), quiet);console.log(level.draw(x));for (let step = 1; step <= 5; step++) { if (wolf.findEffect("bleed") !== null) level.addBlood(x); x++; tickEffects(wolf, quiet); console.log(level.draw(x));}console.log("Blood spilled: " + quiet.blood);#w..........##,w.........##,,w........##,,,w.......##,,,.w......##,,,..w.....#Blood spilled: 3There is a drop exactly on the cells the wolf left while it was bleeding. After the third turn the bleeding expires and the trail stops. The silent Quiet is here on purpose: this calculation needs no messages.
In the game the movement is the same. The enemy leaves blood at every step (moveEnemy) and the hero at stepHero, both with the same condition. Here is the enemy's step and the cell that accepts the blood:
function moveEnemy(game, level, enemy, x, y) { if (enemy.hasEffect('bleed')) level.addBlood(enemy.x, enemy.y); enemy.x = x; enemy.y = y;}// Blood does not cover a corpse: the corpse stays visible.addBlood(x, y) { if (this.decor[y][x] === ' ' && this.isWalkable(x, y)) this.decor[y][x] = ',';}Here is the trail in the game: the drops , lead through the gallery to the den of the wolf.
####.##### #########,### The upper gallery #.# Level 1 of 3 ####,#### ......,...# Around: .......,..# L wolf leader (asle .......,..# w blood wolf (aslee ######@### ..... .....,... ........,L. w.......... ......... #####Iva, barbarian lv1 xp 19/20 gold 12L [############] 48/48 M [########] 4/4The blood trail: Follow the blood to the den.The door creaks open.You see: wolf leader.The idea
The heavy wound
When life drops under a quarter, the wound is heavy: the creature's strikes become less accurate. The rule lives in two small places: isHeavilyWounded and the accuracy.
// 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);}Under 25% is hp × 4 < maxHp (no fractions and no division). Accuracy is 75 plus twice the agility, and with a heavy wound another 15 less. The program poisons Iva, who starts with 14 of 40 life. On the second turn the poison pushes her under the limit:
const hero = new Creature("Iva", 40, stats(10, 4, 10));hero.hp = 14;const log = new Log();applyEffect(hero, makeEffect("poison", 3, 3), log);console.log(report(hero));for (let turn = 1; turn <= 3; turn++) { console.log("Turn " + turn); tickEffects(hero, log); console.log(report(hero));}hero.heal(10);console.log(report(hero));Poison seeps into Iva.Life 14/40 [psn:3] accuracy 95Turn 1Poison burns Iva (3).Life 11/40 [psn:2] accuracy 95Turn 2Poison burns Iva (3).Iva is badly wounded!Life 8/40 [psn:1] accuracy 80Turn 3Poison burns Iva (3).Poison wears off Iva.Life 5/40 accuracy 80Life 15/40 accuracy 95On turn 2 life is 8 and 8 × 4 = 32 is under 40, so accuracy drops from 95 to 80. The message "heavy wound" comes exactly once. This is what hurt does: it asks before the damage (wasHeavy) and says something only when the crossing happens now.
// Damage outside a strike: never below zero, and it says when the wound became heavy.function hurt(ctx, target, amount) { const wasHeavy = target.isHeavilyWounded(); target.hp = Math.max(0, target.hp - amount); if (target.isAlive() && !wasHeavy && target.isHeavilyWounded()) ctx.say(t("combat.wounded", target.name));}On turn 3 life drops further, but the message does not repeat. At the end heal(10) brings life to 15 and accuracy is 95 again. A heavy wound is not an effect in the list: it is computed from life and disappears as soon as life goes up.
isHeavilyWounded() {
return this.hp * < this.maxHp;
}Under 25% is the same as hp × 4 < maxHp. We multiply instead of dividing maxHp / 4 to stay in whole numbers: this way there is no rounding and the result is the same in all four languages.
More precisely, if you are curious
The strength of a hit is said in words according to the share of the target's life it takes. Thresholds: under 10% "scratches", up to 25% "hits", up to 45% "slashes", above that "smashes". This is why bleeding is quiet (small numbers), while the Warden's first hit feels like a hit.
// The word for the strength of a hit depends on how much of the target's life it takes.function damageWord(damage, maxHp) { const share = percent(damage, maxHp); if (share < 10) return t('verb.scratch'); if (share < 25) return t('verb.hit'); if (share < 45) return t('verb.cut'); return t('verb.crush');}More precisely
Who calls tickEffects
Effects have no clock of their own: somebody has to call tickEffects. In battle it is the start of each creature's turn. A stunned creature skips its turn. beginTurn remembers whether there is a stun before tickEffects, because ticking removes it (it lasts one turn).
// The start of a turn: effects act, cooldowns go down. A stunned fighter skips the turn.beginTurn(actor) { actor.energy -= 100; const stunned = actor.hasEffect('stun'); tickEffects(actor, this); actor.tickCooldowns(); this.handleDeaths(); if (!actor.isAlive()) return false; // After a stun the next turn is certain: otherwise three enemies with clubs would lock the hero down. actor.stunProof = stunned; if (stunned) { this.say(t('combat.stunned', { name: actor.name })); return false; } return true;}On the map outside battle the turn is the world (worldTurn): the hero acts and the hero's effects tick after every step, and then the effects of every enemy tick. An enemy off screen is ticked with Quiet: if it bleeds to death, nobody sees it, and no loot drops.
So a turn of effects is the same thing in two places. The effect does not know where it is called from, because everything it needs is in target and ctx. This is why adding a new effect is one class and one line in makeEffect: the combat system and the map are not touched. The next lesson, actions, is about the effects that skills put on others.
Exercise
Cold
Solve this in your editor.
Cold. Add an effect chill. Its rule is stack with at most 3 layers, and each layer takes 2 from agility. Agility is computed through modifier, so it has to be multiplied by the layers. Work on the code from the lesson: Effect, Creature, makeEffect, applyEffect and tickEffects.
Also add the texts of the effect to the table of texts:
fx.on.chill: Cold clings to {name}.fx.more.chill: {name} chills further (layers: {n}).fx.off.chill: Cold wears off {name}.fxs.chill: coldThe program creates Iva (life 30, strength 10, defense 4, agility 10) and prints report (the state and the accuracy, as in the program for the heavy wound). First it prints the state without effects. Then it applies makeEffect("chill", 2, 1) four times and prints the state after each. At the end it makes two turns with tickEffects and prints "Turn" and the state after each. The output is:
Life 30/30 accuracy 95Cold clings to Iva.Life 30/30 [cold:2] accuracy 91Iva chills further (layers: 2).Life 30/30 [coldx2:2] accuracy 87Iva chills further (layers: 3).Life 30/30 [coldx3:2] accuracy 83Iva chills further (layers: 3).Life 30/30 [coldx3:2] accuracy 83Turn 1Life 30/30 [coldx3:1] accuracy 83Turn 2Cold wears off Iva.Life 30/30 accuracy 95Accuracy drops by 4 per layer (2 agility × 2). The fourth application stays at 3 layers, but says "layers: 3" and puts the turns back to 2. After the two turns the cold expires and accuracy is 95 again.
More tasks
- Wall of fire. The effect
burnis already in the code. Make it stack up to 3 times and show how the damage changes. - Immunity. Add
immune(a set ofids) to the creature. What shouldapplyEffectreturn, and what is the difference between immunity and the ruleignore? - Bandage. Make a potion that removes bleeding and puts
regenfor 3 turns with power 2. How much life is given back after three turns, if life is not full?
Test
Test yourself
Related lessons
Section titled "Related lessons"- Builds on: Inheritance · Polymorphism · Arrays: push, splice and sort · Map and Set
- Tasks on this topic: Chronicles of Arcanum