shisaku ~/devlog
$ auth github

Author sign in

Sign in with GitHub to comment on devlog entries.

Continue with GitHub
← all posts

Devlog #19 — Twenty-Three Times, With Receipts

The benchmark from Devlog #17 names four culprits — a missing TOML file, a per-frame String clone, health bars for nobody, and companions rescanning the whole arena — and the same ladder comes back 23x faster: 10,000 live enemies at 109 fps.

Date: July 5, 2026

Focus: Devlog #17 built the instrument and ended on an uncomfortable curve: 4.6 fps at ten thousand enemies, cause unknown, suspicions withheld. This is the payoff devlog — the one where the instrument names four culprits, four fixes land in four commits, and the same ladder on the same machine comes back twenty-three times faster. With receipts.


Two nights ago the benchmark’s first completed run drew a curve I refused to editorialize about. Ten thousand enemies: 215 milliseconds a frame. A comfortable optimization campaign — real SSAA, texture atlas, baked noise, batching — and yet the ladder said the game drowned at a fraction of the target. I promised the diagnosis would come from measurement, not vibes.

Here’s the diagnosis. And the cure. And the after-picture.


1. Reading the crime scene

The “before” ladder (release build, vsync off, thirteen stages from 1 to 10 000 enemies) had three tells buried in its columns:

The marginal cost. From 20 to 2 000 enemies, every additional enemy cost a steady ~42 µs per frame. Then past 2 000 — where new spawns land mostly off-screen — the marginal cost dropped to ~14 µs. That split is a confession: ~14 µs of simulation per enemy, plus ~28 µs extra for every visible one. Two different diseases.

The draws column. ~0.7 GPU draw calls per visible enemy — 3 649 at the top stage — where the texture atlas should have produced a couple dozen. Something was splitting the sprite batch almost per enemy.

The shape of the fps cliff. Frame cost rising linearly with total entity count, even off-screen ones, meant something was touching every enemy every frame — and something else was touching every enemy per consumer per frame.


2. Four culprits, four commits

One missing data file undid the entire atlas. ShadowLurker’s sprite is skeleton_swordless — and no skeleton_swordless_spritesheet.toml existed, so a third of the horde loaded as a standalone texture outside the atlas. Round-robin spawning then interleaved that texture between two atlased kinds: a batch break every ~1.4 enemies. The fix is the best kind there is: one new metadata file, zero code. Three and a half thousand draw calls deleted by TOML.

The ECS mirror paid spawn cost every frame. The bridge that mirrors enemies into the ECS cloned each enemy’s kind String and rewrote five components — identity, transform, motion, health, stats — per enemy, per frame. Ten thousand heap allocations a frame for data that never changes after spawn. Now there’s a hot lane: a Copy struct of position/velocity/health, three component writes, zero allocations. Statics are written once at spawn — an audit confirmed every stat mutation in the codebase happens before the ECS entity exists.

Health bars for an audience of nobody. Every visible enemy drew a bar every frame — seven rectangles plus a hex-string color parse each. Five thousand rectangles a frame, mostly above full-health background creatures whose bar communicates nothing. Bars are now information-gated: recently damaged (with a data-driven linger and fade), elite, mid-windup, or damaged near the player. Full-health trash draws nothing. And the palette is parsed once at load instead of once per bar.

The companions were the linear term. This was the big one, and the one I never would have guessed at from the code alone. Companion AI recomputed whole-arena tactical queries every single frame — nearest-enemy-to-player once, nearest-enemy per companion, and for ranged companions a densest-cluster query that counted neighbours for every candidate. Quadratic in a swarm, times five companions, times sixty frames a second: millions of distance checks per second, growing with every enemy alive anywhere on the map.

Two fixes. The cluster query became cell-first: score the spatial grid’s occupied cells by their 3×3 neighbourhood population — a cost that depends on the number of grid cells, not the number of enemies — then refine to a weighted centroid near the winning cell. And the anchors themselves went on a 150-millisecond tactical cadence, staggered per companion so they never all rescan the same frame. A companion steering toward a point 150 ms old is behaviorally invisible. A companion rescanning ten thousand enemies per frame is not.


3. The after-picture

Same machine, same resolution, same settings, same deterministic spiral. Before on the left, after on the right:

entities   before             after
       1   3.4 ms   298 fps   3.4 ms   291 fps
     100   8.2 ms   122 fps   3.7 ms   273 fps
     200  12.9 ms    78 fps   3.9 ms   255 fps
    1000  46.7 ms    21 fps   4.2 ms   238 fps
    2000  88.1 ms    11 fps   5.1 ms   196 fps
    5000 144.4 ms     7 fps   7.2 ms   139 fps
   10000 215.6 ms     5 fps   9.2 ms   109 fps

The report’s own summary line says it best: smooth_60_up_to went from 200 to 10 000. Every stage of the ladder — including the one with ten thousand simultaneous, live, pathing, attacking enemies — now fits inside the 60 fps frame budget, p95 included. The 1-in-20 slowest frame at the heaviest stage is 12.5 ms.

Draw calls at ten thousand enemies: 3 649 → 128. Marginal cost per enemy: ~42 µs → ~0.6 µs. That last number is the one I keep rereading. Sub-microsecond per entity is the class of per-entity budget the factory games play in — and this is with the full storm post-stack, bloom, supersampled scene, and five companions running A* and abilities.

Total: 23.5× at the top of the ladder, in four commits, none of which touched gameplay behavior.


4. What actually did it

Worth being honest about the anatomy of the win, because it resists the usual narrative. There was no heroic algorithm. The four fixes were: a missing TOML file, a struct split, a timer on a query, and counting things in buckets before counting them individually. Every one of them was obvious — after the instrument pointed at it. None of them was on my list before it did. The two I’d have bet on beforehand (the post-processing stack, the vertex submission path) turned out to be innocent bystanders.

The benchmark ladder, the CSV diffs, the draws column, the budget guides — the whole measuring apparatus from Devlog #17 earned its existence inside 48 hours. Measure honestly. It keeps being the whole lesson.

Fine print, so the number stays honest: this is one machine, a benchmark that isolates entity load (the wave director, XP, and level-ups are deliberately quiet during measurement), and a spiral that puts a realistic fraction — not all — of the horde on screen at once. Real gameplay stacks more systems on top. That’s fine. The point of a baseline is to be comparable, and this one now says the entity engine has room for anything the game design will plausibly ask of it — and then a decimal place more.


5. Where the road goes now

The backlog’s remaining items are quality-of-life for the frame, not rescue operations: routing the last area effects through the spatial grid, staged per-frame index lists, and the hot/cold split of enemy state that opens the door to a proper structure-of-arrays future. Each lands the same way now — ladder before, ladder after, two CSVs, no arguing.

At 109 fps with ten thousand enemies, the next interesting ceiling isn’t performance at all. It’s design: what kind of encounter deserves a horde that size?

The instrument is watching either way.

$ comments

Reader notes

0 notes

No notes yet.

If you're reading this as a developer: this devlog is built in the open.

If you're reading this as a modder: the direction is source-visible, inspectable systems.