Lecture 26

Tower Defense: Enemies & Waves

CS 470 — Week 10: 3D Tower Defense (1/3)

By the End of Week 10

tower-defense-complete.mp4
Complete tower defense: 5 waves of enemies, 2 tower types, upgrades, money system, win/loss conditions

Three Lectures

TODAY

L26: Enemies

Types, Spawning
Waves, Health

THURSDAY

L27: Towers

State Machines
Money, Combat

NEXT WEEK

L28: Systems

Upgrades, Selection
Win/Loss, Polish

What Makes This Hard

Spawning timing

Enemies must appear one at a time with delays — but the game loop runs every frame.

Data-driven waves

How do we define 5 different waves without 5 different code paths?

Wave transitions

Next wave starts only when all enemies are gone AND spawning is done.

Path movement

Enemies need to follow a curve without custom steering code.

Engine Solution: PathFollow3D

# Without PathFollow3D — we write all this:
func _process(delta: float) -> void:
    var direction := (next_waypoint - position).normalized()
    velocity = direction * speed
    position += velocity * delta
    if position.distance_to(next_waypoint) < 0.1:
        next_waypoint = get_next_point()

# With PathFollow3D — just this:
func _process(delta: float) -> void:
    progress += speed * delta

One line. Engine handles position, rotation, and curve following.

Data Pattern: Arrays Define Waves

# Wave data lives here — no code changes to tweak difficulty
const WAVE_1 := ["slime", "slime", "slime"]
const WAVE_2 := ["slime", "goblin", "slime", "goblin"]
const WAVE_3 := ["goblin", "goblin", "orc"]

var waves := [WAVE_1, WAVE_2, WAVE_3]

# Spawning logic stays the same for every wave
var wave_index := 0
for enemy_type in waves[wave_index]:
    _spawn_enemy(enemy_type)
    await get_tree().create_timer(1.0).timeout

Wave difficulty is data. Spawning is logic. Keep them separate.

What We Build Today

  1. Enemy types — Slime, Goblin, Orc with a stats dictionary
  2. Path movement — PathFollow3D, one line of movement code
  3. Wave spawning — 5 waves defined as arrays, async delays
  4. Health system — HP bars that scale and color-shift
  5. Player HP — Decreases when enemies reach the end

Each increment is tested before moving on.

Project Setup

Project Structure

tower-defense/
+-- scenes/
|   +-- world.tscn          # Main scene (already set up)
|   +-- enemy.tscn          # PathFollow3D + mesh + health bar
+-- scripts/
    +-- World.gd            # Wave spawning  [we write this]
    +-- Enemy.gd            # Movement + health  [we write this]
    +-- PathVisualizer.gd   # Visual road  [bonus]

What the Starter Scripts Contain

# Enemy.gd — what's already there
extends PathFollow3D

# === TODO 1: Add STATS dictionary ===
# === TODO 2: Add instance variables ===
# === TODO 3: Write initialize() ===
# === TODO 4: Write _process() for movement ===
# === TODO 5: Write take_damage() and _die() ===

# World.gd — what's already there
extends Node3D

# === TODO 1: Add wave constants ===
# === TODO 2: Add state variables ===
# === TODO 3: Write _start_next_wave() ===
# === TODO 4: Write _spawn_enemy() ===
# === TODO 5: Write damage handlers ===

World Scene Tree

World (Node3D) — World.gd attached
  +- Camera3D (isometric: pos 0,20,15 rot -50,0,0)
  +- Ground (CSGBox3D, size 40x1x30)
  +- EnemyPath (Path3D — curve already drawn)
  +- SpawnTimer (Timer, one-shot)
  +- WaveTimer (Timer, one-shot)
  +- UI (CanvasLayer)
      +- HPLabel (Label, top-left)
      +- WaveLabel (Label, top-right)

We do not write await $SpawnTimer.timeout — we use create_timer() inline instead.

Enemy Scene Tree

Enemy (PathFollow3D) — Enemy.gd attached
  +- MeshInstance3D (CapsuleMesh, height 1.5)
  +- HealthBar (Node3D, floats above enemy)
      +- Background (MeshInstance3D, black, 1x0.15)
      +- Fill (MeshInstance3D, colored, 1x0.12)

PathFollow3D is the root — position along the curve is automatic.

How PathFollow3D Works

  • progress — distance in metres from the start of the curve
  • progress_ratio — same thing, normalized 0.0 → 1.0
  • Update progress each frame → position and rotation update automatically
  • loop = false — enemy stops instead of wrapping back to start
  • Enemy must be a direct child of the Path3D node to follow the curve

Rule: use progress for movement, progress_ratio for completion check.

Increment 1: Enemy Types

The Goal

Three enemy types, each with different HP, speed, and damage.

One dictionary maps type name → stats. One function applies them.

Same enemy scene, three different behaviours — no duplicate scenes.

Enemy Stats

Type HP Speed (m/s) Damage Color Role
Slime 30 2.0 10 Green Tutorial enemy
Goblin 50 3.5 15 Orange Fast threat
Orc 100 2.5 25 Red Tank — slow but tanky

Slime = safe. Goblin = fast. Orc = threatens even well-defended lanes.

STATS Dictionary (Enemy.gd)

extends PathFollow3D

const STATS := {
    "slime":  { "hp": 30,  "speed": 2.0, "damage": 10,
                "color": Color(0.2, 0.8, 0.2) },
    "goblin": { "hp": 50,  "speed": 3.5, "damage": 15,
                "color": Color(0.8, 0.6, 0.2) },
    "orc":    { "hp": 100, "speed": 2.5, "damage": 25,
                "color": Color(0.8, 0.2, 0.2) }
}

const — immutable, shared across all instances. Tweaking balance = editing this block only.

Instance Variables (Enemy.gd)

var enemy_type: String = "slime"  # set by spawner before initialize()
var max_hp:     int    = 0
var current_hp: int    = 0
var speed:      float  = 0.0
var damage:     int    = 0

var _mesh_material: StandardMaterial3D = null  # cached — no per-frame alloc
var _bar_material:  StandardMaterial3D = null

@onready var mesh_instance:   MeshInstance3D = $MeshInstance3D
@onready var health_bar_fill: MeshInstance3D = $HealthBar/Fill

initialize() (Enemy.gd)

func initialize() -> void:
    var stats := STATS[enemy_type]

    max_hp     = stats["hp"]
    current_hp = max_hp
    speed      = stats["speed"]
    damage     = stats["damage"]

    # Mesh colour — create material once, keep a reference
    _mesh_material = StandardMaterial3D.new()
    _mesh_material.albedo_color = stats["color"]
    mesh_instance.set_surface_override_material(0, _mesh_material)

    # Health bar material — also cached for _update_health_bar
    _bar_material = StandardMaterial3D.new()
    health_bar_fill.set_surface_override_material(0, _bar_material)
    _update_health_bar()  # start green and full

_ready() — Configure PathFollow3D

func _ready() -> void:
    loop = false  # stop at end, don't wrap back to start

Why not put stats in _ready()?
_ready() fires when the node enters the tree.
But enemy_type is set by the spawner after instantiation.
Calling initialize() explicitly after setting enemy_type gives us control over the order.

Quick Test — Increment 1

# Temporarily add to World.gd _ready():
func _ready() -> void:
    var e := preload("res://scenes/enemy.tscn").instantiate()
    $EnemyPath.add_child(e)
    e.enemy_type = "orc"
    e.initialize()

Run F5. A red capsule appears at the start of the path.

Confirms: scene instantiation, stats lookup, and mesh colour work.

Remove this code before Increment 3.

Increment 2: Path Movement

The Goal

Enemies move along the Path3D curve — automatically.

Every frame: add speed * delta to progress
At end: progress_ratio >= 1.0 → emit signal + free

Write This (Enemy.gd)

signal reached_end(damage: int)

func _process(delta: float) -> void:
    progress += speed * delta
    if progress_ratio >= 1.0:
        _reach_end()

func _reach_end() -> void:
    reached_end.emit(damage)  # World.gd subtracts this from player HP
    queue_free()

The signal carries damage — World.gd doesn't need to know the enemy type.

Why progress for Movement?

# ✓ CORRECT — speed is metres per second (matches stats table)
progress += speed * delta
# speed = 2.0 means the enemy moves 2 metres per second

# ✗ WRONG — speed would need to be a fraction of total curve length
progress_ratio += speed * delta
# speed = 2.0 would mean enemy crosses the entire path in 0.5 s

Rule: progress for movement, progress_ratio only for completion.

Test It — Increment 2

Keep the manual orc from Increment 1. Run F5.

The red capsule walks the full path and disappears at the end.

# Add temporarily to _reach_end to confirm the signal fires:
func _reach_end() -> void:
    print("Reached end! Damage: ", damage)
    reached_end.emit(damage)
    queue_free()

You should see the print in the Output panel. Remove it before continuing.

Increment 3: Wave Spawning

The Goal

5 waves spawn automatically — enemies spaced 1 second apart.

Remove the manual test code from _ready(). World.gd owns spawning now.

Wave Definitions (World.gd)

const WAVE_1 := ["slime", "slime", "slime"]
const WAVE_2 := ["slime", "slime", "goblin", "slime"]
const WAVE_3 := ["goblin", "goblin", "slime", "goblin", "slime"]
const WAVE_4 := ["goblin", "goblin", "goblin", "orc"]
const WAVE_5 := ["orc", "goblin", "goblin", "orc", "slime"]

var waves := [WAVE_1, WAVE_2, WAVE_3, WAVE_4, WAVE_5]

Escalates: 3 slimes → mixed → orc-heavy. Adjust freely — no code changes needed.

State Variables (World.gd)

const SPAWN_DELAY := 1.0   # seconds between enemies in a wave
const WAVE_DELAY  := 5.0   # seconds between waves

var current_wave:    int  = 0      # index; 0 = no wave started
var enemies_remaining: int = 0     # live enemies this wave
var is_spawning:     bool = false   # true while emitting enemies
var _between_waves:  bool = false   # guard: prevents double-transition

@onready var enemy_path: Path3D = $EnemyPath
var enemy_scene := preload("res://scenes/enemy.tscn")

_start_next_wave() (World.gd)

func _start_next_wave() -> void:
    if current_wave >= waves.size():
        _show_victory()
        return

    current_wave += 1
    var wave_enemies: Array = waves[current_wave - 1]
    enemies_remaining = wave_enemies.size()  # set BEFORE spawning begins

    _update_ui()
    is_spawning = true
    _spawn_wave(wave_enemies)

Async Wave Spawning (World.gd)

func _spawn_wave(wave_enemies: Array) -> void:
    for enemy_type in wave_enemies:
        _spawn_enemy(enemy_type)
        await get_tree().create_timer(SPAWN_DELAY).timeout
    # All enemies have been spawned
    is_spawning = false
    _check_wave_complete()  # maybe they all died already?

await suspends this function — engine runs normally during each 1-second wait.

_spawn_enemy() — Both Signals (World.gd)

func _spawn_enemy(enemy_type: String) -> void:
    var enemy: Enemy = enemy_scene.instantiate()
    enemy_path.add_child(enemy)   # must be child of Path3D

    enemy.enemy_type = enemy_type
    enemy.initialize()

    # Connect BOTH exit paths
    enemy.reached_end.connect(_on_enemy_reached_end)  # walks off the end
    enemy.died.connect(_on_enemy_died)                 # killed by a tower

Two signals, not one — towers (L27) will kill enemies via died, not reached_end.

Wire It: _ready() (World.gd)

func _ready() -> void:
    _update_ui()
    _start_next_wave()

Game starts → first wave begins immediately. UI shows "Wave: 1 / 5".

In L28 we'll add a "Start Wave" button here to give the player setup time.

Test It — Increment 3

Run F5. Three green slimes spawn 1 second apart and march down the path.

  • Slimes appear one at a time — not all at once
  • Each one walks the full path and vanishes
  • Wave 2 does not start yet (handlers not written)
  • UI shows "Wave: 1 / 5" and "HP: 100"
  • No errors in the Output panel

Increment 4: Health & Damage

The Goal

  • HP bars above enemies scale + shift green → red as damage is taken
  • Enemies die when HP reaches 0 (towers will call this in L27)
  • Player HP decreases when enemies reach the end
  • Next wave starts 5 seconds after all enemies are gone

After this increment: all 5 waves cycle automatically.

Health Bar Update (Enemy.gd)

func _update_health_bar() -> void:
    if _bar_material == null:
        return
    var pct := float(current_hp) / float(max_hp)  # 0.0 .. 1.0
    health_bar_fill.scale.x = pct

    # Green (full HP) → Red (empty HP) gradient
    _bar_material.albedo_color = Color.GREEN.lerp(Color.RED, 1.0 - pct)

We already call this at the end of initialize() — bar starts green and full-width.

Take Damage + Death (Enemy.gd)

signal died()   # World.gd listens to this too

func take_damage(amount: int) -> void:
    current_hp -= amount
    current_hp = maxi(current_hp, 0)  # clamp — don't go negative
    _update_health_bar()
    if current_hp <= 0:
        _die()

func _die() -> void:
    died.emit()    # triggers _on_enemy_died in World.gd
    queue_free()

Towers (L27) call take_damage(). The died signal was already connected in _spawn_enemy.

Player HP Variables (World.gd)

const PLAYER_START_HP := 100

var player_hp: int = PLAYER_START_HP

@onready var hp_label:   Label = $UI/HPLabel
@onready var wave_label: Label = $UI/WaveLabel

Add alongside the wave state variables at the top of World.gd.

If every enemy reaches the end unopposed: total damage = 3(10) + 4(15) + 5(15) + 4(25) + 5(20) = ~310. Player loses without towers.

Enemy Reached End Handler (World.gd)

func _on_enemy_reached_end(damage: int) -> void:
    player_hp -= damage
    player_hp = maxi(player_hp, 0)
    _update_ui()

    enemies_remaining -= 1
    _check_wave_complete()

    if player_hp <= 0:
        _show_game_over()

Enemy Died Handler (World.gd)

func _on_enemy_died() -> void:
    enemies_remaining -= 1
    _check_wave_complete()

Simpler — no HP penalty when towers kill an enemy.

Both handlers decrement enemies_remaining. Only one fires per enemy.

In L26 this handler has no effect. In L27 when towers exist, it drives wave progression automatically.

Check Wave Complete (World.gd)

func _check_wave_complete() -> void:
    # Three conditions must ALL be false to proceed
    if enemies_remaining > 0 or is_spawning or _between_waves:
        return

    _between_waves = true                                # guard against double-fire
    await get_tree().create_timer(WAVE_DELAY).timeout
    _between_waves = false
    _start_next_wave()

_between_waves prevents two simultaneous enemy deaths from triggering two wave starts.

UI Update (World.gd)

func _update_ui() -> void:
    hp_label.text   = "HP: %d" % player_hp
    wave_label.text = "Wave: %d / %d" % [current_wave, waves.size()]

# Stubs — full implementation in L28
func _show_victory() -> void:
    print("VICTORY! All waves cleared.")

func _show_game_over() -> void:
    print("GAME OVER! Player HP reached 0.")

Call _update_ui() from _ready(), _start_next_wave(), and _on_enemy_reached_end().

Test It — Increment 4

Run F5 and let all 5 waves complete automatically.

  • Wave 1 spawns 3 slimes, HP drops from 100 to 70
  • 5-second pause, then Wave 2 starts
  • All 5 waves cycle through
  • "VICTORY!" prints after Wave 5 clears
  • Health bars stay green — nothing calls take_damage yet

The full threat loop runs end-to-end. Thursday we fight back.

Bonus: Path Visualization

The Goal

A brown "dirt road" runs along the Path3D curve.

Players immediately understand: enemies walk here, don't place towers here.

Pure code — no art assets, no sprites, just CSG boxes.

Baked Curve Sampling

  • Godot pre-computes (bakes) curve positions at import time
  • curve.get_baked_length() → total length in metres
  • curve.sample_baked(offset) → Vector3 at that metre mark
  • Sample every 1 metre → place a CSG box at each point
  • Raise boxes by 0.05 m to avoid z-fighting with the ground

PathVisualizer.gd (Attach to EnemyPath)

extends Node3D

func _ready() -> void:
    var path   := get_parent() as Path3D
    var curve  := path.curve
    var length := int(curve.get_baked_length())
    var mat    := _make_road_material()

    for offset in range(0, length, 1):
        var pos := curve.sample_baked(offset)
        var box := CSGBox3D.new()
        box.position          = pos + Vector3(0, 0.05, 0)
        box.size              = Vector3(2.0, 0.1, 1.0)
        box.material_override = mat
        add_child(box)

func _make_road_material() -> StandardMaterial3D:
    var mat := StandardMaterial3D.new()
    mat.albedo_color = Color(0.4, 0.3, 0.2)  # dirt brown
    return mat

Test It — Bonus

Attach PathVisualizer.gd to the EnemyPath node, then F5.

A brown road appears along the path. Enemies walk along it.

  • Tiles flicker? Increase y-offset from 0.05 to 0.1
  • Scene tree lag? Change loop step from 1 to 2 (half as many tiles)

Full Picture & Summary

Enemy.gd — Complete Structure

extends PathFollow3D

signal reached_end(damage: int)
signal died()

const STATS := { "slime": {...}, "goblin": {...}, "orc": {...} }

var enemy_type: String = "slime"
var max_hp: int; var current_hp: int; var speed: float; var damage: int
var _mesh_material: StandardMaterial3D; var _bar_material: StandardMaterial3D
@onready var mesh_instance: MeshInstance3D = $MeshInstance3D
@onready var health_bar_fill: MeshInstance3D = $HealthBar/Fill

func _ready()             -> void  # loop = false
func initialize()         -> void  # stats lookup, materials, _update_health_bar
func _process(delta)      -> void  # progress += speed*delta; check progress_ratio
func _reach_end()         -> void  # reached_end.emit(damage); queue_free()
func take_damage(n: int)  -> void  # current_hp -= n; clamp; update bar; _die?
func _die()               -> void  # died.emit(); queue_free()
func _update_health_bar() -> void  # scale.x = pct; lerp colour

World.gd — Complete Structure

extends Node3D

const WAVE_1..5; const SPAWN_DELAY := 1.0; const WAVE_DELAY := 5.0
const PLAYER_START_HP := 100

var waves := [...]
var current_wave: int; var enemies_remaining: int
var is_spawning: bool; var _between_waves: bool
var player_hp: int = PLAYER_START_HP
@onready var enemy_path: Path3D; @onready hp_label, wave_label: Label
var enemy_scene := preload("res://scenes/enemy.tscn")

func _ready()                  -> void  # _update_ui(); _start_next_wave()
func _start_next_wave()        -> void  # victory check; increment; _spawn_wave
func _spawn_wave(arr)          -> void  # for loop + await  [async]
func _spawn_enemy(type)        -> void  # instantiate; both signal connections
func _on_enemy_reached_end(dmg)-> void  # player_hp; enemies_remaining; check
func _on_enemy_died()          -> void  # enemies_remaining; check
func _check_wave_complete()    -> void  # 3-condition guard + await  [async]
func _update_ui()              -> void  # label text
func _show_victory()           -> void  # stub (L28)
func _show_game_over()         -> void  # stub (L28)

What We Built Today

  • ✓ Three enemy types — one stats dictionary, one scene
  • ✓ PathFollow3D movement — one line per frame
  • ✓ Wave spawning — 5 waves as data, async 1-second delays
  • ✓ Health bars — scale + green-to-red gradient
  • ✓ Player HP — decreases when enemies reach the end
  • ✓ Wave completion — 3-condition guard, 5-second break

The threat is complete. 5 waves march without being stopped.

Next: Lecture 27 — Towers & Money

What We Add Thursday

✓ Tower base class + state machine
✓ Instant tower (laser, instant damage)
✓ Projectile tower (fires bullets)
✓ Enemy detection via Area3D
✓ Grid-based placement
✓ Money: earn on kills, spend on towers

Before Thursday

  1. Run the complete 5-wave cycle and confirm "VICTORY!" prints
  2. Tweak wave compositions — what happens if Wave 3 starts with an orc?
  3. Try SPAWN_DELAY := 0.3 — does the game handle a dense crowd?
  4. Think: what states would a tower need? (hint: look at the L27 preview above)