If the default craft of survivors-likes, bullet heavens, and tower defenses in recent years has been to hang a script, a light, and a collider on every enemy, every bullet, and every pickup, then the roughly eighteen-minute guide Naio Games published on 15 October 2026 lays that invoice open for peers. They wrote from measured work on their Godot 4 spell-crafting roguelite Hexwright. Target scenes can reach about 1,000 enemies, 778 projectiles, and nearly 10,000 nodes. Past about 120 enemies, frame rate fell from 48 to 32. The reason was plain. Past that point every frame ran two physics ticks, so all per-enemy work doubled.
I read their public guide end to end. The facts that hold are simple. The whole toolkit sits in plain GDScript plus Godot’s own shader language. No C#, no C++ module, no GDExtension. One constraint was shipping a web build; C# web export was not an option for them. In their logs the slow part was rarely GDScript itself. It was asking the engine to do one small thing per object, hundreds of times a frame. What moved the numbers was changing the shape of that work.
The image that runs through this piece is pay by kind. It sounds like bookkeeping. On a frame budget it is a set of auditable ledger changes.

1. Industry default, every object pays its own bill
Survivors-likes and bullet heavens share one shape. A few kinds of things, each repeated hundreds of times. Any extra millisecond of light, one callback, one texture switch on each object is multiplied by the horde into a full-frame disaster. The default writing style is fixed too. Bullets own `_physics_process`. Enemies own `Area2D`. Pickups own `PointLight2D`. Trails use a `Line2D`. The engine wires them into physics, rendering, and signals. It feels convenient. The bill is still charged per object.
Naio lists some sharp local costs. Eight hundred eighty pickup lights once cost about 5 ms a frame on their own. Enemy-vs-player collision checks were about 54% of all enemy cost. Three hundred bullets through 240 enemies cost 7.9 ms with hits on against 4.9 ms with hits off. Three hundred bullet trails fell from 13.7 ms to 5.0 ms only after draw calls fell from 316 to 28. Those numbers come from their own playtest logs and benchmark scenes. Machines differ, the direction does not. Cost often lands first on paying per object, not on a clever AI fragment.
The fork from default is here. Default asks how to finish each object’s behaviour. They ask whether one kind of thing can pay a single management fee, while instances only pay for their own data.

2. Rewriting the ledger, pools, one owner per kind, fake lights, grid maths
The first cut is create and destroy. Mid-fight `instantiate()` builds the node tree, runs `_ready()`, and registers with physics and rendering. `queue_free()` tears it down again. Fifty of those in one frame, a shotgun spell or a wave dying together, is a visible hitch. The fix is to prewarm pools while loading, then only acquire and release in the fight. Author scenes inert, hidden, collision off, and `activate()` only after parenting. On every reuse write every visual setting, defaults included, or a recycled fireball keeps its old glow. Physics interpolation on a hidden node is ignored; reset it before show, or the bullet slides in from where it last died. Log a pool miss. Do not silently cut the spell.
The second cut is callback shape. Three hundred bullets each running `_physics_process` means three hundred engine callbacks. Bullets keep only `step(delta)`. A `bullet_manager` walks the live list backwards. Release uses swap-remove in constant time. If pause uses `time_scale = 0`, managers should return at once. Their own pitfall was bullets and enemies stopping while area spells and beams kept sweeping at zero speed, costing 6–14 ms on the level-up screen. The same idea applies to drawing. Two hundred status icons can become one drawer looping in a single `_draw()`.
The third cut is lights. A real `PointLight2D` adds draw work to every sprite it touches, and each sprite caps how many lights can reach it. Fifty glowing bullets through a crowd multiply the crowd’s cost. They kept real lights for a few sources that matter, the player, a boss, a campfire. Bullets and pickups use a soft additive glow sprite for floor light, shared texture and material so they batch. When night `CanvasModulate` darkens the screen, a global shader parameter multiplies sprite colour back up. Half dark becomes ×2 lift so they stay readable. Eight hundred eighty pickup lights at about 5 ms alone is the clearest receipt for this change.
The fourth cut is collision. If all you need is who sits in a circle or what a segment swept, a spatial grid plus distance tests is usually cheaper than physics pairing. Once per tick, drop enemies into 64 px cells. Queries visit only covered cells, then exact-distance the few candidates. Enemies can leave the physics engine; hit logic still calls the old `on_hit`. Stable identity must use instance id, never list index, because swap-remove reuses indices. Boss radius is a dedicated trap. Queries must grow by the largest monster radius to avoid misses. A boss at about 45 px radius makes every bullet query scan extra cells. They keep monsters larger than about 32 px on a separate list and check those few after the grid. With 600 bullets, 60 enemies, and one boss, physics fell from 17.4–18.3 ms to 15.5–15.7 ms, about 2 ms. The guide notes that is only part of the fight cost; hit processing still owns the rest.
These four cuts share one principle. Pools remove mid-fight create tax. One owner per kind removes per-object callback tax. Fake lights remove per-object lighting tax. Grid maths removes per-object physics pairing tax. Hit rules on the gameplay side stay as they were. What changes is who pays.

3. Numbers keep falling, atlases, MultiMesh, redraw only change, GPU straight flight
Texture batching is often the cheapest 2D cut and barely touches gameplay code. Godot merges neighbouring sprites only while texture and material stay the same. Walking monsters with eight directions times eight steps as separate images can land 241 draw calls for 240 monsters. Packed into a padded atlas, the same scene falls to 2 draw calls. They measured frame time still around 2.2 ms; on a fast GPU that is headroom more than a visible fps jump. Pad frames so outline shaders do not sample neighbours. Materials split batches too. Three special materials among 200 enemies cost three extra batches. One unique material per enemy costs 200.
Further down, hundreds of identical small things, pickups, sparks, trail segments, that need no front-to-back sort against other objects, do not need a node each. They hang a MultiMesh on the RenderingServer and write instance data only when something lands or is collected. Idle bob runs on the GPU; resting pickups cost zero script. Trails follow. One `Line2D` per bullet is one draw call per bullet. A ring of segment slots in one MultiMesh lets each tick stamp an aged segment; the shader tapers and fades by age. In their 300-arrow benchmark, whole frame time fell 13.7 → 5.0 ms, and draw calls 316 → 28.
The skill tree is another expensive screen. A web of hexes, threads, notches, icons, prices, and names is mostly still; only a little liquid animates. Calling `queue_redraw()` on the whole web every frame cost 49.9 ms fully upgraded, about 36 ms of it GDScript re-issuing the same commands, and 116 ms at 4× the nodes. The fix cuts the still part into tiles, each a retained canvas-item command list, re-recorded only when dirty. Pan and zoom only move or scale tiles. A thin live layer redraws what truly animates. Today’s 87 nodes fell 49.9 → 10.7 ms. At 4× nodes, 116 → 15.2 ms. Panning went from a full redraw every frame to zero redraws. One level up, render resolution. `canvas_items` stretch draws at screen pixels; a 4K monitor is about 9× the pixels of a 1280×720 design, and every glow and fullscreen shader pays. They made a setting. `viewport` mode renders at design size and scales up, giving players Performance 720p and Sharp native.
GPU flight is the cut easiest to overshoot. Straight bullets only need start, velocity, and launch time; the shader places them each frame without script transforms. About 800 glowing bullets, body plus halo plus floor glow, dropped draw calls from about 1,150 to about 480. Frame time stayed inside benchmark noise; at 300 plain arrows nothing moved, because identical sprites with one material already batch. Real play bit back. Most of their bullets home, orbit, or boomerang, so they bend every tick and rewrite anchors every tick, adding 4–6 ms, worse than plain nodes. Only straight-flying bullets keep the GPU path. The guide’s closing note is clear. Straight-bullet GPU drawing, pickup MultiMesh, and 30 Hz staggered thinking had not yet had a full playtest. Everything else was measured on their machine; your numbers will differ.

4. Warm-up, staggered think, and the hard boundary of unchanged gameplay
While the loading curtain still hides the world, they place effect instances in view for a few frames so shader pipelines compile. Off-screen items are culled and never compile. Pools prewarm from design peaks, projectiles per cast times casts per second times lifetime. First-fireball hitch is often first-draw compile. Their Windows build defaults to Direct3D 12; rare 100–600 ms stalls where neither code nor rendering is busy often mean GPU pipeline compile. Launch can switch to Vulkan; Steam launch options can offer a second Play entry.
Think can be rarer than move. Movement must run every tick or the horde stutters. Deciding where to go need not. Two halves of the horde think on alternate ticks, about 30 Hz, each handed time since last think so cooldowns keep real time. Bosses and monsters mid-wind-up still think every tick. Their scale note is that a think was 1.85 of a monster’s 8.7 µs, about 0.9 ms saved per tick at 1,000. Stagger must survive deaths. Separation that scanned half the horde per tick but forced a full pass on any death lost the gain, because something dies every tick. Moving saved results with each monster cut separation from 5.4 → 2.9 ms. Browsers run about 1.4–1.7× slower; a `.web` override gives the web build a smaller horde cap.
The guide returns again and again to one hard boundary. Optimisation may swap implementation. It must not quietly change feel. Grid hits still fire once on entry. Pickups freeze when paused. Visual rebuilds must keep calling the old hit handlers. Boss entry needs its own test. Never add or remove physics objects inside a physics callback. They cap catch-up steps at 2 so one slow frame cannot snowball. Editor F5 debugging made GDScript 2–3× slower, 31–35 ms against 11–19 ms. Ship numbers come from the release export players get, with an opt-in `-- --perflog`. Busy machines lie; the same benchmark swung 9.4 vs 3.0 ms, a 3× spread, so run A, then B, then A again. Headless misses draw calls, lights, and batching. Averages hide stutters; always log the worst frame.
So pay by kind, for an indie team, is concrete. Name the thing you have hundreds of. Lights, trails, callbacks, physics pairing, texture switches, full-screen redraw. Whichever tops the log gets its billing shape changed first. Build a benchmark scene with switches that turn hits, trails, or visuals off one at a time and answer one question. Do not start by dreaming a C++ module will save you. Their logs say the slow part was often one small per-object step multiplied hundreds of times.
Closing, independence is repriced inside the ledger
Put this guide back into the Godot production floor of autumn 2026 and what it hits first is a quiet craft habit. People default to optimisation as a faster language. People default to performance pain disappearing when a native plugin arrives later. People default to survivors-like stutter as a genre tax you can only pay by cutting count. Naio opens another path. The language can stay GDScript. What is expensive is the shape of the work. Change per-object billing into per-kind billing, and the frame budget moves with it.
Their checklist is almost a work order. A light on every bullet or pickup, try additive glow plus a night-lift uniform. A `Line2D` trail per bullet, try MultiMesh segments. `instantiate` and `queue_free` mid-fight, try a prewarmed pool. `_process` on every pooled object, try one manager. `Area2D` on every enemy and bullet, try a grid plus circle or capsule maths. One image per animation frame, try a padded atlas. Hundreds of identical small sprites, try a MultiMesh on the RenderingServer. Script moving every bullet every frame, try an anchor in instance data and rewrite on change. First-time effect hitch, try shader warm-up while loading. `queue_redraw` every frame on a big, mostly still screen, try retained tiles plus a thin live layer. A 4K screen drawing 720p art, try a render-quality option. Every monster deciding every tick, try 30 Hz alternating halves while move stays every tick. A web build with the desktop horde, try a `.web` override. Starting from it just feels laggy, pull a perf log from real play and fix the biggest number.
Executable prompts stay short.
- Write your multiplied-by-count list. Lights, callbacks, physics, draw calls, full-screen redraw. Which three die when multiplied by hundreds. Those change shape first.
- Build a reproducible benchmark fight with switches. Turn hits, trails, visuals off separately. Answer one question. On the same machine run A, B, then A again.
- When you swap collision or drawing, regress hit-once-on-entry, pause freeze, and boss radius. Visuals and hit logic ship in the same commit.
- Straight bullets may go GPU. Homing and boomerangs stay CPU first. Prewarm pools and shaders, but do not hide misses by cutting spells. Write gameplay rules as tests.
The 15 October long piece will not make bullet heavens easy. It only reprices what kind of repetition you are paying for. Teams willing to rewrite the ledger now have a public homework set with before and after numbers, and a closing reminder already in the text. Measure the biggest number first. Then stop paying per object.

Sources and further reading
Naio Games (2026-10-15) Godot 4 Performance in Pure GDScript, Many Things on Screen, No Lag
Hexwright product page (spell-crafting roguelite, in development) https://naiogames.com/hexwright