GameGuide

Phase 6: Elites, Access Cards and the boss

Goal: glowing elite enemies drop Access Cards that give a free pick (and the chance at an evolution from Phase 5). A slow, tough mini-boss shows up mid-run with a slam attack you can see coming and dodge. When the run timer hits the end, a boss arrives; kill it to win, or it eventually enrages and wipes you out.

Checklist

The Access Card comes first because elites drop it, so it needs to exist before the elite code refers to it.


6.1 Access Cards

What

A pickup that, when you walk over it, gives a free single-choice pick. It reuses the Phase 5 Access Card rules, so it can offer an evolution.

Why isn’t it pulled in like XP?

In Swarm you have to walk over Access Cards; your pickup radius doesn’t vacuum them up. It’s a small decision that matters: elites often die in the middle of a crowd, and going in to grab the card is a risk you choose to take.

How the layers make this easy

The player’s pickup area only looks at layer 4. If the Access Card simply isn’t on layer 4, the pickup area never sees it and it’s never pulled in. Instead, the card watches for the player’s body (layer 2) touching it. No new code in the player is needed.

Step A: the script

File: res://scripts/props/pickups/access_card.gd

class_name AccessCard
extends Pickup

const SPIN_SPEED: float = 2.0

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

func _process(delta: float) -> void:
	rotate_y(SPIN_SPEED * delta)

func _on_body_entered(body: Node3D) -> void:
	if is_queued_for_deletion() or not (body is Player):
		return
	collect(body as Player)
	queue_free()

func collect(_player: Player) -> void:
	game_state.access_card_collected.emit()

Step B: the scene

In the Godot editor:

  1. New Scene > Area3D, renamed AccessCard.
  2. Child CollisionShape3D with a BoxShape3D of size (1, 1, 1). It’s a bit bigger than it looks, so it’s easy to grab.
  3. Child MeshInstance3D with a BoxMesh of size (0.8, 0.05, 0.5), with a gold material and emission on.
  4. On AccessCard, under Collision: Layer none; Mask only 2 (player). Keep Monitoring on; untick Monitorable.
  5. Attach access_card.gd and save as res://scenes/props/pickups/access_card.tscn.
AccessCard (Area3D)       script: access_card.gd, no layer, mask 2
├─ CollisionShape3D       BoxShape3D 1 x 1 x 1
└─ MeshInstance3D         flat gold card, glowing

6.2 Elite enemies

What

An elite is a normal enemy type with a flag switched on:

The spawner gets an Elite tick-box per entry, so you can schedule “an elite grunt every 60 seconds”.

Why a flag instead of separate elite enemy types?

Swarm’s elites are “special versions of normal enemies”. A flag means any enemy type can be elite without making a second resource for each. If you later want a truly unique elite, you can still make its own EnemyData.

Step A: data changes

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

Add at the bottom:

# Mini-bosses and bosses use their own scene; leave empty for the regular enemy scene.
@export var enemy_scene: PackedScene
@export var drops_access_card: bool = false

enemy_scene is used by mini-bosses and the boss in 6.3 and 6.4. drops_access_card lets a non-elite enemy (a mini-boss) drop a card too.

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

Add at the bottom:

@export var elite: bool = false

Step B: the enemy script

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

This touches a lot of the file, so replace the whole file with:

class_name Enemy
extends CharacterBody3D

signal died(enemy: Enemy)
signal health_changed(current: float, maximum: float)

const XP_ORB_SCENE: PackedScene = preload("res://scenes/props/pickups/xp_orb.tscn")
const ACCESS_CARD_SCENE: PackedScene = preload("res://scenes/props/pickups/access_card.tscn")
# How fast knockback fades, in metres per second per second.
const KNOCKBACK_DECAY: float = 20.0
const ELITE_HP_MULT: float = 8.0
const ELITE_XP_MULT: float = 10.0
const ELITE_SIZE: float = 1.4

@export var data: EnemyData
# Set by the spawner before the enemy is added to the scene.
@export var is_elite: bool = false

@onready var model_holder: Node3D = $Model
@onready var collision_shape: CollisionShape3D = $CollisionShape3D
@onready var elite_glow: MeshInstance3D = $Model/EliteGlow

var max_hp: float = 0.0
var current_hp: float = 0.0
var damage_mult: float = 1.0
var model_scale: float = 1.0
var _attack_cooldown_left: float = 0.0
var _flash_tween: Tween
var _knockback: Vector3 = Vector3.ZERO

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

func _make_elite() -> void:
	max_hp *= ELITE_HP_MULT
	model_scale = ELITE_SIZE
	model_holder.scale = Vector3.ONE * model_scale
	elite_glow.visible = true
	# Shapes are shared by every enemy using this scene, so resize a copy.
	var capsule: CapsuleShape3D = collision_shape.shape.duplicate()
	capsule.radius *= ELITE_SIZE
	capsule.height *= ELITE_SIZE
	collision_shape.shape = capsule

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 + _knockback.x
	velocity.z = move.z + _knockback.z
	_knockback = _knockback.move_toward(Vector3.ZERO, KNOCKBACK_DECAY * delta)
	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 * damage_mult)
		_attack_cooldown_left = data.attack_cooldown

func take_damage(amount: float) -> void:
	if current_hp <= 0.0:
		return
	current_hp -= amount
	health_changed.emit(current_hp, max_hp)
	_play_hit_flash()
	if current_hp <= 0.0:
		die()

func apply_knockback(force: Vector3) -> void:
	_knockback += force

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

func _drop_loot() -> void:
	var orb: XpOrb = XP_ORB_SCENE.instantiate()
	orb.amount = data.xp_value * (ELITE_XP_MULT if is_elite else 1.0)
	_spawn_here(orb)
	if is_elite or data.drops_access_card:
		_spawn_here(ACCESS_CARD_SCENE.instantiate())

# Deferred: die() often runs inside a physics callback, where adding physics objects isn't allowed.
func _spawn_here(node: Node3D) -> void:
	node.position = global_position
	get_tree().current_scene.add_child.call_deferred(node)

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

What changed and why

Step C: the glow

In the Godot editor:

  1. Open enemy.tscn. Under Model, add a MeshInstance3D named EliteGlow.
  2. Mesh > New SphereMesh: Radius 1.2, Height 2.4.
  3. Material: New StandardMaterial3D: Transparency Alpha; Shading Mode Unshaded; Albedo yellow with alpha about 60.
  4. Save.
Enemy (CharacterBody3D)
├─ CollisionShape3D
└─ Model (Node3D)
   └─ EliteGlow (MeshInstance3D)   see-through yellow sphere, hidden by code unless elite

Because EliteGlow is inside Model, it grows with the elite’s model scale and pops along with the hit flash.

Step D: the spawner

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

Find this (inside _physics_process()):

		for n: int in entry.count:
			spawn(entry.enemy)

Replace with:

		for n: int in entry.count:
			spawn(entry.enemy, entry.elite)

Find this:

func spawn(enemy_data: EnemyData) -> Enemy:
	if get_tree().get_node_count_in_group("enemies") >= max_enemies:
		return null

Replace with:

func spawn(enemy_data: EnemyData, elite: bool = false, ignore_cap: bool = false) -> Enemy:
	if not ignore_cap and get_tree().get_node_count_in_group("enemies") >= max_enemies:
		return null

Find this:

	var enemy: Enemy = ENEMY_SCENE.instantiate()
	enemy.data = enemy_data

Replace with:

	var scene: PackedScene = enemy_data.enemy_scene if enemy_data.enemy_scene else ENEMY_SCENE
	var enemy: Enemy = scene.instantiate()
	enemy.data = enemy_data
	enemy.is_elite = elite

ignore_cap is for the boss (6.4): it should always arrive, even if there are already 300 enemies.

Step E: schedule an elite

In the Godot editor: open flat_land.tscn, select EnemySpawner, and add an entry:

Enemy Start End Interval Count Elite
grunt.tres 45 600 60 1 on

6.3 Mini-bosses with a telegraphed slam

What

A slow, huge “Brute” that:

Why telegraph attacks?

Swarm’s bigger enemies have “long windups and can be dodged by running out of their area”. The warning circle turns a big hit into a test of attention instead of a random health loss. It’s the most important thing that makes mini-bosses feel fair.

Why a subclass?

The Brute still walks, takes damage, flashes, drops loot and can be knocked back, all exactly like Enemy. It just adds one attack. extends Enemy gives it everything for free; it only writes the slam.

Step A: the telegraph

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

class_name Telegraph
extends Node3D

signal finished

var radius: float = 4.0
var windup: float = 1.0
var damage: float = 50.0

@onready var outline: MeshInstance3D = $Outline
@onready var fill: MeshInstance3D = $Fill

func _ready() -> void:
	outline.scale = Vector3(radius, 1.0, radius)
	fill.scale = Vector3(0.01, 1.0, 0.01)
	var tween: Tween = create_tween()
	tween.tween_property(fill, "scale", Vector3(radius, 1.0, radius), windup)
	tween.tween_callback(_explode)

func _explode() -> void:
	for node: Node in get_tree().get_nodes_in_group("players"):
		var player: Player = node as Player
		var offset: Vector3 = player.global_position - global_position
		offset.y = 0.0
		if offset.length() <= radius:
			player.take_damage(damage)
	finished.emit()
	queue_free()

The outline shows the full danger zone straight away. The fill grows to meet it over windup seconds, so you can see exactly how long you have. Both are flat cylinders 1 m in radius, scaled to the real radius, the same trick as the Pulse Aura ring.

A tween made with create_tween() belongs to its node, so it pauses automatically when the game pauses (for example, if you level up mid-windup).

In the Godot editor:

Telegraph (Node3D)           script: telegraph.gd
├─ Outline (MeshInstance3D)  CylinderMesh r 1, h 0.02; red, alpha about 60, Unshaded; Position Y -0.95
└─ Fill (MeshInstance3D)     CylinderMesh r 1, h 0.03; red, alpha about 130, Unshaded; Position Y -0.94

Save as res://scenes/characters/enemy/telegraph.tscn. The -0.95 places it at the feet of the player it’s aimed at. The fill sits 1 cm higher so the two don’t flicker against each other.

Step B: the mini-boss script

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

class_name MiniBoss
extends Enemy

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

@export var slam_radius: float = 4.0
@export var slam_damage: float = 80.0
@export var slam_windup: float = 1.2
@export var slam_cooldown: float = 6.0
# Only starts a slam when a player is this close.
@export var slam_range: float = 10.0

@onready var hp_label: Label3D = $HpLabel

var _slam_timer: float = 3.0
var _winding_up: bool = false

func _ready() -> void:
	super()
	health_changed.connect(_update_label)
	_update_label(current_hp, max_hp)

func _physics_process(delta: float) -> void:
	if _winding_up:
		return
	super(delta)
	_slam_timer -= delta
	if _slam_timer > 0.0:
		return
	var target: Player = Targeting.nearest_player(get_tree(), global_position)
	if target and global_position.distance_to(target.global_position) <= slam_range:
		_start_slam(target.global_position)

func _start_slam(at: Vector3) -> void:
	_winding_up = true
	var telegraph: Telegraph = TELEGRAPH_SCENE.instantiate()
	telegraph.radius = slam_radius
	telegraph.windup = slam_windup
	telegraph.damage = slam_damage * damage_mult
	telegraph.position = at
	telegraph.finished.connect(_on_slam_finished)
	get_tree().current_scene.add_child(telegraph)

func _on_slam_finished() -> void:
	_winding_up = false
	_slam_timer = slam_cooldown

func _update_label(current: float, maximum: float) -> void:
	hp_label.text = "%d / %d" % [ceili(current), ceili(maximum)]

Walking through it

Step C: the mini-boss scene (an inherited scene)

An inherited scene starts as a copy of another scene and stays linked to it. If you later change enemy.tscn (say, a better glow), the mini-boss gets the change too.

In the Godot editor:

  1. Scene > New Inherited Scene…, pick res://scenes/characters/enemy/enemy.tscn.
  2. Rename the root to MiniBoss. In the Inspector, drag mini_boss.gd onto the Script property to replace enemy.gd.
  3. Select CollisionShape3D. Click the CapsuleShape3D, then click the small arrow next to it and choose Make Unique. Then set Radius 1.0 and Height 3.0.

    Why Make Unique? In an inherited scene, the shape still belongs to enemy.tscn. Changing it without making it unique would make every regular enemy huge too.

  4. Add a Label3D child of the root named HpLabel: Position Y 2.2, Billboard Enabled, Font Size 48, No Depth Test on (so it’s visible through other enemies).
  5. Save as res://scenes/characters/enemy/mini_boss.tscn.
MiniBoss (CharacterBody3D)   script: mini_boss.gd (inherits enemy.tscn)
├─ CollisionShape3D          unique capsule, r 1.0, h 3.0
├─ Model (Node3D)
│  └─ EliteGlow
└─ HpLabel (Label3D)         billboard, y 2.2

Step D: the Brute

In the Godot editor:

  1. Make res://scenes/characters/enemy/brute_model.tscn: a MeshInstance3D with a purple BoxMesh of size (2, 2.6, 2).
  2. Make res://resources/enemies/brute.tres (EnemyData):
Field Value
Id / Display Name brute / Brute
Model Scene brute_model.tscn
Enemy Scene mini_boss.tscn
Max Hp 600
Move Speed 2.5
Damage 25
Attack Range 2.2
Attack Cooldown 1.5
Xp Value 20
Drops Access Card on

Attack Range is bigger because the Brute’s body is wider: its centre can’t get as close to you.

  1. In flat_land.tscn’s EnemySpawner, add a one-off entry:
Enemy Start End Interval Count Elite
brute.tres 300 301 999 1 off

A window of one second with a huge interval means “spawn exactly once, at 5:00”.


6.4 The boss

What

Why an enrage timer?

Without one, a player with a strong defensive build could kite the boss forever. Swarm uses “enrage, then a lethal nova” to make the boss a real damage check. Our times are shorter than Swarm’s to make testing easier; tune them to taste.

Step A: game state signals

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

Find this:

signal access_card_collected

Replace with:

signal access_card_collected
signal boss_spawned(boss: Enemy)
signal boss_defeated

Why go through game_state? The HUD and main.gd need to react to the boss, but the boss is created deep inside the spawner. Announcing through the autoload means nobody has to go looking for the boss node; they just listen.

Step B: the boss script

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

class_name Boss
extends MiniBoss

# Seconds after spawning before the boss enrages, then before it wipes everyone.
const ENRAGE_TIME: float = 180.0
const WIPE_DELAY: float = 30.0
const ENRAGE_DAMAGE_MULT: float = 1.2

var _alive_time: float = 0.0
var _enraged: bool = false

func _ready() -> void:
	super()
	game_state.boss_spawned.emit(self)

func _physics_process(delta: float) -> void:
	super(delta)
	_alive_time += delta
	if not _enraged and _alive_time >= ENRAGE_TIME:
		_enraged = true
		damage_mult = ENRAGE_DAMAGE_MULT
		elite_glow.visible = true
	if _alive_time >= ENRAGE_TIME + WIPE_DELAY:
		_wipe()

func _wipe() -> void:
	for node: Node in get_tree().get_nodes_in_group("players"):
		(node as Player).take_damage(INF)

func die() -> void:
	game_state.boss_defeated.emit()
	super()

Step C: the boss scene and data

In the Godot editor:

  1. Scene > New Inherited Scene…, pick mini_boss.tscn. Rename the root Boss and swap the script to boss.gd.
  2. Make Unique on the collision shape again, then set Radius 1.5 and Height 4.0. Move HpLabel up to Y 3.0.
  3. Select Model/EliteGlow and set its Scale to (2.5, 2.5, 2.5). Otherwise the enrage glow is hidden inside the boss’s much bigger model.
  4. With Boss selected, set the slam settings in the Inspector: Slam Radius 6, Slam Damage 150, Slam Windup 1.5, Slam Cooldown 5, Slam Range 15.
  5. Save as res://scenes/characters/enemy/boss.tscn.
  6. Make boss_model.tscn (a dark red BoxMesh of size (3, 3.6, 3)) and res://resources/enemies/boss.tres:
Field Value
Id / Display Name boss / The Overseer
Model Scene boss_model.tscn
Enemy Scene boss.tscn
Max Hp 5000
Move Speed 3
Damage 40
Attack Range 2.8
Attack Cooldown 1.2
Xp Value 0

Step D: the spawner spawns the boss

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

Find this:

@export var max_enemies: int = 300

Replace with:

@export var max_enemies: int = 300
@export var boss: EnemyData
# Seconds into the run when the boss arrives.
@export var boss_time: float = 600.0

var _boss_spawned: bool = false

Find this:

func _physics_process(delta: float) -> void:
	var time: float = game_state.run_time

Replace with:

func _physics_process(delta: float) -> void:
	var time: float = game_state.run_time
	if boss and not _boss_spawned and time >= boss_time:
		_boss_spawned = true
		spawn(boss, false, true)

In the Godot editor: select EnemySpawner in flat_land.tscn, set Boss to boss.tres, and leave Boss Time at 600.

Testing tip: set Boss Time to 30 while you test, so you don’t have to survive 10 minutes each time. Remember to put it back.

The regular wave entries all end at 600, so the boss fight is just you and the boss. If you’d like some enemies to keep coming during the fight (Swarm does this), give one entry an End time past 600.

Step E: the boss health bar

In the Godot editor: open hud.tscn and add:

Hud (CanvasLayer)
├─ ...
└─ BossBox (VBoxContainer)          Anchors Preset: Center Top; Position Y 60
   ├─ BossName (Label)              Horizontal Alignment Center, font size 28
   └─ BossBar (ProgressBar)         Custom Minimum Size (600, 24); Show Percentage off

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

Find this:

@onready var xp_bar: ProgressBar = $XpBar

Replace with:

@onready var xp_bar: ProgressBar = $XpBar
@onready var boss_box: VBoxContainer = $BossBox
@onready var boss_name_label: Label = $BossBox/BossName
@onready var boss_bar: ProgressBar = $BossBox/BossBar

Find this:

	game_state.gold_changed.connect(_on_gold_changed)

Replace with:

	game_state.gold_changed.connect(_on_gold_changed)
	game_state.boss_spawned.connect(_on_boss_spawned)
	boss_box.visible = false

Add these functions at the bottom:

func _on_boss_spawned(boss: Enemy) -> void:
	boss_box.visible = true
	boss_name_label.text = boss.data.display_name
	boss.health_changed.connect(_on_boss_health_changed)
	_on_boss_health_changed(boss.current_hp, boss.max_hp)

func _on_boss_health_changed(current: float, maximum: float) -> void:
	boss_bar.max_value = maximum
	boss_bar.value = current

Step F: winning

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

Find this:

	player.died.connect(_on_player_died)

Replace with:

	player.died.connect(_on_player_died)
	game_state.boss_defeated.connect(_on_boss_defeated)

Add at the bottom:

func _on_boss_defeated() -> void:
	run_end_screen.show_result(true)

RunEndScreen.show_result(true) has been waiting for this since Phase 2.


Test it

  1. Set the spawner’s Boss Time to 30 and the elite entry’s Start to 5 for quick testing.
  2. F5, Gunner, Start.
  3. At about 0:05 a big glowing grunt appears. It takes many more hits, and when it dies it drops a big pile of XP and a spinning gold card.
  4. Walk near the card: it is not pulled in. Walk over it: the Access Card! pick appears with one choice.
  5. At 0:30 the boss arrives. Its name and health bar appear at the top.
  6. Get close: it stops, a red circle appears under you and fills up. Step out before it fills and you take no damage. Stay in and you take a big hit.
  7. Kill the boss: Victory!
  8. Or wait 3.5 minutes after it spawns: it glows, hits harder, and then wipes you, showing Defeated.
  9. Put Boss Time back to 600 and the elite start back to 45, then play normally. The Brute appears at 5:00 with its health number floating above it.

If something breaks:

Problem Likely cause
Node not found: "Model/EliteGlow" The EliteGlow node is missing or named differently in enemy.tscn.
All enemies became huge The collision shape was edited in an inherited scene without Make Unique.
Access Card is pulled in like XP It’s on layer 4. It should have no layer and mask 2.
Access Card can’t be picked up Its mask isn’t 2, or Monitoring is off.
Boss never appears Boss isn’t set on the spawner, or boss.tres has no Enemy Scene.
Telegraph circles float in the air Position Y on Outline and Fill isn’t about -0.95.
Mini-boss never moves again after a slam The telegraph’s finished signal isn’t emitted (check _explode()), or it isn’t connected in _start_slam().

Full file listings

File: res://scripts/props/pickups/access_card.gd

class_name AccessCard
extends Pickup

const SPIN_SPEED: float = 2.0

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

func _process(delta: float) -> void:
	rotate_y(SPIN_SPEED * delta)

func _on_body_entered(body: Node3D) -> void:
	if is_queued_for_deletion() or not (body is Player):
		return
	collect(body as Player)
	queue_free()

func collect(_player: Player) -> void:
	game_state.access_card_collected.emit()

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
@export var xp_value: float = 1.0
# Mini-bosses and bosses use their own scene; leave empty for the regular enemy scene.
@export var enemy_scene: PackedScene
@export var drops_access_card: bool = false

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
@export var elite: bool = false

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

class_name Enemy
extends CharacterBody3D

signal died(enemy: Enemy)
signal health_changed(current: float, maximum: float)

const XP_ORB_SCENE: PackedScene = preload("res://scenes/props/pickups/xp_orb.tscn")
const ACCESS_CARD_SCENE: PackedScene = preload("res://scenes/props/pickups/access_card.tscn")
# How fast knockback fades, in metres per second per second.
const KNOCKBACK_DECAY: float = 20.0
const ELITE_HP_MULT: float = 8.0
const ELITE_XP_MULT: float = 10.0
const ELITE_SIZE: float = 1.4

@export var data: EnemyData
# Set by the spawner before the enemy is added to the scene.
@export var is_elite: bool = false

@onready var model_holder: Node3D = $Model
@onready var collision_shape: CollisionShape3D = $CollisionShape3D
@onready var elite_glow: MeshInstance3D = $Model/EliteGlow

var max_hp: float = 0.0
var current_hp: float = 0.0
var damage_mult: float = 1.0
var model_scale: float = 1.0
var _attack_cooldown_left: float = 0.0
var _flash_tween: Tween
var _knockback: Vector3 = Vector3.ZERO

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

func _make_elite() -> void:
	max_hp *= ELITE_HP_MULT
	model_scale = ELITE_SIZE
	model_holder.scale = Vector3.ONE * model_scale
	elite_glow.visible = true
	# Shapes are shared by every enemy using this scene, so resize a copy.
	var capsule: CapsuleShape3D = collision_shape.shape.duplicate()
	capsule.radius *= ELITE_SIZE
	capsule.height *= ELITE_SIZE
	collision_shape.shape = capsule

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 + _knockback.x
	velocity.z = move.z + _knockback.z
	_knockback = _knockback.move_toward(Vector3.ZERO, KNOCKBACK_DECAY * delta)
	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 * damage_mult)
		_attack_cooldown_left = data.attack_cooldown

func take_damage(amount: float) -> void:
	if current_hp <= 0.0:
		return
	current_hp -= amount
	health_changed.emit(current_hp, max_hp)
	_play_hit_flash()
	if current_hp <= 0.0:
		die()

func apply_knockback(force: Vector3) -> void:
	_knockback += force

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

func _drop_loot() -> void:
	var orb: XpOrb = XP_ORB_SCENE.instantiate()
	orb.amount = data.xp_value * (ELITE_XP_MULT if is_elite else 1.0)
	_spawn_here(orb)
	if is_elite or data.drops_access_card:
		_spawn_here(ACCESS_CARD_SCENE.instantiate())

# Deferred: die() often runs inside a physics callback, where adding physics objects isn't allowed.
func _spawn_here(node: Node3D) -> void:
	node.position = global_position
	get_tree().current_scene.add_child.call_deferred(node)

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

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
@export var boss: EnemyData
# Seconds into the run when the boss arrives.
@export var boss_time: float = 600.0

var _boss_spawned: bool = false
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
	if boss and not _boss_spawned and time >= boss_time:
		_boss_spawned = true
		spawn(boss, false, true)
	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, entry.elite)

func spawn(enemy_data: EnemyData, elite: bool = false, ignore_cap: bool = false) -> Enemy:
	if not ignore_cap and 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 scene: PackedScene = enemy_data.enemy_scene if enemy_data.enemy_scene else ENEMY_SCENE
	var enemy: Enemy = scene.instantiate()
	enemy.data = enemy_data
	enemy.is_elite = elite
	get_tree().current_scene.add_child(enemy)
	enemy.global_position = spawn_position
	return enemy

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

class_name Telegraph
extends Node3D

signal finished

var radius: float = 4.0
var windup: float = 1.0
var damage: float = 50.0

@onready var outline: MeshInstance3D = $Outline
@onready var fill: MeshInstance3D = $Fill

func _ready() -> void:
	outline.scale = Vector3(radius, 1.0, radius)
	fill.scale = Vector3(0.01, 1.0, 0.01)
	var tween: Tween = create_tween()
	tween.tween_property(fill, "scale", Vector3(radius, 1.0, radius), windup)
	tween.tween_callback(_explode)

func _explode() -> void:
	for node: Node in get_tree().get_nodes_in_group("players"):
		var player: Player = node as Player
		var offset: Vector3 = player.global_position - global_position
		offset.y = 0.0
		if offset.length() <= radius:
			player.take_damage(damage)
	finished.emit()
	queue_free()

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

class_name MiniBoss
extends Enemy

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

@export var slam_radius: float = 4.0
@export var slam_damage: float = 80.0
@export var slam_windup: float = 1.2
@export var slam_cooldown: float = 6.0
# Only starts a slam when a player is this close.
@export var slam_range: float = 10.0

@onready var hp_label: Label3D = $HpLabel

var _slam_timer: float = 3.0
var _winding_up: bool = false

func _ready() -> void:
	super()
	health_changed.connect(_update_label)
	_update_label(current_hp, max_hp)

func _physics_process(delta: float) -> void:
	if _winding_up:
		return
	super(delta)
	_slam_timer -= delta
	if _slam_timer > 0.0:
		return
	var target: Player = Targeting.nearest_player(get_tree(), global_position)
	if target and global_position.distance_to(target.global_position) <= slam_range:
		_start_slam(target.global_position)

func _start_slam(at: Vector3) -> void:
	_winding_up = true
	var telegraph: Telegraph = TELEGRAPH_SCENE.instantiate()
	telegraph.radius = slam_radius
	telegraph.windup = slam_windup
	telegraph.damage = slam_damage * damage_mult
	telegraph.position = at
	telegraph.finished.connect(_on_slam_finished)
	get_tree().current_scene.add_child(telegraph)

func _on_slam_finished() -> void:
	_winding_up = false
	_slam_timer = slam_cooldown

func _update_label(current: float, maximum: float) -> void:
	hp_label.text = "%d / %d" % [ceili(current), ceili(maximum)]

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

class_name Boss
extends MiniBoss

# Seconds after spawning before the boss enrages, then before it wipes everyone.
const ENRAGE_TIME: float = 180.0
const WIPE_DELAY: float = 30.0
const ENRAGE_DAMAGE_MULT: float = 1.2

var _alive_time: float = 0.0
var _enraged: bool = false

func _ready() -> void:
	super()
	game_state.boss_spawned.emit(self)

func _physics_process(delta: float) -> void:
	super(delta)
	_alive_time += delta
	if not _enraged and _alive_time >= ENRAGE_TIME:
		_enraged = true
		damage_mult = ENRAGE_DAMAGE_MULT
		elite_glow.visible = true
	if _alive_time >= ENRAGE_TIME + WIPE_DELAY:
		_wipe()

func _wipe() -> void:
	for node: Node in get_tree().get_nodes_in_group("players"):
		(node as Player).take_damage(INF)

func die() -> void:
	game_state.boss_defeated.emit()
	super()

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.

signal xp_changed(current: float, required: float)
signal leveled_up(new_level: int)
signal gold_changed(total: int)
signal access_card_collected
signal boss_spawned(boss: Enemy)
signal boss_defeated

const MAX_LEVEL: int = 99

var selected_character: CharacterData
var selected_level: PackedScene

var passive_slots: int = 3

# --- Per-run tracking ---
var run_time: float = 0.0
var level: int = 1
var xp: float = 0.0
var xp_to_next: float = 5.0
var gold: int = 0

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

func reset_run() -> void:
	run_time = 0.0
	level = 1
	xp = 0.0
	xp_to_next = xp_required(1)
	gold = 0

# XP needed to go from this level to the next. Tune freely.
func xp_required(from_level: int) -> float:
	return 5.0 + 10.0 * (from_level - 1)

func add_xp(amount: float) -> void:
	if level >= MAX_LEVEL:
		return
	xp += amount
	while xp >= xp_to_next and level < MAX_LEVEL:
		xp -= xp_to_next
		level += 1
		xp_to_next = xp_required(level)
		leveled_up.emit(level)
	xp_changed.emit(xp, xp_to_next)

func add_gold(amount: int) -> void:
	gold += amount
	gold_changed.emit(gold)

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

class_name Hud
extends CanvasLayer

const SLOT_SIZE: Vector2 = Vector2(48, 48)

@onready var hp_bar: ProgressBar = $TopBar/HBox/HpBar
@onready var level_label: Label = $TopBar/HBox/LevelLabel
@onready var gold_label: Label = $TopBar/HBox/GoldLabel
@onready var timer_label: Label = $TopBar/HBox/TimerLabel
@onready var weapon_icons: HBoxContainer = $Loadout/WeaponIcons
@onready var passive_icons: HBoxContainer = $Loadout/PassiveIcons
@onready var xp_bar: ProgressBar = $XpBar
@onready var boss_box: VBoxContainer = $BossBox
@onready var boss_name_label: Label = $BossBox/BossName
@onready var boss_bar: ProgressBar = $BossBox/BossBar

var player: Player

func setup(target_player: Player) -> void:
	player = target_player
	player.health_changed.connect(_on_health_changed)
	player.loadout_changed.connect(_refresh_loadout)
	game_state.xp_changed.connect(_on_xp_changed)
	game_state.leveled_up.connect(_on_leveled_up)
	game_state.gold_changed.connect(_on_gold_changed)
	game_state.boss_spawned.connect(_on_boss_spawned)
	boss_box.visible = false
	_on_health_changed(player.current_hp, player.max_hp)
	_on_xp_changed(game_state.xp, game_state.xp_to_next)
	_on_leveled_up(game_state.level)
	_on_gold_changed(game_state.gold)
	_refresh_loadout()

func _process(_delta: float) -> void:
	var total_seconds: int = floori(game_state.run_time)
	timer_label.text = "%02d:%02d" % [floori(total_seconds / 60.0), total_seconds % 60]

func _on_health_changed(current: float, maximum: float) -> void:
	hp_bar.max_value = maximum
	hp_bar.value = current

func _on_xp_changed(current: float, required: float) -> void:
	xp_bar.max_value = required
	xp_bar.value = current

func _on_leveled_up(new_level: int) -> void:
	level_label.text = "Lv %d" % new_level

func _on_gold_changed(total: int) -> void:
	gold_label.text = "%d gold" % total

func _refresh_loadout() -> void:
	for child: Node in weapon_icons.get_children():
		child.queue_free()
	for child: Node in passive_icons.get_children():
		child.queue_free()
	for weapon: Weapon in player.weapons:
		var tooltip: String = "%s\n%s\n%s" % [weapon.get_display_name(), weapon.stats.description, weapon.data.scaling_text()]
		var caption: String = "EVO" if weapon.evolved else ("Lv %d" % weapon.level)
		weapon_icons.add_child(_make_slot(weapon.get_icon(), caption, tooltip))
	for passive: OwnedPassive in player.passives:
		var tooltip: String = passive.data.describe_level(passive.level)
		passive_icons.add_child(_make_slot(passive.data.icon, "Lv %d" % passive.level, tooltip))

func _make_slot(icon: Texture2D, caption: String, tooltip: String) -> Control:
	var slot: VBoxContainer = VBoxContainer.new()
	slot.tooltip_text = tooltip
	var picture: TextureRect = TextureRect.new()
	picture.texture = icon
	picture.custom_minimum_size = SLOT_SIZE
	picture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
	picture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
	picture.mouse_filter = Control.MOUSE_FILTER_PASS
	var label: Label = Label.new()
	label.text = caption
	label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	slot.add_child(picture)
	slot.add_child(label)
	return slot

func _on_boss_spawned(boss: Enemy) -> void:
	boss_box.visible = true
	boss_name_label.text = boss.data.display_name
	boss.health_changed.connect(_on_boss_health_changed)
	_on_boss_health_changed(boss.current_hp, boss.max_hp)

func _on_boss_health_changed(current: float, maximum: float) -> void:
	boss_bar.max_value = maximum
	boss_bar.value = current

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

extends Node3D

const PLAYER_SCENE: PackedScene = preload("res://scenes/characters/player/player.tscn")
const HUD_SCENE: PackedScene = preload("res://scenes/ui/hud.tscn")
const LEVEL_UP_SCENE: PackedScene = preload("res://scenes/ui/level_up_screen.tscn")
const RUN_END_SCENE: PackedScene = preload("res://scenes/ui/run_end_screen.tscn")
const BUILD_OVERLAY_SCENE: PackedScene = preload("res://scenes/ui/build_overlay.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)
	game_state.boss_defeated.connect(_on_boss_defeated)

	var hud: Hud = HUD_SCENE.instantiate()
	add_child(hud)
	hud.setup(player)

	var level_up_screen: LevelUpScreen = LEVEL_UP_SCENE.instantiate()
	level_up_screen.player = player
	add_child(level_up_screen)

	var build_overlay: BuildOverlay = BUILD_OVERLAY_SCENE.instantiate()
	build_overlay.player = player
	add_child(build_overlay)

	run_end_screen = RUN_END_SCENE.instantiate()
	add_child(run_end_screen)

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

func _unhandled_input(event: InputEvent) -> void:
	# Debug shortcuts; ignored in exported release builds.
	if not OS.is_debug_build():
		return
	var key: InputEventKey = event as InputEventKey
	if key == null or not key.pressed or key.echo:
		return
	match key.keycode:
		KEY_F1:
			game_state.access_card_collected.emit()
		KEY_F2:
			game_state.add_xp(game_state.xp_to_next - game_state.xp)

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)

func _on_boss_defeated() -> void:
	run_end_screen.show_result(true)

Next: Phase 7: Character kit