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.
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.
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.
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.
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()
extends Pickup, so it’s in the "pickups" group and has collect(). It just never uses the attraction part.super() in _ready() runs Pickup._ready() first (joining the group, turning off physics processing), then adds the card’s own setup._process (every drawn frame) rather than _physics_process.is_queued_for_deletion() stops a double pickup if two players touch it in the same frame (co-op).In the Godot editor:
AccessCard.(1, 1, 1). It’s a bit bigger than it looks, so it’s easy to grab.(0.8, 0.05, 0.5), with a gold material and emission on.AccessCard, under Collision: Layer none; Mask only 2 (player). Keep Monitoring on; untick Monitorable.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
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”.
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.
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
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)
max_hp lives on the enemy now. Before, the enemy read data.max_hp directly. But the data is a shared resource: if an elite changed data.max_hp, every grunt would get elite health. So the enemy copies the value into its own max_hp and changes that copy.enemy.tscn shares one CapsuleShape3D. duplicate() makes this enemy its own copy. (This is the same idea as “Local to Scene” on the pickup area in Phase 3, done in code because only some enemies need it.)model_scale remembers the enemy’s normal size, so the hit flash pops relative to it. Without this, an elite’s flash would shrink it back to normal size.health_changed lets things like a boss health bar listen.damage_mult lets the boss’s enrage increase its damage in 6.4.data.drops_access_card.In the Godot editor:
enemy.tscn. Under Model, add a MeshInstance3D named EliteGlow.1.2, Height 2.4.Alpha; Shading Mode Unshaded; Albedo yellow with alpha about 60.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.
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.
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 |
A slow, huge “Brute” that:
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.
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.
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.
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)]
if _winding_up: return comes before super(delta). While winding up, the mini-boss skips all normal behaviour: it stands still and doesn’t melee. That pause is the player’s window to escape or punish it._start_slam(target.global_position) copies the position at that moment; the circle doesn’t follow you. That’s what makes it dodgeable.finished signal. The telegraph tells the mini-boss when it’s done, so the mini-boss can start moving again. If the mini-boss dies mid-windup, Godot automatically disconnects the signal, and the slam still lands (which feels fair: it was already committed).@exports mean every mini-boss or boss using this script can have its own slam settings, set in the Inspector.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:
res://scenes/characters/enemy/enemy.tscn.MiniBoss. In the Inspector, drag mini_boss.gd onto the Script property to replace enemy.gd.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.
HpLabel: Position Y 2.2, Billboard Enabled, Font Size 48, No Depth Test on (so it’s visible through other enemies).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
In the Godot editor:
res://scenes/characters/enemy/brute_model.tscn: a MeshInstance3D with a purple BoxMesh of size (2, 2.6, 2).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.
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”.
boss_time (10:00 by default), the spawner creates the boss, ignoring the enemy cap.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.
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.
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()
Boss extends MiniBoss extends Enemy. The boss walks like an enemy, slams like a mini-boss, and adds the enrage timer and the win/lose signals._alive_time counts after super(delta), so it keeps counting even while the boss is mid-slam (MiniBoss._physics_process returns early during a windup, but that only skips the rest of MiniBoss’s code, not the boss’s).take_damage(INF): INF is infinity, so no amount of defence or health survives it.die() emits before super(), because super() ends with queue_free().In the Godot editor:
mini_boss.tscn. Rename the root Boss and swap the script to boss.gd.1.5 and Height 4.0. Move HpLabel up to Y 3.0.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.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.res://scenes/characters/enemy/boss.tscn.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 |
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
30while 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.
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
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.
30 and the elite entry’s Start to 5 for quick testing.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(). |
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