CS 470 — Week 10: 3D Tower Defense
Path3DToday: swap the fixed path for a central HQ and add five more systems.
A fixed path is predictable. Players memorise choke points and win every time on the second run.
A central HQ changes this — enemies approach from any direction; every corner of the map matters.
albedo_color.a = 0.6destroyed signal → triggers game overvar material := StandardMaterial3D.new()
material.albedo_color = Color(0.4, 0.6, 1.0, 0.6)
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
func _flash_red() -> void:
if is_flashing: # guard: prevents stacking
return
is_flashing = true
var material := mesh_instance.get_surface_override_material(0)
var tween := create_tween()
tween.tween_property(material, "albedo_color",
FLASH_COLOR, FLASH_DURATION)
tween.tween_property(material, "albedo_color",
BASE_COLOR, FLASH_DURATION)
await tween.finished
is_flashing = false
Two chained tween_property calls: red in 0.15 s, then back.
extends PathFollow3D
func _process(delta):
progress += speed * delta
if progress_ratio >= 1.0:
_reach_end()
extends Node3D
var target: Node3D = null
func _process(delta):
_move_toward_target(delta)
func _move_toward_target(delta: float) -> void:
if not target or not is_instance_valid(target):
return
var direction := global_position.direction_to(
target.global_position)
direction.y = 0.0 # stay flat on the ground
global_position += direction * speed * slow_factor * delta
if direction.length_squared() > 0.01:
look_at(global_position + direction, Vector3.UP)
if global_position.distance_to(
target.global_position) < 1.5:
_reach_hq()
slow_factor (normally 1.0) is modified by the Slow Tower.
Two towers (instant + projectile) differ only in cost and range — that is not a meaningful strategic choice.
A Slow Tower introduces a new axis: time instead of damage.
func _perform_attack() -> void:
if not current_target:
return
current_target.take_damage(tower_damage) # light damage
if current_target.has_method("slow"):
current_target.slow(2.0, 0.4) # 40% speed, 2 s
Stats: cost $125, range 9.0, cooldown 0.8 s, damage 8.
var slow_factor: float = 1.0
var slow_timer: float = 0.0
func slow(duration: float, factor: float) -> void:
slow_factor = factor # e.g. 0.4
slow_timer = duration # e.g. 2.0
func _tick_slow(delta: float) -> void:
if slow_timer > 0.0:
slow_timer -= delta
if slow_timer <= 0.0:
slow_factor = 1.0 # restore full speed
slow_factor multiplied into global_position += each frame.
DetectionAreaBest placed near the HQ where enemies bunch up.
func _perform_attack() -> void:
var areas := detection_area.get_overlapping_areas()
for area in areas:
var enemy = area.get_parent()
if enemy.has_method("take_damage") and enemy != self:
enemy.take_damage(tower_damage)
_pulse_effect() # scale ring briefly for visual feedback
Time complexity: O(k) where k = enemies in range. No projectiles, no allocation.
| Tower | Target | Complexity | Best near |
|---|---|---|---|
| Instant | 1 enemy | O(1) | Choke points |
| Projectile | 1 enemy | O(1) + projectile | Long approaches |
| Slow | 1 enemy | O(1) | Fast enemies |
| AOE | All in range | O(k) | HQ perimeter |
Without sell, a misplaced tower is a permanent mistake. With sell, early placements become investments you can liquidate.
Refund rate: 50 %. Discourages buy/sell cycling while preserving strategic flexibility.
func _try_sell_tower() -> void:
if not selected_tower:
return
var refund: int = int(selected_tower.tower_cost * 0.5)
money += refund
# Three structures must stay in sync
var cell := _world_to_cell(selected_tower.global_position)
occupied_cells.erase(cell)
placed_towers.erase(selected_tower)
selected_tower.queue_free()
selected_tower = null
_update_ui()
Miss any one of these and the grid state becomes inconsistent.
Splitter (purple) → 2 × Slime on death
_die() emits split_at(global_position)World._on_enemy_split(pos)pos ± small random offsetsignal split_at(position: Vector3)
func _die() -> void:
if enemy_type == "splitter":
split_at.emit(global_position) # before queue_free
died.emit(enemy_type)
queue_free()
func _on_enemy_split(position: Vector3) -> void:
for i in range(2):
var slime := enemy_scene.instantiate()
add_child(slime)
var offset := Vector3(randf_range(-0.5, 0.5),
0, randf_range(-0.5, 0.5))
slime.global_position = position + offset
slime.enemy_type = "slime"
slime.target = hq
slime.initialize()
slime.died.connect(_on_enemy_died)
slime.split_at.connect(_on_enemy_split)
enemies_remaining_in_wave += 1
enemies_remaining_in_wave += 1 is critical — without it the wave ends early.
Without a preview, the player watches enemies arrive and reacts. With preview, they plan — and feel clever when it works.
Shown for 3 s before each wave: "Wave 3: Goblin ×2, Splitter ×1"
func _show_wave_preview(wave_enemies: Array) -> void:
var counts: Dictionary = {}
for e in wave_enemies:
counts[e] = counts.get(e, 0) + 1
var parts := PackedStringArray()
for type in counts:
parts.append("%s x%d" % [type.capitalize(), counts[type]])
wave_preview_label.text = "Wave %d: %s" % [
current_wave, ", ".join(parts)]
wave_preview_label.visible = true
await get_tree().create_timer(WAVE_PREVIEW_DURATION).timeout
wave_preview_label.visible = false
func _on_speed_button_pressed() -> void:
if Engine.time_scale < 1.5:
Engine.time_scale = 2.0
speed_button.text = "1x"
else:
Engine.time_scale = 1.0
speed_button.text = "2x"
Engine.time_scale = 1.0 in_game_over(), _game_won(), and _on_restart_pressed().
const TOWER_COSTS: Array[int] = [100, 150, 125, 200]
func _scene_for_type(type: int) -> PackedScene:
match type:
0: return tower_instant_scene
1: return tower_projectile_scene
2: return tower_slow_scene
3: return tower_aoe_scene
return tower_instant_scene
Adding a fifth tower = one constant + one match branch. Placement logic doesn't change.
| Feature | Key technique |
|---|---|
| Central HQ | Node3D target + direction_to, tween flash |
| Slow Tower | slow_factor on enemy, timer countdown |
| AOE Tower | get_overlapping_areas() loop |
| Sell | Three-structure cleanup (cells + array + scene) |
| Split-on-death | split_at signal, children connect back |
| Wave preview | Dict frequency count, await timer |
| 2× speed | Engine.time_scale, reset on all exits |
You get this codebase as a starter. Design a level around defending the HQ.
SPAWN_POINTS (≥ 3)World.gd + 2–3 sentence rationale