Let’s Learn Godot 4 by Making an RPG — Part 11: Spawning Enemy AI🤠

Christine Coomans
9 min readJun 30, 2023

--

Imagine how much work it would be to manually add 50+ enemies to our scene. This not only looks cluttered, but it also means that we will only have 50 enemy’s in our scene, and if we kill them all, they’d be gone until we reload the game. To fix this little conundrum, we can create an Enemy Spawner that will spawn our enemies at random locations throughout the map and at a constant value, meaning we will never have more or fewer enemies than we defined. Each time an enemy is removed by us killing them, the spawner will spawn a new one, and so forth.

We’ll also make our spawner flexible enough so that we can change the spawn area and max enemies in the Inspector panel. This means we can instance multiple spawners in our Main scene to have x amount of enemies spawn in different areas. Does this sound interesting? Well, what are you waiting for? Let’s spawn some enemies!

WHAT YOU WILL LEARN IN THIS PART:

· How to work with the Timer node.

· How to create scene references.

· How to reference nodes in other scenes.

· How to reference TileMap properties.

· How to work with rectangle properties.

First, let’s remove all the instances of the Enemy scene from our Main scene. We won’t instance our enemy like this again since the spawner will take care of that.

Let’s create a new Scene with a Node2D as its root. This will allow us to draw our enemy nodes onto our scene.

Rename this Node2D as “EnemySpawner” and save it under your Scenes folder.

We need to add a Timer node to this scene so that we can spawn an enemy every second — unless we’ve already reached our max enemy amount. Enable AutoStart, because we want this timer to start as soon as our game starts so that we can start spawning enemies.

We also need to go back to our Enemy scene and disable the timer there, because we don’t want that Enemy to start moving before they’ve completed spawning. We’ll start this timer later on in the code itself.

Attach a script to your root node and save it underneath your Scripts folder.

Finally, let’s add a new Node2D node that will we’ll organize the spawned enemies underneath.

In this script, we need to define a few variables. These variables will:

  • · Randomize our enemy’s spawn position.
  • · Set and export the enemy max values.
  • · Set and export the enemy’s current spawn count.
  • · Set a reference to our Map’s tilemap property so that our enemy doesn’t spawn on certain layers (for example, our water and foliage).
###EnemySpawner.gd

extends Node2D

# Node refs
@onready var spawned_enemies = $SpawnedEnemies
@onready var tilemap = get_tree().root.get_node("Main/Map")

# Enemy stats
@export var max_enemies = 20
var enemy_count = 0
var rng = RandomNumberGenerator.new()

In our Global script, we’ll also load a reference to our Enemy scene.

### Global.gd

extends Node

# Scene resources
@onready var pickups_scene = preload("res://Scenes/Pickup.tscn")
@onready var enemy_scene = preload("res://Scenes/Enemy.tscn")

To spawn our enemies, we need to create a function that will spawn the existing enemies at the start of the game, and the rest of the enemies when the timer times out. We can call our function spawn_enemy(). In this function, we will create an instance of the reference to our enemy scene. When we create an instance of our scene it saves this as a resource without loading it from the disk each time we call it. This saves space and time.

###EnemySpawner.gd

# older code

# --------------------------------- Spawning ------------------------------------
func spawn_enemy():
var enemy = Global.enemy_scene.instantiate()

Now that we have an instance of our Enemy scene, we can add our enemy as a child of our node hierarchy via the add_child() method. In simple terms, we’re adding enemies from our Enemy scene to our spawner’s scene tree.

###EnemySpawner.gd

# older code

# --------------------------------- Spawning ------------------------------------
func spawn_enemy():
var enemy = Global.enemy_scene.instantiate()
spawned_enemies.add_child(enemy)

If you think about it logically, you know that you cannot just spawn an enemy in any position. What if our game spawns it under a building? Or in the water? We won’t be able to get to the enemy, so to prevent this issue from happening, we have to create a function that will define the valid spawn positions for our enemy. We’ve done this before in our Pickups spawner. Now depending on the layers that you added to your Tilemap, the next part might be different on your side than it is on mine.

In the part where we added our Map, we created the following layers:

As you already know, our layers each have an ID assigned to each of them. We start counting at 0, so that means that water == 0, grass == 1, sand == 2, and foliage ==3. We only want our enemy to spawn on our grass(1) or sand(2) layers. Anywhere where our other layers (0, 3) are present, we don’t want them to spawn.

Since we’ll be referencing these layers in both our Pickup spawning functionality and Enemy spawning, let’s redefine the layer constants from our Main script in our Global script. We’ll then also need to reference these constants correctly in our Main script.

### Global.gd

extends Node

# Scene resources
@onready var pickups_scene = preload("res://Scenes/Pickup.tscn")
@onready var enemy_scene = preload("res://Scenes/Enemy.tscn")

# Pickups
enum Pickups { AMMO, STAMINA, HEALTH }

# TileMap layers
const WATER_LAYER = 0
const GRASS_LAYER = 1
const SAND_LAYER = 2
const FOLIAGE_LAYER = 3
const EXTERIOR_1_LAYER = 4
const EXTERIOR_2_LAYER = 5
### Main.gd

# older code

# --------------------------------------- Pickup spawning -----------------------
# Valid pickup spawn location
func is_valid_spawn_location(layer, position):
var cell_coords = Vector2(position.x, position.y)

# Check if there's a tile on the water, foliage, or exterior layers
if map.get_cell_source_id(Global.WATER_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.FOLIAGE_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.EXTERIOR_1_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.EXTERIOR_2_LAYER, cell_coords) != -1:
return false

# Check if there's a tile on the grass or sand layers
if map.get_cell_source_id(Global.GRASS_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.SAND_LAYER, cell_coords) != -1:
return true

return false

Now in our EnemySpawner script, let’s copy and paste the entire is_valid_spawn_location() function from our Main script. Replace the variable map with tilemap.

###EnemySpawner.gd

# --------------------------------- Spawning ------------------------------------
# older code

# Valid spawn location
func is_valid_spawn_location(layer, position):
var cell_coords = Vector2(position.x, position.y)
# Check if there's a tile on the water, foliage, or exterior layers
if tilemap.get_cell_source_id(Global.WATER_LAYER, cell_coords) != -1 || tilemap.get_cell_source_id(Global.FOLIAGE_LAYER, cell_coords) != -1 || tilemap.get_cell_source_id(Global.EXTERIOR_1_LAYER, cell_coords) != -1 || tilemap.get_cell_source_id(Global.EXTERIOR_2_LAYER, cell_coords) != -1:
return false
# Check if there's a tile on the grass or sand layers
if tilemap.get_cell_source_id(Global.GRASS_LAYER, cell_coords) != -1 || tilemap.get_cell_source_id(Global.SAND_LAYER, cell_coords) != -1:
return true
return false

Now in our spawn_enemy function, we need to randomly select a position on the map. We’ll then need to check if that position is a valid spawn location using the is_valid_spawn_location function. If it’s valid, we’ll spawn the enemy at that position. If not, we’ll try another random position. This once again works similarly to what we did when we spawned our pickups.

###EnemySpawner.gd

# --------------------------------- Spawning ------------------------------------
func spawn_enemy():
var attempts = 0
var max_attempts = 100 # Maximum number of attempts to find a valid location
var spawned = false

while not spawned and attempts < max_attempts:
# Randomly select a position on the map
var random_position = Vector2(
rng.randi() % tilemap.get_used_rect().size.x,
rng.randi() % tilemap.get_used_rect().size.y
)
# Check if the position is a valid spawn location
if is_valid_spawn_location(Global.GRASS_LAYER, random_position) || is_valid_spawn_location(Global.SAND_LAYER, random_position):
# Spawn enemy
var enemy = Global.enemy_scene.instantiate()
enemy.position = tilemap.map_to_local(random_position) + Vector2(16, 16) / 2
spawned_enemies.add_child(enemy)
spawned = true
else:
attempts += 1
if attempts == max_attempts:
print("Warning: Could not find a valid spawn location after", max_attempts, "attempts.")

Next, we need to spawn the enemies. We can do this in our Timer node’s timeout() signal. Connect the signal to your scene root, where you will see the func _on_timer_timeout(): function added to the end of your script.

If our enemy count is not bigger than our max enemies, we will call our spawn_enemies function. This will spawn an enemy every 1 second untill the max amount of enemies allowed have been spawned.

###EnemySpawner.gd

# --------------------------------- Spawning ------------------------------------
# Spawn enemy
func _on_timer_timeout():
if enemy_count < max_enemies:
spawn_enemy()
enemy_count = enemy_count + 1

Now if you create an instance of your EnemySpawner scene in your Main scene (make sure you have no Enemy scene in your Main scene), and you run your game, you will notice that the enemies spawn.

If you don’t want enemies in your scene, just set your spawner values to 0 in the Inspector panel.

Ensure that you TileMap node’s transform properties are set to (0,0) — otherwise your enemies will spawn with an offset!

PICKUP SPAWNER

Whilst we’re at it, let’s create a new scene that will contain our PickupSpawner. This will make our project more dynamic and reusable. Recreate the steps that you took for your EnemySpawner (Create a new scene, add nodes, connect script, connect timeout signal). Please don’t add a Timer node to this scene

Now, in your PickupSpawner.gd script, copy over the code that you added in your Main script into your newly created script.

### PickupSpawner.gd

extends Node2D

# Node refs
@onready var map = get_tree().root.get_node("Main/Map")
@onready var spawned_pickups = $SpawnedPickups

var rng = RandomNumberGenerator.new()

func _ready():
# Spawn between 5 and 10 pickups
var spawn_pickup_amount = rng.randf_range(5, 10)
spawn_pickups(spawn_pickup_amount)

# --------------------------------------- Pickup spawning -----------------------
# Valid pickup spawn location
func is_valid_spawn_location(layer, position):
var cell_coords = Vector2(position.x, position.y)
# Check if there's a tile on the water, foliage, or exterior layers
if map.get_cell_source_id(Global.WATER_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.FOLIAGE_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.EXTERIOR_1_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.EXTERIOR_2_LAYER, cell_coords) != -1:
return false
# Check if there's a tile on the grass or sand layers
if map.get_cell_source_id(Global.GRASS_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.SAND_LAYER, cell_coords) != -1:
return true
return false

# Spawn pickup
func spawn_pickups(amount):
var spawned = 0
var attempts = 0
var max_attempts = 1000 # Arbitrary number, adjust as needed

while spawned < amount and attempts < max_attempts:
attempts += 1
var random_position = Vector2(randi() % map.get_used_rect().size.x, randi() % map.get_used_rect().size.y)
var layer = randi() % 2
if is_valid_spawn_location(layer, random_position):
var pickup_instance = Global.pickups_scene.instantiate()
pickup_instance.item = Global.Pickups.values()[randi() % 3]
pickup_instance.position = map.map_to_local(random_position) + Vector2(16, 16) / 2
spawned_pickups.add_child(pickup_instance)
spawned += 1

Then, in your Main scene, delete the SpawnedPickups node, and instance the PickupSpawner instead. Your Main script should look like the one below.

### Main.gd

extends Node2D

Now your pickups and enemies should spawn when you run the scene! The enemy should still roam around and chase the player if you come close to them.

Next up, we’re going to add the functionality for our player to shoot and deal damage to our enemy. Remember to save your game project, and I’ll see you in the next part.

Your final code for this part should look like this.

TUTORIAL RELEASE

LINKS TO OTHER PARTS

The tutorial series has 23 chapters. I’ll be releasing all of the chapters in sectional daily parts over the next couple of weeks.

You can find the updated list of the tutorial links for all 23 parts in this series here.

FULL TUTORIAL

If you like this series or want to skip the wait and access the offline, full version of the tutorial series, you can support me by buying the offline booklet for just $3 on Ko-fi!😊

--

--

Christine Coomans
Christine Coomans

Written by Christine Coomans

Just a *redacted* trying to human. 👾Visit my website for more cool resources: https://christinecdevs.site

No responses yet