What I learned spawning enemies off-screen in Godot 4
Every horde game needs enemies to appear somewhere, and the player should never see the moment they appear. That sounds like one line of code. It took me five fixes to get a spawner that doesn't pop enemies in on screen, inside walls, or outside the map. Here is what each fix taught me.
Lesson 1: a fixed radius only works at one zoom level
The first version everybody writes picks a random angle around the player and pushes the point out by a fixed distance:
@export var spawn_radius := 700.0
func _spawn_point() -> Vector2:
var angle := randf() * TAU
return player.global_position + Vector2.RIGHT.rotated(angle) * spawn_radius
It's fine as long as nothing changes. Then I zoomed the camera out for a boss and enemies started popping in on screen. Later I tested on a wider window and it happened again. The radius was a guess about the screen size, and the screen size changed.
The fix is to ask the camera what it can actually see:
func _visible_rect() -> Rect2:
var cam := get_viewport().get_camera_2d()
var size := get_viewport().get_visible_rect().size / cam.zoom
var center := cam.get_screen_center_position()
return Rect2(center - size / 2.0, size)
Two details matter here. Dividing by zoom is what makes it correct when the camera
zooms in or out (in Godot 4, a zoom above 1 means you see less of the world). And
get_screen_center_position() is better than player.global_position, because with
position smoothing or camera limits the camera isn't centered on the player.
Lesson 2: spawn on the edge of the rectangle, not on a circle
My next attempt used the rectangle's half-diagonal as the radius. Nothing popped in anymore, but enemies spawned to the left and right took noticeably longer to arrive than the ones above and below. On a 16:9 screen, a circle big enough to clear the corners is far too big at the sides.
Spawning directly on the border of the visible rect (plus a margin) fixes that. Every enemy starts the same short distance off screen:
func _random_point_on_edge(rect: Rect2) -> Vector2:
var w := rect.size.x
var h := rect.size.y
var d := randf() * (2.0 * w + 2.0 * h)
if d < w:
return rect.position + Vector2(d, 0)
d -= w
if d < h:
return rect.position + Vector2(w, d)
d -= h
if d < w:
return rect.position + Vector2(w - d, h)
d -= w
return rect.position + Vector2(0, h - d)
Picking a distance along the whole perimeter (instead of picking a side first) keeps the
spread even. Otherwise the short sides get as many enemies as the long ones. The margin
is just _visible_rect().grow(64.0), which should be about the size of your biggest enemy
sprite so nothing is half visible when it appears.
Lesson 3: off-screen also means "inside the wall you can't see"
On an open field this was done. On a map with buildings, some enemies spawned inside rocks and got stuck forever, and the wave counter waited for kills that couldn't happen.
A physics point query tells you if a spot is free before you use it:
@export_flags_2d_physics var blocking_mask := 1
@export var map_rect := Rect2(0, 0, 4096, 4096)
func _is_free(point: Vector2) -> bool:
var q := PhysicsPointQueryParameters2D.new()
q.position = point
q.collision_mask = blocking_mask
return get_world_2d().direct_space_state.intersect_point(q, 1).is_empty()
func _pick_spawn_point() -> Vector2:
var rect := _visible_rect().grow(64.0)
for i in 8:
var p := _random_point_on_edge(rect)
if map_rect.has_point(p) and _is_free(p):
return p
return Vector2.INF
The map_rect check covers the other version of the same bug. When the player stands near
the edge of the map, part of the spawn border is outside the playable area. I tried
clamping those points back inside, and that pulled them right onto the screen. Rejecting
them and trying again was the fix that actually worked.
If all eight tries fail, the function returns Vector2.INF and the caller skips this
spawn. With an accumulator-based wave system, the missed enemy just comes out on the next
frame. That's better than an infinite loop when the player is trapped in a corridor.
Lesson 4: the parent you pick decides who moves
I once added enemies as children of the spawner, and the spawner was a child of the player. Every enemy moved with the player and kept the same distance from them, so the horde could never catch up. It looked like an AI bug and took me an embarrassingly long time to find.
Enemies belong under a world node that never moves:
@export var enemy_parent: Node2D
func _spawn(scene: PackedScene) -> void:
var p := _pick_spawn_point()
if p == Vector2.INF:
return
var enemy: Node2D = scene.instantiate()
enemy.position = p
enemy_parent.add_child.call_deferred(enemy)
Setting position before adding the child means _ready() already sees the right spot.
That only works because enemy_parent sits at the origin. If it doesn't, set
global_position after the node is in the tree.
Lesson 5: "Can't change this state while flushing queries"
The call_deferred above is there because of this error. I had an extra spawn triggered
by body_entered (an enemy that splits when it touches the player). Adding a physics
body from inside a physics callback isn't allowed, and Godot complains loudly about it.
Deferring the add_child to the end of the frame fixes it. Since you can't always know
which code path will call _spawn(), deferring every time is the simplest rule.
What I'd do differently starting over
Base the spawn area on the camera from the first day, never on a number I measured on my own monitor. Spawn on the rectangle's edge and reject bad points instead of clamping them. And put every enemy under one world node, so "who moves with whom" is never a question.
My Survivors Template for Godot 4 still uses the simple ring spawner from Lesson 1, with the radius exposed in the inspector. Around it are the systems I didn't want to rebuild every time: waves, enemies, weapons and upgrades as data, XP, and a playable loop. If you're starting a horde game, it gives you that loop to begin with, and the edge-of-screen version above drops into it as one function.