GameGuide

Phase 3: XP and level-up

Goal: enemies drop XP orbs that fly to you when you get close. Filling the XP bar pauses the game and offers 3 random choices (new passives, passive levels, weapon levels). A HUD shows health, XP, level, gold, the run timer, and your weapons and passives.

Checklist

The XP counter comes before the orbs this time, because orbs call game_state.add_xp() when collected.


3.1 XP, levels and gold in game_state

What

game_state tracks the run’s level, XP, XP needed for the next level, and gold. It announces changes through signals.

Why in game_state and not on the player?

In Swarm, XP is shared: any player picking up an orb levels up the whole team together. Putting XP in the autoload already works that way, so co-op wouldn’t need it moved. It’s also exactly what your comment at the top of game_state.gd says the file is for.

Why a while loop in add_xp()?

A big XP pickup (the boss, or a vacuum in Phase 9) could be worth enough for two levels at once. An if would only level you once and leave you with an overfull bar. The while keeps levelling until the leftover XP fits, and emits leveled_up once per level, so the level-up screen queues up one choice per level.

How

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

Find this:

var selected_character: CharacterData

Replace with:

signal xp_changed(current: float, required: float)
signal leveled_up(new_level: int)
signal gold_changed(total: int)

const MAX_LEVEL: int = 99

var selected_character: CharacterData

Find this:

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

Replace with:

# --- 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

Find the reset_run() function and replace it with:

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)

game_state now has a variable called level, and start_run() has a parameter also called level. Inside that function the parameter would hide the variable, which Godot warns about (“shadowing”). Rename the parameter:

Find this:

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

Replace with:

func start_run(character: CharacterData, level_scene: PackedScene) -> void:
	selected_character = character
	selected_level = level_scene

About the XP curve

5 + 10 × (level - 1) means 5 XP for level 2, then 15, 25, 35, and so on. Each level asks for a bit more, so early levels come quickly (lots of choices to start) and later ones slow down. This is the single best number to tweak if levelling feels too fast or slow. Swarm caps at level 99, so we do too.


3.2 XP orbs and the pickup radius

What

Why a Pickup base class?

XP orbs are the first pickup, but not the last. Access Cards (Phase 6), gold coins (Phase 8) and heal packs (Phase 9) all need “fly to the player, then do something”. Writing the flying part once means each new pickup only writes collect().

Why does the player have the detection area and not each orb?

Hundreds of orbs might be lying around. If each one checked the distance to the player every frame, that’s hundreds of checks per frame for orbs that are nowhere near you. Instead, the orbs sit completely idle (physics processing switched off). The player’s pickup area does the detecting: Godot’s physics engine tells us when an orb enters it, and only then does that orb wake up.

Step A: the Pickup base class

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

class_name Pickup
extends Area3D

const ATTRACT_ACCELERATION: float = 40.0
const COLLECT_DISTANCE: float = 0.6

var _target: Player = null
var _speed: float = 0.0

func _ready() -> void:
	add_to_group("pickups")
	# Idle pickups cost nothing until a player attracts them.
	set_physics_process(false)

func attract(player: Player) -> void:
	if _target != null:
		return
	_target = player
	set_physics_process(true)

func _physics_process(delta: float) -> void:
	if not is_instance_valid(_target) or _target.is_dead:
		_target = null
		_speed = 0.0
		set_physics_process(false)
		return
	_speed += ATTRACT_ACCELERATION * delta
	var to_target: Vector3 = _target.global_position - global_position
	var distance: float = to_target.length()
	if distance <= COLLECT_DISTANCE:
		collect(_target)
		queue_free()
		return
	global_position += to_target / distance * minf(_speed * delta, distance)

# Each pickup type overrides this.
func collect(_player: Player) -> void:
	pass

How it works:

Step B: the XP orb

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

class_name XpOrb
extends Pickup

var amount: float = 1.0

func collect(player: Player) -> void:
	game_state.add_xp(amount * player.xp_mult)

The player who picks it up applies their XP bonus, and the XP goes into the shared pool.

In the Godot editor:

  1. Scene > New Scene > Other Node > Area3D, renamed XpOrb.
  2. Child CollisionShape3D with a SphereShape3D, radius 0.3.
  3. Child MeshInstance3D with a SphereMesh, radius 0.2, height 0.4, and a StandardMaterial3D that’s green with Emission on (green).
  4. On XpOrb, under Collision: Layer only 4 (interactable); Mask none. Untick Monitoring; keep Monitorable ticked.
  5. Attach xp_orb.gd and save as res://scenes/props/pickups/xp_orb.tscn.
XpOrb (Area3D)            script: xp_orb.gd, layer 4, no mask, Monitoring off
├─ CollisionShape3D       SphereShape3D r 0.3
└─ MeshInstance3D         green glowing sphere

Monitoring vs Monitorable: Monitoring means “I look for things overlapping me”. Monitorable means “other areas can see me”. The orb doesn’t need to look for anything (the player’s area does that), so Monitoring is off. That’s one less thing for physics to check, times every orb on the floor.

Step C: the player’s pickup area

In the Godot editor:

  1. Open player.tscn. Under Player, add an Area3D named PickupArea.
  2. Give it a child CollisionShape3D with a SphereShape3D, radius 3 (code will overwrite this).
  3. Click the SphereShape3D to open it, expand Resource, and tick Local to Scene. (See the note below.)
  4. On PickupArea, under Collision: Layer none; Mask only 4. Keep Monitoring on; untick Monitorable.
  5. Save.
Player (CharacterBody3D)
├─ ...
└─ PickupArea (Area3D)          no layer, mask 4
   └─ CollisionShape3D          SphereShape3D, Local to Scene

Why “Local to Scene”? Resources like shapes are shared by default: every copy of player.tscn uses the exact same SphereShape3D. Changing its radius for one player would change it for all of them. With one player you’d never notice, but in co-op one player’s Pickup Range passive would grow everyone’s radius. Local to Scene gives each copy its own shape.

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 pickup_area: Area3D = $PickupArea
@onready var pickup_shape: CollisionShape3D = $PickupArea/CollisionShape3D

Find this:

func _ready() -> void:
	add_to_group("players")

Replace with:

func _ready() -> void:
	add_to_group("players")
	pickup_area.area_entered.connect(_on_pickup_area_entered)

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())
	(pickup_shape.shape as SphereShape3D).radius = pickup_range

Add this function anywhere in the file:

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

Because the radius is set in recalculate_stats(), picking up a Pickup Range passive grows the sphere straight away. Godot notices any orbs that are now inside the bigger sphere and calls area_entered for them.

Step D: enemies drop orbs

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

Add at the bottom:

@export var xp_value: float = 1.0

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

Find this:

signal died(enemy: Enemy)

Replace with:

signal died(enemy: Enemy)

const XP_ORB_SCENE: PackedScene = preload("res://scenes/props/pickups/xp_orb.tscn")

Find this:

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

Replace with:

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
	_spawn_here(orb)

# 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)

Why call_deferred?

Enemies usually die inside a bullet’s body_entered signal, which runs while Godot’s physics engine is in the middle of working out collisions. Adding a new physics object (the orb is an Area3D) at that exact moment isn’t allowed. Godot would print “Can’t change this state while flushing queries. Use call_deferred()”. call_deferred means “do this at the end of the frame, once physics is finished”.

Why set position rather than global_position? The orb isn’t in the scene yet (it’s added at the end of the frame), so it has no global position to set. Because Main sits at the world origin (see the README), position before adding equals global_position after.


3.3 The rules for which choices can appear

What

The rules (from Swarm)

  1. An owned weapon that isn’t at max level can be offered as “level up”.
  2. An owned passive that isn’t at max level can be offered as “level up”.
  3. A new passive is only offered if you have a free passive slot.
  4. (Phase 4) A new weapon is only offered if you have a free weapon slot.
  5. If nothing at all can be offered, give two fallbacks instead: heal 25% or gain gold.

Why separate “building offers” from “showing them”?

The level-up screen (3.4) only knows how to display a list of offers and call apply() on the one you click. Everything about which offers exist lives in UpgradeOffers. That means:

Step A: describing passives

The offer needs text like “Damage +20%”. PassiveData can produce that itself.

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

Add at the bottom:

func value_at(level: int) -> float:
	return values[clampi(level - 1, 0, values.size() - 1)]

func is_percent() -> bool:
	match stat:
		Stat.DAMAGE_MULT, Stat.MOVE_SPEED, Stat.PICKUP_RANGE, Stat.XP_MULT, \
				Stat.GOLD_MULT, Stat.AREA_SIZE_MULT, Stat.DURATION_MULT:
			return true
	return false

func describe_level(level: int) -> String:
	var value: float = value_at(level)
	if is_percent():
		return "%s +%d%%" % [display_name, roundi(value * 100.0)]
	return "%s +%s" % [display_name, String.num(value)]

Step B: more passives

Make these in res://resources/passives/ (right-click > New Resource… > PassiveData). Use res://icon.svg as the icon for now.

File Id Display Name Stat Values
damage.tres (exists) passive_damage Damage DAMAGE_MULT 0.1, 0.2, 0.3, 0.4, 0.5
cdr.tres passive_cdr Cooldown CDR 10, 20, 30, 40, 50
projectile.tres passive_projectile Projectiles PROJECTILE 1, 1, 2, 2, 3
max_hp.tres passive_max_hp Max Health MAX_HP 150, 300, 450, 600, 750
move_speed.tres passive_move_speed Move Speed MOVE_SPEED 0.08, 0.16, 0.24, 0.32, 0.4
pickup_range.tres passive_pickup_range Pickup Range PICKUP_RANGE 0.3, 0.6, 0.9, 1.2, 1.5

Remember the Phase 0 rule: percentage stats as fractions, flat stats as plain numbers.

Step C: the Upgrades autoload

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

extends Node

# Everything that can show up as a level-up choice.
# Signature weapons aren't listed; each character brings its own.
var weapons: Array[WeaponData] = []
var passives: Array[PassiveData] = [
	preload("res://resources/passives/damage.tres"),
	preload("res://resources/passives/cdr.tres"),
	preload("res://resources/passives/projectile.tres"),
	preload("res://resources/passives/max_hp.tres"),
	preload("res://resources/passives/move_speed.tres"),
	preload("res://resources/passives/pickup_range.tres"),
]

This follows the same pattern as your Characters autoload. weapons is empty until Phase 4.

In the Godot editor: Project > Project Settings > Globals > Autoload. Pick upgrades.gd as the path, set the name to Upgrades, and click Add.

Step D: helpers on the player

The offers need to ask the player “do you own this passive?”, level up a weapon, heal, and announce “my loadout changed” so the HUD can redraw.

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

Find this:

signal died

Replace with:

signal died
signal loadout_changed

Find the whole add_passive() function and replace it with:

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 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 heal(amount: float) -> void:
	if is_dead:
		return
	current_hp = minf(current_hp + amount, max_hp)
	health_changed.emit(current_hp, max_hp)

add_passive() does the same job as before, but the “find it” loop is now its own function (get_passive()), because the offer rules need it too.

Step E: UpgradeOffer

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

class_name UpgradeOffer
extends RefCounted

enum Kind { NEW_PASSIVE, PASSIVE_LEVEL, WEAPON_LEVEL, HEAL, GOLD }

const HEAL_FRACTION: float = 0.25
const FALLBACK_GOLD: int = 25

var kind: Kind = Kind.HEAL
var title: String = ""
var description: String = ""
var icon: Texture2D
var passive_data: PassiveData
var weapon: Weapon

func apply(player: Player) -> void:
	match kind:
		Kind.NEW_PASSIVE, Kind.PASSIVE_LEVEL:
			player.add_passive(passive_data)
		Kind.WEAPON_LEVEL:
			player.level_up_weapon(weapon)
		Kind.HEAL:
			player.heal(player.max_hp * HEAL_FRACTION)
		Kind.GOLD:
			game_state.add_gold(FALLBACK_GOLD)

Why RefCounted? An offer is a short-lived bundle of data. RefCounted objects delete themselves automatically once nothing refers to them, so you never need to free them.

Why an enum Kind? It’s a fixed list of named options. match kind: reads much more clearly than comparing strings, and a typo like Kind.HAEL is caught by the editor instead of silently doing nothing.

Step F: UpgradeOffers

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

class_name UpgradeOffers
extends RefCounted

static func build_level_up(player: Player, count: int = 3) -> Array[UpgradeOffer]:
	var candidates: Array[UpgradeOffer] = []
	_add_weapon_offers(player, candidates)
	_add_passive_offers(player, candidates)
	if candidates.is_empty():
		return _fallback_offers()
	candidates.shuffle()
	if candidates.size() > count:
		candidates.resize(count)
	return candidates

static func _add_weapon_offers(player: Player, candidates: Array[UpgradeOffer]) -> void:
	for weapon: Weapon in player.weapons:
		if weapon.is_max_level():
			continue
		var offer: UpgradeOffer = UpgradeOffer.new()
		offer.kind = UpgradeOffer.Kind.WEAPON_LEVEL
		offer.weapon = weapon
		offer.title = "%s  Lv %d" % [weapon.get_display_name(), weapon.level + 1]
		offer.description = weapon.data.level_stats(weapon.level + 1).description
		offer.icon = weapon.get_icon()
		candidates.append(offer)

static func _add_passive_offers(player: Player, candidates: Array[UpgradeOffer]) -> void:
	for passive: OwnedPassive in player.passives:
		if passive.level < Player.MAX_PASSIVE_LEVEL:
			candidates.append(_passive_offer(passive.data, passive.level + 1))
	if player.passives.size() >= player.passive_slots:
		return
	for data: PassiveData in Upgrades.passives:
		if player.get_passive(data) == null:
			candidates.append(_passive_offer(data, 1))

static func _passive_offer(data: PassiveData, level: int) -> UpgradeOffer:
	var offer: UpgradeOffer = UpgradeOffer.new()
	offer.passive_data = data
	offer.description = data.describe_level(level)
	offer.icon = data.icon
	if level == 1:
		offer.kind = UpgradeOffer.Kind.NEW_PASSIVE
		offer.title = "NEW  %s" % data.display_name
	else:
		offer.kind = UpgradeOffer.Kind.PASSIVE_LEVEL
		offer.title = "%s  Lv %d" % [data.display_name, level]
	return offer

static func _fallback_offers() -> Array[UpgradeOffer]:
	var heal: UpgradeOffer = UpgradeOffer.new()
	heal.kind = UpgradeOffer.Kind.HEAL
	heal.title = "Patch Up"
	heal.description = "Restore %d%% of your max health." % roundi(UpgradeOffer.HEAL_FRACTION * 100.0)
	var gold: UpgradeOffer = UpgradeOffer.new()
	gold.kind = UpgradeOffer.Kind.GOLD
	gold.title = "Pocket Change"
	gold.description = "Gain %d gold." % UpgradeOffer.FALLBACK_GOLD
	var offers: Array[UpgradeOffer] = [heal, gold]
	return offers

How it works

  1. Every valid choice is added to candidates, following the rules listed at the top of this step.
  2. If there are none, return the two fallbacks.
  3. Otherwise shuffle and keep the first count (3). Every valid choice has the same chance.

Arrays are passed by reference. When build_level_up() passes candidates into _add_weapon_offers(), both functions are looking at the same array. Appending inside the helper adds to the original. That’s why the helpers don’t need to return anything.

Why are these static? UpgradeOffers has no state of its own; it reads the player and the Upgrades list and returns a result. Static functions make that clear: you call UpgradeOffers.build_level_up(player) without creating anything first.

Later: Swarm doesn’t weight choices, but many games make owned items more likely than new ones. If you want that, this is the only file you’d change.


3.4 The level-up screen

What

A CanvasLayer that listens for game_state.leveled_up, pauses the game, shows the offers as buttons, applies the one you click, and unpauses. If several level-ups happened at once, it shows them one after another.

Why a queue?

If one orb gives you two levels, leveled_up fires twice in the same frame. Without a queue, the second call would overwrite the first set of choices. The queue holds one entry per pending choice; each time you pick, the next one is shown, and the game only unpauses once the queue is empty.

The queue stores a reason rather than just a counter, because Phase 5 adds a second reason (Access Cards) that builds its choices differently.

Step A: the script

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

class_name LevelUpScreen
extends CanvasLayer

enum Reason { LEVEL_UP }

const CHOICE_SIZE: Vector2 = Vector2(560, 96)
const ICON_SIZE: int = 64

@onready var title_label: Label = $Center/Panel/Margin/VBox/Title
@onready var choices_box: VBoxContainer = $Center/Panel/Margin/VBox/Choices

var player: Player
# Holds Reason values, one per pending pick.
var _queue: Array[int] = []

func _ready() -> void:
	visible = false
	game_state.leveled_up.connect(_on_leveled_up)

func _on_leveled_up(_new_level: int) -> void:
	_enqueue(Reason.LEVEL_UP)

func _enqueue(reason: Reason) -> void:
	_queue.append(reason)
	if not visible:
		_show_next()

func _show_next() -> void:
	if _queue.is_empty():
		visible = false
		get_tree().paused = false
		return
	var reason: int = _queue.pop_front()
	var offers: Array[UpgradeOffer] = []
	match reason:
		Reason.LEVEL_UP:
			title_label.text = "Level Up!"
			offers = UpgradeOffers.build_level_up(player)
	_show_offers(offers)
	visible = true
	get_tree().paused = true

func _show_offers(offers: Array[UpgradeOffer]) -> void:
	for child: Node in choices_box.get_children():
		child.queue_free()
	var first_button: Button = null
	for offer: UpgradeOffer in offers:
		var button: Button = Button.new()
		button.text = "%s\n%s" % [offer.title, offer.description]
		button.icon = offer.icon
		button.alignment = HORIZONTAL_ALIGNMENT_LEFT
		button.custom_minimum_size = CHOICE_SIZE
		button.add_theme_constant_override("icon_max_width", ICON_SIZE)
		button.pressed.connect(_on_offer_chosen.bind(offer))
		choices_box.add_child(button)
		if first_button == null:
			first_button = button
	if first_button:
		first_button.grab_focus()

func _on_offer_chosen(offer: UpgradeOffer) -> void:
	offer.apply(player)
	_show_next()

Walking through it

Step B: the scene

In the Godot editor:

  1. Scene > New Scene > Other Node > CanvasLayer, renamed LevelUpScreen.
  2. In the Inspector: Layer 10; Process > Mode Always.
  3. Build:
LevelUpScreen (CanvasLayer)            script: level_up_screen.gd, Layer 10, Process Mode Always
├─ Dimmer (ColorRect)                  Anchors Preset: Full Rect, Color black with alpha about 150
└─ Center (CenterContainer)            Anchors Preset: Full Rect
   └─ Panel (PanelContainer)
      └─ Margin (MarginContainer)      all margins 24
         └─ VBox (VBoxContainer)       Separation 16
            ├─ Title (Label)           Horizontal Alignment Center, font size 40
            └─ Choices (VBoxContainer) Separation 8
  1. Attach the script and save as res://scenes/ui/level_up_screen.tscn.

The Dimmer darkens the game behind the menu so it’s obvious the game is paused. It also blocks mouse clicks from reaching anything underneath.


3.5 The HUD

What

A CanvasLayer showing:

Why signals instead of checking every frame?

The HUD could read player.current_hp 60 times a second, but most frames nothing has changed. Listening to health_changed, xp_changed, loadout_changed and so on means the HUD only does work when something actually changes. The timer is the exception: it changes every frame anyway, so it updates in _process().

Step A: the script

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

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)
	_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:
		weapon_icons.add_child(_make_slot(weapon.get_icon(), "Lv %d" % weapon.level))
	for passive: OwnedPassive in player.passives:
		passive_icons.add_child(_make_slot(passive.data.icon, "Lv %d" % passive.level))

func _make_slot(icon: Texture2D, caption: String) -> Control:
	var slot: VBoxContainer = VBoxContainer.new()
	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
	var label: Label = Label.new()
	label.text = caption
	label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	slot.add_child(picture)
	slot.add_child(label)
	return slot

Notes

Step B: the scene

In the Godot editor:

  1. Scene > New Scene > Other Node > CanvasLayer, renamed Hud. Leave Layer at 1.
  2. Build:
Hud (CanvasLayer)                        script: hud.gd
├─ TopBar (MarginContainer)              Anchors Preset: Top Wide; all margins 16
│  └─ HBox (HBoxContainer)               Separation 16
│     ├─ HpBar (ProgressBar)             Custom Minimum Size (300, 24); Show Percentage off
│     ├─ LevelLabel (Label)
│     ├─ GoldLabel (Label)
│     ├─ Spacer (Control)                Container Sizing > Horizontal: tick Expand
│     └─ TimerLabel (Label)              font size 32
├─ Loadout (VBoxContainer)               Layout > Position (16, 64)
│  ├─ WeaponIcons (HBoxContainer)
│  └─ PassiveIcons (HBoxContainer)
└─ XpBar (ProgressBar)                   Custom Minimum Size (0, 16) FIRST, then
                                         Anchors Preset: Bottom Wide; Show Percentage off
  1. Attach the script and save as res://scenes/ui/hud.tscn.

The Spacer trick: an empty Control set to Expand soaks up all the spare width in the HBoxContainer, pushing TimerLabel to the far right.

XpBar order matters: setting the minimum height before choosing Bottom Wide means the preset keeps it 16 px tall. If you do it the other way round and the bar looks wrong, just choose Bottom Wide again.

Tip: give the HP bar a red fill and the XP bar a blue one: Theme Overrides > Styles > Fill > New StyleBoxFlat, then set its Bg Color.


3.6 Wire it all up in main.gd

What

Create the HUD and the level-up screen when a run starts, give them the player, and add a debug key (F2 = instant level-up) for testing.

Why a debug key?

Testing “what happens when I have 3 passives at max level?” would take ten minutes of play every time. A key that levels you up instantly makes that ten seconds. OS.is_debug_build() is true when you run from the editor and false in an exported release build, so players can never use it.

How

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

Replace the whole file with:

extends Node3D

const PLAYER_SCENE: PackedScene = preload("res://scenes/characters/player/player.tscn")
const 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")

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)

	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)

	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_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)

Order matters here too: hud.setup() is called after add_child(hud), because setup() uses the HUD’s @onready variables, which only exist once the HUD is in the tree. The level-up screen’s player is set before adding it, since it only needs to be ready by the time the first level-up happens.

event as InputEventKey converts the event if it’s a key press, or gives null if it’s something else (mouse, gamepad). key.echo is true for the repeated events you get while holding a key; ignoring those means holding F2 gives one level, not dozens.

Co-op note: in Swarm, every player picks their own upgrade and the game waits for all of them. That would mean one LevelUpScreen per player, each building offers for its own player, plus a “waiting for others” state.


Test it

  1. F5, Gunner, Start.
  2. Killed enemies drop green orbs. Walk within about 3 m and they speed toward you.
  3. The XP bar at the bottom fills. At 5 XP the game pauses and Level Up! shows 3 choices.
  4. Pick a passive: the game resumes, the HUD shows its icon with Lv 1, and the effect applies (for example, Move Speed makes you visibly faster).
  5. Pick Pickup Range: orbs now get pulled from further away.
  6. Press F2 repeatedly:
    • Once 3 passives are owned (your passive slot count), no NEW passives appear any more.
    • Machine Gun Lv 2…5 appears until it’s maxed.
    • Once everything is maxed, you get Patch Up and Pocket Change. Gold goes up in the HUD.
  7. Collect a lot of XP at once (F2 a few times quickly, or a crowd of orbs). The screen shows one pick after another before unpausing.

If something breaks:

Problem Likely cause
Orbs never move Orb isn’t on layer 4, or PickupArea doesn’t mask 4, or PickupArea has Monitoring off.
“Can’t change this state while flushing queries” The orb is being added without call_deferred.
Level-up screen shows but buttons don’t respond LevelUpScreen isn’t Process Mode: Always.
Game stays paused after picking _show_next() isn’t reaching the empty-queue branch. Check _on_offer_chosen calls _show_next().
Identifier "Upgrades" not declared The autoload isn’t registered, or its name isn’t exactly Upgrades.
HUD bars start empty hud.setup(player) isn’t being called, or it’s called before add_child(hud).
Icons are huge EXPAND_IGNORE_SIZE is missing on the TextureRect.

Full file listings

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)

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/props/pickups/pickup.gd

class_name Pickup
extends Area3D

const ATTRACT_ACCELERATION: float = 40.0
const COLLECT_DISTANCE: float = 0.6

var _target: Player = null
var _speed: float = 0.0

func _ready() -> void:
	add_to_group("pickups")
	# Idle pickups cost nothing until a player attracts them.
	set_physics_process(false)

func attract(player: Player) -> void:
	if _target != null:
		return
	_target = player
	set_physics_process(true)

func _physics_process(delta: float) -> void:
	if not is_instance_valid(_target) or _target.is_dead:
		_target = null
		_speed = 0.0
		set_physics_process(false)
		return
	_speed += ATTRACT_ACCELERATION * delta
	var to_target: Vector3 = _target.global_position - global_position
	var distance: float = to_target.length()
	if distance <= COLLECT_DISTANCE:
		collect(_target)
		queue_free()
		return
	global_position += to_target / distance * minf(_speed * delta, distance)

# Each pickup type overrides this.
func collect(_player: Player) -> void:
	pass

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

class_name XpOrb
extends Pickup

var amount: float = 1.0

func collect(player: Player) -> void:
	game_state.add_xp(amount * player.xp_mult)

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

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

class_name Enemy
extends CharacterBody3D

signal died(enemy: Enemy)

const XP_ORB_SCENE: PackedScene = preload("res://scenes/props/pickups/xp_orb.tscn")

@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:
	_drop_loot()
	died.emit(self)
	queue_free()

func _drop_loot() -> void:
	var orb: XpOrb = XP_ORB_SCENE.instantiate()
	orb.amount = data.xp_value
	_spawn_here(orb)

# 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 * 1.25
	_flash_tween = create_tween()
	_flash_tween.tween_property(model_holder, "scale", Vector3.ONE, 0.1)

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

class_name PassiveData
extends GameData

enum Stat { MAX_HP, DEFENCE, DAMAGE_MULT, CDR, HP_REGEN, MOVE_SPEED, PROJECTILE,
	CRIT_CHANCE, PICKUP_RANGE, XP_MULT, GOLD_MULT, AREA_SIZE_MULT, DURATION_MULT }

@export var stat: Stat = Stat.MAX_HP

# Total bonus at each level, not a per-level delta.
# Percentage stats use fractions (0.1 = +10%); flat stats use plain numbers.
@export var values: Array[float] = [0.0, 0.0, 0.0, 0.0, 0.0]

func value_at(level: int) -> float:
	return values[clampi(level - 1, 0, values.size() - 1)]

func is_percent() -> bool:
	match stat:
		Stat.DAMAGE_MULT, Stat.MOVE_SPEED, Stat.PICKUP_RANGE, Stat.XP_MULT, \
				Stat.GOLD_MULT, Stat.AREA_SIZE_MULT, Stat.DURATION_MULT:
			return true
	return false

func describe_level(level: int) -> String:
	var value: float = value_at(level)
	if is_percent():
		return "%s +%d%%" % [display_name, roundi(value * 100.0)]
	return "%s +%s" % [display_name, String.num(value)]

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

extends Node

# Everything that can show up as a level-up choice.
# Signature weapons aren't listed; each character brings its own.
var weapons: Array[WeaponData] = []
var passives: Array[PassiveData] = [
	preload("res://resources/passives/damage.tres"),
	preload("res://resources/passives/cdr.tres"),
	preload("res://resources/passives/projectile.tres"),
	preload("res://resources/passives/max_hp.tres"),
	preload("res://resources/passives/move_speed.tres"),
	preload("res://resources/passives/pickup_range.tres"),
]

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

class_name UpgradeOffer
extends RefCounted

enum Kind { NEW_PASSIVE, PASSIVE_LEVEL, WEAPON_LEVEL, HEAL, GOLD }

const HEAL_FRACTION: float = 0.25
const FALLBACK_GOLD: int = 25

var kind: Kind = Kind.HEAL
var title: String = ""
var description: String = ""
var icon: Texture2D
var passive_data: PassiveData
var weapon: Weapon

func apply(player: Player) -> void:
	match kind:
		Kind.NEW_PASSIVE, Kind.PASSIVE_LEVEL:
			player.add_passive(passive_data)
		Kind.WEAPON_LEVEL:
			player.level_up_weapon(weapon)
		Kind.HEAL:
			player.heal(player.max_hp * HEAL_FRACTION)
		Kind.GOLD:
			game_state.add_gold(FALLBACK_GOLD)

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

class_name UpgradeOffers
extends RefCounted

static func build_level_up(player: Player, count: int = 3) -> Array[UpgradeOffer]:
	var candidates: Array[UpgradeOffer] = []
	_add_weapon_offers(player, candidates)
	_add_passive_offers(player, candidates)
	if candidates.is_empty():
		return _fallback_offers()
	candidates.shuffle()
	if candidates.size() > count:
		candidates.resize(count)
	return candidates

static func _add_weapon_offers(player: Player, candidates: Array[UpgradeOffer]) -> void:
	for weapon: Weapon in player.weapons:
		if weapon.is_max_level():
			continue
		var offer: UpgradeOffer = UpgradeOffer.new()
		offer.kind = UpgradeOffer.Kind.WEAPON_LEVEL
		offer.weapon = weapon
		offer.title = "%s  Lv %d" % [weapon.get_display_name(), weapon.level + 1]
		offer.description = weapon.data.level_stats(weapon.level + 1).description
		offer.icon = weapon.get_icon()
		candidates.append(offer)

static func _add_passive_offers(player: Player, candidates: Array[UpgradeOffer]) -> void:
	for passive: OwnedPassive in player.passives:
		if passive.level < Player.MAX_PASSIVE_LEVEL:
			candidates.append(_passive_offer(passive.data, passive.level + 1))
	if player.passives.size() >= player.passive_slots:
		return
	for data: PassiveData in Upgrades.passives:
		if player.get_passive(data) == null:
			candidates.append(_passive_offer(data, 1))

static func _passive_offer(data: PassiveData, level: int) -> UpgradeOffer:
	var offer: UpgradeOffer = UpgradeOffer.new()
	offer.passive_data = data
	offer.description = data.describe_level(level)
	offer.icon = data.icon
	if level == 1:
		offer.kind = UpgradeOffer.Kind.NEW_PASSIVE
		offer.title = "NEW  %s" % data.display_name
	else:
		offer.kind = UpgradeOffer.Kind.PASSIVE_LEVEL
		offer.title = "%s  Lv %d" % [data.display_name, level]
	return offer

static func _fallback_offers() -> Array[UpgradeOffer]:
	var heal: UpgradeOffer = UpgradeOffer.new()
	heal.kind = UpgradeOffer.Kind.HEAL
	heal.title = "Patch Up"
	heal.description = "Restore %d%% of your max health." % roundi(UpgradeOffer.HEAL_FRACTION * 100.0)
	var gold: UpgradeOffer = UpgradeOffer.new()
	gold.kind = UpgradeOffer.Kind.GOLD
	gold.title = "Pocket Change"
	gold.description = "Gain %d gold." % UpgradeOffer.FALLBACK_GOLD
	var offers: Array[UpgradeOffer] = [heal, gold]
	return offers

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

class_name LevelUpScreen
extends CanvasLayer

enum Reason { LEVEL_UP }

const CHOICE_SIZE: Vector2 = Vector2(560, 96)
const ICON_SIZE: int = 64

@onready var title_label: Label = $Center/Panel/Margin/VBox/Title
@onready var choices_box: VBoxContainer = $Center/Panel/Margin/VBox/Choices

var player: Player
# Holds Reason values, one per pending pick.
var _queue: Array[int] = []

func _ready() -> void:
	visible = false
	game_state.leveled_up.connect(_on_leveled_up)

func _on_leveled_up(_new_level: int) -> void:
	_enqueue(Reason.LEVEL_UP)

func _enqueue(reason: Reason) -> void:
	_queue.append(reason)
	if not visible:
		_show_next()

func _show_next() -> void:
	if _queue.is_empty():
		visible = false
		get_tree().paused = false
		return
	var reason: int = _queue.pop_front()
	var offers: Array[UpgradeOffer] = []
	match reason:
		Reason.LEVEL_UP:
			title_label.text = "Level Up!"
			offers = UpgradeOffers.build_level_up(player)
	_show_offers(offers)
	visible = true
	get_tree().paused = true

func _show_offers(offers: Array[UpgradeOffer]) -> void:
	for child: Node in choices_box.get_children():
		child.queue_free()
	var first_button: Button = null
	for offer: UpgradeOffer in offers:
		var button: Button = Button.new()
		button.text = "%s\n%s" % [offer.title, offer.description]
		button.icon = offer.icon
		button.alignment = HORIZONTAL_ALIGNMENT_LEFT
		button.custom_minimum_size = CHOICE_SIZE
		button.add_theme_constant_override("icon_max_width", ICON_SIZE)
		button.pressed.connect(_on_offer_chosen.bind(offer))
		choices_box.add_child(button)
		if first_button == null:
			first_button = button
	if first_button:
		first_button.grab_focus()

func _on_offer_chosen(offer: UpgradeOffer) -> void:
	offer.apply(player)
	_show_next()

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

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)
	_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:
		weapon_icons.add_child(_make_slot(weapon.get_icon(), "Lv %d" % weapon.level))
	for passive: OwnedPassive in player.passives:
		passive_icons.add_child(_make_slot(passive.data.icon, "Lv %d" % passive.level))

func _make_slot(icon: Texture2D, caption: String) -> Control:
	var slot: VBoxContainer = VBoxContainer.new()
	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
	var label: Label = Label.new()
	label.text = caption
	label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	slot.add_child(picture)
	slot.add_child(label)
	return slot

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")

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)

	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)

	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_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)

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

# --- 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)

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())
	(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 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 heal(amount: float) -> void:
	if is_dead:
		return
	current_hp = minf(current_hp + amount, max_hp)
	health_changed.emit(current_hp, max_hp)

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

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)

Next: Phase 4: Weapon slots and more weapons