Skip to content

M8.4 — Closing the Loop

What you'll learn

  • How to assemble a playable arena from a TileMapLayer with collision and a navigation mesh, and the systems built across M1–M8 into one running slice.
  • How to add floating damage numbers — the last feedback piece — riding the M3 damage signals.
  • How a level transition (a door/portal) moves the player to a new arena while persistence (M8) carries the run.
  • Where the closed loop is, exactly, and what each of the eight modules contributes to one turn of it.

How it applies

  • A loop is a game; pieces are not. Until now you built systems in isolation. This chapter wires them into the thing a player actually does: enter a room, fight, loot, equip, get stronger, move deeper, and have it all persist. That cycle — not any single system — is what makes it an ARPG.
  • The arena is the stage every system performs on. A tilemap with collision and navigation is where movement (M2), combat (M3), enemy AI (M4), and spawning meet. It is also the unit a level transition swaps, which is how a single-room slice becomes a descent.
  • Floating numbers complete the feedback loop. M3.4 added flash and knockback; damage numbers add the quantitative read — how hard did that hit? With stats (M5) and crits, the numbers are now meaningful, and seeing them is a core part of the genre's satisfaction.
  • Shipping a slice is the real lesson. The genre's hardest skill is not any one system; it is integrating them into a stable, playable whole and stopping there. A finished vertical slice you can hand someone is worth more than ten half-built systems.

Concepts

The arena

A playable room needs three things: floor and walls the player can see, collision so walls stop actors, and a navigation mesh so enemies path (M4.3). In Godot 4.x, a TileMapLayer provides the visuals; its tiles carry physics layers (collision shapes per tile) for the walls, and you bake a NavigationRegion2D from the walkable tiles.

  • Visuals + collision: paint floor and wall tiles in a TileMapLayer; in the TileSet, add a physics layer and draw collision polygons on the wall tiles. Now walls are solid (M2's move_and_slide stops at them) on the world layer (M3.1).
  • Navigation: add a navigation layer to the TileSet (or a NavigationRegion2D covering the floor) so M4.3's agents path around the walls.

The arena is a scene (res://scenes/world/arena_01.tscn) containing the TileMapLayer, the NavigationRegion2D, spawn points, and a player spawn marker. The World node (M1.4) holds the current arena.

Floating damage numbers

The last feedback piece rides the damage signals already in place. When damage is dealt, spawn a small Label at the hit location that floats up and fades. It is a perfect object-pool candidate (M4.4) given how many spawn in a fight.

Example

A damage number that floats and fades, then frees (or returns to a pool):

# res://scripts/damage_number.gd  (on a Label, scene damage_number.tscn)
extends Label

func show_amount(amount: int, is_crit: bool, at: Vector2) -> void:
    text = str(amount)
    global_position = at
    modulate = Color(1, 0.9, 0.3) if is_crit else Color.WHITE
    scale = Vector2.ONE * (1.4 if is_crit else 1.0)
    var t := create_tween()
    t.set_parallel(true)
    t.tween_property(self, "global_position:y", at.y - 40.0, 0.6)   # float up
    t.tween_property(self, "modulate:a", 0.0, 0.6)                  # fade out
    t.chain().tween_callback(queue_free)                            # then remove

Crits are bigger and tinted, so the player reads a crit at a glance. The tween floats the number up while fading it, then frees it. Spawn one from wherever damage is applied — e.g., the Health component (M3.2) emitting a damaged(amount, is_crit, position) signal that a world-level listener turns into a number. Reusing the existing damage event keeps the numbers consistent with the actual hits.

Level transition

A descent is a sequence of arenas. A transition is a trigger (an Area2D door/portal) that, on the player entering, swaps the current arena for the next and repositions the player — while the run state (M8) persists across the change.

Example

A portal that loads the next arena:

# res://scripts/portal.gd  (on an Area2D)
@export var next_arena: PackedScene

func _ready() -> void:
    body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node2D) -> void:
    if not body.is_in_group("player"):
        return
    SaveSystem.save_game()                         # persist before transition (M8.2/8.3)
    _swap_arena()

func _swap_arena() -> void:
    var world := get_tree().current_scene.get_node("World")
    for child in world.get_children():
        if child.is_in_group("arena"):
            child.queue_free()
    var arena := next_arena.instantiate()
    world.add_child(arena)
    # move the player to the new arena's spawn marker

The portal saves first (so the descent survives a quit at any point), frees the old arena, and instances the next. The player, inventory, equipment, level, and stats are unchanged — they live on the player and the autoloads, not in the arena — so the run carries across. Enemies and loot are transient (M8.1): the new arena spawns its own. This is the single-room slice becoming a dungeon.

The closed loop, named

Here is one turn of the loop, with the module behind each step:

  1. Move into a room (M2) across a tiled arena with collision and navigation (M8.4).
  2. Fight an enemy: attack (M3.3), the enemy detects and fights back (M4), hits flash/knock/number (M3.4, M8.4), damage resolves through the formula (M5.2).
  3. Kill it: its Health hits zero (M3.2), it emits enemy_died (M4.1).
  4. Loot: the loot system rolls a weighted drop (M6.3), generates a rolled item (M6.1/M6.2), and spawns a rarity-colored pickup (M6.4); XP awards and may level you up (M5.3).
  5. Equip: pick up the item (M6.4 → inventory M7.1), open the bag (M7.3), read the tooltip's comparison (M7.4), drag it onto a slot (M7.2) — stats recompute (M5.4) and you are stronger.
  6. Descend: a portal (M8.4) loads a tougher arena; the run persists (M8.2/8.3).
  7. Repeat, now stronger against tougher enemies.

That cycle is the ARPG. Every module was a piece of it; this chapter is where they become the game.

Where to stop, and isometric as a stretch

The slice is complete: a player who can fight, loot, equip, descend, and resume. Resist bolting on systems before the loop is solid — a stable slice is the deliverable. Two natural extensions, deliberately left as exercises: an isometric look (Godot's TileMapLayer supports isometric tile shapes and Y-sort for depth, which gives the Torchlight-style three-quarter view but adds depth-sorting and tile-math complexity — worth doing after the orthogonal slice works), and procedural arenas (stitching or generating rooms instead of hand-built ones). Both build on exactly what you have; neither belongs in the first finished version.

Walkthrough

  1. Build res://scenes/world/arena_01.tscn: a TileMapLayer with a TileSet that has a physics layer (collision on wall tiles, on the world layer) and a navigation layer (or a NavigationRegion2D over the floor). Paint a room. Add a player-spawn Marker2D, a few Spawners (M4.4), and a Portal (Area2D) to a second arena. Put the arena (in group arena) under World in main.tscn.
  2. Add floating damage numbers: have Health (M3.2) emit damaged(amount, is_crit, position) on damage; a world listener instances damage_number.tscn and calls show_amount. Pool them (M4.4) if a busy fight stutters.
  3. Add the Portal script and wire next_arena; confirm it saves (M8.2) before swapping.
  4. Wire startup: on launch, SaveSystem.load_game() (M8.3) — if it returns false, start a new run in arena_01; otherwise restore and place the player.
  5. Press F5 and play one full turn of the loop: walk in, fight a spawned enemy, watch damage numbers and feedback, kill it, collect the rarity-colored drop, equip it via the tooltip-compared inventory, see your stats rise, step through the portal to a tougher arena, and confirm the run persisted (quit and relaunch mid-descent to verify).
  6. Play it for real for a few minutes. The thing in front of you is an ARPG core loop you built.

Optional sanity check

Run the whole loop end to end and verify each module's contribution is visible: movement and collision (M2/arena), an enemy that chases and is stopped by walls (M4 + navigation), a hit that flashes, knocks, and shows a number (M3.4/M8.4), a kill that drops a colored item and grants XP (M6/M5.3), an equip that changes the tooltip-previewed stats (M7/M5.4), and a portal-and-relaunch that preserves the run (M8.2/8.3). If any step is missing or wrong, it points at exactly one module to revisit — the clean decomposition that made building the loop tractable also makes debugging it tractable.

Self-check quiz

Q1 — When the player steps through a portal to the next arena, why do their level, inventory, and equipment persist while the enemies and loot do not?

A. The engine saves actors but not enemies. B. The run state lives on the player and the autoloads (Inventory, Equipment, GameState), which aren't part of the arena, while enemies and loot are transient (M8.1) belonging to the arena that was freed; the new arena spawns its own. C. Enemies are saved to a separate file. D. Loot is permanent but enemies are not.

Reveal answer

B. Run state is held outside the arena (on the player node and the autoloads), so freeing the old arena and instancing a new one doesn't touch it; enemies and ground loot are transient state (M8.1) tied to the arena and reconstructed fresh. A, C, D misdescribe where state lives.

Q2 — Why do floating damage numbers spawn from a damage signal (e.g., Health's damaged) rather than from the attack code?

A. The attack code can't access Labels. B. Riding the existing damage event keeps the numbers consistent with the actual damage applied (same value, same crit, same position) and works for every damage source — player, enemy, traps — without special-casing each attacker. C. Signals are faster than function calls. D. Labels require signals to display.

Reveal answer

B. Spawning numbers from the point where damage is applied means every source produces a correct number for free, with the real value and crit flag, exactly like M3.4's flash/knockback rode the same events. Spawning from each attacker's code would duplicate the logic and risk the shown number diverging from the dealt damage. A, C, D are false.

Q3 — Why is an orthogonal top-down slice the right scope to finish before attempting an isometric look?

A. Isometric is impossible in Godot. B. Orthogonal delivers the genre feel without depth-sorting and tile-math complexity; isometric builds on the same systems but adds Y-sort/depth concerns better tackled after the loop is stable. C. Isometric can't use TileMapLayer. D. Orthogonal games can't have loot.

Reveal answer

B. Isometric is a visual layer on top of the same gameplay, with added depth-sorting and coordinate complexity; finishing the orthogonal loop first means that complexity is added to a working game rather than a half-built one. A and C are false (Godot's TileMapLayer supports isometric). D is absurd.

Integration question

Q4 — open

Describe one complete turn of the Emberdelve loop from the player entering a room to stepping through the portal stronger, and name the module responsible for each step. Then make the case for why this eight-module decomposition — building each system in isolation and integrating last — was the right way to reach a working ARPG slice, addressing both the construction and the debugging benefits.

Reveal expected answer

One turn: the player moves into a tiled room with collision and navigation (M2 movement on the M8.4 arena); an enemy detects and chases them, pathing around walls (M4.2/M4.3), having been spawned by the room's spawner (M4.4); the player attacks (M3.3), the hitbox meets the enemy's hurtbox (M3.1), damage is rolled through the formula with the player's stats and the enemy's armor (M5.2), and the hit flashes, knocks back, and shows a floating number (M3.4, M8.4); the enemy's Health hits zero and emits diedenemy_died (M3.2/M4.1); the loot system rolls a weighted drop and rolls a rarity/affix item (M6.3/M6.2/M6.1), spawns a rarity-colored pickup (M6.4), and XP awards, possibly leveling the player (M5.3); the player picks it up into the inventory (M6.4/M7.1), opens the bag UI, reads the tooltip's comparison (M7.3/M7.4), and equips it (M7.2), which recomputes their stats so they're stronger (M5.4); a portal saves and loads the next, tougher arena (M8.4/M8.2/M8.3) while the run persists; and the cycle repeats. The eight-module decomposition was right for construction because each system was small, testable, and built on loosely-coupled seams (signals, components, typed resources), so a module could be finished and verified before the next depended on it — the FSM didn't need loot to exist, loot didn't need the UI, the UI didn't need saves — which kept any one step within reach of a beginner. It was right for debugging because the same decomposition localizes faults: in the assembled loop, a missing flash points at M3.4, a hit that deals no damage points at M3.1's layers, a stat that doesn't change on equip points at M5.4/M7.2, and a run that doesn't persist points at M8 — each symptom maps to one module to revisit, because the systems communicate through explicit, inspectable seams rather than a tangle. Building the pieces in isolation and integrating last is what made an ARPG — a genre defined by many interacting systems — tractable to build and to fix, which is the whole reason the book is structured as eight paradigm shifts rather than one monolith.