M4.4 — Spawning Enemies¶
What you'll learn
- Spawn enemies at runtime from a
PackedScene, capped and timed by aSpawner. - Express monster density as exported knobs.
- Recognize when object pooling is worth it versus
instantiate/queue_freechurn.
How it applies
- Density is the difficulty. An ARPG's challenge curve is largely "how many enemies, how fast, how tough." A spawner that exposes count and rate as knobs is how a designer tunes that curve without editing code, and how a QA tester reproduces a "too hard / too easy" report by setting known values.
- Runtime spawning is unavoidable. You cannot hand-place every enemy for a dungeon the player clears and re-enters. Spawning from a scene at chosen positions is the mechanism behind waves, packs, and respawns.
- Churn has a cost. Continuously creating and freeing nodes (every enemy, every loot drop, every hit spark) fragments work and can stutter on weaker hardware. Pooling — reusing a fixed set of instances — is the standard answer when the churn is high, and recognizing when it is warranted is a performance-judgment skill.
- Spawning composes with the bus. Spawned enemies emit
enemy_diedlike any other; the spawner can listen to know when a wave is cleared. No special wiring — spawned actors are ordinary actors.
Concepts¶
Instancing a scene at runtime¶
A saved scene (enemy_skeleton.tscn) is loaded as a PackedScene and instantiate()d to make a live
node, which you then add to the tree under World and position. Load the PackedScene once (with
preload or an exported reference), instantiate many times.
Example
The minimal spawn: one enemy at a position.
@export var enemy_scene: PackedScene # assign enemy_skeleton.tscn in the Inspector
func spawn_at(world_position: Vector2) -> void:
var enemy := enemy_scene.instantiate()
enemy.global_position = world_position
get_tree().current_scene.get_node("World").add_child(enemy)
instantiate() builds a fresh copy of the scene; setting global_position before or after
add_child both work, but setting it after ensures the node is in the tree first. Exporting
enemy_scene as a PackedScene lets a designer swap which enemy this spawner produces without code.
A simple spawner¶
A Spawner is a node that owns the "how many, how often, where" policy. A common shape: keep a cap on
live enemies, and on a timer, if below the cap, spawn one at a random point within a region (or at one of
several marker positions).
Example
A capped, timed spawner. It spawns until max_alive enemies exist, refilling as they die:
# res://scripts/spawner.gd
extends Node2D
@export var enemy_scene: PackedScene
@export var max_alive: int = 6
@export var spawn_interval: float = 2.0
@export var spawn_radius: float = 200.0
var _alive: int = 0
func _ready() -> void:
SignalBus.enemy_died.connect(_on_enemy_died)
var timer := Timer.new()
timer.wait_time = spawn_interval
timer.autostart = true
timer.timeout.connect(_on_timer)
add_child(timer)
func _on_timer() -> void:
if _alive >= max_alive:
return
var offset := Vector2.RIGHT.rotated(randf() * TAU) * randf() * spawn_radius
var enemy := enemy_scene.instantiate()
enemy.global_position = global_position + offset
get_parent().add_child(enemy)
_alive += 1
func _on_enemy_died(_enemy: Node, _at: Vector2) -> void:
_alive = max(0, _alive - 1)
The spawner caps concurrent enemies at max_alive, spawns one every spawn_interval until the cap,
and refills when an enemy dies (it hears SignalBus.enemy_died, M4.1). Vector2.RIGHT.rotated(randf()
* TAU) * randf() * spawn_radius picks a random point in a disc around the spawner. Every value is an
exported knob.
Density as a knob¶
The three exports — max_alive, spawn_interval, spawn_radius — are the density curve for this
spawner. A calm area sets a low cap and long interval; a horde room sets a high cap and short interval.
Multiple spawners with different settings shape a level's pacing. Keeping these as data (exports, or later
a Resource) means tuning difficulty is editing numbers, not logic — the same one-knob philosophy as
speed (M2.1) and detection range (M4.2).
For reproducibility, a spawner can draw its random offsets from GameState.rng (M1.4) so a seeded run
produces the same spawn pattern — a property a tester can rely on to reproduce a layout.
Object pooling: what and when¶
Each instantiate() allocates a node tree; each queue_free() tears one down. For things that churn
rapidly — projectiles, hit sparks, damage numbers, and in a dense game even enemies — this constant
build/teardown can cause frame hitches, because the work clusters and the engine touches memory hard.
Object pooling pre-creates a fixed set of instances, keeps freed ones hidden and disabled in a "pool," and reuses them instead of freeing and re-instantiating: spawning becomes "take an idle one from the pool, reset it, show it"; despawning becomes "hide it, return it to the pool." No allocation churn.
Pooling is worth it when the churn is high and measurable. For a handful of enemies spawning every couple seconds, plain instantiate/free is fine — pooling adds complexity for no gain. The judgment: profile first; pool the things that actually churn (M8's damage numbers and any projectiles are the usual candidates), not everything by default.
Example
The shape of a pool, conceptually (not the full implementation): on startup, instantiate N enemies,
hide() them, disable their processing, and store them in an array. To spawn, pop one, reposition and
reset its Health/state, show() it, re-enable processing. On death, instead of queue_free, reset
and push it back. The win is zero allocation during gameplay; the cost is you must reset every piece
of per-instance state on reuse, because the node is recycled rather than fresh — a classic source of
"the recycled enemy kept the dead one's HP" bugs if reset is incomplete.
Walkthrough¶
- Create
res://scripts/spawner.gdwith the capped, timed spawner above. Type it. - In
main.tscn, add aNode2Dchild ofWorldnamedSpawner, attachspawner.gd, and position it where enemies should appear. In the Inspector, set Enemy Scene toenemy_skeleton.tscnand tunemax_alive,spawn_interval,spawn_radius. - (Optional, reproducible) Change the offset line to draw from
GameState.rng:var angle := GameState.rng.randf() * TAUandvar dist := GameState.rng.randf() * spawn_radius. - Press
F5. Skeletons appear around the spawner up to the cap, chase and attack the player (M4.2–M4.3), and when you kill them, new ones spawn to refill — the first taste of a real encounter. - Tune for feel: set
max_alivehigh andspawn_intervallow to feel a horde; reset to a comfortable value. Note how the same code produces a calm or frantic room purely from the exported numbers.
Optional sanity check
Set max_alive to 3 and confirm exactly three skeletons ever exist at once: kill one and watch a
fourth appear only after a death frees a slot. If more than three appear, the _alive counter isn't
being decremented (check the SignalBus.enemy_died connection) — a counter desync that, in a pooled
system, would be the same class of bug as forgetting to reset a recycled instance. Restore your tuned
value.
Self-check quiz¶
Q1 — How does the spawner know to refill after an enemy dies, without tracking individual enemies?
A. It polls every enemy's Health each frame.
B. It listens to SignalBus.enemy_died and decrements its live counter, spawning again when below the
cap.
C. Each enemy calls the spawner directly on death.
D. It re-counts the World's children every frame.
Reveal answer
B. The spawner subscribes to the same global death event every enemy already emits (M4.1), so it learns of any death without referencing specific enemies or polling. A and D waste work. C couples enemies to the spawner; the bus exists precisely to avoid that — and an enemy spawned by one spawner shouldn't need to know which spawner made it.
Q2 — Why are max_alive, spawn_interval, and spawn_radius exported rather than hardcoded?
A. Exports run faster. B. They are the density/difficulty curve; exporting them lets a designer or tester tune encounters and reproduce reports by editing numbers, with no code change. C. Godot requires spawner fields to be exported. D. So they can be saved to disk automatically.
Reveal answer
B. Those three numbers are the encounter's difficulty expressed as data; surfacing them as knobs is how pacing is tuned and how a tester sets a known configuration to reproduce a bug. A is false. C is false. D is false (exporting doesn't persist them at runtime; that's M8's job).
Q3 — When is object pooling worth the added complexity?
A. Always; never use instantiate/queue_free.
B. When something churns rapidly enough that allocation/teardown causes measurable hitches
(projectiles, hit sparks, damage numbers, dense enemies); profile first, pool the actual churners.
C. Only for enemies, never for effects.
D. Never; pooling is obsolete in Godot 4.
Reveal answer
B. Pooling pays off specifically where churn is high and shows up in profiling; for low-rate spawns the plain approach is simpler and adequate. A over-applies it (and pooling adds reset-bug risk). C is backwards — fast-churning effects are prime pooling candidates. D is false.
Integration question¶
Q4 — open
A spawned enemy is an ordinary enemy_skeleton.tscn instance — it chases (M4.2), paths (M4.3), takes
and deals damage (M3), and emits enemy_died (M4.1). Explain how the spawner leverages each of those
without special-casing spawned enemies, and contrast the reset discipline a pooled spawner would
require with the fresh-instance guarantee the current instantiate approach gives for free.
Reveal expected answer
The spawner does nothing special to make spawned enemies behave — it instantiates the same scene a
hand-placed enemy uses, so the new enemy's detection area acquires the player and runs its FSM
(M4.2), its NavigationAgent2D paths around walls (M4.3), its Hurtbox/Health take the player's
hits and its attack Hitbox deals damage (M3), and on death it emits SignalBus.enemy_died (M4.1).
The spawner only listens to that death event to keep its count current; it never reaches into the
enemy. The reset contrast: instantiate() builds a brand-new node tree every time, so every
per-instance value (Health at full, FSM in Idle, no leftover target or knockback) is fresh by
construction — you get correct initial state for free, at the cost of allocation. A pooled spawner
trades that allocation away by recycling instances, but then you become responsible for resetting
every piece of per-instance state on reuse — Health back to max, state machine back to Idle,
target/velocity/knockback cleared, signals not double-connected — because the node carries
the dead enemy's state until you overwrite it. The classic pooling bug ("the reused enemy spawned
already half-dead") is exactly the fresh-instance guarantee you gave up, which is why pooling is
introduced only where the churn justifies taking on that discipline.