Lecture 27

Tower Defense: Towers & Money

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

Where We Left Off

lecture-26-recap.mp4
5 waves spawning, enemies marching down path, player HP decreasing

We can spawn enemies. Now we need to defend.

Today's Goal

lecture-27-complete.mp4
Placing towers, auto-attacking enemies, earning money on kills

Place towers, kill enemies, earn money, win the game.

Two Core Patterns Today

Pattern 1: State Machines

IDLE → TARGETING → ATTACKING

Towers cycle through states, finding enemies and attacking automatically.

Pattern 2: Resource Economy

Earn money on kills, spend on towers

Every decision has cost. Can't place everything everywhere.

Today's Increments

  1. Tower base class — state machine, detection area
  2. Instant tower — shoots beams, deals instant damage
  3. Projectile tower — spawns homing bullets
  4. Grid placement — raycasting + snap to grid
  5. Money system — earn on kills, spend on towers

Five increments. Each one builds on the previous. Let's start with the tower base class.

Increment 1: Tower Base Class

The Goal

Towers cycle through states, finding and targeting enemies.

State machine: IDLE (scan for enemies) → TARGETING (lock on, rotate) → ATTACKING (deal damage, cooldown).

Tower Scene Structure

TowerInstant (Node3D, extends Tower.gd)
  ├─ MeshInstance3D (CylinderMesh, blue)
  ├─ RangeIndicator (MeshInstance3D, ring, hidden)
  └─ DetectionArea (Area3D)
      └─ CollisionShape3D (SphereShape3D, radius = range)

Area3D detects enemies in range. RangeIndicator shows tower's reach (will be used in L28).

State Enum (Tower.gd)

extends Node3D

enum State { IDLE, TARGETING, ATTACKING }

var current_state: State = State.IDLE
var current_target: PathFollow3D = null
var time_since_last_attack: float = 0.0

# Stats (override in child classes)
var tower_damage: int = 10
var tower_range: float = 8.0
var attack_cooldown: float = 1.0

State Machine Loop

func _process(delta: float) -> void:
    time_since_last_attack += delta
    
    match current_state:
        State.IDLE:
            _find_target()
            if current_target:
                _change_state(State.TARGETING)
        
        State.TARGETING:
            if not _is_target_valid():
                current_target = null
                _change_state(State.IDLE)
                return
            
            look_at(current_target.global_position, Vector3.UP)
            
            if time_since_last_attack >= attack_cooldown:
                _change_state(State.ATTACKING)
        
        State.ATTACKING:
            _perform_attack()
            time_since_last_attack = 0.0
            _change_state(State.TARGETING)

Finding the Nearest Enemy

@onready var detection_area: Area3D = $DetectionArea

func _find_target() -> void:
    var bodies = detection_area.get_overlapping_bodies()
    if bodies.is_empty():
        return
    
    var closest_enemy: PathFollow3D = null
    var closest_distance := INF
    
    for body in bodies:
        if body is PathFollow3D and body.has_method("take_damage"):
            var dist := global_position.distance_to(body.global_position)
            if dist < closest_distance:
                closest_distance = dist
                closest_enemy = body
    
    current_target = closest_enemy

Target Validation

func _is_target_valid() -> bool:
    if not current_target or not is_instance_valid(current_target):
        return false
    
    var distance := global_position.distance_to(
        current_target.global_position
    )
    return distance <= tower_range

Perform Attack (Override This)

func _perform_attack() -> void:
    # Override in TowerInstant and TowerProjectile
    pass

Visual Feedback: Color by State

const COLOR_IDLE := Color(0.2, 0.4, 0.8)       # Blue
const COLOR_TARGETING := Color(0.8, 0.6, 0.2)  # Orange
const COLOR_ATTACKING := Color(0.8, 0.2, 0.2)  # Red

func _change_state(new_state: State) -> void:
    current_state = new_state
    _update_color()

func _update_color() -> void:
    var color: Color
    match current_state:
        State.IDLE: color = COLOR_IDLE
        State.TARGETING: color = COLOR_TARGETING
        State.ATTACKING: color = COLOR_ATTACKING
    
    var material := StandardMaterial3D.new()
    material.albedo_color = color
    mesh_instance.set_surface_override_material(0, material)

Increment 2: Instant Tower

The Goal

Tower deals instant damage with a visual beam effect.

Fast attacks, short range, cheap cost.

TowerInstant Stats

extends "res://scripts/Tower.gd"

func _ready() -> void:
    tower_cost = 100
    tower_damage = 15
    tower_range = 8.0
    attack_cooldown = 1.0
    
    super._ready()  # Call parent _ready

Perform Attack

func _perform_attack() -> void:
    if not current_target:
        return
    
    # Deal instant damage
    current_target.take_damage(tower_damage)
    
    # Visual feedback
    _create_beam_effect()

Beam Effect (Visual Only)

func _create_beam_effect() -> void:
    var cylinder := CylinderMesh.new()
    cylinder.height = global_position.distance_to(
        current_target.global_position
    )
    cylinder.top_radius = 0.05
    cylinder.bottom_radius = 0.05
    
    var line := MeshInstance3D.new()
    line.mesh = cylinder
    line.global_position = global_position.lerp(
        current_target.global_position, 0.5
    )
    line.look_at(current_target.global_position, Vector3.UP)
    line.rotate_object_local(Vector3.RIGHT, PI / 2)
    
    var material := StandardMaterial3D.new()
    material.albedo_color = Color.YELLOW
    material.emission_enabled = true
    material.emission = Color.YELLOW
    line.set_surface_override_material(0, material)
    
    get_tree().root.add_child(line)
    
    await get_tree().create_timer(0.1).timeout
    line.queue_free()

Quick Test

Manually place tower_instant.tscn in World. Run F5.

Tower should turn orange when enemy gets close, flash red when shooting, yellow beam appears.

Increment 3: Projectile Tower

The Goal

Tower spawns projectiles that fly toward enemies.

Longer range, slower cooldown, projectiles can miss if enemy dies.

TowerProjectile Stats

extends "res://scripts/Tower.gd"

var projectile_scene := preload("res://scenes/projectile.tscn")

func _ready() -> void:
    tower_cost = 150
    tower_damage = 10
    tower_range = 12.0
    attack_cooldown = 1.5
    
    super._ready()

Perform Attack (Spawn Projectile)

func _perform_attack() -> void:
    if not current_target:
        return
    
    var projectile := projectile_scene.instantiate()
    get_tree().root.add_child(projectile)
    
    projectile.global_position = global_position + Vector3.UP
    projectile.target = current_target
    projectile.damage = tower_damage

Projectile Scene

Projectile (Node3D)
  └─ MeshInstance3D (SphereMesh, orange emissive)

Simple sphere with glowing material. Script handles movement.

Projectile Movement (Projectile.gd)

extends Node3D

const SPEED := 15.0
const MAX_LIFETIME := 5.0

var target: Node3D = null
var damage: int = 10
var lifetime: float = 0.0

func _process(delta: float) -> void:
    lifetime += delta
    if lifetime > MAX_LIFETIME:
        queue_free()
        return
    
    if not target or not is_instance_valid(target):
        queue_free()
        return
    
    var direction := global_position.direction_to(
        target.global_position
    )
    global_position += direction * SPEED * delta
    look_at(target.global_position, Vector3.UP)
    
    if global_position.distance_to(target.global_position) < 0.5:
        target.take_damage(damage)
        queue_free()

Test It — F5

Place tower_projectile.tscn in World. Orange bullets fly toward enemies.

Watch bullets track moving enemies and hit them.

Increment 4: Grid Placement

The Goal

Press 1 or 2 to select tower type. Click to place on grid.

Preview follows mouse. Green = valid, red = occupied. Snap to 2×2m grid.

Grid Constants (World.gd)

const GRID_SIZE := 2.0

var occupied_cells: Array[Vector2i] = []
var selected_tower_type: int = 0  # 0 = instant, 1 = projectile
var preview_tower: Node3D = null

var tower_instant_scene := preload("res://scenes/tower_instant.tscn")
var tower_projectile_scene := preload("res://scenes/tower_projectile.tscn")

Input: Select Tower Type

func _input(event: InputEvent) -> void:
    if event is InputEventKey and event.pressed:
        if event.keycode == KEY_1:
            selected_tower_type = 0
        elif event.keycode == KEY_2:
            selected_tower_type = 1

Raycasting: Mouse → World Position

@onready var camera: Camera3D = $Camera3D

func _get_world_position_from_mouse() -> Vector3:
    var mouse_pos := get_viewport().get_mouse_position()
    var ray_origin := camera.project_ray_origin(mouse_pos)
    var ray_normal := camera.project_ray_normal(mouse_pos)
    var ray_end := ray_origin + ray_normal * 1000
    
    var space_state := get_world_3d().direct_space_state
    var query := PhysicsRayQueryParameters3D.create(
        ray_origin, ray_end
    )
    var result := space_state.intersect_ray(query)
    
    if result:
        return result.position
    return Vector3.ZERO

Snap to Grid

func _get_grid_cell(world_pos: Vector3) -> Vector2i:
    return Vector2i(
        roundi(world_pos.x / GRID_SIZE),
        roundi(world_pos.z / GRID_SIZE)
    )

func _cell_to_world_pos(cell: Vector2i) -> Vector3:
    return Vector3(
        cell.x * GRID_SIZE,
        0.0,
        cell.y * GRID_SIZE
    )

Tower Preview

func _process(_delta: float) -> void:
    if selected_tower_type >= 0:
        _update_tower_preview()

func _update_tower_preview() -> void:
    if preview_tower:
        preview_tower.queue_free()
    
    var scene := tower_instant_scene if selected_tower_type == 0 \
                  else tower_projectile_scene
    preview_tower = scene.instantiate()
    preview_tower.is_preview = true  # Disable AI
    add_child(preview_tower)
    
    var world_pos := _get_world_position_from_mouse()
    var cell := _get_grid_cell(world_pos)
    preview_tower.position = _cell_to_world_pos(cell)
    
    # Set transparency
    preview_tower.modulate = Color(1, 1, 1, 0.5)

Placement Validation

func _can_place_tower(cell: Vector2i) -> bool:
    return not (cell in occupied_cells)

func _input(event: InputEvent) -> void:
    # ... (tower selection from earlier)
    
    if event is InputEventMouseButton:
        if event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
            _place_selected_tower()

Place Tower

func _place_selected_tower() -> void:
    var world_pos := _get_world_position_from_mouse()
    var cell := _get_grid_cell(world_pos)
    
    if not _can_place_tower(cell):
        return
    
    var scene := tower_instant_scene if selected_tower_type == 0 \
                  else tower_projectile_scene
    var tower := scene.instantiate()
    add_child(tower)
    tower.position = _cell_to_world_pos(cell)
    
    occupied_cells.append(cell)

Test It — F5

Press 1. Preview appears. Click. Tower places and starts attacking.

Try placing multiple towers. Watch them work together.

Increment 5: Money System

The Goal

Start with $500. Earn money when enemies die. Spend money to place towers.

Can't place towers you can't afford. Forces strategic choices.

Money Constants (World.gd)

const START_MONEY := 500
const KILL_REWARD_SLIME := 25
const KILL_REWARD_GOBLIN := 40
const KILL_REWARD_ORC := 75

var money: int = START_MONEY

@onready var money_label: Label = $UI/MoneyLabel

Emit Enemy Type on Death (Enemy.gd)

signal died(enemy_type: String)

func _die() -> void:
    died.emit(enemy_type)
    queue_free()

Award Money on Kill (World.gd)

func _spawn_enemy(enemy_type: String) -> void:
    var enemy := enemy_scene.instantiate()
    enemy_path.add_child(enemy)
    enemy.enemy_type = enemy_type
    enemy.initialize()
    
    enemy.reached_end.connect(_on_enemy_reached_end)
    enemy.died.connect(_on_enemy_died)  # New connection

func _on_enemy_died(enemy_type: String) -> void:
    # Award money based on type
    match enemy_type:
        "slime": money += KILL_REWARD_SLIME
        "goblin": money += KILL_REWARD_GOBLIN
        "orc": money += KILL_REWARD_ORC
    
    enemies_remaining_in_wave -= 1
    _check_wave_complete()
    _update_ui()

Check Money Before Placing

func _can_place_tower(cell: Vector2i, cost: int) -> bool:
    if money < cost:
        return false
    if cell in occupied_cells:
        return false
    return true

func _place_selected_tower() -> void:
    var world_pos := _get_world_position_from_mouse()
    var cell := _get_grid_cell(world_pos)
    
    var cost := 100 if selected_tower_type == 0 else 150
    
    if not _can_place_tower(cell, cost):
        return
    
    money -= cost
    
    # ... (instantiate and place tower)
    
    _update_ui()

UI Update

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

Test It — F5

Complete game! Place towers, kill enemies, earn money, survive 5 waves.

Try running out of money. Try different tower placements.

Bonus: Grid Overlay

The Goal

Show subtle grid lines so players see cell boundaries.

GridOverlay.gd (Attach to World)

extends Node3D

const GRID_SIZE := 2.0

func _ready() -> void:
    # Draw vertical lines (x-axis)
    for x in range(-10, 11):
        _draw_line(
            Vector3(x * GRID_SIZE, 0.01, -15),
            Vector3(x * GRID_SIZE, 0.01, 15)
        )
    
    # Draw horizontal lines (z-axis)
    for z in range(-7, 8):
        _draw_line(
            Vector3(-20, 0.01, z * GRID_SIZE),
            Vector3(20, 0.01, z * GRID_SIZE)
        )

func _draw_line(from: Vector3, to: Vector3) -> void:
    # Create thin line mesh
    var mesh := ImmediateMesh.new()
    var material := StandardMaterial3D.new()
    material.albedo_color = Color(0.5, 0.5, 0.5, 0.3)
    material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
    # ... (line generation code)

What We Built Today

  • ✓ Tower base class with state machine (IDLE → TARGETING → ATTACKING)
  • ✓ Instant tower with beam attacks
  • ✓ Projectile tower with homing bullets
  • ✓ Grid-based placement with raycasting
  • ✓ Money economy: earn on kills, spend on towers
  • ✓ Complete playable game loop

Next Lecture 28

Upgrades & Win/Loss

  • ✓ 3-level upgrade system with stat progression
  • ✓ Tower selection with range indicators
  • ✓ Win/loss UI panels with restart
  • ✓ Final polish and balance