Stage 08 — Three hundred: a measurement stage
You will build: nothing new, mostly. You will open the Monitor, put numbers on every per-frame cost in this game, decide what those numbers justify, and install one real fix — a pool for the floating text — because it's the one cost that spikes. You'll learn: the frame budget as arithmetic · the Monitor panel as an instrument · "when does this code actually run" · pooling and the reset you always forget.
Why this exists#
Every performance section you've ever read starts with "it's slow" and ends with "I added a pool." This stage refuses that shape. The reference project hit 300 enemies and the answer was measure: which lines run per frame, how many times, and for how long — and then the fix earns its keep against a number, not a feeling. The project's frame-skipped flip (stage 03) was cheap insurance before we could prove it was needed; this stage is where the insurance gets its policy number.
Build it#
A — The budget, in arithmetic#
60 fps means the whole game — input, physics, your 300 enemies, rendering — gets 1000 / 60 = 16.6 ms per frame. That's the entire budget. It is not "fast" or "slow"; it's a number with a deadline.
Open the debugger's Monitor tab (bottom panel → Debugger → Monitor) while the game runs at 300 enemies. You need three numbers, written down:
- FPS — you want 60, and you want to know what it's doing when 30 golems die in one swing
- Physics (ms) — the physics step: your
move_and_slide, the 300 chase updates, the area overlap processing. This is the number the enemy count actually touches - Process (ms) — everything in
_process: mostly zero in this game, which is itself a finding
Write them down. Your numbers will differ from anyone else's machine; the shape is the lesson and the shape is: physics cost is roughly linear in enemy count, and 300 simple enemies sit at a small fraction of the 16.6 ms on a modern laptop.
Predict before you run: the frame skip from stage 03 cuts the skin-flip work to one sixth. By what fraction of the Physics ms does the frame go down when you comment the skip out?
The answer on most machines is: not visibly. That's the stage-03 habit being honest with itself — the flip was cheap insurance, and at 300 enemies the premium is a rounding error. The skip stays, because it's six lines and the cost is zero; it's just not the reason this game runs at 60. Do not credit an optimisation you have not observed. (This is the same rule the almanac's own build pipeline lives by — a claimed speedup without a measured number is a claim, not a fact.)
B — Where the money actually goes#
The per-frame cost of one golem, itemised:
| Work | Cost class | Why it's this class |
|---|---|---|
_physics_process call + distance + move | microseconds | a few float ops; the square root is the priciest line and it's one |
| skin flip check (every 6th frame) | near zero | one comparison, gated |
| area overlap processing | the real cost | the physics server tests this golem's two areas against everything they mask, every frame |
| the hitbox's shape test (only during its 4 active frames) | spikes, briefly | 300 golems × a live player hitbox = 300 overlap tests for four frames |
The table's point is the third row: an Area2D is not free. The physics server maintains
a broadphase — a spatial index of every area and body — and your 300 golems bring 600 areas
(outer + hurtbox) into it every frame, all of them monitored against their masks. This is the
numbered retrospective on decision 1: stage 02 guessed that 300 CharacterBody2Ds
would be the expensive choice; stage 08 measures it. Bodies are areas plus a physics solver
entry plus collision response — roughly three times the bookkeeping, and the solver runs its
own pass. The guess was right, and it's now a fact with a monitor trace behind it.
You don't fix this. At 300, the overlap processing is a comfortable fraction of budget. What you do is know which row of the table grows when you double the count — so that at 1,000 enemies you reach for the right tool (fewer, dumber areas; a separated hurtbox layer that only the live hitbox masks; or spatial partitioning) instead of the obvious one (faster math).
Representative numbers from one machine (yours will differ — the slope is the lesson, not the intercept):
| Enemies alive | Physics ms | Fraction of the 16.6 ms budget |
|---|---|---|
| 100 | ~1.4 | 8% |
| 300 | ~3.9 | 23% |
| 600 | ~7.8 | 47% |
| 1,000 | ~13.2 | 80% |
Roughly linear in the count, as the table's third row says it should be — and the frame skip from stage 03 moves this curve down by a few percent, which is why it is insurance, not the engine. (A curve diagram would be the wrong tool here: the site's value-over-time figures are time-true by contract, and an enemy-count axis on a time diagram would be a confident lie. A table carries the number honestly — tier 0 of the ladder the architecture doc draws.)
C — "When does this code actually run"#
The nearest-enemy scan (stage 05) is the line everyone assumes is the bottleneck: 300 distance checks! Measure it instead:
func _nearest_enemy() -> Enemy:
var t0 := Time.get_ticks_usec()
var best: Enemy = null
var best_distance := INF
for node in get_tree().get_nodes_in_group("enemies"):
var enemy := node as Enemy
if not is_instance_valid(enemy):
continue
var d := global_position.distance_squared_to(enemy.global_position)
if d < best_distance:
best_distance = d
best = enemy
var t1 := Time.get_ticks_usec()
if t1 - t0 > 200: # more than 200 microseconds is a finding, not a number
push_warning("nearest scan took %d us with %d enemies" % [t1 - t0, best_distance])
return best
Run it for a minute at 300 enemies. You will hear nothing — a 300-entry scan is tens of microseconds, and it runs once per attack per weapon, i.e. a few times a second, not 60 times. The warning threshold (200 µs ≈ 1.2% of the whole frame) is the number that would mean "this line has become the story." The habit is the point: before optimising any line, write down how often it runs — per frame, per second, per event — because a line that runs once a second can be absurdly slow and still cost you nothing, and a line that runs 300×60 times a second must be absurdly fast to cost you anything.
D — The one real fix: the float text pool#
Find the spike: 30 golems die in one swing → 30 instantiate() + 30 add_child + 30 tweens
in one frame, and each text lives ~1 s and dies. Watch the FPS in the Monitor as you
farm a clump — there's a dip, a few frames of it, every time. instantiate is not free (it
walks the scene, allocates the object tree) and a burst of thirty of them in one frame is a
burst.
The pool: keep twenty-four texts alive and invisible; "spawn" means take one out, reset it, use it; "death" means hide it and put it back.
class_name FloatTextPool
extends Node
const POOL_SIZE := 24
const SCENE: PackedScene = preload("res://entities/float_text.tscn")
var _free: Array[Node2D] = []
func _ready() -> void:
for i in POOL_SIZE:
var t: Node2D = SCENE.instantiate()
t.setup(self)
t.visible = false
add_child(t)
_free.append(t)
## Take a text out (or mint a new one past the floor), reset it, point it.
func spawn(at: Vector2, value: String) -> void:
var t: Node2D
if _free.is_empty():
t = SCENE.instantiate() # a burst beyond the pool still works; it's a floor, not a wall
t.setup(self)
add_child(t)
else:
t = _free.pop_back()
t.reset(at, value)
## A finished text returns itself — the pool never calls this on a busy node.
func retire(t: Node2D) -> void:
_free.append(t)
Notice the flow: spawn takes a node out and never puts it back. The node re-enters the
pool from its own ending, once its tween finishes. A pool where the borrower and the
librarian are the same book is not a bug — it's the only shape that can't double-spawn.
The float text earns two changes for pool life:
# float_text.gd — the pooled shape
extends Node2D
var _pool: FloatTextPool
@onready var label: Label = $Label
func setup(pool: FloatTextPool) -> void:
_pool = pool
## Reset-then-play: a pooled node must end every life in a state the next life can start from.
func reset(at: Vector2, value: String) -> void:
scale = Vector2.ONE
modulate = Color.WHITE
position = at
label.text = value
visible = true
_play()
func _play() -> void:
# (the stage-04 tween, unchanged, except the ending:)
await tween.finished
if _pool != null:
visible = false
_pool.retire(self)
else:
queue_free()
The ending is the lesson, and it has two teeth:
- The reset is the cost people forget. A pooled node comes back with its last life's
scale (1.6), alpha (0.0), and position.
resetrestores all three before the tween, or your damage numbers arrive pre-shrunk, invisible, or from the wrong corner. Break it on purpose: comment thescale/modulatelines out and watch what a pool is for. - A pooled node must be able to die mid-life. If the scene reloads (stage 09) while
twenty texts are flying, the pool frees its children — and a freed node's
awaitresumes on a dead object the next frame. The guard: the tween is bound to the float text, the float text is a child of the pool, and the pool is a child ofEntities— whenEntitiesfrees, the tweens die with their targets and theawaitnever resumes. Containment is the crash fix; the ownership-map diagram in the save/load shelf is the picture of this exact argument.
Wire it: the pool goes under Entities (it must outlive any single text and die with the
world), EnemyStats and PlayerStats call Globals.float_text_pool.spawn(...) instead of
instantiate + add. (Give Globals a float_text_pool property the same way it has
player — group lookup, one autoload.)
E — Commit#
git add . && git commit -m "stage 08: monitor the crowd, measure the scan, pool the float text"
Checkpoint — definition of done#
- Three Monitor numbers written down at 300 enemies, and you can say which one the enemy count is actually touching
- The frame skip commented out: you measured the difference and can state it in ms — even if the statement is "not visibly"
- You know which row of the cost table (B) grows when the count doubles, and you can name the tool you'd reach for at 1,000 enemies without building it
- The nearest-scan warning never fires at 300; you can say why in one sentence (how often it runs, not how fast it is)
- 30 simultaneous deaths: the FPS dip is smaller than before the pool, or the pool is explained as insurance for a machine slower than yours
- Broken-pool drill: you commented out the reset lines, saw the pre-shrunk/invisible texts, and restored them — a pool you haven't broken once, you don't understand yet
- Zero warnings; committed
Stretch (no instructions)#
Push the enemy cap to 1,000 and watch the table's third row. Then build the "fewer, dumber areas" fix: merge each golem's outer area and hurtbox into one area whose mask covers both jobs, and measure the row again. Two areas per enemy became one; the broadphase entry count dropped by half. That's the whole first lesson of the performance shelf — you just did it in a real game.
If you get stuck#
- Monitor shows 60 fps but the game feels slow → you're probably measuring the render clock while the physics runs at a different rate; check Project Settings → Physics → Common → ticks per second (60) and the Monitor's Physics line. Smooth-but-laggy is a physics story; stuttering is usually a one-frame allocation spike — the pool's job.
- Pooled texts stack in the same spot → the
positionline inresetis missing, or you reset before taking the node out of the pool's bookkeeping and the old position wins. Order: pop, reset, visible. Every time. awaitresumes on a freed node after a scene reload → the float text outlived its pool because the pool wasn't underEntities. Check the parent chain; containment is the fix (point D.2).- The pool makes things worse (24 always-visible nodes) →
visible = falseis not skipping physics for aNode2Dwith no physics process — it's fine. If you gave the text aCharacterBodyor anArea2Dout of habit, that's the cost; a float text is aNode2Dand aLabel, nothing else.