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.
take_damage() function that reduces incoming damage by defence.died signal and an is_dead flag.health_changed signal so the HUD (Phase 3) can update the health bar."players"."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.
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.
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.
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()
_die() does, line by lineremove_from_group("players") means enemies stop chasing a dead player, and main.gd can check “is anyone left?” by counting the group.set_physics_process(false) stops movement, regen and aiming.weapon_holder.process_mode = DISABLED stops every weapon in one go. That’s one reason weapons share a parent node.died.emit() is emitted last, after everything above has happened, so anything listening sees the player fully dead.About regen while testing: your base
hp_regenis10.0per second, which completely cancels out one grunt’s damage. If you can’t seem to die, temporarily lower it to1.0or2.0inrecalculate_stats().
EnemyData: a resource holding an enemy type’s numbers (health, speed, damage).Enemy: one script used by every regular enemy. It reads its numbers from EnemyData.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.
EnemyDataFile: 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.
Targeting find playersFile: 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.
Enemy scriptFile: 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)
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).
In the Godot editor:
Enemy.Model.enemy.gd to Enemy.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
In the Godot editor:
GruntModel. Set Mesh > New BoxMesh with size (1, 1.6, 1). Give the mesh a New StandardMaterial3D with a red albedo.(0.6, 0.4, 0) so you can see which way it faces.res://scenes/characters/enemy/grunt_model.tscn.RunnerModel: an orange box of size (0.7, 1.2, 0.7), saved as res://scenes/characters/enemy/runner_model.tscn.In the Godot editor:
resources/ and choose New Folder… called enemies.resources/enemies/, choose New Resource…, search for EnemyData, and save as grunt.tres.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 |
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.
Set what everything is (layer) and what it bumps into or detects (mask).
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.
| 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.
In the Godot editor:
player.tscn, select Player, and under Collision set Layer: only 2. Mask: 1 and 3.enemy.tscn, select Enemy, and set Layer: only 3. Mask: 1, 2 and 3.When a bullet overlaps an enemy, deal damage (with a crit roll), then either keep going (pierce) or disappear.
The bullet code checks body is Enemy. That type didn’t exist until step 2.2.
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()
body_entered is a built-in Area3D signal: “a physics body started overlapping me”. We connect it in _ready() so we don’t have to do it in the editor.body will almost always be an enemy. The is Enemy check is a safety net in case you later put something else on layer 3.pierce = 0, the first hit makes it -1 and the bullet is removed. With pierce = 1, it survives one hit and is removed on the second.queue_free() doesn’t delete the bullet instantly; it deletes it at the end of the frame. If the bullet touched three enemies in the same frame, all three body_entered calls would still arrive. The pierce < 0 check makes the extra calls do nothing.game_state counts how long the current run has lasted.SpawnEntry resource describes one stream of enemies: which type, when it starts and stops, how often, and how many at once.EnemySpawner node in the level reads a list of those entries and creates enemies on a ring just off-screen.“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.
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.
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.
SpawnEntryFile: 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
EnemySpawnerFile: 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
_timers[i] belongs to entries[i]. When an entry’s time window is active, its timer counts down; at zero it spawns count enemies and resets to interval. Timers start at 0, so an entry spawns immediately when its window opens.max_enemies is a safety cap. Hundreds of physics bodies get expensive; if your frame rate drops, lower this.TAU is a full circle in radians (2π). cos(angle) and sin(angle) turn a random angle into a point on a circle.clampf keeps spawn points on the 300 × 300 floor. Without it, walking near an edge would spawn enemies in mid-air off the map, and they’d fall forever.enemy.data is set before add_child(), because the enemy’s _ready() reads data.max_hp. Order matters for the same reason as with weapons in Phase 1.players.pick_random() picks a random player to spawn around (always you, for now). In co-op this spreads enemies between players.In the Godot editor:
res://scenes/levels/flat_land.tscn.FlatLand, add a child Node, rename it EnemySpawner, and attach enemy_spawner.gd.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 |
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.
A “Defeated” screen with Retry and Main Menu buttons that appears when the last player dies and pauses the game.
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.
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.
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")
_ready()), so there’s no editor step to forget.won is always false for now. Phase 6 calls show_result(true) when the boss dies.start_run() again, which calls reset_run(), then reloads main.tscn from scratch.In the Godot editor:
RunEndScreen.20; Process > Mode Always.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.
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.
main.gdFile: 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 emittingdied, this check already means “end the run only when the last player falls”. Swarm’s downed-and-revive system would go here later.
print(current_hp) in take_damage() temporarily; the HUD comes in Phase 3.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. |
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