doc 10 of 11

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:

WorkCost classWhy it's this class
_physics_process call + distance + movemicrosecondsa few float ops; the square root is the priciest line and it's one
skin flip check (every 6th frame)near zeroone comparison, gated
area overlap processingthe real costthe 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, briefly300 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 alivePhysics msFraction of the 16.6 ms budget
100~1.48%
300~3.923%
600~7.847%
1,000~13.280%

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:

  1. 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. reset restores all three before the tween, or your damage numbers arrive pre-shrunk, invisible, or from the wrong corner. Break it on purpose: comment the scale/modulate lines out and watch what a pool is for.
  2. 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 await resumes 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 of Entities — when Entities frees, the tweens die with their targets and the await never 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 position line in reset is 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.
  • await resumes on a freed node after a scene reload → the float text outlived its pool because the pool wasn't under Entities. Check the parent chain; containment is the fix (point D.2).
  • The pool makes things worse (24 always-visible nodes) → visible = false is not skipping physics for a Node2D with no physics process — it's fine. If you gave the text a CharacterBody or an Area2D out of habit, that's the cost; a float text is a Node2D and a Label, nothing else.