M8.4 — Closing the Loop¶
What you'll learn
- How to assemble a playable arena from a
TileMapLayerwith 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'smove_and_slidestops at them) on theworldlayer (M3.1). - Navigation: add a navigation layer to the TileSet (or a
NavigationRegion2Dcovering 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:
- Move into a room (M2) across a tiled arena with collision and navigation (M8.4).
- 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).
- Kill it: its Health hits zero (M3.2), it emits
enemy_died(M4.1). - 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).
- 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.
- Descend: a portal (M8.4) loads a tougher arena; the run persists (M8.2/8.3).
- 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¶
- Build
res://scenes/world/arena_01.tscn: aTileMapLayerwith a TileSet that has a physics layer (collision on wall tiles, on theworldlayer) and a navigation layer (or aNavigationRegion2Dover the floor). Paint a room. Add a player-spawnMarker2D, a fewSpawners (M4.4), and aPortal(Area2D) to a second arena. Put the arena (in grouparena) underWorldinmain.tscn. - Add floating damage numbers: have
Health(M3.2) emitdamaged(amount, is_crit, position)on damage; a world listener instancesdamage_number.tscnand callsshow_amount. Pool them (M4.4) if a busy fight stutters. - Add the
Portalscript and wirenext_arena; confirm it saves (M8.2) before swapping. - Wire startup: on launch,
SaveSystem.load_game()(M8.3) — if it returns false, start a new run inarena_01; otherwise restore and place the player. - Press
F5and 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). - 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 died → enemy_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.