M5.3 — Experience & Leveling¶
What you'll learn
- Award XP on
SignalBus.enemy_diedand level up against a growing XP curve. - Apply per-level growth through the M5.1 StatBlock (on a per-instance copy).
- Recognize increasing-cost/steady-reward as the genre's engagement engine.
How it applies
- Leveling is the player's sense of progress. Between loot drops, the level bar is the constant, reliable signal that effort is accumulating. A curve that is too flat trivializes the game; too steep stalls it. The curve is the pacing.
- Growing cost is deliberate, not stingy. If each level cost the same XP, early enemies would carry the player forever. Making each level cost more means the player must seek tougher enemies (worth more XP) to keep pace — which pulls them deeper into the dungeon. The increasing cost is what keeps the difficulty and the reward moving together.
- Stat growth must route through the one stat surface. A level-up raises max health, damage, etc.
Because M5.1 centralized those in a StatBlock and M5.4 will add a modifier layer, level growth has a
clean place to apply — not scattered
+=on random nodes. - It's a curve you tune and test. The level thresholds are a formula with a constant; a tester checks the early levels (fast, to hook the player) and the late levels (slow, to gate content) as boundary cases, exactly like the damage formula.
Concepts¶
Awarding XP on death¶
The enemy already announces its death on the bus (M4.1). XP is just another listener. Each enemy carries an XP value (a field, or part of its StatBlock); on death, the player's XP system adds it.
Example
An XP system (on the player, or a small dedicated node) listens for deaths and accumulates:
# in player.gd (or an XP component)
var level: int = 1
var xp: int = 0
func _ready() -> void:
SignalBus.enemy_died.connect(_on_enemy_died)
func _on_enemy_died(enemy: Node, _at: Vector2) -> void:
var reward: int = enemy.xp_reward if "xp_reward" in enemy else 1
gain_xp(reward)
The player doesn't reach into the enemy beyond reading its reward; the bus delivered the event. (A
cleaner version puts xp_reward on the enemy's StatBlock so it's authored data, not a loose field.)
The XP curve¶
The threshold to reach the next level should grow with level. The classic shape is polynomial or exponential: each level costs more than the last. A simple, tunable polynomial:
xp_to_next(level) = base * level ^ exponent
with, say, base = 50 and exponent = 1.5. Level 1→2 costs 50; 2→3 costs ~141; 10→11 costs ~1581. The
curve starts cheap (fast early levels hook the player) and steepens (later levels gate progress). A purely
exponential base * growth ^ level grows even faster; pick the shape that matches how long you want a run
to last.
Example
Computing the threshold and handling level-up. gain_xp adds and rolls over any number of levels in
one call (a big XP award can grant several levels):
@export var xp_base: float = 50.0
@export var xp_exponent: float = 1.5
func xp_to_next(lvl: int) -> int:
return int(round(xp_base * pow(lvl, xp_exponent)))
func gain_xp(amount: int) -> void:
xp += amount
while xp >= xp_to_next(level):
xp -= xp_to_next(level)
level += 1
_on_level_up()
SignalBus.xp_changed.emit(xp, xp_to_next(level), level)
func _on_level_up() -> void:
# raise stats — see below
pass
The while (not if) is load-bearing: a single large XP gain can cross several thresholds, and the
loop drains them one level at a time, subtracting each level's cost as it goes — the same
drain-the-accumulator pattern used for any "carry the remainder" counter. The leftover XP carries
toward the next level.
Applying level-up growth¶
A level-up raises the player's stats. With the M5.1 StatBlock, the cleanest approach keeps a base StatBlock and applies level growth as a derived layer (which M5.4 generalizes for items). The simplest working version bumps the live values:
Example
@export var health_per_level: int = 5
@export var damage_per_level: int = 1
func _on_level_up() -> void:
stats.max_health += health_per_level
stats.damage_min += damage_per_level
stats.damage_max += damage_per_level
health.max_health = stats.max_health
health.heal(health_per_level) # grant the new HP so leveling feels rewarding
SignalBus.player_leveled.emit(level)
Note this mutates the player's stats — which must therefore be a per-instance StatBlock, not a
shared base asset (M5.1's duplicate() rule): the player should carry its own StatBlock so leveling
doesn't edit a shared .tres. M5.4 replaces this direct mutation with a layered recompute
(base + level + items), which is the production approach; this version is the readable first step.
Increasing cost, steady reward — the engine¶
A kill is worth roughly a fixed amount of XP for a given enemy; a level costs more each time. So the number of kills per level rises as you level. To keep leveling at a steady felt pace, the player must fight enemies worth more XP — i.e., go deeper toward tougher enemies. This is the same structural engine as an idle game's exponential building costs against linear income: the cost curve outruns the flat reward, so the player must scale their source of reward to keep up. The increasing-cost/steady-reward gap is not friction to minimize; it is the thing that keeps the player pushing forward.
Walkthrough¶
- Add
xp_rewardto the enemy's StatBlock (or as an exported field onenemy.gd); set the skeleton's to a small value (e.g., 3). - Add the XP fields and methods to the player (
level,xp,xp_to_next,gain_xp,_on_level_up), and connectSignalBus.enemy_diedto_on_enemy_died. Add thexp_changedandplayer_leveledsignals toSignalBus. - Ensure the player carries a per-instance StatBlock: in
_ready,stats = stats.duplicate()so level-ups don't edit the sharedplayer.tresasset. - Add a temporary listener to verify: connect
SignalBus.xp_changedto a printerfunc(cur, need, lvl): print("L", lvl, " ", cur, "/", need)andplayer_leveledtofunc(l): print("LEVEL UP -> ", l). - Press
F5. Kill skeletons; watch XP accumulate and the level rise, with each level requiring more XP than the last. Confirm a big XP award (temporarily setxp_rewardto 1000) jumps several levels in one kill — thewhileloop draining thresholds. - Remove the temporary printers (a real XP bar is M7/M8 UI).
Optional sanity check
Set xp_reward to exactly xp_to_next(1) and confirm one kill takes you to level 2 with xp back to
0 (clean threshold). Then set it one higher and confirm you reach level 2 with the remainder
carried, not discarded — proof the xp -= xp_to_next(level) subtraction preserves leftover XP rather
than resetting to zero, the same remainder-preserving discipline a flat reset would get wrong.
Self-check quiz¶
Q1 — Why does gain_xp use a while loop over the threshold rather than a single if?
A. while is faster.
B. A single large XP award can cross several level thresholds at once; the while grants one level
per iteration, subtracting each level's cost, so multi-level jumps and the XP remainder are handled
correctly.
C. if cannot compare integers.
D. The loop prevents XP from being negative.
Reveal answer
B. One big reward (a boss, a stored-up turn-in) can be worth multiple levels; the while
drains thresholds until the remaining XP is below the next requirement, leveling the right number
of times and carrying the leftover. An if would grant at most one level and mishandle the
overflow. A is irrelevant. C is false. D is not its purpose.
Q2 — Why does the XP-to-next-level cost grow with level instead of staying constant?
A. To punish the player. B. So the player must seek tougher, higher-XP enemies to keep leveling at a steady pace — the increasing cost against steady per-kill reward is what keeps difficulty and reward advancing together. C. Because Godot can't store a constant threshold. D. To make the math harder.
Reveal answer
B. A flat cost would let early enemies carry the player indefinitely; a rising cost forces the player toward better XP sources (deeper, tougher enemies), which is the genre's progression engine — the same exponential-cost/linear-reward shape idle games use. A misreads a design device as hostility. C and D are false.
Q3 — Why must the player carry a per-instance StatBlock (via duplicate()) before level-ups mutate it?
A. Per-instance Resources render faster.
B. Because an assigned .tres is shared by reference; mutating it for level-ups would edit the shared
base asset (and any other user of it), so the player needs its own copy to grow.
C. Because StatBlocks can't be edited at runtime otherwise.
D. Because duplicate() resets the stats to zero.
Reveal answer
B. This is the M5.1 shared-by-reference rule: leveling changes the player's stats, and doing
that on a shared asset would corrupt the design data and affect anything else referencing it. A
duplicate gives the player a private, mutable copy. A is irrelevant. C is false (you can edit a
shared one — that's the problem). D is false (duplicate() copies values, it doesn't zero them).
Integration question¶
Q4 — open
XP arrives via SignalBus.enemy_died (M4.1), accumulates against a growing curve, and raises stats
held in the M5.1 StatBlock. Explain how this chapter reuses three earlier decisions (the bus, the
StatBlock, and the duplicate-for-divergence rule), and articulate why the increasing-cost curve is the
same design pattern as an idle game's building costs despite the genres looking nothing alike.
Reveal expected answer
Three reuses: the bus (M4.1) delivers enemy_died to the XP system as just another listener,
so awarding XP required no change to enemies — the same death event that drives loot and counters
drives leveling; the StatBlock (M5.1) is where level-up growth lands, giving leveling a single,
typed surface to raise instead of scattering increments across nodes; and the
duplicate-for-divergence rule (M5.1) is why the player carries its own StatBlock before
leveling mutates it, so growth doesn't corrupt the shared base asset. The increasing-cost curve is
structurally identical to an idle game's exponential building costs: in both, the cost to advance
grows while the reward per unit of effort (a kill here, a click/tick there) stays roughly flat,
so the number of units needed per advancement rises over time. The player's only way to keep the
felt pace steady is to scale their source of reward — fight tougher, higher-XP enemies (here) or
buy higher-tier generators (idle). The surface differs (real-time combat vs spreadsheet), but the
engagement engine is the same gap between a climbing cost curve and a flat reward rate, which is
what continually pushes the player toward bigger sources of progress.