Lecture 28

Tower Defense: Upgrades & Win/Loss

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

Where We Left Off

lecture-27-recap.mp4
Complete tower defense gameplay: place towers, kill enemies, earn money

Fully playable, but no upgrades. No way to make towers stronger.

The Strategic Problem

Once you place towers, they never improve.

Wave 5 has 2 orcs (200 HP each). Your Level 1 towers barely scratch them.

We need a way to spend money on existing towers.

Today's Goal

lecture-28-complete.mp4
Complete game: upgrade towers, range indicators, win/loss screens

Right-click to select. Press U to upgrade. Win or lose with style.

The Core Pattern: Stat Progression

# Level 1 (base)
damage = 15
range = 8.0
cooldown = 1.0

# Level 2
damage = 15 + 5 = 20
range = 8.0 × 1.2 = 9.6
cooldown = 1.0 × 0.9 = 0.9

# Level 3
damage = 15 + 10 = 25
range = 8.0 × 1.4 = 11.2
cooldown = 1.0 × 0.8 = 0.8

Each upgrade uses multiplicative scaling. No hardcoded stat tables — just formulas.

Today's Increments

  1. Upgrade system — 3 levels with stat formulas
  2. Tower selection — right-click to select, show range
  3. Upgrade UI — show stats, cost, press U to upgrade
  4. Win/loss screens — detect game over, show panels
  5. Restart — reload scene button

Five increments. By the end, complete polished game.

Increment 1: Upgrade System

The Goal

Towers can upgrade to Level 3, getting stronger each time.

Cost increases (1.5× base). Stats improve. Visual feedback (scale up).

Upgrade Design Table

Level Cost (Instant) Damage Range Cooldown
1 $100 15 8.0m 1.0s
2 +$150 20 (+5) 9.6m (+20%) 0.9s (−10%)
3 +$225 25 (+10 total) 11.2m (+40% total) 0.8s (−20% total)

Total investment for L3 instant tower: $100 + $150 + $225 = $475

New Constants (Tower.gd)

const MAX_LEVEL := 3
const UPGRADE_COST_MULTIPLIER := 1.5
const DAMAGE_PER_LEVEL := 5

# Rename old stats to "base" stats
var base_damage: int = 10
var base_range: float = 8.0
var base_cooldown: float = 1.0

# Current stats (recalculated on upgrade)
var tower_damage: int = 10
var tower_range: float = 8.0
var attack_cooldown: float = 1.0

var level: int = 1

Child Class Setup (TowerInstant.gd)

func _ready() -> void:
    tower_cost = 100
    
    # Set BASE stats
    base_damage = 15
    base_range = 8.0
    base_cooldown = 1.0
    
    # Copy to current stats
    tower_damage = base_damage
    tower_range = base_range
    attack_cooldown = base_cooldown
    
    super._ready()

Upgrade Function (Tower.gd)

func upgrade() -> void:
    if level >= MAX_LEVEL:
        return
    
    level += 1
    
    # Recalculate stats from base + level
    tower_damage = base_damage + (DAMAGE_PER_LEVEL * (level - 1))
    tower_range = base_range * (1.0 + 0.2 * (level - 1))
    attack_cooldown = base_cooldown * (1.0 - 0.1 * (level - 1))
    
    # Update detection range to match new range
    _setup_detection_range()
    
    # Visual feedback: scale up
    var tween := create_tween()
    var new_scale := 1.0 + 0.2 * (level - 1)
    tween.tween_property(mesh_instance, "scale", 
        Vector3.ONE * new_scale, 0.3)

Helper Functions

func get_upgrade_cost() -> int:
    if level >= MAX_LEVEL:
        return 0
    return int(tower_cost * UPGRADE_COST_MULTIPLIER)

func can_upgrade() -> bool:
    return level < MAX_LEVEL

func get_info_string() -> String:
    return "Level %d | Damage: %d | Range: %.1f" % [
        level, tower_damage, tower_range
    ]

Quick Test

Add a tower manually. In console, call tower.upgrade() twice.

Tower should scale up. Print stats to confirm they increase.

Increment 2: Tower Selection

The Goal

Right-click tower to select. Range indicator appears.

Shows tower's detection radius. Visual feedback for planning.

New State (World.gd)

var selected_tower: Node3D = null
var placed_towers: Array[Node3D] = []

Show Range Indicator (Tower.gd)

@onready var range_indicator: MeshInstance3D = $RangeIndicator

func _ready() -> void:
    # ... other setup
    range_indicator.visible = false

func show_range() -> void:
    range_indicator.visible = true

func hide_range() -> void:
    range_indicator.visible = false

Right-Click Input (World.gd)

func _input(event: InputEvent) -> void:
    # ... existing key handling for 1/2
    
    if event is InputEventMouseButton:
        if event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
            _place_selected_tower()
        elif event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
            _select_tower_at_mouse()

Select Tower at Mouse

func _select_tower_at_mouse() -> void:
    var world_pos := _get_world_position_from_mouse()
    var cell := _get_grid_cell(world_pos)
    
    # Deselect previous
    if selected_tower:
        selected_tower.hide_range()
    
    # Find tower at this cell
    for tower in placed_towers:
        var tower_cell := _get_grid_cell(tower.global_position)
        if tower_cell == cell:
            selected_tower = tower
            tower.show_range()
            _update_ui()
            return
    
    # No tower found
    selected_tower = null
    _update_ui()

Track Placed Towers

func _place_selected_tower() -> void:
    # ... existing placement logic
    
    var tower := scene.instantiate()
    add_child(tower)
    tower.position = _cell_to_world_pos(cell)
    
    occupied_cells.append(cell)
    placed_towers.append(tower)  # NEW: Track tower

Test It — F5

Place tower. Right-click it. Range ring appears.

Right-click empty space — range ring disappears.

Increment 3: Upgrade UI

The Goal

UI shows tower stats and upgrade cost. Press U to upgrade.

Can't upgrade if not enough money or max level reached.

Tower Info Label (World.gd)

@onready var tower_info_label: Label = $UI/TowerInfoLabel

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
    
    if selected_tower:
        tower_info_label.text = selected_tower.get_info_string()
        var cost := selected_tower.get_upgrade_cost()
        if cost > 0:
            tower_info_label.text += " | Upgrade: $%d (U)" % cost
        else:
            tower_info_label.text += " | MAX LEVEL"
    else:
        tower_info_label.text = ""

Upgrade Input (World.gd)

func _input(event: InputEvent) -> void:
    # ... existing mouse/key handling
    
    if event.is_action_pressed("ui_text_completion_replace"):  # U key
        _upgrade_selected_tower()

Upgrade Selected Tower

func _upgrade_selected_tower() -> void:
    if not selected_tower:
        return
    
    if not selected_tower.can_upgrade():
        print("Tower already max level!")
        return
    
    var cost := selected_tower.get_upgrade_cost()
    if money < cost:
        print("Not enough money! Need $%d" % cost)
        return
    
    money -= cost
    selected_tower.upgrade()
    _update_ui()

Test It — F5

Place tower. Right-click. Press U. Tower scales up, stats increase, money decreases.

Try upgrading to Level 3. Try when broke.

Increment 4: Win/Loss Screens

The Goal

When HP hits 0: Game Over screen. When Wave 5 ends: Victory screen.

Pause game. Show panel with message and restart button.

UI Structure (World Scene)

UI (CanvasLayer)
  ├─ HPLabel
  ├─ WaveLabel
  ├─ MoneyLabel
  ├─ TowerInfoLabel
  └─ GameOverPanel (Panel, centered, hidden)
      └─ VBoxContainer
          ├─ GameOverLabel ("GAME OVER" or "VICTORY!")
          └─ RestartButton

Game State Flag (World.gd)

var game_over: bool = false

@onready var game_over_panel: Panel = $UI/GameOverPanel
@onready var game_over_label: Label = $UI/GameOverPanel/VBoxContainer/GameOverLabel
@onready var restart_button: Button = $UI/GameOverPanel/VBoxContainer/RestartButton

func _ready() -> void:
    game_over_panel.visible = false
    restart_button.pressed.connect(_on_restart_pressed)
    # ... rest of setup

Game Over (HP = 0)

func _on_enemy_reached_end(damage: int) -> void:
    player_hp -= damage
    player_hp = maxi(player_hp, 0)
    _update_ui()
    
    enemies_remaining_in_wave -= 1
    _check_wave_complete()
    
    if player_hp <= 0:
        _game_over()

func _game_over() -> void:
    game_over = true
    game_over_label.text = "GAME OVER"
    game_over_panel.visible = true
    get_tree().paused = true

Victory (All Waves Defeated)

func _start_next_wave() -> void:
    if current_wave >= waves.size():
        _game_won()
        return
    
    # ... spawn next wave

func _game_won() -> void:
    game_over = true
    game_over_label.text = "VICTORY!"
    game_over_panel.visible = true
    get_tree().paused = true

Restart Button

func _on_restart_pressed() -> void:
    get_tree().paused = false
    get_tree().reload_current_scene()

Pause Mode Configuration

  • Set GameOverPanel → Process Mode → Always
  • Set RestartButton → Process Mode → Always
  • All other nodes stay Inherit (default)

This lets UI remain interactive when game is paused.

Test It — F5

Let enemies kill you → Game Over screen. Restart. Place towers, win → Victory screen.

Test both outcomes.

Increment 5: Context Switching

The Problem

When tower is selected, preview still shows on hover.

Confusing UX. Should be: placement mode OR selection mode, not both.

Hide Preview When Selected

func _update_tower_preview() -> void:
    # If tower is selected, hide preview
    if selected_tower:
        if preview_tower:
            preview_tower.queue_free()
            preview_tower = null
        return
    
    # Otherwise show preview as normal
    # ... existing preview logic

Deselect on Tower Type Change

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

func _deselect_tower() -> void:
    if selected_tower:
        selected_tower.hide_range()
        selected_tower = null
        _update_ui()

Test It — F5

Right-click tower. Move mouse — no preview. Press 1 — preview returns.

Modes don't conflict anymore.

What We Built This Week

Lecture 26

  • Enemy types
  • Wave spawning
  • Path following
  • Health tracking

Lecture 27

  • State machines
  • Two tower types
  • Grid placement
  • Money economy

Lecture 28

  • Upgrade system
  • Tower selection
  • Win/loss screens
  • Complete polish

Key Patterns Learned

  • Arrays for data-driven design — wave definitions
  • State machines — tower AI
  • Resource management — money economy
  • Signals for decoupling — enemy death → world response
  • Formula-driven progression — upgrade scaling
  • Grid-based placement — raycasting + snapping

Extension Ideas

  • Speed toggle: Button to set Engine.time_scale = 2.0
  • Sell towers: Right-click + S key, refund 50% of investment
  • Third tower type: Area-of-effect (splash damage)
  • Enemy abilities: Flying (ignores path), armored (takes less damage)
  • Tower targeting modes: First, last, strongest, weakest

Quiz 05 will ask you to implement one of these.

Week 11 Preview

Monkey Ball

  • ✓ RigidBody3D with tilt controls
  • ✓ Follow camera
  • ✓ Checkpoints and respawning
  • ✓ Goal detection

Next week: physics-based 3D puzzle platformer.

Congratulations!

You built a complete 3D tower defense game.

  • ✓ 3 enemy types, 5 waves
  • ✓ 2 tower types, 3 upgrade levels
  • ✓ Full economy, win/loss, restart

This is portfolio-worthy work.