GameGuide

Phase 7: Character kit

Goal: the Gunner gets a full Swarm-style kit:

The HUD shows each one’s status (“locked”, cooldown, “READY”, or kill progress).

Checklist

How this maps to the to-do list: 7.1, 7.2 and 7.4 are the “built-in passive”, 7.3 and 7.5 to 7.7 are the “E ability and R ultimate”, and 7.8 is “extend CharacterData”.


How Swarm’s character kits work

Part Unlocks Control Notes
Signature weapon Start Automatic, you aim it Already done (Phase 1).
Innate Start Automatic Usually reacts to something (kills, damage taken).
Active ability Level 3 You press it Short-to-medium cooldown.
Ultimate Level 6 You press it Long cooldown, big effect.

Abilities don’t get picked on level-ups. Instead they get stronger automatically as the run level goes up, and they scale with the same stats as weapons: damage, CDR, area, projectiles.


7.1 Timed bonuses on the player

What

A way to give the player a stat bonus for a few seconds, such as “+100 CDR for 6 seconds”. It’s used by the innate here and by boons in Phase 9.

Why plug it into recalculate_stats()?

All stats are rebuilt from scratch in one place (Phase 0). If a buff changed cdr directly, then the next time anything called recalculate_stats() (say, picking up a passive mid-buff), the buff would be wiped out. Keeping a list of active buffs and applying each one inside recalculate_stats() means buffs survive any recalculation, and they disappear cleanly when they run out.

Each buff is its own source, so it stacks the same way as everything else: percentage buffs multiply, flat buffs add.

How

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

Find this:

const AUTO_AIM_RANGE: float = 25.0

Replace with:

const AUTO_AIM_RANGE: float = 25.0

# A temporary stat bonus; each one counts as its own source.
class TimedBonus:
	var stat: PassiveData.Stat
	var amount: float
	var time_left: float

Find this:

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

Replace with:

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

# --- Temporary buffs ---
var _timed_bonuses: Array[TimedBonus] = []

Find this in recalculate_stats():

	for passive: OwnedPassive in passives:
		_apply_bonus(passive.data.stat, passive.bonus())

Replace with:

	for passive: OwnedPassive in passives:
		_apply_bonus(passive.data.stat, passive.bonus())
	for bonus: TimedBonus in _timed_bonuses:
		_apply_bonus(bonus.stat, bonus.amount)

Find this at the start of _physics_process():

func _physics_process(delta: float) -> void:
	_regenerate(delta)

Replace with:

func _physics_process(delta: float) -> void:
	_tick_timed_bonuses(delta)
	_regenerate(delta)

Add these functions below heal():

func add_timed_bonus(stat: PassiveData.Stat, amount: float, duration: float) -> void:
	var bonus: TimedBonus = TimedBonus.new()
	bonus.stat = stat
	bonus.amount = amount
	bonus.time_left = duration
	_timed_bonuses.append(bonus)
	recalculate_stats()

func _tick_timed_bonuses(delta: float) -> void:
	var expired: bool = false
	# Walk backwards so removing an entry doesn't skip the next one.
	for i: int in range(_timed_bonuses.size() - 1, -1, -1):
		_timed_bonuses[i].time_left -= delta
		if _timed_bonuses[i].time_left <= 0.0:
			_timed_bonuses.remove_at(i)
			expired = true
	if expired:
		recalculate_stats()

Notes


7.2 Kill tracking

What

game_state counts kills for the run and emits enemy_killed whenever any enemy dies.

Why not use each enemy’s died signal?

The innate needs to hear about every enemy. Connecting to died on hundreds of enemies as they spawn would be messy. One global signal on the autoload is simpler, and the run’s kill count is useful for the end screen and objectives (Phase 8).

How

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

Find this:

signal boss_defeated

Replace with:

signal boss_defeated
signal enemy_killed(enemy: Enemy)

Find this:

var gold: int = 0

Replace with:

var gold: int = 0
var kills: int = 0

Find this in reset_run():

	gold = 0

Replace with:

	gold = 0
	kills = 0

Add at the bottom:

func register_kill(enemy: Enemy) -> void:
	kills += 1
	enemy_killed.emit(enemy)

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

Find this:

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

Replace with:

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

Co-op note: Swarm’s innates count kills made by that player. That would need take_damage() to know who dealt the damage (an extra source parameter passed down from weapons). For single-player, every kill is yours.


7.3 The Ability base class

What

A base class for all three kinds of ability, handling:

Why exports on the node instead of a data resource?

Weapons use WeaponData because they have 5 levels and appear as offers, so there’s a lot of data to manage. Abilities have no levels or offers; each one belongs to one character. Putting the few settings straight on the scene’s root node (as @exports) is the simplest thing that works. You set them in the Inspector when you build each ability scene.

How

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

class_name Ability
extends Node3D

@export var display_name: String = ""
@export var icon: Texture2D
# Shown on the HUD, for example "E".
@export var key_hint: String = ""
# Leave empty for innate abilities that are never pressed.
@export var input_action: StringName = &""
@export var unlock_level: int = 1
@export var base_cooldown: float = 8.0
@export_group("Damage")
@export var base_damage: float = 0.0
@export var damage_per_level: float = 0.0

var player: Player
var cooldown_left: float = 0.0

func is_unlocked() -> bool:
	return game_state.level >= unlock_level

func _physics_process(delta: float) -> void:
	cooldown_left = maxf(0.0, cooldown_left - delta)

func _unhandled_input(event: InputEvent) -> void:
	if input_action != &"" and event.is_action_pressed(input_action):
		try_cast()

func try_cast() -> void:
	if not is_unlocked() or cooldown_left > 0.0 or player.is_dead:
		return
	cast()
	cooldown_left = get_cooldown()

func get_cooldown() -> float:
	return base_cooldown / (1.0 + player.cdr / 100.0)

# Abilities grow with the run level, like Swarm.
func get_damage() -> float:
	return (base_damage + damage_per_level * game_state.level) * player.damage_mult

func get_status_text() -> String:
	if not is_unlocked():
		return "unlocks at Lv %d" % unlock_level
	if cooldown_left > 0.0:
		return "%.1fs" % cooldown_left
	return "READY"

# Each ability overrides this.
func cast() -> void:
	pass

Notes


7.4 The innate: Adrenaline

What

Count kills. At 50 (or instantly on an elite kill), gain +100 CDR and +20% Move Speed for 6 seconds, then start counting again.

Why this design?

It mirrors Jinx’s “Get Excited!” from the wiki: it rewards aggressive play, and the elite shortcut makes elites feel even more worth chasing.

How

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

extends Ability

const KILLS_NEEDED: int = 50
const BONUS_CDR: float = 100.0
const BONUS_MOVE_SPEED: float = 0.2
const BONUS_DURATION: float = 6.0

var _kills: int = 0

func _ready() -> void:
	game_state.enemy_killed.connect(_on_enemy_killed)

func _on_enemy_killed(enemy: Enemy) -> void:
	if player.is_dead:
		return
	# An elite counts as a full set of kills.
	_kills += KILLS_NEEDED if enemy.is_elite else 1
	if _kills < KILLS_NEEDED:
		return
	_kills = 0
	player.add_timed_bonus(PassiveData.Stat.CDR, BONUS_CDR, BONUS_DURATION)
	player.add_timed_bonus(PassiveData.Stat.MOVE_SPEED, BONUS_MOVE_SPEED, BONUS_DURATION)

func get_status_text() -> String:
	return "%d / %d kills" % [_kills, KILLS_NEEDED]

+100 CDR means cooldowns are halved (1 / (1 + 100/100) = 0.5), so every weapon fires twice as fast for 6 seconds. That’s very noticeable, which is exactly what an innate should be.

In the Godot editor:

  1. New Scene > Node3D, renamed Adrenaline, with adrenaline.gd attached.
  2. Inspector: Display Name Adrenaline, Key Hint empty, Input Action empty, Unlock Level 1.
  3. Save as res://scenes/characters/player/abilities/adrenaline.tscn.

7.5 A reusable explosion

What

A scene that, the moment it’s added, damages every enemy in a radius and flashes an orange ring. The grenade, the rockets, and later the Phase 9 sea mine all use it.

Why a scene instead of copying the damage loop?

Three things need “boom, damage in a circle, show it”. One scene means one place to improve the visuals (add particles or a sound later) and every explosion in the game gets the upgrade.

How

File: res://scripts/combat/explosion.gd

class_name Explosion
extends Node3D

const FLASH_TIME: float = 0.2

var radius: float = 3.0
var damage: float = 30.0
var crit_chance: float = 0.0

@onready var ring: MeshInstance3D = $Ring

# Set position, radius and damage before adding this to the scene.
func _ready() -> void:
	for enemy: Enemy in Targeting.enemies_in_radius(get_tree(), global_position, radius):
		enemy.take_damage(Weapon.roll_crit(damage, crit_chance))
	ring.scale = Vector3(radius, 1.0, radius)
	var tween: Tween = create_tween()
	tween.tween_interval(FLASH_TIME)
	tween.tween_callback(queue_free)

Why does the damage happen in _ready()? An explosion is instant. Doing it in _ready() means “the moment you’re added, explode”. That’s also why the comment says to set everything (including position) before adding it: _ready() runs during add_child().

In the Godot editor:

Explosion (Node3D)            script: explosion.gd
└─ Ring (MeshInstance3D)      CylinderMesh r 1, h 0.05; orange, alpha about 150, Unshaded; Position Y -0.95

Save as res://scenes/combat/explosion.tscn. Create the scenes/combat/ and scripts/combat/ folders if needed.


7.6 The special: Frag Grenade (E)

What

Press E (from level 3) to lob a grenade 8 m in your aim direction. It explodes on landing.

How

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

extends Ability

const EXPLOSION_SCENE: PackedScene = preload("res://scenes/combat/explosion.tscn")
const THROW_DISTANCE: float = 8.0
const FLIGHT_TIME: float = 0.4
const BASE_RADIUS: float = 4.0

func cast() -> void:
	var start: Vector3 = player.global_position
	var target: Vector3 = start + player.aim_direction * THROW_DISTANCE
	var grenade: MeshInstance3D = MeshInstance3D.new()
	var mesh: SphereMesh = SphereMesh.new()
	mesh.radius = 0.25
	mesh.height = 0.5
	grenade.mesh = mesh
	grenade.position = start
	get_tree().current_scene.add_child(grenade)
	var tween: Tween = grenade.create_tween()
	tween.tween_property(grenade, "position", target, FLIGHT_TIME)
	tween.tween_callback(_explode.bind(target))
	tween.tween_callback(grenade.queue_free)

func _explode(at: Vector3) -> void:
	var explosion: Explosion = EXPLOSION_SCENE.instantiate()
	explosion.radius = BASE_RADIUS * player.area_size_mult
	explosion.damage = get_damage()
	explosion.crit_chance = player.crit_chance
	explosion.position = at
	get_tree().current_scene.add_child(explosion)

How it works

In the Godot editor:

  1. New Scene > Node3D, renamed FragGrenade, with frag_grenade.gd attached.
  2. Inspector: Display Name Frag Grenade, Key Hint E, Input Action special, Unlock Level 3, Base Cooldown 8. Under Damage: Base Damage 60, Damage Per Level 6.
  3. Save as res://scenes/characters/player/abilities/frag_grenade.tscn.

7.7 The ultimate: Rocket Barrage (R)

What

Press R (from level 6) to fire a cone of rockets (9 plus your projectile bonus). Each one explodes on the first enemy it hits.

Step A: projectiles that explode

The rockets are Projectiles with one extra ability: exploding instead of hitting one enemy. Adding an optional explosion_radius to Projectile means any future weapon can fire explosive shots too.

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

Find this:

class_name Projectile
extends Area3D

Replace with:

class_name Projectile
extends Area3D

const EXPLOSION_SCENE: PackedScene = preload("res://scenes/combat/explosion.tscn")

Find this:

var pierce: int = 0

Replace with:

var pierce: int = 0
# When above 0, hitting an enemy causes an explosion instead of a single hit.
var explosion_radius: float = 0.0

Find this in _on_body_entered():

	var enemy: Enemy = body as Enemy
	enemy.take_damage(Weapon.roll_crit(damage, crit_chance))

Replace with:

	var enemy: Enemy = body as Enemy
	if explosion_radius > 0.0:
		_explode()
	else:
		enemy.take_damage(Weapon.roll_crit(damage, crit_chance))

Add at the bottom:

func _explode() -> void:
	var explosion: Explosion = EXPLOSION_SCENE.instantiate()
	explosion.radius = explosion_radius
	explosion.damage = damage
	explosion.crit_chance = crit_chance
	explosion.position = global_position
	get_tree().current_scene.add_child.call_deferred(explosion)

Why skip the direct hit? The explosion already covers the enemy that was hit. Doing both would double-damage it.

Why call_deferred here but not in the grenade? This runs inside body_entered, a physics callback. Kills from the explosion can spawn XP orbs (physics objects), so it’s safer to explode at the end of the frame. The grenade’s explosion comes from a tween, which isn’t a physics callback.

Step B: the rocket scene

In the Godot editor: right-click bullet.tscn > Duplicate…, name it rocket.tscn, open it, and change the mesh to a red SphereMesh with radius 0.25 and height 0.5. It keeps projectile.gd and the same layer and mask.

Step C: the ability

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

extends Ability

const ROCKET_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/rocket.tscn")
const BASE_ROCKETS: int = 9
const CONE_DEGREES: float = 50.0
const ROCKET_SPEED: float = 20.0
const ROCKET_LIFETIME: float = 1.5
const EXPLOSION_RADIUS: float = 2.0

func cast() -> void:
	var count: int = BASE_ROCKETS + player.projectile
	for i: int in count:
		var angle: float = deg_to_rad(randf_range(-CONE_DEGREES, CONE_DEGREES) / 2.0)
		var rocket: Projectile = ROCKET_SCENE.instantiate()
		rocket.direction = player.aim_direction.rotated(Vector3.UP, angle)
		rocket.speed = ROCKET_SPEED * randf_range(0.8, 1.2)
		rocket.lifetime = ROCKET_LIFETIME
		rocket.damage = get_damage()
		rocket.crit_chance = player.crit_chance
		rocket.explosion_radius = EXPLOSION_RADIUS * player.area_size_mult
		get_tree().current_scene.add_child(rocket)
		rocket.global_position = player.global_position + rocket.direction * 0.8

Why random angles and speeds? A neat, evenly spaced fan looks mechanical. Random angles within the cone and slightly different speeds make it look like a chaotic barrage, much closer to Swarm’s “rockets fly in random directions within a cone”.

It scales with Projectiles (more rockets), Area (bigger explosions), Damage, Crit and CDR, which is why abilities feel stronger as your build comes together.

In the Godot editor:

  1. New Scene > Node3D, renamed RocketBarrage, with rocket_barrage.gd attached.
  2. Inspector: Display Name Rocket Barrage, Key Hint R, Input Action ult, Unlock Level 6, Base Cooldown 50. Base Damage 120, Damage Per Level 15.
  3. Save as res://scenes/characters/player/abilities/rocket_barrage.tscn.

7.8 CharacterData, spawning abilities, and the HUD

Step A: CharacterData

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

Find this:

@export_group("Loadout")
@export var signature_weapon: WeaponData

Replace with:

@export_group("Loadout")
@export var signature_weapon: WeaponData

@export_group("Abilities")
@export var innate_scene: PackedScene
@export var special_scene: PackedScene
@export var ultimate_scene: PackedScene

In the Godot editor: open gunner.tres and under Abilities set Innate Scene to adrenaline.tscn, Special Scene to frag_grenade.tscn, and Ultimate Scene to rocket_barrage.tscn.

A character is now completely described by one resource: model, base stat bonuses, signature weapon, and three abilities. A new character is “duplicate gunner.tres and swap things out”.

Step B: the player spawns abilities

In the Godot editor: open player.tscn and add a Node3D child named Abilities.

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

Find this:

@onready var weapon_holder: Node3D = $Weapons

Replace with:

@onready var weapon_holder: Node3D = $Weapons
@onready var ability_holder: Node3D = $Abilities

Find this:

# --- Temporary buffs ---
var _timed_bonuses: Array[TimedBonus] = []

Replace with:

# --- Abilities ---
var abilities: Array[Ability] = []

# --- Temporary buffs ---
var _timed_bonuses: Array[TimedBonus] = []

Find this:

	if character.signature_weapon:
		_spawn_weapon(character.signature_weapon)

Replace with:

	if character.signature_weapon:
		_spawn_weapon(character.signature_weapon)
	for scene: PackedScene in [character.innate_scene, character.special_scene, character.ultimate_scene]:
		if scene:
			_spawn_ability(scene)

func _spawn_ability(scene: PackedScene) -> void:
	var ability: Ability = scene.instantiate()
	ability.player = self
	ability_holder.add_child(ability)
	abilities.append(ability)

Find this in _die():

	weapon_holder.process_mode = Node.PROCESS_MODE_DISABLED

Replace with:

	weapon_holder.process_mode = Node.PROCESS_MODE_DISABLED
	ability_holder.process_mode = Node.PROCESS_MODE_DISABLED

ability.player = self is set before add_child(), so the ability’s _ready() (Adrenaline connects a signal there) can rely on player being set.

Step C: HUD status lines

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

Hud (CanvasLayer)
├─ ...
└─ AbilityBox (VBoxContainer)      Anchors Preset: Bottom Left; Position about (16, -110)

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

Find this:

@onready var boss_bar: ProgressBar = $BossBox/BossBar

var player: Player

Replace with:

@onready var boss_bar: ProgressBar = $BossBox/BossBar
@onready var ability_box: VBoxContainer = $AbilityBox

var player: Player
var _ability_labels: Dictionary[Ability, Label] = {}

Find this at the end of setup():

	_refresh_loadout()

Replace with:

	_refresh_loadout()
	_build_ability_labels()

Find this:

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]

Replace with:

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]
	for ability: Ability in _ability_labels:
		_ability_labels[ability].text = _ability_text(ability)

Add at the bottom:

func _build_ability_labels() -> void:
	for ability: Ability in player.abilities:
		var label: Label = Label.new()
		ability_box.add_child(label)
		_ability_labels[ability] = label

func _ability_text(ability: Ability) -> String:
	var prefix: String = ("[%s] " % ability.key_hint) if ability.key_hint != "" else ""
	return "%s%s: %s" % [prefix, ability.display_name, ability.get_status_text()]

Dictionary[Ability, Label] is a typed dictionary (Godot 4.4 and newer): every key must be an Ability and every value a Label. It works like a normal dictionary but catches mistakes earlier. Looping over a dictionary with for ability in _ability_labels goes through its keys.

Why update in _process()? Cooldowns tick down every frame, so the text changes every frame anyway. This is the same reasoning as the timer label.


Test it

  1. F5, Gunner, Start. The bottom-left shows:
     Adrenaline: 0 / 50 kills
     [E] Frag Grenade: unlocks at Lv 3
     [R] Rocket Barrage: unlocks at Lv 6
    
  2. Kill 50 enemies (or one elite). Weapons visibly fire faster and you move quicker for 6 seconds, then everything returns to normal.
  3. Pick up a passive during the Adrenaline buff. The buff should still be active afterwards (that’s the point of 7.1).
  4. Reach level 3 (F2 helps). E throws a grenade that explodes 8 m ahead, and the HUD counts down the cooldown.
  5. Reach level 6. R fires a spray of rockets that explode on contact.
  6. Take Projectiles and Area Size: more rockets, and bigger grenade and rocket explosions.

If something breaks:

Problem Likely cause
Invalid access to property 'is_dead' on a base object of type 'Nil' ability.player wasn’t set before add_child().
E or R does nothing Input Action doesn’t exactly match the Input Map name (special, ult), or the level isn’t high enough.
Buff never ends _tick_timed_bonuses(delta) isn’t called in _physics_process().
Buff disappears when picking a passive The _timed_bonuses loop is missing from recalculate_stats().
Can't change this state while flushing queries from rockets _explode() in projectile.gd isn’t using call_deferred.
Dictionary[Ability, Label] is a parse error Your Godot is older than 4.4. Use var _ability_labels: Dictionary = {} instead.

Full file listings

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

class_name Ability
extends Node3D

@export var display_name: String = ""
@export var icon: Texture2D
# Shown on the HUD, for example "E".
@export var key_hint: String = ""
# Leave empty for innate abilities that are never pressed.
@export var input_action: StringName = &""
@export var unlock_level: int = 1
@export var base_cooldown: float = 8.0
@export_group("Damage")
@export var base_damage: float = 0.0
@export var damage_per_level: float = 0.0

var player: Player
var cooldown_left: float = 0.0

func is_unlocked() -> bool:
	return game_state.level >= unlock_level

func _physics_process(delta: float) -> void:
	cooldown_left = maxf(0.0, cooldown_left - delta)

func _unhandled_input(event: InputEvent) -> void:
	if input_action != &"" and event.is_action_pressed(input_action):
		try_cast()

func try_cast() -> void:
	if not is_unlocked() or cooldown_left > 0.0 or player.is_dead:
		return
	cast()
	cooldown_left = get_cooldown()

func get_cooldown() -> float:
	return base_cooldown / (1.0 + player.cdr / 100.0)

# Abilities grow with the run level, like Swarm.
func get_damage() -> float:
	return (base_damage + damage_per_level * game_state.level) * player.damage_mult

func get_status_text() -> String:
	if not is_unlocked():
		return "unlocks at Lv %d" % unlock_level
	if cooldown_left > 0.0:
		return "%.1fs" % cooldown_left
	return "READY"

# Each ability overrides this.
func cast() -> void:
	pass

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

extends Ability

const KILLS_NEEDED: int = 50
const BONUS_CDR: float = 100.0
const BONUS_MOVE_SPEED: float = 0.2
const BONUS_DURATION: float = 6.0

var _kills: int = 0

func _ready() -> void:
	game_state.enemy_killed.connect(_on_enemy_killed)

func _on_enemy_killed(enemy: Enemy) -> void:
	if player.is_dead:
		return
	# An elite counts as a full set of kills.
	_kills += KILLS_NEEDED if enemy.is_elite else 1
	if _kills < KILLS_NEEDED:
		return
	_kills = 0
	player.add_timed_bonus(PassiveData.Stat.CDR, BONUS_CDR, BONUS_DURATION)
	player.add_timed_bonus(PassiveData.Stat.MOVE_SPEED, BONUS_MOVE_SPEED, BONUS_DURATION)

func get_status_text() -> String:
	return "%d / %d kills" % [_kills, KILLS_NEEDED]

File: res://scripts/combat/explosion.gd

class_name Explosion
extends Node3D

const FLASH_TIME: float = 0.2

var radius: float = 3.0
var damage: float = 30.0
var crit_chance: float = 0.0

@onready var ring: MeshInstance3D = $Ring

# Set position, radius and damage before adding this to the scene.
func _ready() -> void:
	for enemy: Enemy in Targeting.enemies_in_radius(get_tree(), global_position, radius):
		enemy.take_damage(Weapon.roll_crit(damage, crit_chance))
	ring.scale = Vector3(radius, 1.0, radius)
	var tween: Tween = create_tween()
	tween.tween_interval(FLASH_TIME)
	tween.tween_callback(queue_free)

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

extends Ability

const EXPLOSION_SCENE: PackedScene = preload("res://scenes/combat/explosion.tscn")
const THROW_DISTANCE: float = 8.0
const FLIGHT_TIME: float = 0.4
const BASE_RADIUS: float = 4.0

func cast() -> void:
	var start: Vector3 = player.global_position
	var target: Vector3 = start + player.aim_direction * THROW_DISTANCE
	var grenade: MeshInstance3D = MeshInstance3D.new()
	var mesh: SphereMesh = SphereMesh.new()
	mesh.radius = 0.25
	mesh.height = 0.5
	grenade.mesh = mesh
	grenade.position = start
	get_tree().current_scene.add_child(grenade)
	var tween: Tween = grenade.create_tween()
	tween.tween_property(grenade, "position", target, FLIGHT_TIME)
	tween.tween_callback(_explode.bind(target))
	tween.tween_callback(grenade.queue_free)

func _explode(at: Vector3) -> void:
	var explosion: Explosion = EXPLOSION_SCENE.instantiate()
	explosion.radius = BASE_RADIUS * player.area_size_mult
	explosion.damage = get_damage()
	explosion.crit_chance = player.crit_chance
	explosion.position = at
	get_tree().current_scene.add_child(explosion)

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

extends Ability

const ROCKET_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/rocket.tscn")
const BASE_ROCKETS: int = 9
const CONE_DEGREES: float = 50.0
const ROCKET_SPEED: float = 20.0
const ROCKET_LIFETIME: float = 1.5
const EXPLOSION_RADIUS: float = 2.0

func cast() -> void:
	var count: int = BASE_ROCKETS + player.projectile
	for i: int in count:
		var angle: float = deg_to_rad(randf_range(-CONE_DEGREES, CONE_DEGREES) / 2.0)
		var rocket: Projectile = ROCKET_SCENE.instantiate()
		rocket.direction = player.aim_direction.rotated(Vector3.UP, angle)
		rocket.speed = ROCKET_SPEED * randf_range(0.8, 1.2)
		rocket.lifetime = ROCKET_LIFETIME
		rocket.damage = get_damage()
		rocket.crit_chance = player.crit_chance
		rocket.explosion_radius = EXPLOSION_RADIUS * player.area_size_mult
		get_tree().current_scene.add_child(rocket)
		rocket.global_position = player.global_position + rocket.direction * 0.8

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

class_name Projectile
extends Area3D

const EXPLOSION_SCENE: PackedScene = preload("res://scenes/combat/explosion.tscn")

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
# When above 0, hitting an enemy causes an explosion instead of a single hit.
var explosion_radius: float = 0.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
	if explosion_radius > 0.0:
		_explode()
	else:
		enemy.take_damage(Weapon.roll_crit(damage, crit_chance))
	pierce -= 1
	if pierce < 0:
		queue_free()

func _explode() -> void:
	var explosion: Explosion = EXPLOSION_SCENE.instantiate()
	explosion.radius = explosion_radius
	explosion.damage = damage
	explosion.crit_chance = crit_chance
	explosion.position = global_position
	get_tree().current_scene.add_child.call_deferred(explosion)

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

class_name CharacterData
extends GameData

@export var model_scene: PackedScene

# Bonuses are additive on top of player.gd's base stats, so they can only raise them.
@export_group("Stat Bonuses")
@export_range(0, 500) var bonus_max_hp: int = 0
@export_range(0, 25) var bonus_defence: int = 0

@export_group("Loadout")
@export var signature_weapon: WeaponData

@export_group("Abilities")
@export var innate_scene: PackedScene
@export var special_scene: PackedScene
@export var ultimate_scene: PackedScene

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
signal enemy_killed(enemy: Enemy)

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
var kills: 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
	kills = 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)

func register_kill(enemy: Enemy) -> void:
	kills += 1
	enemy_killed.emit(enemy)

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()
	game_state.register_kill(self)
	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/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
@onready var ability_box: VBoxContainer = $AbilityBox

var player: Player
var _ability_labels: Dictionary[Ability, Label] = {}

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()
	_build_ability_labels()

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]
	for ability: Ability in _ability_labels:
		_ability_labels[ability].text = _ability_text(ability)

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

func _build_ability_labels() -> void:
	for ability: Ability in player.abilities:
		var label: Label = Label.new()
		ability_box.add_child(label)
		_ability_labels[ability] = label

func _ability_text(ability: Ability) -> String:
	var prefix: String = ("[%s] " % ability.key_hint) if ability.key_hint != "" else ""
	return "%s%s: %s" % [prefix, ability.display_name, ability.get_status_text()]

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

extends CharacterBody3D
class_name Player

signal stats_changed
signal health_changed(current: float, maximum: float)
signal died
signal loadout_changed
const MAX_PASSIVE_LEVEL: int = 5
# The signature weapon plus 4 picked up during the run.
const MAX_WEAPON_SLOTS: int = 5
const AUTO_AIM_RANGE: float = 25.0

# A temporary stat bonus; each one counts as its own source.
class TimedBonus:
	var stat: PassiveData.Stat
	var amount: float
	var time_left: float

@export var character_data: CharacterData
@onready var character_model: Node3D = $CharacterModel
@onready var camera: Camera3D = $Camera3D
@onready var weapon_holder: Node3D = $Weapons
@onready var ability_holder: Node3D = $Abilities
@onready var pickup_area: Area3D = $PickupArea
@onready var pickup_shape: CollisionShape3D = $PickupArea/CollisionShape3D


# --- 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] = []

# --- Abilities ---
var abilities: Array[Ability] = []

# --- Temporary buffs ---
var _timed_bonuses: Array[TimedBonus] = []

# --- 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")
	pickup_area.area_entered.connect(_on_pickup_area_entered)
	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)
	for scene: PackedScene in [character.innate_scene, character.special_scene, character.ultimate_scene]:
		if scene:
			_spawn_ability(scene)

func _spawn_ability(scene: PackedScene) -> void:
	var ability: Ability = scene.instantiate()
	ability.player = self
	ability_holder.add_child(ability)
	abilities.append(ability)

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())
	for bonus: TimedBonus in _timed_bonuses:
		_apply_bonus(bonus.stat, bonus.amount)
	(pickup_shape.shape as SphereShape3D).radius = pickup_range
	# 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 get_passive(data: PassiveData) -> OwnedPassive:
	for passive: OwnedPassive in passives:
		if passive.data.id == data.id:
			return passive
	return null

func add_passive(data: PassiveData) -> bool:
	var owned: OwnedPassive = get_passive(data)
	if owned:
		if owned.level >= MAX_PASSIVE_LEVEL:
			return false
		owned.level += 1
	else:
		if passives.size() >= passive_slots:
			return false
		passives.append(OwnedPassive.new(data))
	recalculate_stats()
	loadout_changed.emit()
	return true

func get_weapon(data: WeaponData) -> Weapon:
	for weapon: Weapon in weapons:
		if weapon.data.id == data.id:
			return weapon
	return null

func add_weapon(data: WeaponData) -> bool:
	var owned: Weapon = get_weapon(data)
	if owned:
		return level_up_weapon(owned)
	if weapons.size() >= MAX_WEAPON_SLOTS:
		return false
	_spawn_weapon(data)
	loadout_changed.emit()
	return true

func level_up_weapon(weapon: Weapon) -> bool:
	if weapon.is_max_level():
		return false
	weapon.set_level(weapon.level + 1)
	loadout_changed.emit()
	return true

func evolve_weapon(weapon: Weapon) -> void:
	weapon.evolve()
	loadout_changed.emit()

func heal(amount: float) -> void:
	if is_dead:
		return
	current_hp = minf(current_hp + amount, max_hp)
	health_changed.emit(current_hp, max_hp)

func add_timed_bonus(stat: PassiveData.Stat, amount: float, duration: float) -> void:
	var bonus: TimedBonus = TimedBonus.new()
	bonus.stat = stat
	bonus.amount = amount
	bonus.time_left = duration
	_timed_bonuses.append(bonus)
	recalculate_stats()

func _tick_timed_bonuses(delta: float) -> void:
	var expired: bool = false
	# Walk backwards so removing an entry doesn't skip the next one.
	for i: int in range(_timed_bonuses.size() - 1, -1, -1):
		_timed_bonuses[i].time_left -= delta
		if _timed_bonuses[i].time_left <= 0.0:
			_timed_bonuses.remove_at(i)
			expired = true
	if expired:
		recalculate_stats()

func _on_pickup_area_entered(area: Area3D) -> void:
	if area is Pickup:
		(area as Pickup).attract(self)

func _physics_process(delta: float) -> void:
	_tick_timed_bonuses(delta)
	_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
	ability_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)

Next: Phase 8: Progress between runs