doc 3 of 5

Part 02 — A thousand of anything

You will build: the per-frame × per-entity cost model, a frame-skip you can justify by the rate its answer changes, and the naive-scan-vs-hash comparison — drawn, not asserted. You'll learn: why per-run cost × run rate is the only number that matters · the presentation line that pays per-second prices · and the spatial hash as the fix the steering shelf earns.

Why this exists#

Part 01 measured the frame; this part measures the entity — because the frame's cost is the entity's cost times the count, and the count is the one variable a game actually has. "Make it run at 1000" is not a different game; it's the same game with the per-entity lines amplified by ten, and the amplification falls on some lines and not others. This part finds which lines amplify, and installs the two tools that stop them: the frame-skip (pay as often as the answer changes) and the hash (ask only the cells that matter).

Build it#

A — The per-entity table, and which row amplifies#

Take one entity from your scene (the survivor's golem, the 3D farm's crop) and list every line of code that runs per entity, per frame, with its cost class:

WorkCost classAmplifies with the count?
_physics_process call + a few float ops (the chase, the growth check)microsecondsLinearly — 1000 entities is 1000× the float ops, and 1000× a microsecond is still milliseconds
A square root (distance_to)a few nsLinearly, and cheaply — the stage 03 distance_squared_to avoidance matters only when the compare is the loop's body
Physics-server work (the body's solve, the area's overlap tests)the real costSuper-linearly — the broadphase tests pairs, and a pair count grows with the product of the populations it connects (part 04's row)
An allocation (instantiate, a String build, an array append)a spikePer event — not per frame; the clump-death's 30 allocations are 30 spikes, part 03's pool

The table's point is the classes, not the numbers: the float ops amplify linearly and cheaply (1000 of them is a non-event), the physics work amplifies and expensively (the row that actually grows), and the allocations don't amplify per frame at all (they spike per event). A "performance fix" aimed at the float-ops row is the refused claim — the line that looks like work (a loop, a square root) is the one that costs the least, and the row that costs the most (the physics server, the broadphase) is the one you don't see in your code because it's not your loop.

The measurement, not the model: the table is a prediction. Part 01's guard is the test — wrap the chase update and the overlap handling separately, and the numbers say which row is real on your scene. The survivors stage 08 ran exactly this: the float-ops row was microseconds, the area-overlap row was the finding, and the fix (part 04's mask) went to the row the number named, not the row the eye picked.

B — The frame-skip: pay as often as the answer changes#

The survivor's stage 03 flip is the pattern in its smallest form: the skin's flip_h is a presentation fact that changes when the entity crosses the player's vertical — a few times a minute for a chaser — and the code computed it 60 times a second. The skip:

_frame += 1
if _frame % FLIP_CHECK_EVERY == 0:      # FLIP_CHECK_EVERY = 6 → 10 checks/second
	skin.flip_h = position.x > player.global_position.x

The justification is the rate the answer can honestly change, not the rate the code runs: a fact that changes per-minute doesn't need a per-second check, and the 6× reduction is free (a counter and a modulo). Stage 08 then measured the skip's worth and found it "not visibly" — the honest result, and the reason the skip is insurance, not the engine. The discipline is the part: classify each per-frame line by the rate its answer changes, and pay only that rate — and then measure whether the saving was real, because a line whose answer changes per-frame (the chase, the health check) must keep paying per-frame, and the skip applied to it is a bug wearing a performance costume.

The classification, as a test: if I check this line once a second instead of 60 times, what breaks? The flip: nothing visible (a 100 ms flip lag is sub-perception). The chase: the entity teleports in 60× steps (the answer changed every frame; the skip broke the movement). The test is one sentence per line, and the sentence is the frame-skip's permission.

C — The naive scan, drawn#

The steering shelf's boid runs a neighbour scan: "which other boids are near me?" The naive version asks every cell of the world, every frame, per boid. The hash version asks its own cell and the eight around it. The two are the same question at different costs, and the cost is a position — a region of the grid — not a sentence:

The naive neighbour scan — every cell, asked. A 20 by 11 cell grid, 220 cells, all free. Scanned 220 of 220 cells (100%) — every cell in the grid, from the query origin at cell 9,5.One boid, one frame, 220 cells asked. Sixty boids is 13,200 cell-reads per frame — the loop the eye picks, and the row the measurement says is cheap.
The hashed neighbour scan — nine cells, asked. A 20 by 11 cell grid, 220 cells, all free. Scanned 9 of 220 cells (4%) — a 3 by 3 block around the query origin at cell 9,5.The same boid, the spatial hash's 3×3 block: 9 of 220 cells. Sixty boids is 540 cell-reads — a 24× reduction, and the reduction is the hash's entire job.

Two blocks, not two panels — the corpus's comparison idiom (the boss-fights shelf's two phase-timelines, the enemy-ai shelf's two value-over-times), with the prose doing the "read these against each other" work. The danger (all-cells) scan is the one the eye picks (the loop, the square roots, the "O(n)!" reflex); the neutral (hash) scan is the one the measurement justifies (at the count where 13,200 reads stop being microseconds). The steering shelf's part 05 is where the hash is built — this part is where its case is made, with the two diagrams doing the arguing the prose can't.

The hash's shape, for when you build it: a Dictionary[Vector2i, Array] — the cell is the key, the entities in it are the value — rebuilt (or updated) as entities move, and the query is "read my cell and the eight neighbours." The rebuild cost is the honest one to measure: at 1000 entities, re-hashing every frame is 1000 dictionary writes, and the incremental hash (update only the cells an entity left and entered) is the optimization the rebuild's number justifies. Part 01's guard, on the rebuild, is the measurement that decides.

D — The group lookup, and its run rate#

get_tree().get_nodes_in_group("enemies") is the survivors game's enemy list — and it allocates an array, every call. The Globals.enemies getter (stage 02) calls it on every read, which is fine at the spawner's run rate (once per spawn tick, stage 02's cap check) and wrong at a per-entity-per-frame rate (300 enemies each asking "who are my peers?" every frame is 300 array allocations a frame). The fix isn't a faster lookup; it's the run rate — cache the list where its rate is low (the spawner, the per-second systems) and don't call it where its rate is high (the per-entity frame). Part 01's "how often does it run" is the whole diagnosis: the same line, two run rates, one of them a problem.

E — Commit#

git add . && git commit -m "perf 02: per-entity table, frame-skip, the naive-vs-hash scan"

Checkpoint — definition of done#

  • Your scene's per-entity table is written, with the cost class per row (not a number you guessed), and the row the count amplifies expensively is named (the physics row, for a physics scene — part 04's territory)
  • A frame-skip is installed on a presentation line, justified by the one-sentence test ("if I check this once a second, what breaks?" — nothing visible), and measured (the saving stated in ms, even if the statement is "not visibly" — the insurance, named as such)
  • The two grid-field diagrams are in your notes (or your game's doc), and you can say which scan the eye picks and which the measurement justifies, and at what count the subject scan stops being a non-event
  • The group lookup's run rate is classified: where it's called per-frame (a problem) vs per-tick (fine), and the cache is at the low-rate site
  • You refused the float-ops row: the line that looks like work (the loop, the square root), measured cheap, and left un-optimised
  • Zero warnings; committed

Stretch (no instructions)#

The incremental hash: part C's rebuild, updated only on cell-boundary crossings. An entity crossing from cell (3,4) to (4,4) is one removal and one insertion, not a full re-hash. The measurement that justifies it: part 01's guard on the rebuild at your count — if the rebuild is under the finding, the incremental is the refused claim (a complexity you don't need); if it's over, the incremental is the fix the number earned.

If you get stuck#

  • The frame-skip made the entity stutter (the flip lags visibly, the movement jumps) → you skipped a line whose answer changes per-frame (the one-sentence test, part B, answered "the entity teleports" and skipped it anyway). The skip is for the per-minute facts; the per-frame facts keep paying. Move the skip off the movement and onto the presentation.
  • The hash is slower than the naive scan at your count → the rebuild cost (part C's honest one) exceeds the query saving at a small count. The hash earns at the count where 13,200 reads stop being microseconds — below that, the naive scan is correct, and the hash is the premature optimization the overview refused. Measure the crossover; it's part 01's guard on both, and the count where they cross is the number.
  • The group lookup cache is stale (a dead entity in the cached list) → the cache is built once and never refreshed, and the is_instance_valid guard (stage 05's scan) is the per-read cost the cache was meant to avoid. Refresh the cache at the low-rate site (the spawn tick, the death signal) — the cache's invalidation is part of its design, and a cache with no invalidation is a stale list with a variable name.
  • The per-entity table's "real cost" row is your loop, not the physics server → the scene is bound by code, not by the engine (a per-frame allocation in the chase, a String build in the growth check). That's the good version of the finding: the row you can fix is the row you wrote. Part 03's pool is the allocation's fix; the String build's is the pre-computed constant. The table pointed at your code, and your code is the cost.