Skip to content

M5.3 — Experience & Leveling

What you'll learn

  • Award XP on SignalBus.enemy_died and 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

  1. Add xp_reward to the enemy's StatBlock (or as an exported field on enemy.gd); set the skeleton's to a small value (e.g., 3).
  2. Add the XP fields and methods to the player (level, xp, xp_to_next, gain_xp, _on_level_up), and connect SignalBus.enemy_died to _on_enemy_died. Add the xp_changed and player_leveled signals to SignalBus.
  3. Ensure the player carries a per-instance StatBlock: in _ready, stats = stats.duplicate() so level-ups don't edit the shared player.tres asset.
  4. Add a temporary listener to verify: connect SignalBus.xp_changed to a printer func(cur, need, lvl): print("L", lvl, " ", cur, "/", need) and player_leveled to func(l): print("LEVEL UP -> ", l).
  5. 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 set xp_reward to 1000) jumps several levels in one kill — the while loop draining thresholds.
  6. 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.