---
title: "Many Things on Screen, No Lag"
description: "Ten tips for Godot 4 games with hundreds of enemies and bullets, all in plain GDScript: pooling, fake lights, spatial grids, atlases, MultiMesh and GPU motion. No C# or C++."
author: Naio Games
date: 2026-10-15
canonical: https://naiogames.com/guides/godot-2d-horde-performance
pdf: https://naiogames.com/guides/godot-2d-horde-performance.pdf
license: Code samples are free to use in your own projects.
---

*A practical guide for Godot 4, 2D*

# Many Things on Screen, No Lag

Ten tips for games with hundreds of enemies, bullets and pickups. Each one explains what makes the game slow, why, and how to fix it, with before/after code you can lift into your own project.

**All of it in plain GDScript**, plus Godot's own shader language. No C#, no C++, no GDExtension.

**Topics:** Measuring, Pooling, One manager, Fake lights, Spatial grid, Atlases, MultiMesh, Redraw less, GPU motion, Load early

## Why hordes get slow

Survivors-likes, bullet heavens and tower defences share one shape: a **few kinds** of things, each repeated **hundreds of times**, so every small per-object cost is multiplied by the size of the horde.

> *Diagram:* ONE FRAME AT 60 FPS = 16.7 MS, SHARED BY EVERYTHING; Physics; Enemy logic; Bullets / spells; Sending sprites to GPU; spare; deadline; Every block grows with the number of objects. Run past the deadline and the frame is late: a stutter.; Shrink any block and the time freed is headroom: more enemies, more physics steps, bigger effects.; With a 60 fps cap you won't see a higher fps number. You'll see the stutters stop.

| Tip | In one sentence |
|---|---|
| **1. Measure in real play** | Log timings while people actually play; fix the biggest measured cost. |
| **2. Pool, don't instantiate** | Make it all while loading, then reuse it; nothing is created mid-fight. |
| **3. One owner per kind** | A manager updates a list of objects; objects don't each get an engine callback. |
| **4. Fake most lights** | Use glowing sprites instead of real lights on bullets and pickups. |
| **5. Collisions as maths** | "Who is near this point or line?" is a grid lookup, not a physics job. |
| **6. Share textures** | Neighbours batch only if they share a texture and material: use atlases. |
| **7. Draw copies as one** | Hundreds of identical small things become one MultiMesh, with no nodes. |
| **8. Only redraw what changed** | Redraw only what moved, and render at your art's size. |
| **9. Let the GPU move things** | Send start, velocity and time; the shader moves it, no script. |
| **10. Load early, and don't change the gameplay** | Warm up shaders and pools while loading; keep the gameplay the same. |

- **880 lights**: on pickups once cost about 5 ms a frame on their own

- **241 → 2**: draw calls for 240 walking monsters, once their frames shared one atlas

- **54 %**: of enemy cost was checking enemies against the player

**All of this is GDScript**

Every technique here is written in **plain GDScript**, with Godot's shader language for the GPU parts. There's no C#, no C++ module and no GDExtension. That was partly a constraint (we ship a web build, and C# web export wasn't an option for us). In our logs the slow part was rarely the GDScript itself. It was asking the engine to do one small thing per object, hundreds of times a frame. Changing the shape of that work is what moved our numbers.

**Words we use**

Draw call:

one "draw these" instruction from the CPU to the GPU.

Batch:

neighbouring sprites merged into one draw call.

Tick:

one physics step (60 a second by default).

Headless:

Godot running with no window, so nothing is drawn.

Uniform:

a value your game hands to a shader.

Culling:

skipping anything outside the camera's view.

Canvas item:

anything Godot 2D draws: a Sprite2D, a

_draw()

.

Interpolation:

drawing an object between its last two ticks.

MultiMesh:

one mesh drawn many times in one call.

Atlas:

many images packed into one sheet.

## 1. Measure in real play

On night 11 our frame rate fell from 48 to 32 fps as the horde passed about 120 enemies. The log showed why: past that point every frame ran two physics ticks instead of one, so all per-enemy work doubled. We would not have guessed that.

**Do this**

- **Build a tiny in-game logger.** Once per second, write a CSV row: fps, worst frame, physics ms, draw calls, node count, how many enemies, bullets and pickups are alive, and a timer around each big system.
- **Let people play normally,** then read the file. Lag shows up where it really happens: wave 11, 200 enemies, one particular spell.
- **Build a benchmark scene too:** a scripted fight you can re-run the same way every time. Make it answer one question, with switches to turn hits, trails or visuals off one at a time.

**Watch out**

- **A busy machine lies.** We saw one benchmark swing 3× (9.4 vs 3.0 ms) from background load alone. Run A, then B, then A again.
- **Headless runs measure scripts only.** Draw calls, lights and batching need a real window.
- **Averages hide stutters.** Always log the worst frame too.
- **F5 is slower than your game.** The editor's debugger made GDScript 2–3× slower: 31–35 ms a frame against 11–19 ms.
- **Test the build players get:** a release export, with an opt-in log flag (`-- --perflog`).

**perf_log.gd: a per-second CSV row**

```gdscript
extends Node
var _file: FileAccess
var _acc := 0.0
var _worst := 0.0

func _ready() -> void:
    var path := "user://perf_%d.csv" % Time.get_unix_time_from_system()
    _file = FileAccess.open(path, FileAccess.WRITE)
    _file.store_line("t,fps,worst_ms,physics_ms,draw_calls,nodes,enemies,bullets")

func _process(delta: float) -> void:
    _worst = maxf(_worst, delta * 1000.0)
    _acc += delta
    if _acc < 1.0: return
    _file.store_line("%d,%d,%.1f,%.2f,%d,%d,%d,%d" % [
        Time.get_ticks_msec() / 1000,
        Performance.get_monitor(Performance.TIME_FPS), _worst,
        Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0,
        Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
        Performance.get_monitor(Performance.OBJECT_NODE_COUNT),
        get_tree().get_node_count_in_group(&"enemies"),
        get_tree().get_node_count_in_group(&"bullets")])
    _acc = 0.0; _worst = 0.0
```

**Timing one system**

```gdscript
var t0 := Time.get_ticks_usec()
_update_all_enemies(delta)
enemy_ms_total += (Time.get_ticks_usec() - t0) / 1000.0   # average it into the next CSV row
```

> **Fix the biggest measured number first. Then measure again.**

## 2. Pool, don't instantiate

Creating scenes mid-fight is one of the most common causes of hitches. Make what you'll need before the fight, then reuse it.

**The problem**

`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, a wave dying) is a visible spike.

**The fix**

Keep a **free list per scene**, filled while loading ("prewarm"). In the fight, **acquire** a parked node and **release** it when done. Nothing is created or freed mid-fight, and a counter proves it.

> *Diagram:* ONE LIFE OF A POOLED BULLET, IN ORDER (THE ORDER MATTERS); acquire; parked, hidden; add_child; + set position; configure; then activate(); reset_physics_interpolation(); after it's visible; live: flies, hits; stepped by its manager; release: deactivate() → reset() → remove_child() → back on the free list; deactivate while still in the tree, then remove_child()

**pool.gd: one free list per scene, prewarmed, misses counted**

```gdscript
class_name Pool extends RefCounted
var _free := {}            # scene path -> Array of parked nodes (out of the tree)
var instantiations := 0    # after prewarm this must NOT move during a fight: test it

func prewarm(scene: PackedScene, count: int) -> void:   # behind the loading screen
    for i in count:
        instantiations += 1
        _free.get_or_add(scene.resource_path, []).append(scene.instantiate())
func acquire(scene: PackedScene) -> Node:
    var list: Array = _free.get_or_add(scene.resource_path, [])
    if list.is_empty():                         # a miss: counted, logged, never a crash
        instantiations += 1
        return scene.instantiate()
    return list.pop_back()
func release(node: Node) -> void:
    node.deactivate()   # YOUR method on the bullet, not built-in: hide, collision off, still in the tree
    node.reset()        # YOUR method too: clear this life's hit list, timers, tint...
    node.get_parent().remove_child(node)
    _free[node.scene_file_path].append(node)   # the scene it was made from
```

**Rules we learned the hard way**

- **Author the scene inert** (hidden, collision off) so fresh and recycled nodes start identical; `activate()` only after parenting.
- **Write every visual setting on every reuse,** defaults included: a recycled fireball otherwise keeps its glow.
- **Reset physics interpolation after showing the node:** Godot ignores it on a hidden node, and the bullet slides in from where it last died.
- **Size the prewarm from the design** (projectiles per cast × casts per second × seconds each one lives). Log a miss instead of capping the spell, and free parked nodes on exit.

## 3. One owner per kind of thing

Instead of giving every bullet its own `_process`, let one manager node update them all in one loop.

> *Diagram:* SLOW: 300 CALLBACKS A TICK, ONE PER BULLET; Engine; … × 300 bullets, each with its own callback; FAST: ONE CALLBACK, THEN A PLAIN LOOP; Engine; BulletManager; for b in bullets: b.step(dt) ← cheap script-to-script calls

**bullet.gd (before)**

```gdscript
func _physics_process(delta):
    position += velocity * delta
    life -= delta
    if life <= 0.0:
        pool.release(self)
# × 300 bullets = 300 callbacks a tick
```

**bullet.gd (after)**

```gdscript
var slot := -1   # index in the manager's list

# no _physics_process any more
func step(delta) -> void:
    position += velocity * delta
    life -= delta
    if life <= 0.0:
        manager.release(self)
```

**bullet_manager.gd: swap-remove list, walked backwards**

```gdscript
var live: Array[Bullet] = []

func add(b: Bullet) -> void:
    b.set_physics_process(false)
    b.slot = live.size()
    live.append(b)

func release(b: Bullet) -> void:
    var last := live[live.size() - 1]
    live[b.slot] = last          # the last one fills the hole: O(1), no shifting
    last.slot = b.slot
    live.pop_back()
    b.slot = -1
    pool.release(b)

func _physics_process(delta: float) -> void:
    if delta <= 0.0: return       # paused (time_scale 0): costs nothing
    var i := live.size() - 1
    # backwards: a bullet that dies is replaced by one already updated, never skipped or doubled
    while i >= 0:
        if i < live.size():      # a step can release more than one bullet, so the list may have shrunk
            live[i].step(delta)
        i -= 1
```

- **Share drawing the same way.** If 200 enemies each show a status icon, one drawer can loop over them in a single `_draw()` instead of 200 canvas items.
- **No allocation per tick.** Reuse packed arrays; build no dictionaries or lambdas in hot loops.
- **A pause should cost nothing.** If you pause with `time_scale = 0` (we do, for menus and level-up choices), managers should return at once rather than move 400 objects by zero distance. `get_tree().paused` already skips pausable nodes for you. **Check every kind of object:** our bullets and enemies stopped, but area spells and beams kept sweeping at zero speed and cost 6–14 ms a frame on the level-up screen.

## 4. Fake most lights

We once measured 880 pickup lights costing about 5 ms on their own. A glowing bullet looks great; a real light on every one is a very expensive way to get it.

**The problem**

In Godot's 2D renderer a light adds drawing work to **every sprite it touches**, and each sprite has a cap on how many lights can reach it. Fifty lit bullets flying through a crowd multiply the cost of the crowd.

**The fix**

We used real lights for two jobs. **For the floor glow,** use a soft round sprite with additive blending; every glow shares one texture and material, so they batch. **To keep sprites readable** when night darkens the screen (a `CanvasModulate`), have their shader multiply their colour by the opposite amount: if night is 50 % dark, they brighten ×2, so they end up looking normal.

> *Diagram:* A REAL LIGHT REDRAWS ALL IT TOUCHES; A GLOW SPRITE IS ONE MORE QUAD

*Keep real lights for the few sources that matter (the player, a boss, a campfire). Fake the rest.*

**bullet.tscn (before)**

```text
Bullet (Area2D)
├─ Sprite2D
├─ PointLight2D  # floor light
└─ PointLight2D  # night readability
# 2 lights × 50 bullets = 100 lights
```

**bullet.tscn (after)**

```text
Bullet (Area2D)
├─ Sprite2D      # lifted.gdshader
└─ Glow          # soft circle, floor_glow
# 0 lights; all glows share 1 material
```

**floor_glow.gdshader**

```glsl
shader_type canvas_item;
render_mode blend_add, unshaded;
uniform float gain = 0.05;  // glow brightness
void fragment() {
  // COLOR already = modulate × texel
  COLOR = vec4(COLOR.rgb * COLOR.a * gain, 1.0);
}
```

**lifted.gdshader**

```glsl
shader_type canvas_item;
// declared in Project Settings → Shader Globals
global uniform vec4 night_lift;
void fragment() {
  COLOR.rgb *= night_lift.rgb;
}
```

**the game side, once per frame wherever night is driven**

```gdscript
# ambient = your night CanvasModulate colour; the 0.4 caps the boost at 2.5×
var k := 1.0 / maxf(ambient.r, 0.4)
var lift := Vector4(k, k, k, 1.0)
RenderingServer.global_shader_parameter_set(&"night_lift", lift)
```

**Gotchas**

- In a Godot 4 canvas shader `COLOR` already holds the texel; sampling `TEXTURE` again darkens the sprite.
- `render_mode unshaded` ignores lights, but **not** a `CanvasModulate` tint.
- Shader colour can go above 1.0, so a lifted sprite survives the night's darkening.

## 5. Collisions as maths

If all you need to know is "which enemies are inside this circle" or "what did this bullet pass through", a simple grid answers it far more cheaply than a physics engine.

**The problem**

Every enemy and every bullet is an `Area2D`. The physics engine pairs them, sorts them and sends a signal per contact. In our game, enemies overlapping the player were **54 %** of all enemy cost, and 300 bullets through 240 enemies cost 7.9 ms with hits on against 4.9 ms with hits off.

**The fix**

Once per tick, drop each enemy into a **grid of 64 px cells**. To check a shape, visit only the cells it covers and do an exact distance test on those few. Enemies leave the physics engine entirely; their hit logic doesn't change.

> *Diagram:* A BULLET'S MOVE = A CAPSULE; From where it was to where it is now, so a fast; bullet can never skip over an enemy between ticks.; ONLY SHADED CELLS ARE VISITED; 10 enemies, 4 in the shaded cells, 3 touched.; The other 6 are never looked at.; Cost grows with what's nearby,; not with the size of the horde.

**enemy_grid.gd: build once per tick, query anywhere**

```gdscript
const CELL := 64.0
const MAX_R := 24.0   # biggest NORMAL enemy (bosses: see the gotcha below)
var cells := {}       # Vector2i -> PackedInt32Array of enemy indices

# simplified for reading; in a hot loop, reuse the arrays instead of making new ones each tick
func build(pos: PackedVector2Array) -> void:
    cells.clear()
    for i in pos.size():
        var key := Vector2i(floori(pos[i].x / CELL), floori(pos[i].y / CELL))
        if not cells.has(key): cells[key] = PackedInt32Array()
        cells[key].append(i)

# every enemy touching the segment a->b swept with radius r (a moving bullet)
func query_capsule(a, b, r: float, pos, radius, out: Array) -> void:
    var box := Rect2(a, Vector2.ZERO).expand(b).grow(r + MAX_R)
    var lo := Vector2i((box.position / CELL).floor())
    var hi := Vector2i((box.end / CELL).floor())
    for cy in range(lo.y, hi.y + 1):
        for cx in range(lo.x, hi.x + 1):
            for i in cells.get(Vector2i(cx, cy), PackedInt32Array()):
                var c := Geometry2D.get_closest_point_to_segment(pos[i], a, b)
                if c.distance_to(pos[i]) <= r + radius[i]:
                    out.append(i)
```

## 5. When a boss shows up (continued)

Swapping physics for maths must not change how the game plays, and a grid has one trap that only shows up in boss fights.

**in the bullet: "hit once on entry" survives, and the old hit handler is reused**

```gdscript
_now.clear()
grid.query_capsule(prev_position, position, radius, enemy_pos, enemy_radius, _now)
# remember enemies by a STABLE id, never by list index: with swap-remove, an index can
# point at a different enemy next tick (a missed or a double hit)
_now_ids.clear()
for i in _now:
    var id := enemy(i).get_instance_id()
    _now_ids[id] = true
    if not _inside_ids.has(id): on_hit(enemy(i))   # only NEW arrivals hit; same handler as before
_inside_ids = _now_ids.duplicate()
```

### Gotcha we hit: one huge monster slows every query

**The trap.** A big monster centred in a cell **outside** your search can still overlap your shape, so every query has to look past its own reach by the radius of the **biggest** monster. The natural way is one global maximum, rebuilt each tick. Then a single boss joins the fight, and every bullet's query, even one on the far side of the screen, starts scanning extra cells.

**How we found it.** Our horde fights ran smoothly while the final boss stuttered. A boss has a ~45 px radius against 11–24 px for everyone else, and there were 600–700 bullets in flight against it.

**The fix,** measured: 600 bullets, 60 enemies and one boss went from 17.4–18.3 ms to 15.5–15.7 ms of physics per frame (about 2 ms, ~13 %). Real, but not the whole story: the rest of that fight's cost was hit processing.

> *Diagram:* ONE GLOBAL MAX RADIUS; BOSS; Every bullet's search grows by the boss's size.; BIG MONSTERS IN THEIR OWN LIST; BOSS; Normal-sized search, plus one direct check per boss.

**the fix, inside build()**

```gdscript
const BIG_RADIUS := 32.0   # ~half a cell
for i in count:
    if radius[i] > BIG_RADIUS:
        big.append(i)   # bosses, giant elites
        continue
    bucket(i)
    max_r = maxf(max_r, radius[i])

# every query: its cells, then the few big ones
for i in big:
    if rect.grow(radius[i]).has_point(pos[i]):
        out.append(i)
```

**Before you ship the swap**

- **Test with your biggest monster.** 240 small enemies never show this.
- Call your **existing hit functions**; only the "who did I touch" source changes.
- Never add or remove physics objects **inside a physics callback**; queue them.
- Cap catch-up steps (Physics → Common → Max Physics Steps per Frame; we use 2) so one slow frame can't snowball.

## 6. Share textures

Usually the cheapest change in 2D: no game code changes, only how the art is packed.

**How drawing really works**

Each frame the CPU sends the graphics card a list of instructions: "bind this texture, use this shader, draw these rectangles". Each new **batch** has a setup cost, like a painter switching brush and paint pot. Godot merges neighbouring sprites into one batch automatically, but **only while the texture and the material stay the same.**

> *Diagram:* ONE IMAGE PER ANIMATION FRAME; A3; B1; A7; C2; A1; B6; 6 enemies on different frames → 6 batches; ONE SHARED SHEET (ATLAS); every frame, one image; every sprite shows a rectangle of one sheet; → 1 batch, however many enemies; Real example: 12 looks × 72 frames (8 standing directions + 8 directions × 8 walk steps) + 12 flats = 876 images. Draw calls rose about one; per enemy (197 → 305 from 0 to 100 enemies), because y-sorted neighbours almost never matched.

**thornling_walk.tres (before)**

```text
frames = [
  load("res://enemy/walk/0/east.png"),
  load("res://enemy/walk/0/south_east.png"),
  ...   # 64 images: 8 directions × 8 steps
]
```

**Each frame becomes a region**

```gdscript
var f := AtlasTexture.new()
f.atlas = preload("res://art/enemy_atlas.png")
f.region = Rect2(687, 763, 32, 32)
sprite.texture = f      # game code unchanged
```

**pack_atlas.py: an optional build step (any sprite-sheet tool works)**

```python
CANVAS, PAD = 32, 3                      # transparent padding around every frame
CELL, COLS = CANVAS + 2 * PAD, 32
sheet = Image.new("RGBA", (COLS * CELL, rows * CELL), (0, 0, 0, 0))
for i, png in enumerate(sources):
    x, y = (i % COLS) * CELL + PAD, (i // COLS) * CELL + PAD
    sheet.paste(Image.open(png), (x, y))
    regions[png] = (x, y, CANVAS, CANVAS)     # then rewrite the .tres files to AtlasTexture regions
sheet.save("art/enemy_atlas.png")
```

> **Measured: 240 walking monsters, 241 draw calls → 2. Frame time stayed ~2.2 ms on our fast GPU: it's headroom, not a visible fps jump.**

- **Pad every frame** so smoothing or outline shaders never pick up pixels from the neighbouring frame.
- **Check shaders that assume "UV 0..1 is my whole sprite".** On an atlas, UV is a small rectangle of the sheet; an outline shader must find its cell first.
- **Make the sheet a build step,** with a test that fails, naming the command to run, when someone re-exports art and forgets it.
- **Materials split batches too.** A special material on 3 of 200 enemies costs 3 extra batches; a unique material per enemy costs 200.

## 7. Draw copies as one

Got hundreds of the same small thing (pickups, sparks, trail segments) that don't need to sort in front of or behind other objects? Stop giving each one a node.

> *Diagram:* 3 NODES FOR EVERY PICKUP; Pickup (logic); Sprite; Floor glow; × 400; = 1,200 nodes; 800 drawn items walked,; culled, sorted and positioned every frame; ONE MULTIMESH FOR ALL OF THEM; one quad + one buffer; #0 transform colour custom(a,b,c,d); #1 transform colour custom(a,b,c,d); … × 400 → ONE draw call; Logic stays a light node or plain data;; drawing becomes a single item.

**A MultiMesh straight on the RenderingServer (no nodes)**

```gdscript
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_2D
mm.use_colors = true
mm.use_custom_data = true                 # 4 free floats per instance for the shader
mm.mesh = quad_mesh_built_from_2d_vertices   # see gotchas
mm.instance_count = 400

var ci := RenderingServer.canvas_item_create()
RenderingServer.canvas_item_set_parent(ci, get_canvas_item())
RenderingServer.canvas_item_set_material(ci, pickup_material.get_rid())
RenderingServer.canvas_item_add_multimesh(ci, mm.get_rid(), pickup_texture.get_rid())
RenderingServer.canvas_item_set_custom_rect(ci, true, Rect2(-1e7, -1e7, 2e7, 2e7))
RenderingServer.canvas_item_set_interpolated(ci, false)

# write a slot only when that pickup CHANGES (lands, gets collected), never every frame
mm.set_instance_transform_2d(slot, Transform2D(0.0, landed_at))
mm.set_instance_custom_data(slot, Color(land_time, bob_phase, 1.0, 0.0))
```

**the idle bob, on the GPU**

```glsl
uniform float now;   // game clock, set on physics ticks
void vertex() {
  float age = now - INSTANCE_CUSTOM.x;
  float bob = round(sin(TAU * (1.2 * age
              + INSTANCE_CUSTOM.y)) * 2.0);
  VERTEX.y -= bob * INSTANCE_CUSTOM.z;
}   // resting pickups cost zero script
```

**Trails, the same way**

A `Line2D` per bullet is a draw call per bullet. Instead, keep a **ring of segment slots** in one MultiMesh: each tick a bullet writes one segment stamped with the time, and the shader tapers and fades segments by age. **Whole frame in our 300-arrow benchmark: 13.7 → 5 ms, and 316 → 28 draw calls.**

| Gotcha | Cause → fix |
|---|---|
| Everything vanishes off screen | The visibility box was measured while every instance sat at (0,0) → **custom world-sized cull rect** (above). |
| Streaks on reused slots | Interpolation blended a slot from its previous owner → **interpolation off**, blend by hand if needed. |
| Upside-down sprites | A 3D `QuadMesh` has y-up UVs → **build the quad from 2D vertices** with explicit UVs. |
| Tests read nonsense | Headless Godot stores no MultiMesh data → **keep a CPU copy** of what you wrote and test that. |

## 8. Only redraw what changed

Godot keeps a canvas item's draw commands until you ask it to redraw. A `_draw()` that runs every frame throws that away and pays for the whole picture again, even when almost nothing moved.

**The problem**

Our skill-tree screen is a big web of hexes, threads, level notches, icons, prices and names. A few nodes hold an animated liquid, so the whole web called `queue_redraw()` every frame. Fully upgraded, that cost **49.9 ms a frame** (36 ms of it GDScript re-issuing the same commands), and **116 ms** at 4× the nodes, for a screen that barely moves.

**The fix**

Cut the still part into **tiles**, one canvas item each, and re-record a tile only when something *in it* changes. Pan and zoom just move or scale the tiles. A thin **live layer** redraws every frame, but only what really animates: the selection, the liquid, a burst, a pulse.

> *Diagram:* TILES: RECORDED ONCE; re-record; a purchase dirties ONE tile; pan/zoom dirties none; LIVE LAYER: EVERY FRAME, BUT TINY; the selected node + its hold ring; nodes holding liquid, nodes mid-burst; pulses, the pour stream; live nodes are left OUT of their tile, so nothing draws twice

**The shape of it**

```gdscript
class Tile extends Node2D:          # one canvas item = one retained command list
    var ref_zoom := 1.0; var ref_centre := Vector2.ZERO   # the camera it was recorded with
    func _draw() -> void: web.draw_tile(self)   # runs ONLY after queue_redraw()

func _process(delta) -> void:
    for t in tiles:
        if t.dirty: t.queue_redraw()  # data changed in this tile
        var k := camera.zoom / t.ref_zoom   # camera = your own pan/zoom state (zoom is a float here)
        t.scale = Vector2(k, k)
        t.position = screen / 2 - k * screen / 2 + (t.ref_centre - camera.centre) * camera.zoom
    live_layer.queue_redraw()             # the few things that really animate
```

| Fully upgraded skill tree, measured | Before | After |
|---|---|---|
| Today's 87 nodes | 49.9 ms/frame | 10.7 ms |
| 4× the nodes (a bigger tree later) | 116 ms/frame | 15.2 ms |
| Panning across it | a full redraw every frame | 0 redraws |

### The same idea, one level up: render at your art's size

With the `canvas_items` stretch mode, Godot draws at the **screen's** resolution. A 4K monitor is about 9× the pixels of a 1280×720 design, and every glow and full-screen shader pays for each one (on our test PC, ~8 ms of GPU work per frame, with the fans to match). The `viewport` mode renders at the design size and scales the finished frame up. That's crisp for pixel art, though text gets chunkier. We made it a setting, so players choose:

**Render quality, live, no restart**

```gdscript
get_tree().root.content_scale_mode = Window.CONTENT_SCALE_MODE_VIEWPORT      # "Performance (720p)"
get_tree().root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS  # "Sharp (native)"
```

## 9. Let the GPU move things

Most bullets fly in straight lines. If the graphics card knows where one started, how fast it's going and when it left, it can draw it in the right place every frame without any help from your scripts.

> *Diagram:* WRITE AN ANCHOR ONCE, AND THE SHADER DRAWS start + velocity × (now − t0); fired; write anchor; frames: the GPU moves it; the script does nothing; turned (homing); rewrite anchor; Each physics tick, compare where the anchor says the bullet is with where it really is, and rewrite only on a; mismatch: a turn, bounce, orbit, hit or fade. A straight bullet costs nothing after it's fired.

**flight.gdshaderinc supplies fly(); each bullet shader supplies its own vertex()**

```glsl
// INSTANCE_CUSTOM = (velocity.x, velocity.y, anchor_time, is_live)
uniform float flight_now;                    // set once per frame by the manager
vec2 fly(vec2 v, vec4 c, mat4 model) {
  if (c.w < 0.5) return v;                     // ordinary sprites using this shader: untouched
  float t = max(flight_now - c.z, 0.0);
  mat2 lin = mat2(model[0].xy, model[1].xy);         // VERTEX is still in mesh space here,
  return v + inverse(lin) * (c.xy * t);         // so turn the world offset back into it
}
// --- in each bullet shader, not in the include ---
#include "res://flight.gdshaderinc"
void vertex() { VERTEX = fly(VERTEX, INSTANCE_CUSTOM, MODEL_MATRIX); }
```

**the manager side: re-anchor only on a mismatch**

```gdscript
func after_step(b, prev_pos: Vector2, dt: float) -> void:
    var predicted := b.anchor_pos + b.anchor_vel * (clock - b.anchor_t0)
    if b.look_changed or predicted.distance_squared_to(b.position) > 0.0025:   # = (0.05 px)²
        # lay the anchor on the PREVIOUS tick, with this step's real velocity
        write_anchor(b, prev_pos, (b.position - prev_pos) / dt, clock - dt)

func _process(_delta) -> void:   # one tick behind + the fraction = Godot's own smoothing
    material.set_shader_parameter(&"flight_now",
        clock - last_dt + Engine.get_physics_interpolation_fraction() * last_dt)
```

**Why "one tick behind"**

With physics interpolation on, Godot draws a moving object between its **previous** and **current** tick. Running the shader clock the same way keeps GPU-moved bullets glued to everything the engine draws, like their trails.

**Stay safe**

Guard the code with a per-instance flag so other sprites sharing the shader are unaffected, and let anything the fast path doesn't understand keep drawing the old way.

**What we measured, and where it backfired**

- **~800 glowing bullets** (body + halo + floor glow each): draw calls fell from **~1,150 to ~480**.
- **Frame time** stayed within our benchmark's noise, and at **300 plain arrows nothing changed**: identical sprites with one material *already* batch in Godot.
- **Then real play bit us:** most of our bullets home, orbit or boomerang, so they bent every tick and were rewritten every tick: **4–6 ms a frame**, worse than plain nodes. Now only straight-flying bullets go through it.

## 10. Load early, and don't change the gameplay

**No first-time stutters**

- **Warm up shaders.** A material compiles the first time it's drawn, causing a hitch on the first fireball. Draw each effect **in view** for a few frames while an opaque loading screen covers it. Off-screen items are culled and never compile.
- **Prewarm pools from the design.** Work out how many of each object a maxed-out build can have; create them while loading. Log any shortfall.

**Faster, but plays the same**

- **Keep old objects as the source of truth** while other code reads them; move only the expensive part (e.g. the drawing).
- **Write the rules as tests:** "hits once on entry", "pickups freeze when paused".
- **Give players a renderer choice.** Our Windows build defaults to Direct3D 12 (`rendering_device/driver.windows`). Rare 100–600 ms stalls where neither our code nor rendering was busy usually mean a GPU pipeline compiling, and drivers differ in how they handle that. `--rendering-driver vulkan` switches renderer at launch; Steam launch options can offer it as a second "Play" entry.

**Shader warm-up behind the loading screen**

```gdscript
# runs while the loading curtain (an opaque UI layer on top) still hides the world
func warm_up(effects: Array[PackedScene], at: Vector2) -> void:
    for scene in effects:
        var fx := scene.instantiate()
        fx.position = at        # IN VIEW (e.g. the player's spot): culled items never compile
        warm_root.add_child(fx)
    for i in 4: await get_tree().process_frame  # let every pipeline compile
    warm_root.queue_free()                      # then lift the loading curtain
```

### Think less often than you move

Movement must run every tick or the horde stutters; deciding where to go needn't. Two halves of the horde think on alternate ticks (30 Hz), each handed the time since its last think, so cooldowns keep real time. Bosses and monsters mid-wind-up still think every tick, where a lag would show.

**in each monster's tick: move every tick, think every other**

```gdscript
_think_dt += delta
_think_n += 1
if _think_n >= stride or busy or not brain.staggers():   # stride 2 = 30 Hz; bosses say no
    _think_n = 0
    brain.think(self, _think_dt)    # the banked time, so cooldowns keep real time
    _think_dt = 0.0
# at spawn: _think_n = 0 or 1, alternating, so the two halves take turns
```

**What it bought, and two lessons next to it**

- **Worth it, not magic.** A think was 1.85 of a monster's 8.7 µs: ~0.9 ms a tick saved at 1000.
- **A stagger must survive deaths.** Our separation checked half the horde per tick, but any death forced a full pass, and something dies every tick. Moving each saved result with its monster: 5.4 → 2.9 ms.
- **Browsers are slower** (1.4–1.7× in Chrome): a `.web` setting override gives web a smaller horde.

## Your checklist

| If you find… | Try… |
|---|---|
| A light on every bullet or pickup | Additive glow sprite + a night-lift shader uniform |
| A `Line2D` trail per bullet | Trail segments in one MultiMesh, faded by age |
| `instantiate()` / `queue_free()` mid-fight | A pool, prewarmed while loading |
| `_process` on every pooled object | One manager looping over a list |
| `Area2D` on every enemy and bullet | A spatial grid + circle/capsule maths |
| One image per animation frame | A padded texture atlas |
| Hundreds of identical small sprites | A MultiMesh on the RenderingServer |
| Script moving every bullet every frame | An anchor in instance data; rewrite on change |
| A stutter the first time an effect plays | Shader warm-up while loading |
| `queue_redraw()` every frame on a big, mostly still screen | Retained tiles + a small live layer |
| A 4K screen drawing 720p art | A render-quality option (viewport stretch) |
| Every monster deciding every tick | Think at 30 Hz in alternating halves; move every tick |
| A web build with the desktop horde | A `.web` project-setting override with a smaller cap |
| Timings taken with F5 | Re-measure without the debugger, or in a release build |
| "It just feels laggy" | A perf log from real play; fix the biggest number |

> **Measure, find the thing you have hundreds of, and stop paying for it one at a time.**

Written by the team at Naio Games, an indie game studio, while optimising our Godot 4 game Hexwright. Three things here haven't had a full playtest yet: Tip 9's straight-bullets-only drawing, Tip 7's pickup MultiMesh and 30 Hz thinking. Everything else was measured on our own machine, so your numbers will differ.

---

*This guide was written by [Naio Games](https://naiogames.com/), an indie game studio, while optimising our Godot 4 game Hexwright: a roguelite where you build your own spells and wipe out monsters by the thousands. Read it on the web at https://naiogames.com/guides/godot-2d-horde-performance, or download the [PDF](https://naiogames.com/guides/godot-2d-horde-performance.pdf). If it helped, [wishlist Hexwright on Steam](https://store.steampowered.com/app/4982340/Hexwright/?utm_source=naiogames&utm_campaign=guide-markdown).*
