Skip to main content

Lesson 25: Tower Defense Capstone

  • Module 13: Capstone
  • Lesson 25 of 27
  • โฑ๏ธ About 3 h (instruction + lab)

In this capstone you build a complete tower defense game: enemies march down a road in waves, and the towers you place, upgrade and sell stop them with arrows, cannon shells, frost and lasers. It pulls together particles, UI, juice and game states from this course into one finished game you can hand to a friend, and it is the game you will ship in the next lesson.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Build a complete, playable game from a starter, one system at a time, using Enums and dataclasses to keep towers, enemies and waves as data.
  • Move enemies along a waypoint route at a steady speed and measure which one is closest to the exit.
  • Explain the difference between homing bullets, cannon shells aimed at a point, and instant laser hits, and why each needs its own rule.
  • Debug timing bugs (bonuses paid every frame, frozen cooldowns, pause that does not pause) by driving every timer from one scaled dt.
  • Measure your game's balance with damage-per-second math and small automated tests.

Project: Tower Defense, a six-wave game with four tower types, four enemy types, upgrades, a HUD and screen shake, using CC0 art from Kenney.

In This Lesson

๐Ÿฐ The Game You Will Build

Tower defense is a puzzle about space and money. Enemies follow a fixed road toward your base. You can't touch them directly; instead you spend gold on towers beside the road, and every enemy your towers defeat pays a little more gold for the next wave. Let too many through and you lose. Games like Bloons TD and Kingdom Rush are built on exactly this loop.

Play the small browser version first. Pick a tower, click the grass to build it, then press Start wave. Try Pause and Speed x2 in the middle of a wave, and notice that everything (spawns, cooldowns, frost) pauses and speeds up together. That is not an accident; you will build it on purpose in this lesson.

Tower:
The tower defense wave loop as four numbered steps around a road that leads to a base with 20 HP: 1, a wave spawns; 2, enemies follow the path; 3, towers fire and earn gold; 4, you build and upgrade towers. Each new wave is tougher.
Every wave runs the same loop: spawn, walk the road, fire and earn, then build with what you earned.

The pygame version you will finish has the same rules plus upgrades, selling, particles, screen shake and real art. It is split into small systems, and each one lives in its own class or function:

graph TD G["Game: gold, lives, phase"] --> W["Wave spawner<br/>(queue + timer)"] G --> E["Enemies<br/>(walk the route)"] G --> T["Towers<br/>(target + cooldown)"] T --> P["Projectiles<br/>(bullets, shells)"] P --> H["hit(): damage, slow, reward"] G --> FX["Effects: particles,<br/>shake, pop-ups"] G --> D["draw() and HUD<br/>(never change the rules)"]

๐Ÿ’ก Why this matters

The Game class holds every rule and never draws; the drawing functions read the game and never change it. That split is what lets you test the rules without opening a window (section 8), and it is the same split real studios use between "simulation" and "presentation".

๐Ÿ“‹ Data First: Towers, Enemies and Waves

Before any code moves, decide what exists. Each tower type is a row of numbers: cost, damage, range, shots per second, whether it can hit flying enemies. You met both tools for this earlier: Enum for a fixed set of names (the Python sidebar in Game States & Scenes) and @dataclass for a record with named fields (the sidebar in Saving & Loading). Run this on its own; it prints each tower's damage per second:

from dataclasses import dataclass
from enum import Enum


class TowerKind(Enum):
    ARROW = "arrow"
    CANNON = "cannon"
    FROST = "frost"
    LASER = "laser"


@dataclass
class TowerSpec:
    name: str
    cost: int
    damage: float
    range: float                    # pixels
    fire_rate: float                # shots per second
    shot_speed: float               # pixels per second; 0 means an instant hit
    hits_air: bool
    color: tuple
    sprite: str
    splash: float = 0.0             # blast radius in pixels (cannon only)
    slow_factor: float = 1.0        # 0.5 = half speed
    slow_time: float = 0.0          # seconds


TOWER_SPECS = {
    TowerKind.ARROW: TowerSpec("Arrow", 50, 10, 150, 2.0, 520, True, (120, 220, 120),
                               "towerDefense_tile249.png"),
    TowerKind.CANNON: TowerSpec("Cannon", 90, 28, 130, 0.6, 320, False, (240, 110, 90),
                                "towerDefense_tile250.png", splash=60),
    TowerKind.FROST: TowerSpec("Frost", 70, 3, 120, 1.0, 420, False, (130, 190, 255),
                               "towerDefense_tile226.png", slow_factor=0.5, slow_time=1.5),
    TowerKind.LASER: TowerSpec("Laser", 120, 6, 170, 5.0, 0, True, (255, 230, 90),
                               "towerDefense_tile203.png"),
}

for kind, spec in TOWER_SPECS.items():
    print(f"{spec.name:7} {spec.cost:4} gold  {spec.damage * spec.fire_rate:5.1f} damage per second")

Because the towers are data, the HUD buttons, the build code and the drawing code all loop over TOWER_SPECS instead of checking tower names in if chains. Adding a fifth tower is one new TowerKind member and one new TowerSpec. Enemies get the same treatment with an EnemySpec (health, speed in pixels per second, armor, reward, flying, radius, sprite). A mistyped name such as TowerKind.LSAER raises an AttributeError when that line runs, so you find the typo the first time you test that code; a mistyped string key such as "lsaer" would only fail later with a KeyError.

Waves are data too. Each wave is a list of groups, and each group says which enemy, how many, and how many seconds apart they spawn:

# Each wave is a list of groups: (enemy kind, how many, seconds between spawns).
WAVES = [
    [(EnemyKind.GRUNT, 8, 0.9)],
    [(EnemyKind.GRUNT, 10, 0.7), (EnemyKind.RUNNER, 6, 0.5)],
    [(EnemyKind.RUNNER, 12, 0.45), (EnemyKind.FLYER, 3, 1.0)],
    [(EnemyKind.TANK, 4, 1.6), (EnemyKind.GRUNT, 10, 0.6)],
    [(EnemyKind.FLYER, 8, 0.9), (EnemyKind.GRUNT, 8, 0.6)],
    [(EnemyKind.TANK, 6, 1.3), (EnemyKind.RUNNER, 12, 0.4), (EnemyKind.FLYER, 6, 0.8)],
]

Enemies also grow tougher: each one starts with spec.hp * (1 + 0.15 * (wave_number - 1)) health, so wave 6 enemies have 75% more health than wave 1. Pick one growth rule and keep it in one place; the old version of this game had two competing formulas in different files.

๐Ÿ›ฃ๏ธ The Road: Walking Waypoints

The road is a list of grid cells where it turns. The first and last cells sit just off screen, so enemies walk in and out of view. Each cell becomes a pixel waypoint at the center of its tile. Flying enemies get their own two-point route straight from the entrance to the exit, which is what makes them dangerous:

ROAD_CELLS = [(-1, 2), (3, 2), (3, 6), (7, 6), (7, 2), (11, 2), (11, 7), (15, 7)]


def cell_center(col, row):
    return pygame.Vector2(col * TILE + TILE / 2, row * TILE + TILE / 2)


GROUND_ROUTE = [cell_center(c, r) for c, r in ROAD_CELLS]
AIR_ROUTE = [GROUND_ROUTE[0], GROUND_ROUTE[-1]]      # flyers cut straight across

Moving along the route is the heart of the game. Each frame an enemy may walk speed * dt pixels. If that step would pass the next corner, it stops exactly on the corner and spends the leftover distance on the next leg. Without the leftover, fast enemies (or any enemy during fast-forward) would lose a little distance at every corner and drift out of sync with the timers.

class Enemy:
    def update(self, dt):
        """Walk along the route. Returns True on the frame the enemy reaches the exit."""
        if not self.alive:
            return False
        self.slow_timer = max(0.0, self.slow_timer - dt)
        self.flash = max(0.0, self.flash - dt)
        step = self.current_speed() * dt
        while step > 0 and self.leg < len(self.route) - 1:
            target = self.route[self.leg + 1]
            to_target = target - self.pos
            dist = to_target.length()
            if dist > 0:
                self.heading = to_target / dist
            if step < dist:
                self.pos += self.heading * step
                self.traveled += step
                step = 0
            else:                           # reach the corner, keep the leftover step
                self.pos.update(target)
                self.traveled += dist
                step -= dist
                self.leg += 1
        if self.leg >= len(self.route) - 1:
            self.alive = False
            return True
        return False

Every step also adds to traveled. Subtract that from the route's total length and you know exactly how far each enemy still has to go:

class Enemy:
    def remaining(self):
        """Pixels left to the exit: the smaller, the more dangerous."""
        return self.route_length - self.traveled

The old version of this game estimated progress with 1 - distance / 100, a guess that was wrong on any leg that was not 100 pixels long, so towers sometimes shot at the wrong enemy. Measuring the real distance fixes it, and it works for flyers too, even though their route is shorter.

๐Ÿ”ฎ Predict, then run

A Runner moves at 110 pixels per second. On a frame where fast-forward makes dt 0.04 seconds, it is 1.5 pixels from a corner. How far does it walk after turning the corner? (Answer: it walks 4.4 pixels this frame: 1.5 to reach the corner, then the leftover 2.9 on the next leg.)

๐ŸŽฏ Towers: Range, Targets and Cooldowns

Each frame, a tower does three things: finds a target, turns toward it, and fires if its cooldown has run out. Three rules keep towers honest:

  1. Range and air: a tower can hit an enemy only if it is within range and, for flyers, only if hits_air is true. In this game the Arrow and Laser hit flyers; the Cannon and Frost do not.
  2. Target "first": of all the enemies it can hit, pick the one with the smallest remaining(), the one about to reach your base. key=lambda e: e.remaining() tells min() what to compare (a lambda is a one-line function without a name), and default=None makes it return None instead of raising an error when nothing is in range.
  3. Cooldowns tick every frame: count the cooldown down before looking for a target. If you only count while an enemy is in range, the tower wastes a full cooldown every time a new enemy walks in.
class Tower:
    def can_hit(self, enemy):
        if not enemy.alive or (enemy.spec.flying and not self.spec.hits_air):
            return False
        return self.pos.distance_to(enemy.pos) <= self.range()

    def pick_target(self, enemies):
        """Target "first": the enemy in range with the least road left."""
        return min((e for e in enemies if self.can_hit(e)), key=lambda e: e.remaining(), default=None)

    def update(self, dt, enemies):
        """Count the cooldown down every frame; return a target when it is time to fire."""
        self.cooldown = max(0.0, self.cooldown - dt)
        target = self.pick_target(enemies)
        if target is None:
            return None
        aim = target.pos - self.pos
        if aim.length_squared() > 0:
            self.angle = math.degrees(math.atan2(aim.y, aim.x))
        if self.cooldown > 0:
            return None
        self.cooldown = 1 / self.spec.fire_rate
        return target

A cooldown of 1 / fire_rate seconds means an Arrow (2 shots per second) waits 0.5 seconds between shots. The Laser follows exactly the same rule; in the old version the laser skipped the cooldown and hit on every frame, which made it 12 times stronger at 60 FPS than its numbers said, and even stronger on a faster monitor.

Damage per second (DPS) tells you whether a tower is worth its cost. Armor is subtracted from every hit, with a minimum of 1, so many small hits suffer most against armor:

def effective_dps(damage, fire_rate, armor):
    """Damage per second after armor (every hit does at least 1)."""
    return max(1, damage - armor) * fire_rate


print(f"Arrow vs Grunt:  {effective_dps(10, 2.0, 0):.1f}")    # 20.0
print(f"Arrow vs Tank:   {effective_dps(10, 2.0, 4):.1f}")    # 12.0
print(f"Laser vs Tank:   {effective_dps(6, 5.0, 4):.1f}")     # 10.0
print(f"Cannon vs Tank:  {effective_dps(28, 0.6, 4):.1f}")    # 14.4, and it splashes

The Arrow does more damage per second than the Cannon against a lone Grunt, but against a Tank's 4 armor the Arrow drops from 20 to 12 while the Cannon only drops from 16.8 to 14.4, and the Cannon also hits everything near its target. That trade-off is what makes choosing towers interesting.

โœ… Growth Mindset: My Towers Won't Shoot, Yet

"Nothing happens" is the most common capstone bug, and it is a good one, because the answer is always in one of three places: the tower can't see the enemy (range or hits_air), the cooldown never reaches zero, or the shot is created but never updated. Don't guess. Print one line inside Tower.update, such as print(self.spec.name, self.cooldown, target), run one wave and read the output. Following the data one step at a time is the skill that finishes big projects.

๐Ÿ’ฅ Shots: Bullets, Shells, Lasers and Frost

Towers fire three different kinds of shots, and each needs its own rule for when damage happens:

ShotFlies toDamage happensIf the target dies first
Bullet (Arrow, Frost)The target's current position, every frame (homing)On the target only, when the bullet reaches itThe bullet fizzles
Shell (Cannon)A point chosen when it is fired, led ahead of the targetTo every ground enemy within the blast radiusIt still lands and explodes
Beam (Laser)Nowhere: it hits instantlyOn the target, the moment the tower firesIt cannot happen

To lead a moving target, the cannon estimates the flight time (distance รท shell speed) and aims where the enemy will be after that long: target.pos + target.velocity() * flight_time. The estimate is not perfect at corners, which is fine: the blast radius forgives small misses.

class Projectile:
    """Bullets chase a live target; cannon shells fly to a fixed point and explode."""

    def __init__(self, tower, target):
        self.spec = tower.spec
        self.damage = tower.damage()
        self.pos = pygame.Vector2(tower.pos)
        self.target = target
        self.alive = True
        self.aim = None
        if self.spec.splash > 0:            # lead the target: aim where it will be
            flight_time = self.pos.distance_to(target.pos) / self.spec.shot_speed
            self.aim = target.pos + target.velocity() * flight_time

    def update(self, dt, game):
        if self.aim is None and not self.target.alive:
            self.alive = False              # target died or escaped: the bullet fizzles
            return
        goal = self.aim if self.aim is not None else self.target.pos
        to_goal = goal - self.pos
        step = self.spec.shot_speed * dt
        if to_goal.length() <= step:
            self.pos.update(goal)
            self.alive = False
            game.projectile_hit(self)
        else:
            self.pos += to_goal.normalize() * step

All damage goes through one method, Game.hit(), and splash damage calls it once for each enemy in the blast. The splash falls off from full damage at the center to half at the edge. The old version skipped the blast whenever the original target had already died, so a cannon aimed at a nearly dead enemy often did nothing at all.

class Game:
    def hit(self, enemy, damage, spec):
        if not enemy.alive:
            return
        if spec.slow_time > 0:
            enemy.apply_slow(spec.slow_factor, spec.slow_time)
        if enemy.take_hit(damage):
            self.gold += enemy.spec.reward
            self.kills += 1
            self.popup(f"+{enemy.spec.reward}", enemy.pos)
            self.burst(enemy.pos, (230, 90, 70), 12)

    def projectile_hit(self, projectile):
        if projectile.spec.splash > 0:      # explode where the shell lands
            radius = projectile.spec.splash
            self.burst(projectile.pos, (255, 170, 60), 20)
            self.add_trauma(0.2)
            for enemy in self.enemies:
                if enemy.alive and not enemy.spec.flying:
                    d = enemy.pos.distance_to(projectile.pos)
                    if d <= radius:
                        self.hit(enemy, projectile.damage * (1 - 0.5 * d / radius), projectile.spec)
        else:
            self.hit(projectile.target, projectile.damage, projectile.spec)

Frost slows are applied in exactly one place too. apply_slow() refreshes the timer instead of stacking, and current_speed() is the only place the slow is used. (The old game applied the slow once when the enemy was hit and again when it moved, so frosted enemies crawled at a quarter speed.)

class Enemy:
    def current_speed(self):
        return self.spec.speed * (self.slow_factor if self.slow_timer > 0 else 1.0)

    def apply_slow(self, factor, seconds):
        """The strongest slow wins; hitting again refreshes the timer (never stacks)."""
        self.slow_factor = min(self.slow_factor, factor) if self.slow_timer > 0 else factor
        self.slow_timer = max(self.slow_timer, seconds)

๐ŸŒŠ Waves, Gold and Game Phases

The game is always in one of four phases, stored as an Enum: BUILD (between waves), WAVE, WON and LOST. Starting a wave turns its groups into one flat queue of (kind, gap) pairs. Here is the whole frame update. Read the comments top to bottom; it is the game loop from your very first lesson, just with more to do:

class Game:
    def update(self, dt):
        """dt is real seconds. Pause and fast-forward happen here, so every timer obeys them."""
        dt = 0.0 if self.paused else dt * self.speed
        self.update_effects(dt)
        if self.phase != Phase.WAVE:
            return

        # 1. Spawn on a timer that counts seconds of game time.
        self.spawn_timer -= dt
        while self.spawn_queue and self.spawn_timer <= 0:
            kind, gap = self.spawn_queue.pop(0)
            self.enemies.append(Enemy(kind, self.wave_number))
            self.spawn_timer += gap

        # 2. Move enemies; the ones that reach the exit cost a life.
        for enemy in self.enemies:
            if enemy.update(dt):
                self.lives -= 1
                self.add_trauma(0.5)

        # 3. Towers aim and fire.
        for tower in self.towers.values():
            target = tower.update(dt, self.enemies)
            if target is None:
                continue
            if tower.spec.shot_speed == 0:  # laser: instant hit, still limited by fire_rate
                self.beams.append([tower.pos.copy(), target.pos.copy(), tower.spec.color, 0.08])
                self.hit(target, tower.damage(), tower.spec)
            else:
                self.projectiles.append(Projectile(tower, target))

        # 4. Move shots, then drop everything that is finished.
        for projectile in self.projectiles:
            projectile.update(dt, self)
        self.projectiles = [p for p in self.projectiles if p.alive]
        self.enemies = [e for e in self.enemies if e.alive]

        # 5. Win or lose the wave.
        if self.lives <= 0:
            self.lives = 0
            self.phase = Phase.LOST
            self.message = "The base has fallen. Press R to try again"
        elif not self.spawn_queue and not self.enemies:
            self.finish_wave()

Two details in there prevent the old version's worst bugs:

  • One scaled dt. The first line turns real seconds into game seconds: 0 when paused, doubled at x2. Every timer in the game (spawn gaps, cooldowns, slows, particles, shake) uses that one value, so pause and fast-forward can't miss anything. The old version timed spawns and cooldowns with pygame.time.get_ticks(), a real-world clock, so pausing froze the enemies while the towers' clocks kept running.
  • The spawn timer keeps its leftover. self.spawn_timer += gap adds to whatever is left rather than setting a fresh value, the same subtract-the-remainder trick you used for animation frames.

When the queue is empty and no enemies remain, the wave is over. The bonus is paid by finish_wave(), which also changes the phase. Changing the phase is what makes the bonus a one-time payment: next frame, update() returns early because the phase is no longer WAVE.

class Game:
    def finish_wave(self):
        """Runs once per wave: the phase change stops it from paying again."""
        bonus = wave_bonus(self.wave_number)
        self.gold += bonus
        if self.wave_number >= len(WAVES):
            self.phase = Phase.WON
            self.message = "Every wave defeated! Press R to play again"
        else:
            self.phase = Phase.BUILD
            self.message = f"Wave cleared: +{bonus} gold. Space starts the next one"

โœ… Growth Mindset: A Bug That Pays You Is Still a Bug

If your gold suddenly climbs by thousands, congratulations: you found the most famous tower defense bug, a reward inside a condition that stays true for many frames. It feels like you broke the game, but you actually learned to ask the question that fixes a whole family of bugs: "is this a state (true for many frames) or an event (true for one)?" Events should change the state that triggered them, exactly as finish_wave() does.

โœจ Juice, HUD and Art

With the rules working, the game plays correctly but feels flat. The juice from this course brings it to life:

  • Particles (from Particle Effects): bursts of sparks when enemies die, towers are built and shells explode. Each particle moves with dt, slows down with math.exp(-4 * dt) drag, and fades; its color goes through clamp_color() so it never leaves 0 to 255.
  • Screen shake and hit flash (from Screen Shake, Tweens & Juice): trauma goes up when an enemy escapes or a shell explodes and falls by 1.5 per second; the shake is trauma squared, so small hits barely wobble. A hit enemy flashes brighter for 0.08 seconds. Press K to turn shake off; some players get motion sickness, and a toggle costs one line.
  • Pop-ups: "+5" gold floats up from each defeated enemy, so the player sees where their money comes from.
class Game:
    def add_trauma(self, amount):
        self.trauma = min(1.0, self.trauma + amount)

    def update_effects(self, dt):
        # ... particles, laser beams and gold pop-ups count down here too ...
        self.trauma = max(0.0, self.trauma - 1.5 * dt)
        amount = self.trauma ** 2 * 10 if self.shake_on else 0
        self.shake.update(self.rng.uniform(-1, 1) * amount, self.rng.uniform(-1, 1) * amount)

The world is drawn onto its own Surface and then blitted at the shake offset, so the HUD bar stays still while the battlefield shakes. The HUD itself (from UI & HUD) is a row of four tower buttons with their costs grayed out when you can't afford them, the gold, lives and wave counter, and a message line. Clicking a tower shows its level, upgrade cost and sell value. Range previews are drawn on SRCALPHA Surfaces, cached by radius, because transparency only works on a Surface that has an alpha channel.

The art comes from Kenney's Tower Defense (top-down) pack, which is released under CC0 (public domain), so you may use it in any project. The lab folder includes the 13 sprites the game uses and a CREDITS.md that lists them. Two helpers handle the art:

ASSETS = Path(__file__).parent / "assets"


def load_sprite(filename, color):
    """Load a Kenney sprite, or draw a stand-in if the file is missing."""
    path = ASSETS / filename
    if path.exists():
        return pygame.image.load(path).convert_alpha()
    image = pygame.Surface((TILE, TILE), pygame.SRCALPHA)
    pygame.draw.circle(image, color, (TILE // 2, TILE // 2), TILE // 4)
    return image


_rotation_cache = {}


def rotated(image, degrees):
    """Rotate in 5-degree steps and remember the result (at most 72 per image)."""
    step = int(round(degrees / 5) * 5) % 360
    key = (id(image), step)
    if key not in _rotation_cache:
        _rotation_cache[key] = pygame.transform.rotate(image, -step)
    return _rotation_cache[key]

load_sprite() falls back to a drawn circle if a file is missing, so the game still runs if someone forgets the assets folder. rotated() rounds each angle to 5 degrees and caches the result, the rotation-caching idea from Images & Sprites, with at most 72 rotations stored per sprite. Turret art points up, so towers rotate by angle + 90. The background (grass and road tiles) is drawn once into a Surface at startup and blitted in one call each frame.

๐Ÿงช Test and Balance Your Game

Because Game never draws, you can test it without a window: build a game, feed it frames of 1 / 60 seconds, and check the numbers. This is one of the tests your instructor's lab runs:

def test_wave_bonus_is_paid_once():
    game = lab.Game(seed=0)
    game.start_wave()
    game.spawn_queue.clear()            # an empty wave: it is cleared on the next update
    before = game.gold
    for _ in range(120):                # two seconds at 60 FPS
        game.update(1 / 60)
    assert game.phase == lab.Phase.BUILD
    assert game.gold == before + lab.wave_bonus(1)

Tests like this are how you know a bug stays fixed after you change something else. The lab's test file also checks that the laser respects its fire rate, that slows never stack, that pause and x2 scale every timer, that cooldowns tick with no target, that a cannon shell still explodes when its target dies, and that flyers can only be hit by air-capable towers.

Balance is a different question: is the game fun? Use numbers to find problems and play to confirm them.

  • Compare each tower's damage per second per gold. If one tower wins everywhere, players will build nothing else.
  • Add up a wave's total health (count ร— health ร— growth) and compare it with the damage your towers can deal while enemies are in range.
  • Change one number at a time, play the same waves, and write down what changed. Watching a friend play without helping them is the fastest way to see where the game is confusing.

๐Ÿงญ Do you need an object pool here?

The Spatial Hashing & Object Pools lesson showed how to reuse objects instead of creating new ones. This game creates at most a few dozen projectiles per second, so try it without a pool first and only add one if you measure that creating shots is slow. If you do, give Projectile a reset() method that sets every field, so a recycled shot carries nothing over from its last life:

class Projectile:
    def __init__(self, tower, target):
        self.reset(tower, target)

    def reset(self, tower, target):
        """Set every field, so a recycled shot carries nothing over from its last life."""
        self.spec = tower.spec
        self.damage = tower.damage()
        self.pos = pygame.Vector2(tower.pos)
        self.target = target
        self.alive = True
        self.aim = None
        if self.spec.splash > 0:
            flight_time = self.pos.distance_to(target.pos) / self.spec.shot_speed
            self.aim = target.pos + target.velocity() * flight_time


class ShotPool:
    def __init__(self):
        self.free = []

    def get(self, tower, target):
        if self.free:
            shot = self.free.pop()
            shot.reset(tower, target)
            return shot
        return Projectile(tower, target)

    def release(self, shot):
        self.free.append(shot)

When a shot finishes, call pool.release(shot) instead of dropping it; when a tower fires, call pool.get(tower, target) instead of Projectile(tower, target).

๐Ÿ‹๏ธ Practice Exercise: Finish Tower Defense

Objective: bring the starter game to life so enemies spawn, walk the road, get shot, pay gold and come in six waves, with splash, frost and juice working.

Time: about 2 hours, the rest of this session plus some time at home. Starter file: tower_defense_starter.py and its assets folder (your instructor has them). The map, HUD, building, selling and drawing already work; the numbered to-do comments match the steps below.

  1. Run the starter. Build a few towers with 1โ€“4 and clicks, then press Space. Nothing spawns yet. (โ‰ˆ 5 min)
  2. To-do 1: make the spawner count down spawn_timer and pop enemies from the queue, adding each gap to the timer. Enemies are now created at the entrance, which sits just off the left edge of the screen, so you can't see them yet; print len(game.enemies) once a second to check that the count grows. (โ‰ˆ 10 min)
  3. To-do 2: write the waypoint walk in Enemy.update, keeping the leftover step at corners. Enemies now walk the road and cost you lives. (โ‰ˆ 20 min)
  4. To-do 3: write Tower.pick_target ("first") and count the cooldown down at the top of Tower.update. Towers now aim and fire. (โ‰ˆ 20 min)
  5. To-do 4: write finish_wave() so the bonus is paid once and the phase changes. Watch your gold after a wave: it should rise once, not keep climbing. (โ‰ˆ 10 min)
  6. To-dos 5 and 6: make cannon shells splash with falloff, and make frost apply its slow in Game.hit. (โ‰ˆ 20 min)
  7. To-do 7: add particle bursts and screen shake, and check that K turns the shake off. (โ‰ˆ 15 min)
  8. Play all six waves. Change one balance number (a cost, a fire rate, a wave), play again, and write one sentence about what changed. (โ‰ˆ 20 min)

You are done when:

  • all six waves play, and clearing a wave pays its bonus exactly once;
  • pausing freezes everything and F makes everything run twice as fast, including spawns;
  • cannon shells splash even when their target is already gone, and frosted enemies (with a blue ring) move at half speed, not a quarter;
  • flyers cut straight across the map and only Arrow and Laser towers shoot at them;
  • closing the window prints a line like Reached wave 6 with 14 lives, 380 gold and 88 kills.
๐Ÿ’ก Hint

Work in order and run the game after every to-do; each one makes something new visible. For to-do 2, copy the idea of the while loop from the road section: step is a budget of pixels you spend leg by leg. For to-do 3, min() takes a generator and key=lambda e: e.remaining(). If gold explodes after a wave, to-do 4 is paying without changing self.phase. If nothing slows down, check that hit() calls apply_slow() before take_hit() can mark the enemy dead.

โœ… Example Solution

If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top, plus an extra and frame_budget() condition on the main loop. They let the instructor's checker run the program automatically; when you run it yourself they do nothing. The game expects Kenney's sprites in an assets folder next to it, but it still runs (with plain circles) without them.

"""Tower Defense Capstone: Intermediate Lesson 25 (solution).

Build towers beside the road and stop six waves of enemies before they
reach the exit. Art: Kenney "Tower Defense (top-down)" pack, CC0.

Controls: 1-4 choose a tower, left-click to build or select, U upgrade,
S sell, Space start the next wave, P pause, F fast-forward, K screen
shake on/off, Esc deselect, R restart after the game ends.
"""
import math
import random
from dataclasses import dataclass
from enum import Enum
from pathlib import Path

import pygame


WIDTH, HEIGHT = 960, 640
TILE = 64
COLS, ROWS = 15, 9                  # the play area is 15 x 9 tiles (960 x 576)
HUD_Y = ROWS * TILE                 # the HUD bar fills the bottom 64 pixels
ASSETS = Path(__file__).parent / "assets"

START_GOLD = 120
START_LIVES = 20
SELL_REFUND = 0.7                   # selling returns 70% of what you spent
MAX_LEVEL = 3
HP_GROWTH = 0.15                    # enemies get 15% more health each wave


def clamp_color(r, g, b):
    """Keep every channel a whole number from 0 to 255."""
    return (max(0, min(255, int(r))), max(0, min(255, int(g))), max(0, min(255, int(b))))


# ---- The road: a list of grid cells, turned into pixel waypoints ----------------
ROAD_CELLS = [(-1, 2), (3, 2), (3, 6), (7, 6), (7, 2), (11, 2), (11, 7), (15, 7)]


def cell_center(col, row):
    return pygame.Vector2(col * TILE + TILE / 2, row * TILE + TILE / 2)


def road_tiles(cells):
    """Every on-screen grid cell the road passes through (no towers allowed there)."""
    tiles = set()
    for (c1, r1), (c2, r2) in zip(cells, cells[1:]):
        for c in range(min(c1, c2), max(c1, c2) + 1):
            for r in range(min(r1, r2), max(r1, r2) + 1):
                if 0 <= c < COLS and 0 <= r < ROWS:
                    tiles.add((c, r))
    return tiles


GROUND_ROUTE = [cell_center(c, r) for c, r in ROAD_CELLS]
AIR_ROUTE = [GROUND_ROUTE[0], GROUND_ROUTE[-1]]      # flyers cut straight across
ROAD = road_tiles(ROAD_CELLS)


# ---- Game data: what exists, as Enums and dataclasses ---------------------------
class TowerKind(Enum):
    ARROW = "arrow"
    CANNON = "cannon"
    FROST = "frost"
    LASER = "laser"


class EnemyKind(Enum):
    GRUNT = "grunt"
    RUNNER = "runner"
    TANK = "tank"
    FLYER = "flyer"


class Phase(Enum):
    BUILD = "build"                 # between waves: build and upgrade
    WAVE = "wave"                   # enemies are spawning or on the road
    WON = "won"
    LOST = "lost"


@dataclass
class TowerSpec:
    name: str
    cost: int
    damage: float
    range: float                    # pixels
    fire_rate: float                # shots per second
    shot_speed: float               # pixels per second; 0 means an instant hit
    hits_air: bool
    color: tuple
    sprite: str
    splash: float = 0.0             # blast radius in pixels (cannon only)
    slow_factor: float = 1.0        # 0.5 = half speed
    slow_time: float = 0.0          # seconds


@dataclass
class EnemySpec:
    name: str
    hp: float
    speed: float                    # pixels per second
    armor: float                    # subtracted from every hit (minimum 1 damage)
    reward: int
    flying: bool
    radius: float
    sprite: str


TOWER_SPECS = {
    TowerKind.ARROW: TowerSpec("Arrow", 50, 10, 150, 2.0, 520, True, (120, 220, 120),
                               "towerDefense_tile249.png"),
    TowerKind.CANNON: TowerSpec("Cannon", 90, 28, 130, 0.6, 320, False, (240, 110, 90),
                                "towerDefense_tile250.png", splash=60),
    TowerKind.FROST: TowerSpec("Frost", 70, 3, 120, 1.0, 420, False, (130, 190, 255),
                               "towerDefense_tile226.png", slow_factor=0.5, slow_time=1.5),
    TowerKind.LASER: TowerSpec("Laser", 120, 6, 170, 5.0, 0, True, (255, 230, 90),
                               "towerDefense_tile203.png"),
}

ENEMY_SPECS = {
    EnemyKind.GRUNT: EnemySpec("Grunt", 40, 60, 0, 5, False, 14, "towerDefense_tile245.png"),
    EnemyKind.RUNNER: EnemySpec("Runner", 25, 110, 0, 6, False, 12, "towerDefense_tile247.png"),
    EnemyKind.TANK: EnemySpec("Tank", 160, 38, 4, 15, False, 22, "towerDefense_tile268.png"),
    EnemyKind.FLYER: EnemySpec("Flyer", 50, 75, 0, 10, True, 18, "towerDefense_tile270.png"),
}

# Each wave is a list of groups: (enemy kind, how many, seconds between spawns).
WAVES = [
    [(EnemyKind.GRUNT, 8, 0.9)],
    [(EnemyKind.GRUNT, 10, 0.7), (EnemyKind.RUNNER, 6, 0.5)],
    [(EnemyKind.RUNNER, 12, 0.45), (EnemyKind.FLYER, 3, 1.0)],
    [(EnemyKind.TANK, 4, 1.6), (EnemyKind.GRUNT, 10, 0.6)],
    [(EnemyKind.FLYER, 8, 0.9), (EnemyKind.GRUNT, 8, 0.6)],
    [(EnemyKind.TANK, 6, 1.3), (EnemyKind.RUNNER, 12, 0.4), (EnemyKind.FLYER, 6, 0.8)],
]

KEY_TO_KIND = {pygame.K_1: TowerKind.ARROW, pygame.K_2: TowerKind.CANNON,
               pygame.K_3: TowerKind.FROST, pygame.K_4: TowerKind.LASER}
BUTTONS = {kind: pygame.Rect(8 + i * 132, HUD_Y + 8, 124, 48) for i, kind in enumerate(TowerKind)}


def wave_bonus(number):
    return 20 + 5 * number


# ---- Enemies ------------------------------------------------------------------
class Enemy:
    def __init__(self, kind, wave_number):
        self.kind = kind
        self.spec = ENEMY_SPECS[kind]
        self.route = AIR_ROUTE if self.spec.flying else GROUND_ROUTE
        self.route_length = sum(a.distance_to(b) for a, b in zip(self.route, self.route[1:]))
        self.pos = pygame.Vector2(self.route[0])
        self.leg = 0                        # index of the waypoint we are walking away from
        self.traveled = 0.0                 # pixels walked along the route
        self.heading = pygame.Vector2(1, 0)
        self.max_hp = self.spec.hp * (1 + HP_GROWTH * (wave_number - 1))
        self.hp = self.max_hp
        self.slow_timer = 0.0
        self.slow_factor = 1.0
        self.flash = 0.0                    # seconds of white hit-flash left
        self.alive = True

    def remaining(self):
        """Pixels left to the exit: the smaller, the more dangerous."""
        return self.route_length - self.traveled

    def current_speed(self):
        return self.spec.speed * (self.slow_factor if self.slow_timer > 0 else 1.0)

    def velocity(self):
        return self.heading * self.current_speed()

    def apply_slow(self, factor, seconds):
        """The strongest slow wins; hitting again refreshes the timer (never stacks)."""
        self.slow_factor = min(self.slow_factor, factor) if self.slow_timer > 0 else factor
        self.slow_timer = max(self.slow_timer, seconds)

    def take_hit(self, damage):
        """Apply one hit. Returns True only on the hit that kills."""
        if not self.alive:
            return False
        self.hp -= max(1.0, damage - self.spec.armor)
        self.flash = 0.08
        if self.hp <= 0:
            self.alive = False
            return True
        return False

    def update(self, dt):
        """Walk along the route. Returns True on the frame the enemy reaches the exit."""
        if not self.alive:
            return False
        self.slow_timer = max(0.0, self.slow_timer - dt)
        self.flash = max(0.0, self.flash - dt)
        step = self.current_speed() * dt
        while step > 0 and self.leg < len(self.route) - 1:
            target = self.route[self.leg + 1]
            to_target = target - self.pos
            dist = to_target.length()
            if dist > 0:
                self.heading = to_target / dist
            if step < dist:
                self.pos += self.heading * step
                self.traveled += step
                step = 0
            else:                           # reach the corner, keep the leftover step
                self.pos.update(target)
                self.traveled += dist
                step -= dist
                self.leg += 1
        if self.leg >= len(self.route) - 1:
            self.alive = False
            return True
        return False


# ---- Towers and projectiles -----------------------------------------------------
class Tower:
    def __init__(self, kind, cell):
        self.kind = kind
        self.spec = TOWER_SPECS[kind]
        self.cell = cell
        self.pos = cell_center(*cell)
        self.level = 1
        self.spent = self.spec.cost
        self.cooldown = 0.0                 # seconds until the next shot
        self.angle = -90.0                  # degrees; -90 points up the screen

    def damage(self):
        return self.spec.damage * (1 + 0.5 * (self.level - 1))

    def range(self):
        return self.spec.range * (1 + 0.1 * (self.level - 1))

    def upgrade_cost(self):
        return None if self.level >= MAX_LEVEL else self.spec.cost * self.level

    def can_hit(self, enemy):
        if not enemy.alive or (enemy.spec.flying and not self.spec.hits_air):
            return False
        return self.pos.distance_to(enemy.pos) <= self.range()

    def pick_target(self, enemies):
        """Target "first": the enemy in range with the least road left."""
        return min((e for e in enemies if self.can_hit(e)), key=lambda e: e.remaining(), default=None)

    def update(self, dt, enemies):
        """Count the cooldown down every frame; return a target when it is time to fire."""
        self.cooldown = max(0.0, self.cooldown - dt)
        target = self.pick_target(enemies)
        if target is None:
            return None
        aim = target.pos - self.pos
        if aim.length_squared() > 0:
            self.angle = math.degrees(math.atan2(aim.y, aim.x))
        if self.cooldown > 0:
            return None
        self.cooldown = 1 / self.spec.fire_rate
        return target


class Projectile:
    """Bullets chase a live target; cannon shells fly to a fixed point and explode."""

    def __init__(self, tower, target):
        self.spec = tower.spec
        self.damage = tower.damage()
        self.pos = pygame.Vector2(tower.pos)
        self.target = target
        self.alive = True
        self.aim = None
        if self.spec.splash > 0:            # lead the target: aim where it will be
            flight_time = self.pos.distance_to(target.pos) / self.spec.shot_speed
            self.aim = target.pos + target.velocity() * flight_time

    def update(self, dt, game):
        if self.aim is None and not self.target.alive:
            self.alive = False              # target died or escaped: the bullet fizzles
            return
        goal = self.aim if self.aim is not None else self.target.pos
        to_goal = goal - self.pos
        step = self.spec.shot_speed * dt
        if to_goal.length() <= step:
            self.pos.update(goal)
            self.alive = False
            game.projectile_hit(self)
        else:
            self.pos += to_goal.normalize() * step


# ---- Juice ----------------------------------------------------------------------
class Particle:
    def __init__(self, pos, vel, color, life):
        self.pos = pygame.Vector2(pos)
        self.vel = pygame.Vector2(vel)
        self.color = color
        self.life = self.max_life = life

    def update(self, dt):
        self.life -= dt
        self.pos += self.vel * dt
        self.vel *= math.exp(-4 * dt)       # frame-rate independent drag


# ---- The game: all rules, no drawing --------------------------------------------
class Game:
    def __init__(self, seed=None):
        self.rng = random.Random(seed)
        self.gold = START_GOLD
        self.lives = START_LIVES
        self.phase = Phase.BUILD
        self.wave_number = 0
        self.spawn_queue = []
        self.spawn_timer = 0.0
        self.towers = {}                    # (col, row) -> Tower
        self.enemies = []
        self.projectiles = []
        self.particles = []
        self.beams = []                     # [start, end, color, seconds left]
        self.popups = []                    # [text, position, seconds left]
        self.selected_kind = TowerKind.ARROW
        self.selected_tower = None
        self.paused = False
        self.speed = 1
        self.shake_on = True
        self.trauma = 0.0
        self.shake = pygame.Vector2()
        self.kills = 0
        self.message = "Build towers, then press Space to start wave 1"

    # -- building --
    def can_build(self, cell):
        col, row = cell
        return 0 <= col < COLS and 0 <= row < ROWS and cell not in ROAD and cell not in self.towers

    def build(self, kind, cell):
        spec = TOWER_SPECS[kind]
        if not self.can_build(cell):
            self.message = "You can't build there"
            return None
        if self.gold < spec.cost:
            self.message = f"{spec.name} costs {spec.cost} gold"
            return None
        self.gold -= spec.cost
        tower = Tower(kind, cell)
        self.towers[cell] = tower
        self.burst(tower.pos, (255, 215, 90), 10)
        return tower

    def upgrade(self, tower):
        cost = tower.upgrade_cost()
        if cost is None or self.gold < cost:
            return False
        self.gold -= cost
        tower.spent += cost
        tower.level += 1
        self.popup(f"Level {tower.level}", tower.pos)
        return True

    def sell(self, tower):
        refund = int(tower.spent * SELL_REFUND)
        self.gold += refund
        del self.towers[tower.cell]
        if self.selected_tower is tower:
            self.selected_tower = None
        self.popup(f"+{refund}", tower.pos)
        return refund

    def click(self, pos):
        x, y = pos
        if y >= HUD_Y:
            for kind, rect in BUTTONS.items():
                if rect.collidepoint(pos):
                    self.selected_kind = kind
                    self.selected_tower = None
            return
        cell = (int(x // TILE), int(y // TILE))
        if cell in self.towers:
            self.selected_tower = self.towers[cell]
        else:
            self.selected_tower = None
            self.build(self.selected_kind, cell)

    # -- waves --
    def start_wave(self):
        if self.phase != Phase.BUILD or self.wave_number >= len(WAVES):
            return False
        self.wave_number += 1
        self.spawn_queue = [(kind, gap) for kind, count, gap in WAVES[self.wave_number - 1]
                            for _ in range(count)]
        self.spawn_timer = 0.0
        self.phase = Phase.WAVE
        self.message = f"Wave {self.wave_number} incoming!"
        return True

    def finish_wave(self):
        """Runs once per wave: the phase change stops it from paying again."""
        bonus = wave_bonus(self.wave_number)
        self.gold += bonus
        if self.wave_number >= len(WAVES):
            self.phase = Phase.WON
            self.message = "Every wave defeated! Press R to play again"
        else:
            self.phase = Phase.BUILD
            self.message = f"Wave cleared: +{bonus} gold. Space starts the next one"

    # -- combat --
    def hit(self, enemy, damage, spec):
        if not enemy.alive:
            return
        if spec.slow_time > 0:
            enemy.apply_slow(spec.slow_factor, spec.slow_time)
        if enemy.take_hit(damage):
            self.gold += enemy.spec.reward
            self.kills += 1
            self.popup(f"+{enemy.spec.reward}", enemy.pos)
            self.burst(enemy.pos, (230, 90, 70), 12)

    def projectile_hit(self, projectile):
        if projectile.spec.splash > 0:      # explode where the shell lands
            radius = projectile.spec.splash
            self.burst(projectile.pos, (255, 170, 60), 20)
            self.add_trauma(0.2)
            for enemy in self.enemies:
                if enemy.alive and not enemy.spec.flying:
                    d = enemy.pos.distance_to(projectile.pos)
                    if d <= radius:
                        self.hit(enemy, projectile.damage * (1 - 0.5 * d / radius), projectile.spec)
        else:
            self.hit(projectile.target, projectile.damage, projectile.spec)

    # -- effects --
    def burst(self, pos, color, count):
        for _ in range(count):
            angle = self.rng.uniform(0, math.tau)
            speed = self.rng.uniform(40, 160)
            vel = (math.cos(angle) * speed, math.sin(angle) * speed)
            self.particles.append(Particle(pos, vel, color, self.rng.uniform(0.3, 0.6)))

    def popup(self, text, pos):
        self.popups.append([text, pygame.Vector2(pos), 0.8])

    def add_trauma(self, amount):
        self.trauma = min(1.0, self.trauma + amount)

    def update_effects(self, dt):
        for p in self.particles:
            p.update(dt)
        self.particles = [p for p in self.particles if p.life > 0]
        for beam in self.beams:
            beam[3] -= dt
        self.beams = [b for b in self.beams if b[3] > 0]
        for popup in self.popups:
            popup[1].y -= 30 * dt
            popup[2] -= dt
        self.popups = [p for p in self.popups if p[2] > 0]
        self.trauma = max(0.0, self.trauma - 1.5 * dt)
        amount = self.trauma ** 2 * 10 if self.shake_on else 0
        self.shake.update(self.rng.uniform(-1, 1) * amount, self.rng.uniform(-1, 1) * amount)

    # -- the frame --
    def update(self, dt):
        """dt is real seconds. Pause and fast-forward happen here, so every timer obeys them."""
        dt = 0.0 if self.paused else dt * self.speed
        self.update_effects(dt)
        if self.phase != Phase.WAVE:
            return

        # 1. Spawn on a timer that counts seconds of game time.
        self.spawn_timer -= dt
        while self.spawn_queue and self.spawn_timer <= 0:
            kind, gap = self.spawn_queue.pop(0)
            self.enemies.append(Enemy(kind, self.wave_number))
            self.spawn_timer += gap

        # 2. Move enemies; the ones that reach the exit cost a life.
        for enemy in self.enemies:
            if enemy.update(dt):
                self.lives -= 1
                self.add_trauma(0.5)

        # 3. Towers aim and fire.
        for tower in self.towers.values():
            target = tower.update(dt, self.enemies)
            if target is None:
                continue
            if tower.spec.shot_speed == 0:  # laser: instant hit, still limited by fire_rate
                self.beams.append([tower.pos.copy(), target.pos.copy(), tower.spec.color, 0.08])
                self.hit(target, tower.damage(), tower.spec)
            else:
                self.projectiles.append(Projectile(tower, target))

        # 4. Move shots, then drop everything that is finished.
        for projectile in self.projectiles:
            projectile.update(dt, self)
        self.projectiles = [p for p in self.projectiles if p.alive]
        self.enemies = [e for e in self.enemies if e.alive]

        # 5. Win or lose the wave.
        if self.lives <= 0:
            self.lives = 0
            self.phase = Phase.LOST
            self.message = "The base has fallen. Press R to try again"
        elif not self.spawn_queue and not self.enemies:
            self.finish_wave()


# ---- Drawing --------------------------------------------------------------------
def load_sprite(filename, color):
    """Load a Kenney sprite, or draw a stand-in if the file is missing."""
    path = ASSETS / filename
    if path.exists():
        return pygame.image.load(path).convert_alpha()
    image = pygame.Surface((TILE, TILE), pygame.SRCALPHA)
    pygame.draw.circle(image, color, (TILE // 2, TILE // 2), TILE // 4)
    return image


def load_art():
    art = {"grass": load_sprite("towerDefense_tile024.png", (60, 150, 70)),
           "road": load_sprite("towerDefense_tile093.png", (170, 120, 70)),
           "base": load_sprite("towerDefense_tile181.png", (150, 160, 170)),
           "bullet": load_sprite("towerDefense_tile272.png", (255, 220, 80)),
           "shell": load_sprite("towerDefense_tile275.png", (200, 200, 200))}
    for kind, spec in TOWER_SPECS.items():
        art[kind] = load_sprite(spec.sprite, spec.color)
    for kind, spec in ENEMY_SPECS.items():
        art[kind] = load_sprite(spec.sprite, (220, 80, 80))
    return art


def build_background(art):
    """Draw the grass and the road once; each frame just blits this Surface."""
    background = pygame.Surface((WIDTH, HUD_Y))
    for col in range(COLS):
        for row in range(ROWS):
            tile = art["road"] if (col, row) in ROAD else art["grass"]
            background.blit(tile, (col * TILE, row * TILE))
    return background


_rotation_cache = {}


def rotated(image, degrees):
    """Rotate in 5-degree steps and remember the result (at most 72 per image)."""
    step = int(round(degrees / 5) * 5) % 360
    key = (id(image), step)
    if key not in _rotation_cache:
        _rotation_cache[key] = pygame.transform.rotate(image, -step)
    return _rotation_cache[key]


_circle_cache = {}


def range_circle(radius, color):
    key = (int(radius), color)
    if key not in _circle_cache:
        size = int(radius) * 2 + 2
        circle = pygame.Surface((size, size), pygame.SRCALPHA)
        pygame.draw.circle(circle, (*color, 40), (size // 2, size // 2), int(radius))
        pygame.draw.circle(circle, (*color, 160), (size // 2, size // 2), int(radius), 2)
        _circle_cache[key] = circle
    return _circle_cache[key]


def draw(screen, play, game, art, fonts, background):
    """Draw the world on `play`, then blit it shaken onto the screen, then the HUD."""
    play.blit(background, (0, 0))

    # Range preview under the mouse, or around the selected tower.
    mx, my = pygame.mouse.get_pos()
    hover = (mx // TILE, my // TILE)
    if game.selected_tower is not None:
        t = game.selected_tower
        circle = range_circle(t.range(), (255, 255, 255))
        play.blit(circle, circle.get_rect(center=t.pos))
    elif my < HUD_Y and game.can_build(hover):
        spec = TOWER_SPECS[game.selected_kind]
        circle = range_circle(spec.range, spec.color)
        play.blit(circle, circle.get_rect(center=cell_center(*hover)))

    for tower in game.towers.values():
        play.blit(art["base"], art["base"].get_rect(center=tower.pos))
        turret = rotated(art[tower.kind], tower.angle + 90)   # turret art points up
        play.blit(turret, turret.get_rect(center=tower.pos))
        for i in range(tower.level):
            pygame.draw.circle(play, (255, 215, 90), (tower.pos.x - 12 + i * 12, tower.pos.y + 24), 4)

    for enemy in sorted(game.enemies, key=lambda e: e.spec.flying):  # flyers on top
        image = rotated(art[enemy.kind], math.degrees(math.atan2(enemy.heading.y, enemy.heading.x)))
        if enemy.flash > 0:
            image = image.copy()
            image.fill((120, 120, 120), special_flags=pygame.BLEND_RGB_ADD)
        if enemy.slow_timer > 0:
            pygame.draw.circle(play, (130, 190, 255), enemy.pos, enemy.spec.radius + 4, 2)
        play.blit(image, image.get_rect(center=enemy.pos))
        if enemy.hp < enemy.max_hp:
            bar = pygame.Rect(0, 0, 32, 5)
            bar.midbottom = (enemy.pos.x, enemy.pos.y - enemy.spec.radius - 4)
            pygame.draw.rect(play, (60, 20, 20), bar)
            bar.width = max(0, int(32 * enemy.hp / enemy.max_hp))
            pygame.draw.rect(play, (90, 220, 90), bar)

    for p in game.projectiles:
        image = art["shell"] if p.spec.splash > 0 else art["bullet"]
        play.blit(image, image.get_rect(center=p.pos))
        if p.spec.slow_time > 0:
            pygame.draw.circle(play, (130, 190, 255), p.pos, 5)
    for start, end, color, _ in game.beams:
        pygame.draw.line(play, color, start, end, 3)
    for p in game.particles:
        t = max(0.0, p.life / p.max_life)
        r, g, b = p.color
        pygame.draw.circle(play, clamp_color(r * t + 40, g * t + 40, b * t + 40), p.pos, 2 + 3 * t)
    for text, pos, _ in game.popups:
        label = fonts["small"].render(text, True, (255, 230, 120))
        play.blit(label, label.get_rect(center=pos))

    screen.fill((0, 0, 0))
    screen.blit(play, game.shake)
    draw_hud(screen, game, fonts)


def draw_hud(screen, game, fonts):
    pygame.draw.rect(screen, (28, 32, 44), (0, HUD_Y, WIDTH, HEIGHT - HUD_Y))
    for i, (kind, rect) in enumerate(BUTTONS.items()):
        spec = TOWER_SPECS[kind]
        chosen = kind == game.selected_kind and game.selected_tower is None
        pygame.draw.rect(screen, (60, 70, 96) if chosen else (40, 46, 62), rect, border_radius=6)
        pygame.draw.rect(screen, spec.color, rect, 2, border_radius=6)
        affordable = game.gold >= spec.cost
        color = (235, 235, 235) if affordable else (130, 130, 130)
        screen.blit(fonts["small"].render(f"{i + 1} {spec.name}", True, color), (rect.x + 8, rect.y + 6))
        screen.blit(fonts["small"].render(f"{spec.cost} gold", True, color), (rect.x + 8, rect.y + 26))

    stats = f"Gold {game.gold}   Lives {game.lives}   Wave {game.wave_number}/{len(WAVES)}"
    if game.speed > 1:
        stats += "   x2"
    if game.paused:
        stats += "   PAUSED"
    screen.blit(fonts["big"].render(stats, True, (240, 240, 240)), (540, HUD_Y + 8))
    line = game.message
    if game.selected_tower is not None:
        t = game.selected_tower
        cost = t.upgrade_cost()
        up = f"U: upgrade ({cost})" if cost is not None else "max level"
        line = f"{t.spec.name} level {t.level}   {up}   S: sell (+{int(t.spent * SELL_REFUND)})"
    screen.blit(fonts["small"].render(line, True, (200, 210, 230)), (540, HUD_Y + 38))

    if game.phase in (Phase.WON, Phase.LOST):
        shade = pygame.Surface((WIDTH, HUD_Y), pygame.SRCALPHA)
        shade.fill((0, 0, 0, 150))
        screen.blit(shade, (0, 0))
        title = "Victory!" if game.phase == Phase.WON else "Game Over"
        label = fonts["huge"].render(title, True, (255, 230, 120))
        screen.blit(label, label.get_rect(center=(WIDTH / 2, HUD_Y / 2)))


# ---- Input and the main loop ------------------------------------------------------
def handle_key(game, key):
    if key in KEY_TO_KIND:
        game.selected_kind = KEY_TO_KIND[key]
        game.selected_tower = None
    elif key == pygame.K_SPACE:
        game.start_wave()
    elif key == pygame.K_p:
        game.paused = not game.paused
    elif key == pygame.K_f:
        game.speed = 2 if game.speed == 1 else 1
    elif key == pygame.K_k:
        game.shake_on = not game.shake_on       # accessibility: screen shake on/off
    elif key == pygame.K_ESCAPE:
        game.selected_tower = None
    elif game.selected_tower is not None:
        if key == pygame.K_u:
            game.upgrade(game.selected_tower)
        elif key == pygame.K_s:
            game.sell(game.selected_tower)


def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Tower Defense Capstone")
    clock = pygame.time.Clock()
    fonts = {"small": pygame.font.Font(None, 22), "big": pygame.font.Font(None, 28),
             "huge": pygame.font.Font(None, 80)}
    art = load_art()
    background = build_background(art)
    play = pygame.Surface((WIDTH, HUD_Y))   # the world is drawn here, then shaken
    game = Game(seed=1)

    running = True
    while running:
        dt = min(clock.tick(60) / 1000, 0.05)   # cap long frames (window drags)
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_r and game.phase in (Phase.WON, Phase.LOST):
                    game = Game(seed=1)
                else:
                    handle_key(game, event.key)
            elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
                game.click(event.pos)

        game.update(dt)
        draw(screen, play, game, art, fonts, background)
        pygame.display.flip()

    pygame.quit()
    print(f"Reached wave {game.wave_number} with {game.lives} lives, "
          f"{game.gold} gold and {game.kills} kills.")


if __name__ == "__main__":
    main()

๐Ÿ““ Learning Journal

Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:

  • Key concepts you learned today
  • Techniques that clicked (and the ones that haven't, yet)
  • Questions or confusion to bring to the next session
  • Ideas to try in your own game
  • Progress and feelings: how did this lesson go for you?

โœ๏ธ This lesson's prompts:

  1. Which of the seven to-dos took you longest, and what finally made it work? What would you tell someone starting that to-do tomorrow?
  2. Describe one balance change you tried. Did the numbers predict what you felt when you played?
  3. This game keeps rules (Game) and drawing (draw()) apart. Where in one of your earlier projects would that split have saved you time?

๐Ÿ“ Summary

You built a complete tower defense game. It started as data: Enums and dataclasses for towers, enemies and waves. Enemies walk a waypoint route, carrying leftover distance around corners and measuring exactly how far they have left. Towers find the enemy closest to the exit, count their cooldowns down every frame, and fire bullets that home, shells that lead and splash, or lasers that hit instantly, with all damage and slows passing through one hit() method. A single scaled dt makes pause and fast-forward reach every timer, a phase change makes the wave bonus a one-time payment, and particles, shake, pop-ups and Kenney's art make it feel like a real game.

๐ŸŽ“ Key Takeaways

  • Keep game content as data (Enums, dataclasses, wave lists) so adding a tower or a wave is an edit, not a rewrite.
  • Move along waypoints with a step budget and keep the leftover at corners; measure progress as real distance remaining.
  • Count cooldowns down every frame, and give every tower, including the laser, the same fire-rate rule.
  • Decide when each kind of shot does damage: homing bullets fizzle, shells always explode, lasers hit at once.
  • Scale dt once for pause and speed-up, and make one-time rewards change the state that triggered them.
  • Keep rules and drawing apart, so you can test the rules without a window.

๐Ÿ”ญ Looking Ahead

Your game runs on your computer, with Python installed. In the next lesson, Building Executables, you freeze it into a program your friends can run with a double-click, no Python required.

โ“ Common Questions

Why do enemies follow waypoints instead of using A* pathfinding?

Because the road never changes in this game, a fixed list of corners is all the enemies need, and it is easy to read and edit. A* earns its place when the player can change the route, for example in maze-building games where towers block tiles. Going Further has a challenge for that.

Should towers target the first, last, strongest or closest enemy?

"First" (least road left) is the most common default because it protects your base. Many games let the player choose per tower. Since pick_target is one min() call, a different rule is just a different key: -e.hp for strongest, or the distance to the tower for closest.

My game slows down with lots of enemies. What should I do?

Measure before you change anything: time one frame's update and draw separately. Common costs in this game are creating new Surfaces every frame and rotating sprites without a cache; both are already handled in the solution. Each tower checks every enemy, which is fine for dozens of each; the spatial hash from Spatial Hashing & Object Pools is the tool if you ever need hundreds.

Can I use my own art instead of Kenney's?

Yes. Change the file names in TOWER_SPECS, ENEMY_SPECS and load_art(). Keep the sprites square and pointing the same way (turrets up, enemies right) or adjust the angle offsets. If you use anyone else's art, add it to CREDITS.md with its license, just as the Kenney pack is listed.

Why is the cannon's aim sometimes a little off?

It predicts where the enemy will be by assuming it keeps walking in a straight line. If the enemy turns a corner during the shell's flight, the prediction misses by a bit. The 60-pixel blast radius covers most misses; a smarter prediction could walk the route ahead of the enemy, which is a good stretch goal.

๐ŸŽฏ Quick Quiz

Question 1: A tower's cooldown only counts down while an enemy is in range. What goes wrong?

Question 2: Why does the solution's finish_wave() pay the wave bonus only once?

Question 3: What is the most reliable way to add a x2 speed button?

Question 4: A cannon shell is in flight when another tower defeats its target. What does the solution do?

Question 5: A Laser does 6 damage per hit at 5 shots per second. How much damage per second does it deal to a Tank with 4 armor?

๐ŸŒŸ Going Further

  • Bank tower: add a fifth TowerKind with range 0 that pays gold every few seconds of game time (use a timer driven by the scaled dt). Is it worth building in wave 1? In wave 5?
  • Targeting modes: let the selected tower cycle between first, strongest and closest with a key, by changing the key passed to min().
  • A boss wave: add a seventh wave with one huge, armored enemy that spawns two Runners when it dies.
  • Maze building: remove the fixed road, let towers block tiles, and route ground enemies with the A* search from A* Pathfinding. Refuse any placement that would leave no path to the exit.
  • Save your best run: store the highest wave reached in a JSON file, as in Saving & Loading.
  • Thanks, Kenney: the art is from Kenney's Tower Defense (top-down) pack (CC0). Kenney has many more free packs if you want to reskin the game.
  • Coming up in Game Dev III: Advanced: Difficulty & DDA and Playtesting & Telemetry turn "change one number and play again" into a measured process.