CS 470 — Week 10: 3D Tower Defense (1/3)
Types, Spawning
Waves, Health
State Machines
Money, Combat
Upgrades, Selection
Win/Loss, Polish
Enemies must appear one at a time with delays — but the game loop runs every frame.
How do we define 5 different waves without 5 different code paths?
Next wave starts only when all enemies are gone AND spawning is done.
Enemies need to follow a curve without custom steering code.
# 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.
# 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.
Each increment is tested before moving on.
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]
# 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 ===
We do not write await $SpawnTimer.timeout — we use create_timer() inline instead.
PathFollow3D is the root — position along the curve is automatic.
progress — distance in metres from the start of the curveprogress_ratio — same thing, normalized 0.0 → 1.0progress each frame → position and rotation update automaticallyloop = false — enemy stops instead of wrapping back to startRule: use progress for movement, progress_ratio for completion check.
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.
| 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.
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.
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
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
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.
# 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.
Enemies move along the Path3D curve — automatically.
speed * delta to progress
progress_ratio >= 1.0 → emit signal + free
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.
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.
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.
5 waves spawn automatically — enemies spaced 1 second apart.
Remove the manual test code from _ready(). World.gd owns spawning now.
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.
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")
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)
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.
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.
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.
Run F5. Three green slimes spawn 1 second apart and march down the path.
After this increment: all 5 waves cycle automatically.
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.
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.
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.
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()
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.
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.
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().
Run F5 and let all 5 waves complete automatically.
The full threat loop runs end-to-end. Thursday we fight back.
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.
curve.get_baked_length() → total length in metrescurve.sample_baked(offset) → Vector3 at that metre markextends 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
Attach PathVisualizer.gd to the EnemyPath node, then F5.
A brown road appears along the path. Enemies walk along it.
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
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)
The threat is complete. 5 waves march without being stopped.
SPAWN_DELAY := 0.3 — does the game handle a dense crowd?