Skip to content
wannacode

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:2
1) 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 flee

Under 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:
def __init__(self, effect_id, turns, power):
self.id = effect_id
self.turns = turns
self.power = power
self.stacks = 1
def stack_rule(self):
return 'refresh'
def max_stacks(self):
return 1
def modifier(self, stat):
return 0
def on_apply(self, target, ctx):
ctx.say(t('fx.on.' + self.id, target.name))
def on_turn(self, target, ctx):
pass
def on_expire(self, target, ctx):
ctx.say(t('fx.off.' + self.id, target.name))
def label(self):
name = t('fxs.' + self.id)
return name + 'x' + str(self.stacks) + ':' + str(self.turns) if self.stacks > 1 else name + ':' + str(self.turns)
class PoisonEffect(Effect):
def on_turn(self, target, ctx):
ctx.say(t('fx.tick.poison', target.name, self.power))
hurt(ctx, target, self.power)
class RegenEffect(Effect):
def on_turn(self, target, ctx):
healed = target.heal(self.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:

hero = Creature('Iva', 20, stats(10, 4, 10))
log = Log()
apply_effect(hero, make_effect('poison', 3, 2), log)
apply_effect(hero, make_effect('regen', 2, 1), log)
for turn in range(1, 4):
print(f"Turn {turn}")
tick_effects(hero, log)
print(status(hero))
Output
Poison seeps into Iva.
Iva begins to recover.
Turn 1
Poison burns Iva (2).
Iva recovers (+1).
Life 19/20 [psn:2 rgn:1]
Turn 2
Poison burns Iva (2).
Iva recovers (+1).
Regeneration wears off Iva.
Life 18/20 [psn:1]
Turn 3
Poison burns Iva (2).
Poison wears off Iva.
Life 16/20

Poison 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:
def __init__(self):
self.blood = 0
def say(self, text):
print(text)
def add_blood(self, amount):
self.blood += amount
# The quiet log does not write, but it counts the blood.
class Quiet:
def __init__(self):
self.blood = 0
def say(self, text):
pass
def add_blood(self, amount):
self.blood += amount
class Creature:
def __init__(self, name, max_hp, base):
self.name = name
self.max_hp = max_hp
self.hp = max_hp
self.base = base
self.effects = []
def is_alive(self):
return self.hp > 0
def stat(self, stat):
value = self.base[stat]
for effect in self.effects:
value += effect.modifier(stat)
return 0 if value < 0 else value
# Under 25% life the wound is heavy: strikes become less accurate.
def is_heavily_wounded(self):
return self.hp * 4 < self.max_hp
def accuracy(self):
return 75 + self.stat('agility') * 2 - (15 if self.is_heavily_wounded() else 0)
def heal(self, amount):
before = self.hp
self.hp = min(self.max_hp, self.hp + amount)
return self.hp - before
def find_effect(self, effect_id):
for effect in self.effects:
if effect.id == effect_id:
return effect
return None
def remove_effect(self, effect_id):
kept = []
for effect in self.effects:
if effect.id != effect_id:
kept.append(effect)
self.effects = kept
def 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.
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!',
}
def t(key, name='', n=0):
return TEXT[key].replace('{name}', name).replace('{n}', str(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 (on_apply), every turn (on_turn) and when it expires (on_expire),
# 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 add_blood(amount).
class Effect(ABC):
def __init__(self, effect_id, turns, power):
self.id = effect_id
self.turns = turns
self.power = power
self.stacks = 1
def stack_rule(self):
return 'refresh'
def max_stacks(self):
return 1
def modifier(self, stat):
return 0
def on_apply(self, target, ctx):
ctx.say(t('fx.on.' + self.id, {'name': target.name}))
def on_turn(self, target, ctx):
pass
def on_expire(self, target, ctx):
ctx.say(t('fx.off', {'name': target.name, 'effect': t('fx.' + self.id)}))
def label(self):
name = t('fxs.' + self.id)
return name + 'x' + str(self.stacks) + ':' + str(self.turns) if self.stacks > 1 else name + ':' + str(self.turns)
class PoisonEffect(Effect):
def on_turn(self, target, ctx):
ctx.say(t('fx.tick.poison', {'name': target.name, 'damage': self.power}))
hurt(ctx, target, self.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 to maxStacks(). 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.
def apply_effect(target, effect, ctx):
if not target.is_alive():
return False
existing = target.find_effect(effect.id)
if existing is None:
target.effects.append(effect)
effect.on_apply(target, ctx)
return True
rule = existing.stack_rule()
if rule == 'ignore':
return False
if rule == 'stack':
existing.stacks = min(existing.max_stacks(), existing.stacks + effect.stacks)
ctx.say(t('fx.more.' + effect.id, target.name, existing.stacks))
existing.turns = max(existing.turns, effect.turns)
existing.power = max(existing.power, effect.power)
return True
# Every effect acts, then loses a turn; expired ones are removed.
def tick_effects(creature, ctx):
for effect in list(creature.effects):
if not creature.is_alive():
return
effect.on_turn(creature, ctx)
effect.turns -= 1
if effect.turns <= 0:
creature.remove_effect(effect.id)
effect.on_expire(creature, ctx)

The program puts six effects on a goblin and for each prints whether anything changed and what the goblin has afterwards:

goblin = Creature('Goblin', 40, stats(8, 2, 6))
log = Log()
incoming = [
make_effect('poison', 3, 2),
make_effect('poison', 5, 1),
make_effect('bleed', 3, 1),
make_effect('bleed', 3, 1),
make_effect('stun', 1, 0),
make_effect('stun', 2, 0),
]
for effect in incoming:
label = effect.label()
changed = apply_effect(goblin, effect, log)
print(f"{label} -> {'yes' if changed else 'no'} {status(goblin)}")
Output
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.
def apply_effect(target, effect, ctx):
    if not target.is_alive():
        return False
    existing = target.find_effect(effect.id)
    if existing is None:
        target.effects.append(effect)
        effect.on_apply(target, ctx)
        return True
    rule = existing.stack_rule()
    if rule == 'ignore':
        return False
    if rule == 'stack':
        existing.stacks = min(, existing.stacks + effect.stacks)
        ctx.say(t('fx.more.' + effect.id, target.name, existing.stacks))
    existing.turns = max(existing.turns, effect.turns)
    existing.power = max(existing.power, effect.power)
    return True

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.
def apply_effect(target, effect, ctx):
if not target.is_alive() or target.is_immune(effect.id):
return False
if effect.id == 'stun' and target.stun_proof:
return False
existing = target.find_effect(effect.id)
if existing is None:
target.effects.append(effect)
effect.on_apply(target, ctx)
return True
rule = existing.stack_rule()
if rule == 'ignore':
return False
if rule == 'stack':
existing.stacks = min(existing.max_stacks(), existing.stacks + effect.stacks)
ctx.say(t('fx.more.' + effect.id, {'name': target.name, 'stacks': existing.stacks}))
existing.turns = max(existing.turns, effect.turns)
existing.power = max(existing.power, effect.power)
return True
# The effects' turn: each one acts, then loses a turn; expired ones are removed.
def tick_effects(creature, ctx):
for effect in list(creature.effects):
if not creature.is_alive():
return
effect.on_turn(creature, ctx)
effect.turns -= 1
if effect.turns <= 0:
creature.remove_effect(effect.id)
effect.on_expire(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(Effect):
def __init__(self, turns, stacks):
super().__init__('bleed', turns, 1)
self.stacks = stacks
def stack_rule(self):
return 'stack'
def max_stacks(self):
return 5
def on_turn(self, target, ctx):
damage = self.stacks * self.power
ctx.say(t('fx.tick.bleed', target.name, damage))
hurt(ctx, target, damage)
ctx.add_blood(damage)
def on_expire(self, 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(Effect):
def __init__(self, turns, stacks):
super().__init__('bleed', turns, 1)
self.stacks = stacks
def stack_rule(self):
return 'stack'
def max_stacks(self):
return 5
def on_turn(self, target, ctx):
damage = self.stacks * self.power
ctx.say(t('fx.tick.bleed', target.name, damage))
hurt(ctx, target, damage)
ctx.add_blood(damage)
def on_expire(self, target, ctx):
ctx.say(t('fx.off.bleed', target.name))
# Applies an effect by its rule; returns whether anything changed.
def apply_effect(target, effect, ctx):
if not target.is_alive():
return False
existing = target.find_effect(effect.id)
if existing is None:
target.effects.append(effect)
effect.on_apply(target, ctx)
return True
rule = existing.stack_rule()
if rule == 'ignore':
return False
if rule == 'stack':
existing.stacks = min(existing.max_stacks(), existing.stacks + effect.stacks)
ctx.say(t('fx.more.' + effect.id, target.name, existing.stacks))
existing.turns = max(existing.turns, effect.turns)
existing.power = max(existing.power, effect.power)
return True
# Every effect acts, then loses a turn; expired ones are removed.
def tick_effects(creature, ctx):
for effect in list(creature.effects):
if not creature.is_alive():
return
effect.on_turn(creature, ctx)
effect.turns -= 1
if effect.turns <= 0:
creature.remove_effect(effect.id)
effect.on_expire(creature, ctx)
# Damage outside a strike: never below zero, and it says when the wound became heavy.
def hurt(ctx, target, amount):
was_heavy = target.is_heavily_wounded()
target.hp = max(0, target.hp - amount)
if target.is_alive() and not was_heavy and target.is_heavily_wounded():
ctx.say(t('combat.wounded', target.name))
for turn in range(1, 6):
print(f"Turn {turn}")
if turn <= 3:
apply_effect(wolf, make_effect('bleed', 3, 1), log)
tick_effects(wolf, log)
print(status(wolf))
Step by step, in words
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Turn 4: no strike. tickEffects again takes 3 × 1 = 3 and removes one turn: 1 left. Life 6.
  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).
  7. 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:

wolf = Creature('Wolf', 15, stats(8, 2, 6))
log = Log()
for turn in range(1, 6):
print(f"Turn {turn}")
if turn <= 3:
apply_effect(wolf, make_effect('bleed', 3, 1), log)
tick_effects(wolf, log)
print(status(wolf))
print(f"Blood spilled: {log.blood}")
Output
Turn 1
Wolf is bleeding.
Wolf loses blood (1).
Life 14/15 [bld:2]
Turn 2
Wolf bleeds harder (wounds: 2).
Wolf loses blood (2).
Life 12/15 [bldx2:2]
Turn 3
Wolf bleeds harder (wounds: 3).
Wolf loses blood (3).
Life 9/15 [bldx3:2]
Turn 4
Wolf loses blood (3).
Life 6/15 [bldx3:1]
Turn 5
Wolf loses blood (3).
Wolf is badly wounded!
The wounds of Wolf stop bleeding.
Life 3/15
Blood spilled: 12

The 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(Effect):
def __init__(self, turns, stacks):
super().__init__('bleed', turns, 1)
self.stacks = stacks
def stack_rule(self):
return 'stack'
def max_stacks(self):
return 5
def on_turn(self, target, ctx):
damage = self.stacks * self.power
ctx.say(t('fx.tick.bleed', {'name': target.name, 'damage': damage}))
hurt(ctx, target, damage)
ctx.add_blood(damage)
def on_expire(self, 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?

hero = Creature('Iva', 20, stats(10, 4, 10))
quiet = Quiet()
apply_effect(hero, make_effect('bleed', 3, 1), quiet)
for turn in range(1, 5):
if turn == 2:
apply_effect(hero, make_effect('bleed', 3, 2), quiet)
tick_effects(hero, quiet)
print(f"Life {hero.hp}")
Answer

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(Effect):
def __init__(self, turns, stacks):
super().__init__('bleed', turns, 1)
self.stacks = stacks
def stack_rule(self):
return 'stack'
def max_stacks(self):
return 5
def on_turn(self, target, ctx):
damage = self.power
ctx.say(t('fx.tick.bleed', target.name, damage))
hurt(ctx, target, damage)
ctx.add_blood(damage)
def on_expire(self, target, ctx):
ctx.say(t('fx.off.bleed', target.name))
Output
Turn 1
Wolf is bleeding.
Wolf loses blood (1).
Life 14/15 [bld:2]
Turn 2
Wolf bleeds harder (wounds: 2).
Wolf loses blood (1).
Life 13/15 [bldx2:2]
Turn 3
Wolf bleeds harder (wounds: 3).
Wolf loses blood (1).
Life 12/15 [bldx3:2]
Turn 4
Wolf loses blood (1).
Life 11/15 [bldx3:1]
Turn 5
Wolf loses blood (1).
The wounds of Wolf stop bleeding.
Life 10/15
Blood spilled: 5

The label says wounds: 3, and "loses blood" stays at 1. Which line computes the damage?

Pick the line with the bug.

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 effects

Every 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(Effect):
def modifier(self, stat):
if stat == 'strength':
return self.power
if stat == 'defense':
return -2
return 0
class ShieldEffect(Effect):
def modifier(self, stat):
return self.power if stat == 'defense' else 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:

hero = Creature('Iva', 30, stats(10, 4, 10))
log = Log()
apply_effect(hero, make_effect('rage', 3, 4), log)
apply_effect(hero, make_effect('shield', 2, 5), log)
print(stat_line(hero))
for turn in range(1, 4):
print(f"Turn {turn}")
tick_effects(hero, log)
print(stat_line(hero))
Output
Iva flies into a rage!
A shield guards Iva.
strength 14, defense 7
Turn 1
strength 14, defense 7
Turn 2
Shield wears off Iva.
strength 14, defense 2
Turn 3
Rage wears off Iva.
strength 10, defense 4

Strength 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(Effect):
def modifier(self, stat):
if stat == 'strength':
return self.power
if stat == 'defense':
return -2
return 0
class ShieldEffect(Effect):
def modifier(self, stat):
return self.power if stat == 'defense' else 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:
def __init__(self, floor):
self.floor = floor
self.decor = [' '] * len(floor)
# Blood goes only on a clean floor.
def add_blood(self, x):
if self.decor[x] == ' ' and self.floor[x] == '.':
self.decor[x] = ','
def draw(self, walker_x):
row = ''
for x in range(len(self.floor)):
glyph = self.floor[x]
if self.decor[x] != ' ':
glyph = self.decor[x]
if x == walker_x:
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:

level = Level('#...........#')
wolf = Creature('Wolf', 15, stats(8, 2, 6))
quiet = Quiet()
x = 1
apply_effect(wolf, make_effect('bleed', 3, 1), quiet)
print(level.draw(x))
for step in range(1, 6):
if wolf.find_effect('bleed') is not None:
level.add_blood(x)
x += 1
tick_effects(wolf, quiet)
print(level.draw(x))
print(f"Blood spilled: {quiet.blood}")
Output
#w..........#
#,w.........#
#,,w........#
#,,,w.......#
#,,,.w......#
#,,,..w.....#
Blood spilled: 3

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

def move_enemy(game, level, enemy, x, y):
if enemy.has_effect('bleed'):
level.add_blood(enemy.x, enemy.y)
enemy.x = x
enemy.y = y
# Blood does not cover a corpse: the corpse stays visible.
def add_blood(self, x, y):
if self.decor[y][x] == ' ' and self.is_walkable(x, y):
self.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 12
L [############] 48/48 M [########] 4/4
The 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.
def is_heavily_wounded(self):
return self.hp * 4 < self.max_hp()
def accuracy(self):
return 75 + self.stat('agility') * 2 + self.raw_stat('accuracy') - (15 if self.is_heavily_wounded() else 0)

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:

hero = Creature('Iva', 40, stats(10, 4, 10))
hero.hp = 14
log = Log()
apply_effect(hero, make_effect('poison', 3, 3), log)
print(report(hero))
for turn in range(1, 4):
print(f"Turn {turn}")
tick_effects(hero, log)
print(report(hero))
hero.heal(10)
print(report(hero))
Output
Poison seeps into Iva.
Life 14/40 [psn:3] accuracy 95
Turn 1
Poison burns Iva (3).
Life 11/40 [psn:2] accuracy 95
Turn 2
Poison burns Iva (3).
Iva is badly wounded!
Life 8/40 [psn:1] accuracy 80
Turn 3
Poison burns Iva (3).
Poison wears off Iva.
Life 5/40 accuracy 80
Life 15/40 accuracy 95

On 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.
def hurt(ctx, target, amount):
was_heavy = target.is_heavily_wounded()
target.hp = max(0, target.hp - amount)
if target.is_alive() and not was_heavy and target.is_heavily_wounded():
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.

def is_heavily_wounded(self):
    return self.hp *  < self.max_hp
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.
def damage_word(damage, max_hp):
share = percent(damage, max_hp)
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.
def begin_turn(self, actor):
actor.energy -= 100
stunned = actor.has_effect('stun')
tick_effects(actor, self)
actor.tick_cooldowns()
self.handle_deaths()
if not actor.is_alive():
return False
# After a stun the next turn is certain: otherwise three enemies with clubs would lock the hero down.
actor.stun_proof = stunned
if stunned:
self.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: cold

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

Output
Life 30/30 accuracy 95
Cold clings to Iva.
Life 30/30 [cold:2] accuracy 91
Iva chills further (layers: 2).
Life 30/30 [coldx2:2] accuracy 87
Iva chills further (layers: 3).
Life 30/30 [coldx3:2] accuracy 83
Iva chills further (layers: 3).
Life 30/30 [coldx3:2] accuracy 83
Turn 1
Life 30/30 [coldx3:1] accuracy 83
Turn 2
Cold wears off Iva.
Life 30/30 accuracy 95

Accuracy 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 burn is already in the code. Make it stack up to 3 times and show how the damage changes.
  • Immunity. Add immune (a set of ids) to the creature. What should applyEffect return, and what is the difference between immunity and the rule ignore?
  • Bandage. Make a potion that removes bleeding and puts regen for 3 turns with power 2. How much life is given back after three turns, if life is not full?

Test

Test yourself

1. The wolf has 3 wounds and is bleeding. How much life does it lose per turn, and why?
2. The hero is stunned and a second enemy stuns the hero again. What does the rule ignore do?
3. An effect with turns = 3 is applied on turn 1 and nothing refreshes it. How many times does onTurn run?
4. Agility is 10 and life is 9 of 40. What is the accuracy?
5. Who puts the drop of blood on the floor: the bleeding effect or the creature that walks?

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