doc 2 of 5

Part 01 — Measure first

You will build: the three Monitor numbers, the frame-budget arithmetic, and one measurement that can fail loudly — a timing guard that warns instead of whispering. You'll learn: the 16.6 ms denominator · steady cost vs spike cost · and the discipline that's the whole shelf: a number before a fix, and a refused claim.

Why this exists#

Every performance mistake the almanac's projects made was a measurement mistake before it was a code mistake: the content-visibility claim (architecture doc) that measured to identical frame times; the frame-skip (survivors stage 03) added as insurance before the count that needed it existed; the nearest-scan (stage 08) assumed to be the bottleneck and measured to be tens of microseconds. The code was often fine; the number was missing, and the missing number is what let the wrong fix get built. This part installs the number.

Build it#

A — The budget, in the denominator#

60 fps is 16.6 ms per frame (1000 / 60). That's the whole game — input, physics, every _physics_process on every entity, the render — with a deadline. The budget isn't "fast"; it's a number, and every optimisation that follows is a claim about how much of the 16.6 a part of the game is using. The claim needs the denominator to mean anything: "the physics is 4 ms" is a finding; "the physics is fast" is a vibe.

Open the Monitor (Debugger panel → Monitor tab) with the game running at its real count (the survivors game at 300 enemies, the 3D farm at a full field). Write down three numbers:

  • FPS — and what it does on a burst (30 golems dying in one swing, the survivors stage 08 spike). The steady 60 is the budget; the dip on the burst is the shape (part 03's territory).
  • Physics (ms) — the physics step: the move_and_slides, the chase updates, the area overlap processing. This is the number the entity count touches (part 04's row of the cost table).
  • Process (ms) — the _process work: mostly zero in a physics-driven game, which is itself a finding (the work is on the right clock, part of the audio shelf's clock argument).

The shape on most machines: physics cost is roughly linear in entity count, and a hundred simple entities sit at a small fraction of the 16.6. Your numbers will differ from the reference's; the slope is the lesson, and the slope is what part 02's grid-field diagram plots.

The refused claim: before you touch a line, write down what you expect the measurement to show and why. "I expect the scan to be the bottleneck because it's O(n)" is the expectation; the measurement is the answer. When the answer isn't the expectation (the scan is tens of microseconds, part 01's worked example), the expectation is what gets revised, not the code. A fix built on the expectation, not the measurement, is the content-visibility claim — added, believed, and measured to nothing.

B — The instrument: a guard that fails loudly#

The Monitor reads the frame; a measurement reads the line. The instrument is Time.get_ticks_usec — a microsecond timer — wrapped in a guard that warns past a threshold, the survivors stage 08 pattern:

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 µs is a finding, not a number
		push_warning("nearest scan took %d us with %d enemies" % [t1 - t0, best_distance])
	return best

The design is the repo's testing discipline — "a check that cannot fail loudly is not a check" — applied to a frame:

  • The threshold is a finding, not a number. 200 µs is ~1.2% of the 16.6 ms frame — the point where "this line is now the story." Below it, the measurement is data (write it down, file it); above it, the guard warns, and the line is named in the output the frame it crossed. A measurement that prints a number every frame is noise; one that warns past a threshold is a tripwire.
  • The guard stays in. Stage 08's guard is in the shipped code, not a debug script you delete. A performance tripwire that's removed after the first clean run is a claim, not a check — the next content change (a faster weapon, a second scan) re-crosses the threshold with no one to name it. The warning is cheap (it's a comparison); its absence is the cost.
  • best_distance in the message is a bug, deliberately left for you to find. The warning prints best_distance — a squared distance, in µs-of-scan context — where it means to print the enemy count. The message lies about its own units (the stage 03 dead- zone bug, the distance_squared_to vs distance_to confusion, in a log line). Fix it: the count is get_tree().get_nodes_in_group("enemies").size(), and the message should say what it claims. (A measurement that mislabels its own number is a content-visibility claim with extra steps — the number is there, the reading of it is wrong.)

Run the guard for a minute at the real count. You'll hear nothing — the scan is tens of microseconds, and it runs once per attack, per weapon, a few times a second, not 60 times. The silence is the finding: the line everyone assumed was the bottleneck isn't, because of its run rate, and part 02's "how often does it run" is the reason the microseconds don't matter.

C — Steady vs spike: name the shape before you fix it#

The Monitor's FPS line has two faces, and they want different fixes:

  • Steady — the frame time is elevated all the time, scaling with the count. The survivors game at 300 enemies has a steady physics cost (part 02's table, part 04's row). A steady cost is arithmetic: per-run cost × run rate × count, and the fix is the factor you can reduce (the frame-skip, the mask, the body→area).
  • Spike — the frame is fine until a burst, then dips for a few frames. The survivors stage 08 clump-death (30 golems → 30 instantiate + 30 tweens in one frame) is a spike: an allocation burst, one frame, every clump. A spike cost is containment: the pool (part 03) moves the allocation out of the burst frame and into the steady state, where it's amortised.

The measurement that names the shape: watch the FPS line and the Physics line together during a burst. A steady cost shows as Physics elevated before and after the burst; a spike shows as a dip in FPS with Physics spiking for one frame and recovering. Fix the steady cost with part 02; fix the spike with part 03. A pool aimed at a steady cost does nothing visible (there's no burst to contain); a frame-skip aimed at a spike does nothing visible (the burst isn't the per-frame line). The shape is the diagnosis; the fix is the treatment; a treatment without a diagnosis is the refused claim.

D — Commit#

git add . && git commit -m "perf 01: monitor read, budget arithmetic, the loud guard"

Checkpoint — definition of done#

  • Three Monitor numbers written down at your real count, and you can say which one the entity count is touching (Physics, for a physics-driven game — the row of part 02's table)
  • The budget arithmetic is stated: 16.6 ms at 60 fps, and your Physics number as a fraction of it ("4 ms is 24% of the frame" — the denominator makes the numerator mean something)
  • The guard is in the code, with a threshold that's a finding (a fraction of the frame, named), and it's silent at the current count — the silence measured, not assumed
  • You found and fixed the best_distance mislabel (the guard that lies about its own number) — and you can say why a mislabeled measurement is worse than none
  • You named your scene's cost shape (steady, spike, or both) from the FPS + Physics lines during a burst — the diagnosis part 02 or 03 will treat
  • You refused at least one claim: an optimisation you expected to matter, measured to nothing, and left un-built (the frame-skip, the content-visibility, the "obvious" fix)
  • Zero warnings (other than the guard's, provoked); committed

Stretch (no instructions)#

A second guard, on a different line — the part 02 per-entity table's most expensive row (the chase update, the overlap processing). Same shape: get_ticks_usec around the line, a threshold that's a fraction of the frame, a push_warning that names the line and the count. Two tripwires in the scene means the next content change that crosses either is named in the output, and the "which line got slower" question has an answer that isn't a Monitor delta.

If you get stuck#

  • The Monitor says 60 fps but the game feels slow → you're reading 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 separately. Smooth-but-laggy is a physics story (the step is long, the interpolation hides it — part 04's); stuttering is a spike (part C's shape, part 03's fix).
  • The guard warns constantly (every frame) → the threshold is below the line's real cost, and the warning is noise, not a tripwire. Raise it to the finding (a fraction of the frame the line shouldn't take) — a guard that's always loud is a guard that's been heard, and heard guards get ignored. The threshold is the line between "data" and "finding"; set it at the finding.
  • The measurement is different in the editor than in the export → the editor's overhead (the inspector, the debugger, the scene tree viewport) is in the frame, and the export's isn't. The relative numbers (which line, which shape) hold across both; the absolute frame time doesn't. Measure the shape in the editor (fast iteration), the absolute in the export (the number that ships).
  • You can't tell steady from spike in the Monitor → the Monitor's sampling is per-frame, but the display updates slower than the frame — a one-frame spike can average out. Slow the engine (Engine.time_scale = 0.2, the audio shelf's part C) and the spike stretches across several display updates; the shape that was a blip is now a plateau you can read.