Skip to content

M4.4 — Spawning Enemies

What you'll learn

  • Spawn enemies at runtime from a PackedScene, capped and timed by a Spawner.
  • Express monster density as exported knobs.
  • Recognize when object pooling is worth it versus instantiate/queue_free churn.

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_died like 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_radiusare 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

  1. Create res://scripts/spawner.gd with the capped, timed spawner above. Type it.
  2. In main.tscn, add a Node2D child of World named Spawner, attach spawner.gd, and position it where enemies should appear. In the Inspector, set Enemy Scene to enemy_skeleton.tscn and tune max_alive, spawn_interval, spawn_radius.
  3. (Optional, reproducible) Change the offset line to draw from GameState.rng: var angle := GameState.rng.randf() * TAU and var dist := GameState.rng.randf() * spawn_radius.
  4. 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.
  5. Tune for feel: set max_alive high and spawn_interval low 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.