Lecture 29

Tower Defense: Extended Systems

CS 470 — Week 10: 3D Tower Defense

Lectures 27–28 Recap

  • 3D grid with instant + projectile towers
  • Enemy waves following a Path3D
  • Money system and tower upgrades (right-click + U)
  • Win/loss conditions with restart

Today: swap the fixed path for a central HQ and add five more systems.

Six New Features

  1. Central HQ — enemies target a building, not a path exit
  2. Slow Tower — reduces enemy speed for 2 s
  3. AOE Tower — damages all enemies in radius at once
  4. Sell Tower — S key for 50 % refund
  5. Split-on-Death — splitter enemies spawn two slimes
  6. Wave Preview + 2× Speed — quality-of-life systems

The Problem with Path3D

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.

HQ: Visual Contract

  • Semi-transparent box mesh — albedo_color.a = 0.6
  • Flashes red on each hit via Tween
  • HP bar above the building (green → red)
  • Emits destroyed signal → triggers game over
var material := StandardMaterial3D.new()
material.albedo_color = Color(0.4, 0.6, 1.0, 0.6)
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA

Hit Flash: Tween Pattern

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.

Enemy: From Path to Target

Lecture 28
extends PathFollow3D

func _process(delta):
    progress += speed * delta
    if progress_ratio >= 1.0:
        _reach_end()
Lecture 29
extends Node3D
var target: Node3D = null

func _process(delta):
    _move_toward_target(delta)

_move_toward_target()

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.

Tower Variety: Why?

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.

TowerSlow: The Attack

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.

Enemy: Accepting a Slow

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.

AOE Tower: Area Damage

  • No projectile — pulses damage every 1.5 s
  • Hits every enemy inside its DetectionArea
  • Short range (5.0) — effective in clusters near the HQ
  • Stats: cost $200, damage 15 per pulse

Best placed near the HQ where enemies bunch up.

TowerAOE: Hitting All Enemies

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.

Damage Models Compared

TowerTargetComplexityBest near
Instant1 enemyO(1)Choke points
Projectile1 enemyO(1) + projectileLong approaches
Slow1 enemyO(1)Fast enemies
AOEAll in rangeO(k)HQ perimeter

Sell Tower: Economic Flexibility

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.

_try_sell_tower()

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.

Split-on-Death: Design Goals

  • Punishes tunnelling: ignoring one big enemy spawns two smaller ones
  • Introduces a priority targeting decision
  • Re-uses the existing slime — no new scene needed for the children

Splitter (purple) → 2 × Slime on death

The Signal Chain

Tower hits splitter
↓ hp = 0
_die() emits split_at(global_position)
World._on_enemy_split(pos)
spawn 2 slimes at pos ± small random offset

Enemy: Emitting split_at

signal 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()

World: Spawning the Split Children

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.

Wave Preview: Player Agency

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"

_show_wave_preview()

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

Speed-Up: Engine.time_scale

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"
Always reset on exit. Set Engine.time_scale = 1.0 in
_game_over(), _game_won(), and _on_restart_pressed().

World.gd: Four Tower Types

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.

Summary

FeatureKey technique
Central HQNode3D target + direction_to, tween flash
Slow Towerslow_factor on enemy, timer countdown
AOE Towerget_overlapping_areas() loop
SellThree-structure cleanup (cells + array + scene)
Split-on-deathsplit_at signal, children connect back
Wave previewDict frequency count, await timer
2× speedEngine.time_scale, reset on all exits

Next: Lecture 30 — Level Design

You get this codebase as a starter. Design a level around defending the HQ.

  • Set your SPAWN_POINTS (≥ 3)
  • Write waves 1–5 with a difficulty curve you can defend
  • Submit: World.gd + 2–3 sentence rationale