GameGuide

Phase 2: Something to shoot

Goal: enemies spawn around you in growing waves, chase you, hit you, and die when shot. When your health hits zero, a “Defeated” screen lets you retry or go back to the menu.

Checklist

Why does the player come first? The enemy script calls player.take_damage(). If the enemy were written first, Godot would show “function not found” errors until the player caught up.


2.1 The player can take damage

What

Why a "players" group?

Enemies need to find someone to chase. If they looked for “the player”, co-op would mean rewriting every enemy. Instead, each player adds itself to the "players" group, and enemies chase the nearest member. With one player it works the same; with four it still works.

Why does defence use 100 / (100 + defence)?

It’s the formula League uses for armour:

Defence Damage taken
0 100%
50 67%
100 50%
200 33%

Like the cooldown maths in Phase 0, every point of defence is equally valuable, but you can never become completely immune. maxf(defence, 0.0) stops negative defence (from a future augment) from breaking the formula.

Why signals for health?

The player shouldn’t need to know a HUD exists. It just announces “my health changed” and whoever cares (the HUD, a damage-flash effect, a sound) can listen. That keeps the player script focused on being a player.

How

File: res://scripts/characters/player/player.gd

Find this:

signal stats_changed

Replace with:

signal stats_changed
signal health_changed(current: float, maximum: float)
signal died

Find this:

var current_hp: float = 0.0

Replace with:

var current_hp: float = 0.0
var is_dead: bool = false

Find this:

func _ready() -> void:
	passive_slots = game_state.passive_slots

Replace with:

func _ready() -> void:
	add_to_group("players")
	passive_slots = game_state.passive_slots

Find this at the end of recalculate_stats():

	current_hp = minf(current_hp + maxf(0.0, max_hp - previous_max_hp), max_hp)
	stats_changed.emit()

Replace with:

	current_hp = minf(current_hp + maxf(0.0, max_hp - previous_max_hp), max_hp)
	stats_changed.emit()
	health_changed.emit(current_hp, max_hp)

Find this at the start of _physics_process():

func _physics_process(delta: float) -> void:
	# Gravity in case we fuck something up

Replace with:

func _physics_process(delta: float) -> void:
	_regenerate(delta)
	# Gravity in case we fuck something up

Add these functions anywhere below _physics_process(), for example right after it:

func _regenerate(delta: float) -> void:
	if current_hp >= max_hp:
		return
	current_hp = minf(current_hp + hp_regen * delta, max_hp)
	health_changed.emit(current_hp, max_hp)

func take_damage(amount: float) -> void:
	if is_dead:
		return
	# Like League armour: 100 defence means taking half damage.
	current_hp -= amount * 100.0 / (100.0 + maxf(defence, 0.0))
	health_changed.emit(current_hp, max_hp)
	if current_hp <= 0.0:
		_die()

func _die() -> void:
	current_hp = 0.0
	is_dead = true
	remove_from_group("players")
	set_physics_process(false)
	weapon_holder.process_mode = Node.PROCESS_MODE_DISABLED
	character_model.visible = false
	died.emit()

What _die() does, line by line

About regen while testing: your base hp_regen is 10.0 per second, which completely cancels out one grunt’s damage. If you can’t seem to die, temporarily lower it to 1.0 or 2.0 in recalculate_stats().


2.2 Base enemy

What

Why split data and behaviour?

This is the same pattern you already use for characters (CharacterData plus player.gd) and weapons. One enemy script handles “walk at the player and hit them”. The differences between a slow tanky grunt and a fast fragile runner are just numbers, so they live in resources you can make and tweak in the Inspector without touching code.

Step A: EnemyData

File: res://scripts/resources/enemy_data.gd

class_name EnemyData
extends GameData

@export var model_scene: PackedScene
@export var max_hp: float = 30.0
@export var move_speed: float = 4.0
@export var damage: float = 10.0
# Distance from the enemy's centre at which it can hit a player.
@export var attack_range: float = 1.5
@export var attack_cooldown: float = 1.0

It extends your GameData, so it gets id, display_name and icon for free.

Step B: let Targeting find players

File: res://scripts/utils/targeting.gd

Add this function at the bottom:

static func nearest_player(tree: SceneTree, from: Vector3) -> Player:
	return nearest_in_group(tree, &"players", from) as Player

as Player converts the Node3D result to a Player, so the enemy can call take_damage() on it without Godot complaining. If there are no players, it’s null.

Step C: the Enemy script

File: res://scripts/characters/enemy/enemy.gd

class_name Enemy
extends CharacterBody3D

signal died(enemy: Enemy)

@export var data: EnemyData

@onready var model_holder: Node3D = $Model

var current_hp: float = 0.0
var _attack_cooldown_left: float = 0.0
var _flash_tween: Tween

func _ready() -> void:
	add_to_group("enemies")
	current_hp = data.max_hp
	if data.model_scene:
		model_holder.add_child(data.model_scene.instantiate())

func _physics_process(delta: float) -> void:
	if not is_on_floor():
		velocity += get_gravity() * delta
	var target: Player = Targeting.nearest_player(get_tree(), global_position)
	var move: Vector3 = Vector3.ZERO
	var distance: float = INF
	if target:
		var to_target: Vector3 = target.global_position - global_position
		to_target.y = 0.0
		distance = to_target.length()
		if distance > 0.01:
			var direction: Vector3 = to_target / distance
			move = direction * data.move_speed
			# Same convention as the player: the model's front is +X.
			model_holder.rotation.y = atan2(-direction.z, direction.x)
	velocity.x = move.x
	velocity.z = move.z
	move_and_slide()
	_attack_cooldown_left -= delta
	if target and distance <= data.attack_range and _attack_cooldown_left <= 0.0:
		target.take_damage(data.damage)
		_attack_cooldown_left = data.attack_cooldown

func take_damage(amount: float) -> void:
	if current_hp <= 0.0:
		return
	current_hp -= amount
	_play_hit_flash()
	if current_hp <= 0.0:
		die()

func die() -> void:
	died.emit(self)
	queue_free()

func _play_hit_flash() -> void:
	if _flash_tween:
		_flash_tween.kill()
	model_holder.scale = Vector3.ONE * 1.25
	_flash_tween = create_tween()
	_flash_tween.tween_property(model_holder, "scale", Vector3.ONE, 0.1)

Walking through it

Movement. Each frame the enemy finds the nearest player, works out the flat direction to them, and walks that way at move_speed. to_target / distance is the same as .normalized(); we already have distance, so dividing saves a calculation. If there’s no target (everyone’s dead), move stays zero and the enemy stands still.

Attacking. Instead of a separate “hitbox” area on every enemy, the enemy just checks “am I close enough, and is my attack ready?”. With hundreds of enemies on screen, one fewer physics area each makes a real difference, and the result is the same for a melee attack. Note that _attack_cooldown_left keeps ticking even while walking, so an enemy that has been chasing you a while hits as soon as it arrives.

Taking damage. if current_hp <= 0.0: return at the top stops a bug where two bullets hit in the same frame: without it, the enemy would “die” twice and run its death code twice.

The hit flash. When hit, the model pops to 125% size and shrinks back over 0.1 seconds. It’s a cheap, clear “you hit it” signal that works with any model. If a second hit lands mid-animation, kill() stops the old tween first so two tweens don’t fight over the same property.

The died signal isn’t used yet. It’s there so other code can react to one specific enemy dying (Phase 6 uses it for the boss).

Step D: the enemy scene

In the Godot editor:

  1. Scene > New Scene > Other Node > CharacterBody3D. Rename it Enemy.
  2. Add child CollisionShape3D, then Shape > New CapsuleShape3D. Leave the defaults (radius 0.5, height 2), the same size as the player.
  3. Add child Node3D and rename it Model.
  4. Attach enemy.gd to Enemy.
  5. Save as res://scenes/characters/enemy/enemy.tscn.
Enemy (CharacterBody3D)     script: enemy.gd   (collision set in 2.3)
├─ CollisionShape3D         CapsuleShape3D
└─ Model (Node3D)           the enemy's model is added here by code

Step E: two enemy models

In the Godot editor:

  1. Scene > New Scene > Other Node > MeshInstance3D, renamed GruntModel. Set Mesh > New BoxMesh with size (1, 1.6, 1). Give the mesh a New StandardMaterial3D with a red albedo.
  2. Optional: add a small child box at position (0.6, 0.4, 0) so you can see which way it faces.
  3. Save as res://scenes/characters/enemy/grunt_model.tscn.
  4. Repeat for RunnerModel: an orange box of size (0.7, 1.2, 0.7), saved as res://scenes/characters/enemy/runner_model.tscn.

Step F: two enemy types

In the Godot editor:

  1. In the FileSystem dock, right-click resources/ and choose New Folder… called enemies.
  2. Right-click resources/enemies/, choose New Resource…, search for EnemyData, and save as grunt.tres.
  3. Fill it in, then do the same for runner.tres:
Field grunt.tres runner.tres
Id grunt runner
Display Name Grunt Runner
Model Scene grunt_model.tscn runner_model.tscn
Max Hp 30 15
Move Speed 4 6
Damage 10 5
Attack Range 1.5 1.5
Attack Cooldown 1.0 0.8

Quick check before moving on

Open res://scenes/levels/flat_land.tscn, drag enemy.tscn into the scene, move it to about (10, 0, 0), and set its Data to grunt.tres. Press F5 and start a run. The red box should walk toward you. It will clip through you for now; the next step fixes that.

Delete that test enemy from flat_land.tscn afterwards. The spawner in step 2.5 takes over.


2.3 Collision layers

What

Set what everything is (layer) and what it bumps into or detects (mask).

Why

By default every physics object is on layer 1 and masks layer 1, so everything collides with everything. That’s why the test enemy clipped through you: nothing told it the player was something to bump into. Setting layers and masks properly also makes physics faster, because Godot skips checking pairs that can never interact.

The plan

Object Layer (what it is) Mask (what it looks for) Why
Floor (CSGBox in the level) 1 world n/a Already on layer 1 by default.
Player 2 player 1 world, 3 enemy Stands on the floor; enemies can body-block you, like in Swarm.
Enemy 3 enemy 1 world, 2 player, 3 enemy Stands on the floor, stops at the player, and doesn’t stack inside other enemies.
Bullet 5 projectile 3 enemy Only cares about enemies. Already set in Phase 1.

If you’d rather walk through enemies, untick mask 3 on the player. Many games in this genre do that; Swarm doesn’t.

How

In the Godot editor:

  1. Open player.tscn, select Player, and under Collision set Layer: only 2. Mask: 1 and 3.
  2. Open enemy.tscn, select Enemy, and set Layer: only 3. Mask: 1, 2 and 3.
  3. Save both.

2.4 Bullets hurt enemies

What

When a bullet overlaps an enemy, deal damage (with a crit roll), then either keep going (pierce) or disappear.

Why this wasn’t in Phase 1

The bullet code checks body is Enemy. That type didn’t exist until step 2.2.

How

File: res://scripts/characters/player/weapons/projectile.gd

Find this:

var pierce: int = 0

func _physics_process(delta: float) -> void:

Replace with:

var pierce: int = 0

func _ready() -> void:
	body_entered.connect(_on_body_entered)

func _physics_process(delta: float) -> void:

Add this function at the bottom:

func _on_body_entered(body: Node3D) -> void:
	# Several enemies can be touched in the same frame; stop once used up.
	if pierce < 0:
		return
	if not (body is Enemy):
		return
	var enemy: Enemy = body as Enemy
	enemy.take_damage(Weapon.roll_crit(damage, crit_chance))
	pierce -= 1
	if pierce < 0:
		queue_free()

How it works


2.5 Run timer and spawner

What

Why a list of entries?

“Ramps up over time” just means more streams switch on as the run goes. A list of entries lets you design a level’s whole difficulty curve in the Inspector, for example: “grunts from 0:00, runners join at 1:30, a second faster grunt stream at 3:00”. Each level can have different entries, because the spawner lives in the level scene.

Why spawn on a ring?

Spawning at a fixed distance in a random direction means enemies appear just outside what the camera can see, then walk in from every side. That’s the Swarm “surrounded” feeling, without enemies popping into existence right next to you.

Step A: the run timer

File: res://scripts/autoload/game_state.gd

Find this:

var passive_slots: int = 3

func start_run(character: CharacterData, level: PackedScene) -> void:
	selected_character = character
	selected_level = level
	passive_slots = Profile.unlocked_passive_slots

Replace with:

var passive_slots: int = 3

# --- Per-run tracking ---
var run_time: float = 0.0

func start_run(character: CharacterData, level: PackedScene) -> void:
	selected_character = character
	selected_level = level
	passive_slots = Profile.unlocked_passive_slots
	reset_run()

func reset_run() -> void:
	run_time = 0.0

reset_run() will grow as later phases add XP, gold and kills. Keeping all “start of a run” resets in one function means “Retry” can’t accidentally keep last run’s XP.

File: res://scripts/systems/main.gd

Find this:

func _process(delta: float) -> void:
	pass

Replace with:

func _process(delta: float) -> void:
	game_state.run_time += delta

Why count in main.gd and not in game_state? Autoloads exist in the menus too. Main only exists during a run, and it automatically stops processing when the game is paused (level-up screen, death screen). So the timer pauses when it should, with no extra code.

Step B: SpawnEntry

File: res://scripts/resources/spawn_entry.gd

class_name SpawnEntry
extends Resource

@export var enemy: EnemyData
# Seconds into the run.
@export var start_time: float = 0.0
@export var end_time: float = 600.0
# Seconds between spawns.
@export var interval: float = 2.0
# Enemies created each time.
@export var count: int = 3

Step C: EnemySpawner

File: res://scripts/systems/enemy_spawner.gd

class_name EnemySpawner
extends Node

const ENEMY_SCENE: PackedScene = preload("res://scenes/characters/enemy/enemy.tscn")

@export var entries: Array[SpawnEntry] = []
# Just outside what the camera can see.
@export var spawn_radius: float = 35.0
# Half the width of the floor, so enemies never spawn off the edge.
@export var map_half_size: float = 145.0
@export var max_enemies: int = 300

var _timers: Array[float] = []

func _ready() -> void:
	_timers.resize(entries.size())
	_timers.fill(0.0)

func _physics_process(delta: float) -> void:
	var time: float = game_state.run_time
	for i: int in entries.size():
		var entry: SpawnEntry = entries[i]
		if time < entry.start_time or time > entry.end_time:
			continue
		_timers[i] -= delta
		if _timers[i] > 0.0:
			continue
		_timers[i] = entry.interval
		for n: int in entry.count:
			spawn(entry.enemy)

func spawn(enemy_data: EnemyData) -> Enemy:
	if get_tree().get_node_count_in_group("enemies") >= max_enemies:
		return null
	var players: Array[Node] = get_tree().get_nodes_in_group("players")
	if players.is_empty():
		return null
	var anchor: Node3D = players.pick_random()
	var angle: float = randf() * TAU
	var spawn_position: Vector3 = anchor.global_position + Vector3(cos(angle), 0.0, sin(angle)) * spawn_radius
	spawn_position.x = clampf(spawn_position.x, -map_half_size, map_half_size)
	spawn_position.z = clampf(spawn_position.z, -map_half_size, map_half_size)
	var enemy: Enemy = ENEMY_SCENE.instantiate()
	enemy.data = enemy_data
	get_tree().current_scene.add_child(enemy)
	enemy.global_position = spawn_position
	return enemy

How it works

Step D: add the spawner to the level

In the Godot editor:

  1. Open res://scenes/levels/flat_land.tscn.
  2. Select FlatLand, add a child Node, rename it EnemySpawner, and attach enemy_spawner.gd.
  3. In the Inspector, expand Entries, set the size to 4, and for each element choose New SpawnEntry:
# Enemy Start End Interval Count
0 grunt.tres 0 600 2.0 3
1 grunt.tres 60 600 1.5 4
2 runner.tres 90 600 3.0 5
3 grunt.tres 180 600 1.0 6
  1. Save.

Performance note: Godot 4.4 and later include the Jolt physics engine as an option (Project Settings > Physics > 3D > Physics Engine). It generally copes better with lots of moving bodies. If your frame rate drops with many enemies, it’s worth trying.


2.6 Dying ends the run

What

A “Defeated” screen with Retry and Main Menu buttons that appears when the last player dies and pauses the game.

Why a CanvasLayer?

A CanvasLayer draws 2D UI on top of the 3D world, no matter where the camera is. The Layer number decides which UI is on top when several overlap. We’ll use 20 for this screen so it sits above everything.

Why “Process Mode: Always”?

get_tree().paused = true freezes every node whose process mode is Inherit (the default), including buttons, which would make them unclickable. Setting this screen to Always means it keeps working while the rest of the game is frozen.

Step A: the script

File: res://scripts/ui/run_end_screen.gd

class_name RunEndScreen
extends CanvasLayer

@onready var title_label: Label = $Center/Panel/Margin/VBox/Title
@onready var retry_button: Button = $Center/Panel/Margin/VBox/RetryButton
@onready var main_menu_button: Button = $Center/Panel/Margin/VBox/MainMenuButton

func _ready() -> void:
	visible = false
	retry_button.pressed.connect(_on_retry_pressed)
	main_menu_button.pressed.connect(_on_main_menu_pressed)

func show_result(won: bool) -> void:
	title_label.text = "Victory!" if won else "Defeated"
	visible = true
	get_tree().paused = true

func _on_retry_pressed() -> void:
	get_tree().paused = false
	game_state.start_run(game_state.selected_character, game_state.selected_level)
	get_tree().reload_current_scene()

func _on_main_menu_pressed() -> void:
	get_tree().paused = false
	get_tree().change_scene_to_file("res://scenes/main/main_menu.tscn")

Step B: the scene

In the Godot editor:

  1. Scene > New Scene > Other Node > CanvasLayer. Rename it RunEndScreen.
  2. In the Inspector: Layer 20; Process > Mode Always.
  3. Build this tree:
RunEndScreen (CanvasLayer)           script: run_end_screen.gd, Layer 20, Process Mode Always
└─ Center (CenterContainer)          Layout > Anchors Preset: Full Rect
   └─ Panel (PanelContainer)
      └─ Margin (MarginContainer)    Theme Overrides > Constants: all four margins 32
         └─ VBox (VBoxContainer)     Theme Overrides > Constants > Separation: 16
            ├─ Title (Label)         Text "Defeated", Horizontal Alignment Center,
            │                        Theme Overrides > Font Sizes: 48
            ├─ RetryButton (Button)  Text "Retry"
            └─ MainMenuButton (Button)  Text "Main Menu"

To set Full Rect on Center: select it, then use the Anchor preset button in the toolbar above the 2D view, or Layout > Anchors Preset in the Inspector.

  1. Attach run_end_screen.gd to the root and save as res://scenes/ui/run_end_screen.tscn.

The node names must match the script’s $Center/Panel/Margin/VBox/... paths exactly, including capital letters.

Step C: wire it into main.gd

File: res://scripts/systems/main.gd

Replace the whole file with:

extends Node3D

const PLAYER_SCENE: PackedScene = preload("res://scenes/characters/player/player.tscn")
const RUN_END_SCENE: PackedScene = preload("res://scenes/ui/run_end_screen.tscn")

var player: Player
var run_end_screen: RunEndScreen

# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	var level: Node3D = game_state.selected_level.instantiate()
	add_child(level)
	player = PLAYER_SCENE.instantiate()
	# Floor surface is at y=0 and the capsule is 2 tall, centred on its origin.
	player.position = Vector3(0, 1, 0)
	add_child(player)
	player.died.connect(_on_player_died)

	run_end_screen = RUN_END_SCENE.instantiate()
	add_child(run_end_screen)

func _process(delta: float) -> void:
	game_state.run_time += delta

func _on_player_died() -> void:
	# Only end the run when nobody is left (matters for co-op).
	if get_tree().get_node_count_in_group("players") == 0:
		run_end_screen.show_result(false)

player is now typed as Player rather than CharacterBody3D, so player.died is known to exist.

Co-op note: because the dead player leaves the "players" group before emitting died, this check already means “end the run only when the last player falls”. Swarm’s downed-and-revive system would go here later.


Test it

  1. F5, Gunner, Start.
  2. Grunts appear from every direction and walk toward you. More arrive over time, with runners joining at about 1:30.
  3. Bullets make enemies pop briefly and die after about 3 hits.
  4. Enemies stop at your body instead of passing through, and standing in a crowd lowers your health. Check by adding print(current_hp) in take_damage() temporarily; the HUD comes in Phase 3.
  5. Stand still in a crowd until you die. The “Defeated” screen appears and everything freezes.
  6. Retry starts a fresh run. Main Menu goes back to the menu.
  7. Press C: the aim snaps to the nearest enemy and follows it.

If something breaks:

Problem Likely cause
Invalid access to property or key 'max_hp' on a base object of type 'Nil' An enemy was created without data. Check every spawner entry has an Enemy set.
Bullets pass through enemies Bullet mask isn’t 3, or the enemy’s layer isn’t 3.
Enemies walk through you Enemy mask doesn’t include 2.
Enemies fall through the floor Enemy mask doesn’t include 1.
Buttons on the death screen don’t respond RunEndScreen isn’t set to Process Mode Always.
Next scene is frozen after clicking a button get_tree().paused = false is missing before the scene change.
Node not found: "Center/Panel/Margin/VBox/Title" A node name in run_end_screen.tscn doesn’t match the script.

Full file listings

File: res://scripts/characters/player/player.gd

extends CharacterBody3D
class_name Player

signal stats_changed
signal health_changed(current: float, maximum: float)
signal died
const MAX_PASSIVE_LEVEL: int = 5
const AUTO_AIM_RANGE: float = 25.0

@export var character_data: CharacterData
@onready var character_model: Node3D = $CharacterModel
@onready var camera: Camera3D = $Camera3D
@onready var weapon_holder: Node3D = $Weapons


# --- Set up stats ---
var max_hp: float
var defence: float
var damage_mult: float
# Haste-style: cooldown = base / (1 + cdr / 100), so it never reaches zero.
var cdr: float
var hp_regen: float
var move_speed: float
var projectile: int
var crit_chance: float
var pickup_range: float
var xp_mult: float
var gold_mult: float
var area_size_mult: float
var duration_mult: float
var current_hp: float = 0.0
var is_dead: bool = false

# --- Create passive slots ---
var passive_slots: int = Profile.MAX_PASSIVE_SLOTS
var passives: Array[OwnedPassive] = []

# --- Weapons ---
var weapons: Array[Weapon] = []

# --- Aiming ---
# The model's front is +X, so that's the starting aim.
var aim_direction: Vector3 = Vector3.RIGHT
var auto_aim: bool = false

# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	add_to_group("players")
	passive_slots = game_state.passive_slots
	if character_data == null:
		character_data = game_state.selected_character
	if character_data:
		_apply_character(character_data)
	recalculate_stats()
	current_hp = max_hp

func _apply_character(character: CharacterData) -> void:
	if character.model_scene:
		character_model.add_child(character.model_scene.instantiate())
	if character.signature_weapon:
		_spawn_weapon(character.signature_weapon)

func _spawn_weapon(data: WeaponData) -> Weapon:
	var weapon: Weapon = data.weapon_scene.instantiate()
	weapon.setup(self, data)
	weapon_holder.add_child(weapon)
	weapons.append(weapon)
	return weapon

func recalculate_stats() -> void:
	var previous_max_hp: float = max_hp
	max_hp = 1000.0
	defence = 0.0
	damage_mult = 1.0
	cdr = 0.0
	hp_regen = 10.0
	move_speed = 7.0
	projectile = 0
	crit_chance = 5.0
	pickup_range = 3.0
	xp_mult = 1.0
	gold_mult = 1.0
	area_size_mult = 1.0
	duration_mult = 1.0
	if character_data:
		max_hp += character_data.bonus_max_hp
		defence += character_data.bonus_defence
	for passive: OwnedPassive in passives:
		_apply_bonus(passive.data.stat, passive.bonus())
	# Raising max HP grants the difference rather than healing to full.
	current_hp = minf(current_hp + maxf(0.0, max_hp - previous_max_hp), max_hp)
	stats_changed.emit()
	health_changed.emit(current_hp, max_hp)

# Each call is one "source". Percentage stats multiply between sources; flat stats add.
func _apply_bonus(stat: PassiveData.Stat, amount: float) -> void:
	match stat:
		PassiveData.Stat.MAX_HP:
			max_hp += amount
		PassiveData.Stat.DEFENCE:
			defence += amount
		PassiveData.Stat.DAMAGE_MULT:
			damage_mult *= 1.0 + amount
		PassiveData.Stat.CDR:
			cdr += amount
		PassiveData.Stat.HP_REGEN:
			hp_regen += amount
		PassiveData.Stat.MOVE_SPEED:
			move_speed *= 1.0 + amount
		PassiveData.Stat.PROJECTILE:
			projectile += roundi(amount)
		PassiveData.Stat.CRIT_CHANCE:
			crit_chance += amount
		PassiveData.Stat.PICKUP_RANGE:
			pickup_range *= 1.0 + amount
		PassiveData.Stat.XP_MULT:
			xp_mult *= 1.0 + amount
		PassiveData.Stat.GOLD_MULT:
			gold_mult *= 1.0 + amount
		PassiveData.Stat.AREA_SIZE_MULT:
			area_size_mult *= 1.0 + amount
		PassiveData.Stat.DURATION_MULT:
			duration_mult *= 1.0 + amount

func add_passive(data: PassiveData) -> bool:
	for passive: OwnedPassive in passives:
		if passive.data.id == data.id:
			if passive.level >= MAX_PASSIVE_LEVEL:
				return false
			passive.level += 1
			recalculate_stats()
			return true
	if passives.size() >= passive_slots:
		return false
	passives.append(OwnedPassive.new(data))
	recalculate_stats()
	return true

func _physics_process(delta: float) -> void:
	_regenerate(delta)
	# Gravity in case we fuck something up
	if not is_on_floor():
		velocity += get_gravity() * delta
	
	var input_dir: Vector2 = Input.get_vector("leftward", "rightward", "upward", "downward")
	var direction: Vector3 = Vector3(input_dir.x, 0.0, input_dir.y).normalized()
	if direction:
		velocity.x = direction.x * move_speed
		velocity.z = direction.z * move_speed
	else:
		velocity.x = move_toward(velocity.x, 0, move_speed)
		velocity.z = move_toward(velocity.z, 0, move_speed)
	
	move_and_slide()
	_update_aim()

func _regenerate(delta: float) -> void:
	if current_hp >= max_hp:
		return
	current_hp = minf(current_hp + hp_regen * delta, max_hp)
	health_changed.emit(current_hp, max_hp)

func take_damage(amount: float) -> void:
	if is_dead:
		return
	# Like League armour: 100 defence means taking half damage.
	current_hp -= amount * 100.0 / (100.0 + maxf(defence, 0.0))
	health_changed.emit(current_hp, max_hp)
	if current_hp <= 0.0:
		_die()

func _die() -> void:
	current_hp = 0.0
	is_dead = true
	remove_from_group("players")
	set_physics_process(false)
	weapon_holder.process_mode = Node.PROCESS_MODE_DISABLED
	character_model.visible = false
	died.emit()

func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("auto_aim"):
		auto_aim = not auto_aim

func _update_aim() -> void:
	var aim_point: Variant = _get_aim_point()
	if aim_point == null:
		return
	var point: Vector3 = aim_point
	var to_point: Vector3 = point - global_position
	to_point.y = 0.0
	if to_point.length_squared() < 0.0001:
		return
	aim_direction = to_point.normalized()
	# The model's front is +X, so the angle is measured from +X.
	character_model.rotation.y = atan2(-aim_direction.z, aim_direction.x)

# Returns a Vector3, or null when there's nothing to aim at.
func _get_aim_point() -> Variant:
	if auto_aim:
		var target: Node3D = Targeting.nearest_enemy(get_tree(), global_position, AUTO_AIM_RANGE)
		if target == null:
			return null
		return target.global_position
	var mouse_pos: Vector2 = get_viewport().get_mouse_position()
	var ray_origin: Vector3 = camera.project_ray_origin(mouse_pos)
	var ray_dir: Vector3 = camera.project_ray_normal(mouse_pos)
	return Plane(Vector3.UP, global_position.y).intersects_ray(ray_origin, ray_dir)

File: res://scripts/resources/enemy_data.gd

class_name EnemyData
extends GameData

@export var model_scene: PackedScene
@export var max_hp: float = 30.0
@export var move_speed: float = 4.0
@export var damage: float = 10.0
# Distance from the enemy's centre at which it can hit a player.
@export var attack_range: float = 1.5
@export var attack_cooldown: float = 1.0

File: res://scripts/characters/enemy/enemy.gd

class_name Enemy
extends CharacterBody3D

signal died(enemy: Enemy)

@export var data: EnemyData

@onready var model_holder: Node3D = $Model

var current_hp: float = 0.0
var _attack_cooldown_left: float = 0.0
var _flash_tween: Tween

func _ready() -> void:
	add_to_group("enemies")
	current_hp = data.max_hp
	if data.model_scene:
		model_holder.add_child(data.model_scene.instantiate())

func _physics_process(delta: float) -> void:
	if not is_on_floor():
		velocity += get_gravity() * delta
	var target: Player = Targeting.nearest_player(get_tree(), global_position)
	var move: Vector3 = Vector3.ZERO
	var distance: float = INF
	if target:
		var to_target: Vector3 = target.global_position - global_position
		to_target.y = 0.0
		distance = to_target.length()
		if distance > 0.01:
			var direction: Vector3 = to_target / distance
			move = direction * data.move_speed
			# Same convention as the player: the model's front is +X.
			model_holder.rotation.y = atan2(-direction.z, direction.x)
	velocity.x = move.x
	velocity.z = move.z
	move_and_slide()
	_attack_cooldown_left -= delta
	if target and distance <= data.attack_range and _attack_cooldown_left <= 0.0:
		target.take_damage(data.damage)
		_attack_cooldown_left = data.attack_cooldown

func take_damage(amount: float) -> void:
	if current_hp <= 0.0:
		return
	current_hp -= amount
	_play_hit_flash()
	if current_hp <= 0.0:
		die()

func die() -> void:
	died.emit(self)
	queue_free()

func _play_hit_flash() -> void:
	if _flash_tween:
		_flash_tween.kill()
	model_holder.scale = Vector3.ONE * 1.25
	_flash_tween = create_tween()
	_flash_tween.tween_property(model_holder, "scale", Vector3.ONE, 0.1)

File: res://scripts/utils/targeting.gd

class_name Targeting
extends RefCounted

# Returns the closest node in the group within max_range, or null.
static func nearest_in_group(tree: SceneTree, group: StringName, from: Vector3, max_range: float = INF) -> Node3D:
	var nearest: Node3D = null
	var nearest_dist_sq: float = max_range * max_range
	for node: Node in tree.get_nodes_in_group(group):
		var candidate: Node3D = node as Node3D
		var dist_sq: float = from.distance_squared_to(candidate.global_position)
		if dist_sq < nearest_dist_sq:
			nearest_dist_sq = dist_sq
			nearest = candidate
	return nearest

static func nearest_enemy(tree: SceneTree, from: Vector3, max_range: float = INF) -> Node3D:
	return nearest_in_group(tree, &"enemies", from, max_range)

static func nearest_player(tree: SceneTree, from: Vector3) -> Player:
	return nearest_in_group(tree, &"players", from) as Player

File: res://scripts/characters/player/weapons/projectile.gd

class_name Projectile
extends Area3D

var direction: Vector3 = Vector3.RIGHT
var speed: float = 30.0
var lifetime: float = 1.0
var damage: float = 10.0
var crit_chance: float = 0.0
# Extra enemies this can pass through after the first hit.
var pierce: int = 0

func _ready() -> void:
	body_entered.connect(_on_body_entered)

func _physics_process(delta: float) -> void:
	global_position += direction * speed * delta
	lifetime -= delta
	if lifetime <= 0.0:
		queue_free()

func _on_body_entered(body: Node3D) -> void:
	# Several enemies can be touched in the same frame; stop once used up.
	if pierce < 0:
		return
	if not (body is Enemy):
		return
	var enemy: Enemy = body as Enemy
	enemy.take_damage(Weapon.roll_crit(damage, crit_chance))
	pierce -= 1
	if pierce < 0:
		queue_free()

File: res://scripts/autoload/game_state.gd

extends Node

# --- Game state tracker ---
# This file will be used to keep track of the progress of any given round.
# It should not include any locked in variables on its own, but should be
# used to keep track of per-round data, like current XP, phase of the round,
# tally enemy kills.

var selected_character: CharacterData
var selected_level: PackedScene

var passive_slots: int = 3

# --- Per-run tracking ---
var run_time: float = 0.0

func start_run(character: CharacterData, level: PackedScene) -> void:
	selected_character = character
	selected_level = level
	passive_slots = Profile.unlocked_passive_slots
	reset_run()

func reset_run() -> void:
	run_time = 0.0

File: res://scripts/resources/spawn_entry.gd

class_name SpawnEntry
extends Resource

@export var enemy: EnemyData
# Seconds into the run.
@export var start_time: float = 0.0
@export var end_time: float = 600.0
# Seconds between spawns.
@export var interval: float = 2.0
# Enemies created each time.
@export var count: int = 3

File: res://scripts/systems/enemy_spawner.gd

class_name EnemySpawner
extends Node

const ENEMY_SCENE: PackedScene = preload("res://scenes/characters/enemy/enemy.tscn")

@export var entries: Array[SpawnEntry] = []
# Just outside what the camera can see.
@export var spawn_radius: float = 35.0
# Half the width of the floor, so enemies never spawn off the edge.
@export var map_half_size: float = 145.0
@export var max_enemies: int = 300

var _timers: Array[float] = []

func _ready() -> void:
	_timers.resize(entries.size())
	_timers.fill(0.0)

func _physics_process(delta: float) -> void:
	var time: float = game_state.run_time
	for i: int in entries.size():
		var entry: SpawnEntry = entries[i]
		if time < entry.start_time or time > entry.end_time:
			continue
		_timers[i] -= delta
		if _timers[i] > 0.0:
			continue
		_timers[i] = entry.interval
		for n: int in entry.count:
			spawn(entry.enemy)

func spawn(enemy_data: EnemyData) -> Enemy:
	if get_tree().get_node_count_in_group("enemies") >= max_enemies:
		return null
	var players: Array[Node] = get_tree().get_nodes_in_group("players")
	if players.is_empty():
		return null
	var anchor: Node3D = players.pick_random()
	var angle: float = randf() * TAU
	var spawn_position: Vector3 = anchor.global_position + Vector3(cos(angle), 0.0, sin(angle)) * spawn_radius
	spawn_position.x = clampf(spawn_position.x, -map_half_size, map_half_size)
	spawn_position.z = clampf(spawn_position.z, -map_half_size, map_half_size)
	var enemy: Enemy = ENEMY_SCENE.instantiate()
	enemy.data = enemy_data
	get_tree().current_scene.add_child(enemy)
	enemy.global_position = spawn_position
	return enemy

File: res://scripts/ui/run_end_screen.gd

class_name RunEndScreen
extends CanvasLayer

@onready var title_label: Label = $Center/Panel/Margin/VBox/Title
@onready var retry_button: Button = $Center/Panel/Margin/VBox/RetryButton
@onready var main_menu_button: Button = $Center/Panel/Margin/VBox/MainMenuButton

func _ready() -> void:
	visible = false
	retry_button.pressed.connect(_on_retry_pressed)
	main_menu_button.pressed.connect(_on_main_menu_pressed)

func show_result(won: bool) -> void:
	title_label.text = "Victory!" if won else "Defeated"
	visible = true
	get_tree().paused = true

func _on_retry_pressed() -> void:
	get_tree().paused = false
	game_state.start_run(game_state.selected_character, game_state.selected_level)
	get_tree().reload_current_scene()

func _on_main_menu_pressed() -> void:
	get_tree().paused = false
	get_tree().change_scene_to_file("res://scenes/main/main_menu.tscn")

File: res://scripts/systems/main.gd

extends Node3D

const PLAYER_SCENE: PackedScene = preload("res://scenes/characters/player/player.tscn")
const RUN_END_SCENE: PackedScene = preload("res://scenes/ui/run_end_screen.tscn")

var player: Player
var run_end_screen: RunEndScreen

# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	var level: Node3D = game_state.selected_level.instantiate()
	add_child(level)
	player = PLAYER_SCENE.instantiate()
	# Floor surface is at y=0 and the capsule is 2 tall, centred on its origin.
	player.position = Vector3(0, 1, 0)
	add_child(player)
	player.died.connect(_on_player_died)

	run_end_screen = RUN_END_SCENE.instantiate()
	add_child(run_end_screen)

func _process(delta: float) -> void:
	game_state.run_time += delta

func _on_player_died() -> void:
	# Only end the run when nobody is left (matters for co-op).
	if get_tree().get_node_count_in_group("players") == 0:
		run_end_screen.show_result(false)

Next: Phase 3: XP and level-up