Lesson 07

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.

New code in this lesson

None of the earlier lessons use these. Each one shows the line where it appears here.

randi()

A random whole number. randi() % 4 turns it into 0, 1, 2 or 3.

game.gd
var side: int = randi() % 4

match

Compares one value against a list of options and runs only the block under the one that fits. A tidier row of ifs.

game.gd
match side:

randf_range()

A random decimal between two numbers.

game.gd
0: enemy.global_position = Vector2(randf_range(50, 1100), 10)

tree_exiting

The signal a node sends just before it leaves the scene, for example when it is freed.

game.gd
enemy.tree_exiting.connect(_on_enemy_removed)

or

Joins two conditions. At least one must be true.

enemy.gd
if global_position.x < -50 or global_position.x > 1200:

The scenes

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

game.tscn
  • Game Node2D game.gd
  • Player CharacterBody2D % player.tscn
  • SpawnTimer Timer %
enemy.tscn
  • Enemy CharacterBody2D enemy.gd
  • Polygon2D
  • CollisionShape2D
player.tscn
  • Player CharacterBody2D player.gd
  • Sprite2D
  • CollisionShape2D

FileSystem

  • game.tscn
  • game.gd
  • enemy.tscn
  • enemy.gd
  • player.tscn
  • player.gd

The code

extends Node2D

## Which scene to copy each time we spawn. Set to 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: Player = %Player

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: Enemy = 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