Learning Goals
3 minBy the end of this lesson you can:
- Design five sibling classes that share a parent and differ only in one method.
- Use polymorphic dispatch — same call site, different behaviour per instance.
- Compose status effects (burn, poison, freeze) as attributes the parent tracks.
- Write a battle loop that works for any monster combo, present or future.
Warm-Up · From Yesterday's Shapes
5 minYesterday a list of shapes shared .area(). Today a list of monsters shares .attack(hero). The pattern transfers exactly:
# yesterday for s in shapes: print(s.area()) # today for m in encounters: m.attack(hero)
The loop doesn't care which monster. Each one decides what attacking means for itself.
Polymorphism turns "dozens of if isinstance(...) branches" into "one method call". Every monster type defines its own attack; the orchestrator just runs them.
New Concept · Status Effects on Self
14 minThe shared parent
import random class Monster: def __init__(self, name, hp, base_atk, loot=None): self.name = name self.hp = hp self.max_hp = hp self.base_atk = base_atk self.loot = loot def is_alive(self): return self.hp > 0 def take_damage(self, n): self.hp = max(0, self.hp - n) def attack(self, victim): raise NotImplementedError("Each Monster needs its own attack") def status(self): return f"{self.name:<12} HP {self.hp}/{self.max_hp}"
Notice the explicit NotImplementedError on attack. The parent says "every child must define this" — anyone forgetting gets a clear crash, not a silent skip.
Hero side · status effects
The hero needs a few new attributes for monsters to mess with:
class Hero: def __init__(self, name): self.name = name self.hp = 100 self.max_hp = 100 self.burn_turns = 0 self.poison_turns= 0 self.frozen = False # skip next turn if True def is_alive(self): return self.hp > 0 def take_damage(self, n): self.hp = max(0, self.hp - n) def tick_effects(self): """Called once per turn — apply DOT damage, count down.""" if self.burn_turns > 0: self.take_damage(5) print(f" 🔥 {self.name} burns for 5. ({self.burn_turns - 1} turn(s) left)") self.burn_turns -= 1 if self.poison_turns > 0: self.take_damage(3) print(f" ☠ {self.name} suffers poison for 3. ({self.poison_turns - 1} left)") self.poison_turns -= 1
Each status effect is just a counter. The hero ticks them at the start of each turn.
Five monster children
class FireDragon(Monster): def __init__(self): super().__init__("Fire Dragon", hp=120, base_atk=18, loot="dragon scale") def attack(self, victim): dmg = self.base_atk + random.randint(-3, 3) print(f" 🔥 {self.name} breathes fire on {victim.name}! {dmg} dmg.") victim.take_damage(dmg) victim.burn_turns = 3 # set, not increment — a fresh burn class PoisonSnake(Monster): def __init__(self): super().__init__("Snake", hp=40, base_atk=6, loot="venom sac") def attack(self, victim): dmg = self.base_atk print(f" ☠ {self.name} bites {victim.name}! {dmg} dmg + poison.") victim.take_damage(dmg) victim.poison_turns = max(victim.poison_turns, 4) class IceWraith(Monster): def __init__(self): super().__init__("Ice Wraith", hp=60, base_atk=10, loot="frost shard") def attack(self, victim): if random.random() < 0.4: print(f" ❄ {self.name} freezes {victim.name} — they'll miss next turn!") victim.frozen = True dmg = self.base_atk victim.take_damage(dmg) print(f" ❄ {self.name} chills for {dmg} dmg.") class GoblinPack(Monster): """Hits N times, but each hit is weak.""" def __init__(self): super().__init__("Goblin Pack", hp=50, base_atk=4, loot="gold pile") def attack(self, victim): hits = random.randint(2, 4) for i in range(hits): if not victim.is_alive(): break dmg = self.base_atk + random.randint(-1, 1) print(f" 🗡 Goblin #{i+1} stabs for {dmg}.") victim.take_damage(dmg) class Vampire(Monster): """Steals HP — the only monster that heals itself.""" def __init__(self): super().__init__("Vampire", hp=80, base_atk=12, loot="bloodstone") def attack(self, victim): dmg = self.base_atk + random.randint(-2, 2) print(f" 🧛 {self.name} drains {dmg} from {victim.name}!") victim.take_damage(dmg) self.hp = min(self.max_hp, self.hp + dmg // 2) print(f" ({self.name} heals to {self.hp}/{self.max_hp})")
Each child is 5–10 lines. None of them touches the parent's base behaviour beyond calling victim.take_damage. The status-effect mutations live on the victim — which is what polymorphism enables.
Worked Example · The Battle Loop
12 minSave the full file as monsters_poly.py. The battle loop knows nothing about fire, ice, poison or vampires:
def battle(hero, monster): print(f"\n=== {hero.name} vs {monster.name} ===") turn = 1 while hero.is_alive() and monster.is_alive(): print(f"-- Turn {turn} --") hero.tick_effects() if not hero.is_alive(): break if hero.frozen: print(f" ❄ {hero.name} is frozen and can't attack!") hero.frozen = False else: dmg = 15 + random.randint(-3, 3) print(f" ⚔ {hero.name} attacks for {dmg}.") monster.take_damage(dmg) if not monster.is_alive(): break monster.attack(hero) turn += 1 print(f" → {hero.status()} vs {monster.status()}") winner = hero if hero.is_alive() else monster print(f"\n🏆 {winner.name} wins!") hero = Hero("Aisyah") encounters = [GoblinPack(), PoisonSnake(), IceWraith(), Vampire(), FireDragon()] for monster in encounters: if not hero.is_alive(): print("\n💀 Game over.") break battle(hero, monster) # rest a little between fights hero.hp = min(hero.max_hp, hero.hp + 20) print(f"\nFinal: {hero.status()}")
Read the diff
One battle function. Five wildly different monsters fitting into it without a single if. The hero's burn_turns, poison_turns and frozen get touched by whichever monster has the relevant attack — but the battle code doesn't mention any of them by class. That's polymorphism doing its job: add a new monster, the battle code doesn't change.
Notice how monsters write to the hero's attributes (victim.burn_turns = 3). That works because the hero is just a plain object with public attributes. We could tighten this with _burn_turns + an apply_burn(turns) method — encapsulation from PY-L3-10. Try it as a stretch.
Try It Yourself
13 minAdd a sixth monster: Thunderbird (HP 70, atk 14). Its attack does normal damage + 5 bonus damage to a frozen victim (mention it does extra to anyone burning too — Lightning grounds well).
Hint
class Thunderbird(Monster): def __init__(self): super().__init__("Thunderbird", hp=70, base_atk=14, loot="lightning feather") def attack(self, victim): dmg = self.base_atk if victim.frozen or victim.burn_turns > 0: dmg += 5 print(" ⚡ extra damage on a status-afflicted target!") print(f" ⚡ {self.name} strikes for {dmg}.") victim.take_damage(dmg)
Slots in without touching the battle loop. Polymorphism still earning its keep.
Make a Summoner that randomly picks one of the other monster classes and calls its attack on the hero. Pass the class list at construction.
Hint
class Summoner(Monster): def __init__(self, minion_classes): super().__init__("Summoner", hp=60, base_atk=0, loot="ancient tome") self.minion_classes = minion_classes def attack(self, victim): minion_cls = random.choice(self.minion_classes) minion = minion_cls() print(f" 🌀 Summoner conjures a {minion.name}!") minion.attack(victim) s = Summoner([PoisonSnake, IceWraith, FireDragon]) s.attack(hero)
Polymorphic dispatch in two layers — the battle loop calls summoner.attack, the summoner internally calls minion.attack. Same shape, nested.
Refactor Hero so monsters can't reach in and write victim.burn_turns = 3 directly. Provide methods like apply_burn(turns), apply_poison(turns), freeze(). Update each monster to call those instead.
Hint
class Hero: def __init__(self, name): # ... same ... self._burn = 0 self._poison = 0 self._frozen = False def apply_burn(self, turns): self._burn = max(self._burn, turns) def apply_poison(self, turns): self._poison = max(self._poison, turns) def freeze(self): self._frozen = True def is_frozen_and_clear(self): if self._frozen: self._frozen = False return True return False # in a monster: victim.apply_burn(3) # cleaner — and Hero enforces stacking rules
The hero now controls its own status effects. Monsters request changes; the hero decides what happens.
Mini-Challenge · Monster Index
8 minBuild monster_index.py. Run 100 simulated battles per monster type against a fresh full-HP Hero. Print a table:
Monster Win rate Avg turns Avg HP loss Fire Dragon 72% 6.3 54 Snake 14% 12.1 33 ...
Show one possible solution
# monster_index.py — simulate battles to rank monsters import random # (paste Monster, Hero, all five children, battle from monsters_poly.py) def simulate(monster_cls, n=100): wins = 0 turn_total = 0 hp_loss_total = 0 for _ in range(n): hero = Hero("Aisyah") monster = monster_cls() turns = 0 while hero.is_alive() and monster.is_alive(): turns += 1 hero.tick_effects() if hero.is_alive(): if not hero.frozen: monster.take_damage(15 + random.randint(-3, 3)) else: hero.frozen = False if monster.is_alive(): monster.attack(hero) if not hero.is_alive(): wins += 1 turn_total += turns hp_loss_total += hero.max_hp - hero.hp return { "name": monster_cls().name, "win_rate": wins / n * 100, "avg_turns": turn_total / n, "avg_loss": hp_loss_total / n, } # Silence the prints temporarily import io, contextlib results = [] with contextlib.redirect_stdout(io.StringIO()): for cls in [FireDragon, PoisonSnake, IceWraith, GoblinPack, Vampire]: results.append(simulate(cls)) print(f"{'Monster':<14}{'Win%':>7}{'Turns':>8}{'HP loss':>10}") print("-" * 39) for r in results: print(f"{r['name']:<14}{r['win_rate']:>7.0f}{r['avg_turns']:>8.1f}{r['avg_loss']:>10.1f}")
Non-negotiables: a parameterised simulate function, 100 runs each, and a balance table. contextlib.redirect_stdout is a clean way to silence prints during a simulation — drop this idea in your back pocket.
Recap
3 minFive monsters, five totally different attack methods, one battle loop. Status effects (burn, poison, freeze) live on the victim as attribute counters; monsters set them, the hero ticks them down. The battle code never asks "what kind of monster?" — it calls monster.attack(hero) and the right thing happens. Add a sixth monster and not one orchestrator line changes. That's the power of polymorphism, applied.
Vocabulary Card
- polymorphic dispatch
- Python automatically picks the right method based on the instance's class — no
ifneeded. - status effect
- State on a character that ticks down each turn. Burn, poison, freeze, buff, debuff.
- orchestrator
- A function (like
battle) that drives interactions by calling polymorphic methods on instances. - composition over inheritance
- Status effects are attributes on Hero rather than subclasses of Hero. Bigger systems prefer composition.
Homework
4 minBuild a Healer ally class. Same parent as Monster but a friendly variant — attack takes the hero as the parameter and heals them by 10-20 HP. Plug it into the battle loop with the hero on a five-monster gauntlet — every other encounter is a Healer instead of a monster. Confirm the hero survives more easily.
Stretch. Generalise: make Healer a child of a new base class Encounter; both Monster and Healer inherit. The battle loop becomes encounter.act(hero).
Sample · Healer + Encounter base
class Encounter: def __init__(self, name): self.name = name def act(self, hero): raise NotImplementedError class Healer(Encounter): def __init__(self): super().__init__("Wandering Healer") def act(self, hero): heal = random.randint(10, 20) hero.hp = min(hero.max_hp, hero.hp + heal) print(f" 💖 {self.name} heals {hero.name} for {heal}. ({hero.hp}/{hero.max_hp})") # Now both monsters and healers go in the same list: encounters = [GoblinPack(), Healer(), PoisonSnake(), Healer(), FireDragon()] for e in encounters: if isinstance(e, Healer): e.act(hero) else: battle(hero, e)
Non-negotiables: a common Encounter parent, a Healer that lives in the same list as monsters, and the battle loop handling both. The Stretch turns the isinstance into pure polymorphism — every Encounter has an act method that DTRT.