doc 9 of 11

Stage 07 — The level up: the world stops and asks you a question

You will build: the level calculation and its exponential curve, the WeaponUpgrade Resource, a pause-the-world choice menu with three real options, and the application of a chosen upgrade to a live weapon. You'll learn: get_tree().paused and process_mode · upgrade data as Resources · rolling choices without duplicates · the exp curve as design, and a signal-contract error you'll predict first.

Why this exists#

Everything so far has been the game happening to you. A level-up is the game happening with you: the world stops, three cards appear, and your choice changes which game you're playing for the next few minutes — a glass cannon or a slow tank, a fast weak swing or a slow strong one. That's the survivors loop's heart, and it's built from one signal, one pause call, and one Resource.

Build it#

A — The curve: exponential cost, linear reward#

PlayerStats finishes its exp side:

signal level_up(choices: Array[WeaponUpgrade])

@export var base_max_exp := 2.0

var level := 1
var max_exp_points := 2.0
var exp_points := 0.0:
	set = _set_exp_points

@onready var _weapon: Weapon = $Weapons/GreatLongSword

func gain_exp(amount: float) -> void:
	exp_points += amount
	if exp_points >= max_exp_points:
		_level_up()

func _level_up() -> void:
	level += 1
	exp_points = 0.0
	max_exp_points = base_max_exp * pow(2.0, level - 1)

	var pool: Array[WeaponUpgrade] = _weapon.upgrade_list.duplicate()
	var choices: Array[WeaponUpgrade] = []
	for i in 3:
		if pool.is_empty():
			break
		choices.append(pool.pick_random())
	level_up.emit(choices)

The curve, in kills (one golem = 1 exp):

LevelExp neededCumulative kills
222
346
4814
51630
63262
764126

Doubling cost against constant income means level-ups start as a rhythm (one every ~30 seconds, the dopamine metronome) and end as events (one every few minutes, the moment you stop and look at your cards). The reference project wrote the same curve as max_exp_points *= 2 per level; the pow form is the same numbers with the base made visible — tune base_max_exp and the whole curve re-scales from one export. The overflow here is lost (exp_points = 0.0): a 60-exp drop landing at 59/64 resets the bar to 0 instead of 55. At one-golem granularity that almost never happens; the day a drop is worth more than a level, carry the remainder (exp_points -= max_exp_points) — and the day that arrives, you'll know you needed it because you'll have felt the lost exp.

pick_random() on a duplicate is the roll: it removes each pick from the copy, so the three cards are three different upgrades with no bookkeeping. (Calling pick_random() on the real pool would consume upgrades out of existence — the duplicate is the fence.)

B — The upgrade, as data#

entities/player/weapons/weapon_upgrade.gd:

class_name WeaponUpgrade
extends Resource

@export var upgrade_name := ""
@export var upgrade_description := ""
@export_enum("damage", "attack_speed") var upgrade_type := "damage"
@export var upgrade_value := 1.0

Make two .tres files in the weapon's folder — more_damage.tres ("Sword +5", "Each swing does 5 more damage", damage, 5.0) and faster_swing.tres ("Faster Swing", "The sword swings 20% quicker", attack_speed, 0.2) — and assign both to the weapon's upgrade_list. That's the entire upgrade system's content: two files, zero code. The definition/state split that the farming game used for crops is the same move: a WeaponUpgrade is a kind (shared, immutable), and the weapon's damage/attack_speed are the state it mutates.

C — The weapon's application#

# in Weapon
@export var upgrade_list: Array[WeaponUpgrade] = []

## A level-up card, chosen. Applies to this weapon's own stats.
func apply_upgrade(upgrade: WeaponUpgrade) -> void:
	match upgrade.upgrade_type:
		"damage":
			damage += upgrade.upgrade_value
		"attack_speed":
			attack_speed = maxf(0.25, attack_speed - upgrade.upgrade_value)
			attack_speed_timer.wait_time = attack_speed
		_:
			push_warning("Weapon: unknown upgrade type '%s'" % upgrade.upgrade_type)
  • Lower is faster. attack_speed is a period (seconds per swing), so "faster" is subtract. The maxf(0.25, ...) floor is a design guard: without it, three faster_swing cards at 0.2 reach a 0.1s period — six swings a second — and the hit frame (four animation frames) is longer than the gap between swings. The floor is where "the numbers stopped making sense" becomes a visible wall instead of a silent one.
  • The timer re-syncs in the same breath. Changing wait_time does nothing to a timer that's already running; the period updates at the next start. That's why the re-sync lives next to the stat change — one place, both lines.
  • The weapon doesn't know it was chosen; it knows an upgrade was applied. Cards, menus, and future auto-upgrades all end at this same door.

D — The menu: pause, three cards, one choice#

In the player scene's Interfaces CanvasLayer, add LevelUp — a full-rect Control, visible = false, and in its inspector: Process Mode → When Paused. Under it, a centered PanelContainer with an HBoxContainer of three slots, each slot a VBoxContainer with Name (Label), Desc (Label, smaller, autowrap), and Button.

# LevelUp.gd — the overlay
extends Control

@onready var _slots: Array[Node] = [$Panel/HBox/Slot1, $Panel/HBox/Slot2, $Panel/HBox/Slot3]

func _ready() -> void:
	var stats: PlayerStats = Globals.player.player_stats
	stats.level_up.connect(_on_level_up)

func _on_level_up(choices: Array[WeaponUpgrade]) -> void:
	for i in _slots.size():
		var slot: Node = _slots[i]
		var upgrade := choices[i]
		$Panel/HBox.get_node("Slot%d" % (i + 1))/Name.text = upgrade.upgrade_name
		$Panel/HBox.get_node("Slot%d" % (i + 1))/Desc.text = upgrade.upgrade_description
		var button: Button = slot.get_node("Button")
		if not button.pressed.is_connected(_on_choice.bind(i)):
			button.pressed.connect(_on_choice.bind(i))
	visible = true
	get_tree().paused = true

func _on_choice(index: int) -> void:
	get_tree().paused = false
	visible = false
	var stats: PlayerStats = Globals.player.player_stats
	stats.apply_choice(index)

Predict before you run: in the reference project, PlayerStats emits level_up with no arguments and the menu's handler is declared as _on_level_up(available_upgrades: Array[WeaponUpgrade]). What error do you expect, and at which moment — the emit, the connect, or neither?

At the connect: Godot checks signal arity when the connection is made and refuses with Signal "level_up" (0 arguments) cannot be connected to callable ... (1 argument). The menu silently never fires. This is why the signal declaration is the contract: level_up(choices) declares a one-argument signal, the emit passes the roll, and the handler receives it — all three ends reading the same sentence. (A get_node("Slot%d" % ...) per slot is deliberate ugliness kept visible: three slots is a constant of the design, and when it becomes four, every 3 in this file should be the thing that changes. A loop over get_children() is the cleaner version — find it in the stretch.)

And the stats side, so the menu stays ignorant of weapons:

# PlayerStats
var _current_choices: Array[WeaponUpgrade] = []

# in _level_up, before the emit:
	_current_choices = choices
	level_up.emit(choices)

func apply_choice(index: int) -> void:
	if index < _current_choices.size():
		_weapon.apply_upgrade(_current_choices[index])
	_current_choices.clear()

What paused = true actually freezes: everything whose Process Mode is Inherit — the spawner's timer, all 300 enemies, the drops' chase and their push tween (a coin mid-arc hangs in the air, which is the whole "time stopped for you" feeling, free), the weapon's timer. The only things running are the menu and anything else you marked When Paused. Unpausing resumes mid-tween, mid-chase, mid-swing — no state was lost because nothing ran. (Your music also pauses, if it's on the default bus — the audio shelf turns that accident into a feature: ducking the score under the menu sting.)

E — Commit#

git add . && git commit -m "stage 07: level curve, upgrade resources, pause-and-choose menu"

Checkpoint — definition of done#

  • The first level-up arrives at exactly 2 kills; the second at 6 cumulative — count them
  • The world is stopped while the menu is up: no spawns, no chase, a drop mid-arc frozen — verify by standing near the ring, not by faith
  • The three cards are three different upgrades, every time (roll fifty level-ups in your head: pick_random on a duplicate cannot produce twins)
  • faster_swing twice: the period halves each time and the timer visibly re-syncs — the cadence changes on the very next swing, not the one after
  • A third faster_swing card cannot take you under the 0.25 floor — try to break it, then say out loud what the floor is protecting (the hit frame vs the cadence, stage 05's bar)
  • You met the arity error on purpose: break the emit's arguments once, read the exact message, restore it
  • Zero warnings; committed

Stretch (no instructions)#

A second upgrade type: range doesn't exist for a sword, but knockback does — the shove force in stage 04's hitbox is currently a constant 10.0. Give the weapon a knockback stat, wire it into the shove, and sell it as a card. Three new lines if you did stage 04 honestly; find them.

If you get stuck#

  • The menu appears but the world keeps moving → the overlay's Process Mode is Inherit. Pausing the tree also pauses the thing that's supposed to un-pause it — a classic self-lock. The menu must run when paused, by exactly one property.
  • Cards show empty text → the signal fired before the roll existed (you moved _current_choices = choices after the emit, or the emit passes a different array than the one displayed). The menu and the stats must read the same three objects — print their names from both sides once.
  • Level-ups stop happening → you moved the level check inside _set_exp_points and compared with strict >. The setter clamps the value to max_exp_points before any comparison sees it, so the clamped number is ever only equal to the max, never above — and > against an equality never fires. The check in gain_exp works because it tests >= after the clamp has done its squashing; keep the test in gain_exp, or switch the inner one to >=. Either way, the trap is "comparing a clamped value against its own clamp with a strict inequality."
  • The weapon swings twice as fast but the animation didn't → you halved the timer and the animation length is unchanged, so swings now overlap. Stage 05 said this would be visible; it is. The period and the animation are one design decision with two numbers.