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):
| Level | Exp needed | Cumulative kills |
|---|---|---|
| 2 | 2 | 2 |
| 3 | 4 | 6 |
| 4 | 8 | 14 |
| 5 | 16 | 30 |
| 6 | 32 | 62 |
| 7 | 64 | 126 |
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_speedis a period (seconds per swing), so "faster" is subtract. Themaxf(0.25, ...)floor is a design guard: without it, threefaster_swingcards 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_timedoes 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,
PlayerStatsemitslevel_upwith 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_randomon a duplicate cannot produce twins) -
faster_swingtwice: 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_swingcard 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 = choicesafter 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_pointsand compared with strict>. The setter clamps the value tomax_exp_pointsbefore 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 ingain_expworks because it tests>=after the clamp has done its squashing; keep the test ingain_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.