CS 470 — Week 10: 3D Tower Defense (3/3)
Fully playable, but no upgrades. No way to make towers stronger.
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.
Right-click to select. Press U to upgrade. Win or lose with style.
# 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.
Five increments. By the end, complete polished game.
Towers can upgrade to Level 3, getting stronger each time.
Cost increases (1.5× base). Stats improve. Visual feedback (scale up).
| 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
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
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()
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)
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
]
Add a tower manually. In console, call tower.upgrade() twice.
Tower should scale up. Print stats to confirm they increase.
Right-click tower to select. Range indicator appears.
Shows tower's detection radius. Visual feedback for planning.
var selected_tower: Node3D = null
var placed_towers: Array[Node3D] = []
@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
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()
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()
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
Place tower. Right-click it. Range ring appears.
Right-click empty space — range ring disappears.
UI shows tower stats and upgrade cost. Press U to upgrade.
Can't upgrade if not enough money or max level reached.
@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 = ""
func _input(event: InputEvent) -> void:
# ... existing mouse/key handling
if event.is_action_pressed("ui_text_completion_replace"): # U key
_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()
Place tower. Right-click. Press U. Tower scales up, stats increase, money decreases.
Try upgrading to Level 3. Try when broke.
When HP hits 0: Game Over screen. When Wave 5 ends: Victory screen.
Pause game. Show panel with message and restart button.
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
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
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
func _on_restart_pressed() -> void:
get_tree().paused = false
get_tree().reload_current_scene()
AlwaysAlwaysInherit (default)This lets UI remain interactive when game is paused.
Let enemies kill you → Game Over screen. Restart. Place towers, win → Victory screen.
Test both outcomes.
When tower is selected, preview still shows on hover.
Confusing UX. Should be: placement mode OR selection mode, not both.
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
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()
Right-click tower. Move mouse — no preview. Press 1 — preview returns.
Modes don't conflict anymore.
Engine.time_scale = 2.0Quiz 05 will ask you to implement one of these.
Next week: physics-based 3D puzzle platformer.
You built a complete 3D tower defense game.
This is portfolio-worthy work.