All lessons
Lesson 08

Enemy Spawner

Build new enemies on a timer while the game is already running, instead of placing them by hand in the editor.

Loading the game engine. This takes a moment the first time.

Every lesson before this one used nodes you placed by hand in Godot. This one builds brand new nodes while the game is already running.

The scenes

UI node · 2D node · % unique name · .tscn instanced scene

enemy_spawner.tscn
  • EnemySpawner Node2D enemy_spawner.gd
  • SpawnerPlayer CharacterBody2D % player.tscn
  • SpawnTimer Timer %
player.tscn
  • SpawnerPlayer CharacterBody2D player.gd
  • Sprite2D
  • CollisionShape2D
spawn_enemy.tscn
  • SpawnEnemy CharacterBody2D spawn_enemy.gd
  • ColorRect
  • CollisionShape2D
extends Node2D

## Which scene to copy each time we spawn. Set to spawn_enemy.tscn in Godot.
@export var enemy_scene: PackedScene
## How many seconds to wait between spawns.
@export var spawn_interval: float = 1.5
## The most enemies allowed to be alive at the same time.
@export var max_enemies: int = 10

# How many enemies are alive right now.
var _enemy_count: int = 0

@onready var spawn_timer: Timer = %SpawnTimer
@onready var player: SpawnerPlayer = %SpawnerPlayer

func _ready() -> void:
	spawn_timer.wait_time = spawn_interval
	# "timeout" is a signal every Timer sends when it reaches zero. We put
	# "_on_spawn_timer_timeout" inside ".connect()". That is the function
	# Godot runs each time the timer finishes.
	spawn_timer.timeout.connect(_on_spawn_timer_timeout)
	# A Timer sits still until you start it.
	spawn_timer.start()

func _on_spawn_timer_timeout() -> void:
	# Already at the limit, so skip this one. "return" leaves early.
	if _enemy_count >= max_enemies:
		return
	# Nobody picked a scene in Godot, so there is nothing to copy.
	if enemy_scene == null:
		return

	# "instantiate()" makes one real copy of the saved scene. The copy exists
	# now, but it is NOT in the game until "add_child()" near the bottom.
	var enemy: SpawnEnemy = enemy_scene.instantiate()

	# Pick one of the four screen edges at random.
	# "randi()" gives a random whole number, and "% 4" chops it down to
	# 0, 1, 2, or 3. "match" then runs only the line that matches.
	var side: int = randi() % 4
	match side:
		0: enemy.global_position = Vector2(randf_range(50, 1100), 10)
		1: enemy.global_position = Vector2(randf_range(50, 1100), 638)
		2: enemy.global_position = Vector2(10, randf_range(50, 600))
		3: enemy.global_position = Vector2(1140, randf_range(50, 600))

	# Aim the new enemy at wherever the player is standing right now.
	enemy.direction = (player.global_position - enemy.global_position).normalized()
	# "tree_exiting" is a signal a node sends right before it gets erased.
	# That is how we find out to subtract one from our count.
	enemy.tree_exiting.connect(_on_enemy_removed)
	# NOW the enemy is really in the game and starts moving.
	add_child(enemy)
	_enemy_count += 1

# Runs when any enemy we made is about to be erased.
func _on_enemy_removed() -> void:
	_enemy_count -= 1