Learning Goals
3 minBy the end of this lesson you can:
- Use a generator as the "source of enemies" in a game loop.
- Make difficulty scale by including the wave number in each yield.
- Compose a randomised monster generator with a difficulty ramp.
- Use
itertools.isliceto cap the run at N enemies.
Warm-Up · Generators in Games
5 minIn every wave-based game (Vampire Survivors, Plants vs Zombies, Pac-Man, Space Invaders), there's an enemy source that drips out monsters at the right moments. A generator is the perfect way to model it.
def enemies(): wave = 1 while True: yield wave wave += 1 # Use in a battle loop for wave_no in enemies(): print(f"Wave {wave_no} arriving!") if wave_no > 5: break
The spawner decides what to spawn. The game loop decides when to stop. The two roles cleanly separate — every game engine works this way.
New Concept · Wave-Based Generators
14 minThe Monster class — reused from L3-09
import random class Monster: def __init__(self, name, hp, attack): self.name = name; self.hp = hp; self.attack = attack def is_alive(self): return self.hp > 0 def take_damage(self, n): self.hp = max(0, self.hp - n) def __repr__(self): return f"{self.name}(HP {self.hp}, ATK {self.attack})"
A spawner generator
Each yield builds a fresh monster, scaled by wave number.
def spawn_enemies(): """Infinite spawner — difficulty rises with each wave.""" types = ["Goblin", "Orc", "Skeleton", "Imp", "Wraith"] wave = 1 while True: # Each wave: name from the type pool, HP and ATK scale up kind = random.choice(types) hp = 20 + wave * 5 + random.randint(-5, 5) atk = 4 + wave * 2 + random.randint(-1, 1) yield Monster(f"{kind}-W{wave}", hp, atk) wave += 1
Each call to next(spawn_enemies()) produces a tougher monster. The wave counter lives inside the generator — it remembers between calls.
The game loop
hero = Monster("Aisyah", hp=100, attack=15) for monster in spawn_enemies(): print(f"\nWave incoming: {monster}") while monster.is_alive() and hero.is_alive(): # Hero strikes first monster.take_damage(hero.attack + random.randint(-2, 2)) if monster.is_alive(): hero.take_damage(monster.attack + random.randint(-1, 1)) if not hero.is_alive(): print("\n💀 Hero fell.") break print(f" ✓ Defeated {monster.name}. Hero {hero.hp}/100.") # Tiny rest hero.hp = min(100, hero.hp + 5)
The for-loop consumes one monster per pass. The generator yields a fresh one each time. The loop breaks when the hero dies. The spawner never knows or cares how long the run lasted.
Capping the run with islice
What if you want to stop after, say, 10 waves regardless? Wrap with islice:
from itertools import islice # Take at most 10 enemies for monster in islice(spawn_enemies(), 10): ...
Now there are two exits: hero dies, or 10 waves complete. The generator stays infinite; islice bounds the iteration.
Why a generator instead of a list?
List of all possible monsters (BAD): - Can't enumerate "all possible" — there are infinite waves - Even bounding to 1000 wastes memory if the hero dies in wave 3 - Order is fixed once the list is built Generator (GOOD): - Each enemy is created at the moment it's needed - Memory cost = one monster at a time - The generator can use randomness, time, hero state - Stops cleanly when the consumer stops asking
Multiple-yield-per-wave
What if every wave should be a group of monsters? Easy — yield each one:
def spawn_packs(): """Each wave yields multiple monsters.""" wave = 1 while True: pack_size = 1 + wave // 3 # bigger packs over time for i in range(pack_size): yield Monster(f"Goblin-W{wave}-{i+1}", hp=20 + wave * 3, attack=4 + wave) wave += 1
Two loops, one infinite. The outer counts waves; the inner yields each monster in the pack. Pure compose.
Worked Example · The Wave Survival
12 minSave as spawner.py:
# spawner.py — generator-driven wave game import random from itertools import islice # --- Classes --- class Fighter: def __init__(self, name, hp, attack): self.name = name self.hp = hp self.max_hp = hp self.attack = attack def is_alive(self): return self.hp > 0 def take_damage(self, n): self.hp = max(0, self.hp - n) def heal(self, n): self.hp = min(self.max_hp, self.hp + n) def __repr__(self): return f"{self.name}(HP {self.hp}/{self.max_hp}, ATK {self.attack})" # --- The infinite spawner --- MONSTER_TYPES = ["Goblin", "Orc", "Skeleton", "Imp", "Wraith", "Troll"] def spawn_enemies(): wave = 1 while True: kind = random.choice(MONSTER_TYPES) hp = 25 + wave * 6 + random.randint(-5, 5) atk = 4 + wave * 2 + random.randint(-1, 1) yield wave, Fighter(f"{kind}-W{wave}", hp, atk) wave += 1 # --- The battle --- def fight(hero, monster): while hero.is_alive() and monster.is_alive(): dmg = hero.attack + random.randint(-2, 2) monster.take_damage(dmg) print(f" ⚔ {hero.name} hits {monster.name} for {dmg}. " f"({monster.name} HP {monster.hp}/{monster.max_hp})") if not monster.is_alive(): return dmg = monster.attack + random.randint(-1, 1) hero.take_damage(dmg) print(f" ⚔ {monster.name} hits for {dmg}. " f"({hero.name} HP {hero.hp}/{hero.max_hp})") # --- The run --- random.seed(42) # reproducible hero = Fighter("Aisyah", hp=120, attack=18) spawner = spawn_enemies() # Cap at 15 waves max for wave, enemy in islice(spawner, 15): print(f"\n=== Wave {wave}: {enemy} ===") fight(hero, enemy) if not hero.is_alive(): print(f"\n💀 {hero.name} fell on wave {wave}.") break print(f" ✓ {hero.name} clears the wave. HP {hero.hp}/{hero.max_hp}.") hero.heal(10) else: print("\n🏆 Cleared 15 waves!") print(f"\nFinal: {hero}")
Sample output (truncated)
=== Wave 1: Goblin-W1(HP 36/36, ATK 7) === ⚔ Aisyah hits Goblin-W1 for 17. (Goblin-W1 HP 19/36) ⚔ Goblin-W1 hits for 6. (Aisyah HP 114/120) ⚔ Aisyah hits Goblin-W1 for 19. (Goblin-W1 HP 0/36) ✓ Aisyah clears the wave. HP 114/120. === Wave 2: Orc-W2(HP 39/39, ATK 9) === ⚔ Aisyah hits Orc-W2 for 17. ... === Wave 6: Wraith-W6(HP 60/60, ATK 16) === ⚔ Aisyah hits Wraith-W6 for 18. (Wraith-W6 HP 42/60) ⚔ Wraith-W6 hits for 17. (Aisyah HP 12/120) ... 💀 Aisyah fell on wave 6. Final: Aisyah(HP 0/120, ATK 18)
Read the diff
The spawner is six lines and infinite. The game loop is six lines and bounded by either "hero dies" or "15 waves complete". islice caps the run; for/else handles "survived everything". No list of enemies ever exists — each is created when needed and discarded when defeated.
Try It Yourself
13 minModify the spawner so every 5th wave yields a Boss with doubled HP and attack.
Hint
def spawn_with_bosses(): wave = 1 while True: if wave % 5 == 0: yield wave, Fighter(f"BOSS-W{wave}", hp=120 + wave * 5, attack=18 + wave) else: kind = random.choice(MONSTER_TYPES) yield wave, Fighter(f"{kind}-W{wave}", hp=25 + wave * 6, attack=4 + wave * 2) wave += 1
One if inside the generator. The game loop doesn't change at all.
Spawn one enemy per wave, but the hero alternates with a sibling Ally each turn. When one is at < 20 HP, the other tags in.
Hint
heroes = [Fighter("Aisyah", 120, 18), Fighter("Wei Jie", 100, 16)] active_idx = 0 for wave, enemy in islice(spawner, 15): print(f"\n=== Wave {wave}: {enemy} ===") print(f" Active: {heroes[active_idx].name}") fight(heroes[active_idx], enemy) # Swap if active is low if heroes[active_idx].hp < 20: other = 1 - active_idx if heroes[other].is_alive(): print(f" Tag! {heroes[other].name} takes over.") active_idx = other else: print("Both heroes have fallen.") break
Tag-team is the rare case where shared state across waves matters — implemented in the orchestrator, not the spawner.
After every defeated monster, the hero earns XP equal to wave * 10. Track total XP. When XP crosses 100 * level, level up: +10 max_hp, +2 attack, full heal.
Hint
xp, level = 0, 1 for wave, enemy in islice(spawner, 15): print(f"=== Wave {wave}: {enemy} ===") fight(hero, enemy) if not hero.is_alive(): break xp += wave * 10 while xp >= 100 * level: xp -= 100 * level level += 1 hero.max_hp += 10 hero.attack += 2 hero.hp = hero.max_hp print(f" ✨ Lv {level}! HP {hero.max_hp}, ATK {hero.attack}")
The XP system is on the consumer side; the spawner remains pure.
Mini-Challenge · Wave Statistics
8 minRun 100 silent simulations of the spawner game (use contextlib.redirect_stdout to silence prints). Track: max wave reached, average wave reached, distribution of monster types defeated. Print a small report.
Show one possible solution
# spawner_stats.py — 100 silent runs, report results import random, io, contextlib from itertools import islice from collections import Counter # ... include Fighter, MONSTER_TYPES, spawn_enemies, fight ... def run_one(): hero = Fighter("Aisyah", 120, 18) spawner = spawn_enemies() defeated_types = [] waves_cleared = 0 for wave, enemy in islice(spawner, 25): with contextlib.redirect_stdout(io.StringIO()): fight(hero, enemy) if not hero.is_alive(): return waves_cleared, defeated_types # Hero won — count this monster type_name = enemy.name.split("-")[0] defeated_types.append(type_name) waves_cleared = wave hero.heal(10) return waves_cleared, defeated_types waves_per_run = [] all_types = Counter() for _ in range(100): w, types = run_one() waves_per_run.append(w) all_types.update(types) print(f"Max wave reached : {max(waves_per_run)}") print(f"Avg wave reached : {sum(waves_per_run) / 100:.1f}") print(f"Type counts:") for t, n in all_types.most_common(): print(f" {t:<10} {n}")
Non-negotiables: silent runs via redirect_stdout, Counter for type frequencies, 100-run simulation with summary stats. This is how game-balance teams tune wave difficulty in real engines.
Recap
3 minA generator is the right shape for "spawn an enemy on demand". The spawner stays infinite and pure — it produces one fresh monster per yield, scaled by wave number. The game loop consumes one monster per pass and decides when to stop (hero dies, slice exhausts). State that's "owned by the spawner" (wave counter, monster type pool) stays inside it. State that's "about the run" (hero, XP, score) stays outside. Two well-separated roles.
Vocabulary Card
- spawner generator
- A generator that yields game entities one at a time. Common in wave-based games.
- difficulty ramp
- Each yield uses the wave number to scale stats. Cheap, effective progression.
- islice cap
- Bound an infinite generator to N items in the game loop.
- spawner / consumer separation
- Spawner produces; loop consumes and decides when to stop. Two-sided design.
Homework
4 minBuild loot_drop.py. After every defeated monster, a generator loot_stream() yields one piece of loot — name and value. Some loot is rare (gold ring), some common (gold coin). Track the hero's total loot value across a 10-wave run.
- Define
loot_stream()that usesrandom.choiceswith weights. - In the wave loop, call
next(loot_drop)after each victory. - Accumulate value; print the total + a count of each item at the end.
Sample · loot generator + tally
def loot_stream(): items = [ ("gold coin", 1, 50), # value 1, weight 50 ("silver ring", 5, 30), ("gold ring", 20, 15), ("rare gem", 50, 5), ] names = [name for name, _, _ in items] weights = [w for _, _, w in items] values = {name: v for name, v, _ in items} while True: pick = random.choices(names, weights=weights, k=1)[0] yield pick, values[pick] # In the wave loop: from collections import Counter loot = loot_stream() inventory = Counter() total_value = 0 for wave, enemy in islice(spawner, 10): fight(hero, enemy) if not hero.is_alive(): break name, value = next(loot) inventory[name] += 1 total_value += value print(f" Loot: {name} (+{value})") print(f"\nTotal: {total_value}") for item, n in inventory.most_common(): print(f" {item:<12} x{n}")
Non-negotiables: a weighted loot generator, next(loot) per victory, Counter to tally. The generator is independent of the game loop — drop a different loot table in and the rest works unchanged.