If you followed all the tutorials in this series, at this point all you have is a character that can move freely on a map and a simple GUI. Of course, you must be satisfied to have achieved this result (especially if it’s the first time you create a game), but where is the real fun of an RPG if there are no monsters to hunt for?
In this tutorial, we are going to add monsters to our game. We will give them a very basic artificial intelligence: all they can do is chasing the player along the map when it’s close, or otherwise move randomly.
We will create the monster object (which, by the way, will be a skeleton) as a separate scene, which we’ll instantiate in multiple copies within the main scene. As a last thing, we’ll also see how to create a spawner, i.e. a node that takes care of automatically adding monsters to the scene.
Let’s open the project and pick up where we left off in the last tutorial.
Since we will add two new entities, we will create the respective folders in the FileSystem panel. Under the Entities folder, create the Skeleton and SkeletonSpawner folders:
Now, download the images that we will use for the skeleton by pressing the button below.
Once downloaded, import them into the Skeleton folder. Then, select all the images and, in the Import panel, deselect Flags → Filter and re-import them.
That done, we can move on to creating the skeleton scene.
Creating the skeleton scene
Create a new scene by clicking on the Scenes → New Scene menu:
We need to add a root node to this new scene. Since our monster will move and collide with other nodes on the map, we will use a KinematicBody2D as the root node, just like we did for the player. So, in the Scenes panel, click on the Custom Node button and add a KinematicBody2D node.
Rename the node to Skeleton. You will see a warning that indicates that the collision shape is missing. We will add it later.
To draw the monster on the screen, add an AnimatedSprite node to Skeleton. With the AnimatedSprite node selected, in the Inspector, click next to the Frames property and select new SpriteFrames to create the resource that will contain the skeleton animations.
Click on the newly created resource to open the SpriteFrames editor and create the animations listed in the table below. If you don’t remember how to do it, take a look at the 2D Sprite Animation tutorial.
For now, set down_idle as the default animation. Then, set the Z Index property of AnimatedSprite to 1.
Add a CollisionShape2D node to Skeleton and, in the Inspector, set the Shape property to a new RectangleShape2D resource. Then, resize the rectangle to cover the skeleton body.
The last thing to add to Skeleton is a Timer node. We will use this timer to periodically check the state of the skeleton and decide its behavior (chasing the player or move randomly). With Timer selected, in the Inspector set Wait Time to 0.25 seconds and enable Autostart.
Now click on Scene → Save Scene or press Control + S to save the scene. Call it Skeleton.tscn and save it in the Entities/Skeleton folder.
Skeleton Artificial Intelligence
The Artificial Intelligence of the skeleton will be very simple. If the player is within a certain distance, the skeleton will move towards it. Otherwise, it will randomly decide whether to stay still, change movement direction or continue in the current direction. If the skeleton comes into contact with an obstacle, it will change direction casually in an attempt to get around it.
Attach a script to the Skeleton node. Call it Skelton.gd and save it in the Entitites/Skeleton folder, then open it.
First, let’s add all the variables we’ll need:
# Node references var player # Random number generator var rng = RandomNumberGenerator.new() # Movement variables export var speed = 25 var direction : Vector2 var last_direction = Vector2(0, 1) var bounce_countdown = 0
This is the list of variables and what they are used for:
- player: it is a reference to the Player node. We will need it to get the player’s current position.
- rng: is an object of type RandomNumberGenerator. As the name says, it’s a class for generating pseudo-random numbers. The new() method is used to create an object from a class.
- speed: it’s the movement speed of the skeleton. The export keyword makes this variable visible in the Inspector.
- direction: it’s the current movement direction of the skeleton.
- last_direction: it’s the last direction of movement before stopping. It’s used to choose the correct idle animation.
- bounce_countdown: when the skeleton hits an obstacle, it changes direction for a certain amount of time to try to go around it. This variable is the countdown used to restores the default behavior.
When the skeleton enters the scene tree, we need to do two things:
- get the Player node reference
- Initialize the random number generator
We must perform these operations within the _ready() function:
func _ready(): player = get_tree().root.get_node("Root/Player") rng.randomize()
The get_tree() function returns the current node hierarchy as a SceneTree object. Using the SceneTree root property, we get a reference to the root Viewport, to which we can request a node reference by passing its path to the get_node() function.
The randomize() method of RandomNumberGenerator use a time-based seed to initialize the random number generator.
As we said earlier, we want to use the timer to periodically decide the behavior of the skeleton. Basically, we want to execute a function every time the timer is triggered. To do this, we need to connect the Timer timeout() signal to our script.
In the Node panel, double-click on timeout(), select the Skeleton node and press Connect. The function to manage the timer signal will be automatically created.
Replace the function code with this:
func _on_Timer_timeout(): # Calculate the position of the player relative to the skeleton var player_relative_position = player.position - position if player_relative_position.length() <= 16: # If player is near, don't move but turn toward it direction = Vector2.ZERO last_direction = player_relative_position.normalized() elif player_relative_position.length() <= 100 and bounce_countdown == 0: # If player is within range, move toward it direction = player_relative_position.normalized() elif bounce_countdown == 0: # If player is too far, randomly decide whether to stand still or where to move var random_number = rng.randf() if random_number < 0.05: direction = Vector2.ZERO elif random_number < 0.1: direction = Vector2.DOWN.rotated(rng.randf() * 2 * PI) # Update bounce countdown if bounce_countdown > 0: bounce_countdown = bounce_countdown - 1
The first thing this function does is calculate the player’s position relative to the skeleton. This vector will be used to determine the distance from the player, and to set the direction of movement and the orientation of the skeleton.
Then, the function checks the distance of the skeleton from the player. If it’s within 16 pixels, the skeleton is in contact with the player and doesn’t move (direction is set to zero), but the variable last_direction is set to turn the skeleton towards the player.
If the distance is greater than 16, but within 100 pixels, then the skeleton chases the player, setting its movement direction towards it.
In addition to the distance, the function checks if bouce_countdown is equal to 0. If it is not, the default behavior is ignored and the skeleton moves along the last direction (which, as we will see later, is set after calling move_and_collide() if there is a collision).
The last possibility is that the player is too far away from the monster. In that case, the randf() function is used to generate a random number between 0 and 1. If the number is less than 0.05, the skeleton stops; if it’s greater than 0.05 and less than 0.1, the skeleton will move along a randomly generated direction. This direction is obtained by rotating Vector2.DOWN by a random angle between 0 and 2π radians (0 to 360°). For any other value of the random number, the skeleton maintains the previous behavior.
So, in practice, at each Timer timeout, there is a 5% chance that the skeleton will stop and 5% probability that it will change direction of movement (as long as the player is out of range).
The last function we need to implement to complete the skeleton A.I. is _physics_process(). As we have already seen in the Player movement tutorial, this is the function in which it’s recommended to put code related to physics, including the movement of physical bodies. Enter this code in the script:
func _physics_process(delta): var movement = direction * speed * delta var collision = move_and_collide(movement) if collision != null and collision.collider.name != "Player": direction = direction.rotated(rng.randf_range(PI/4, PI/2)) bounce_countdown = rng.randi_range(2, 5)
In _physics_process(), the function move_and_collide() is used to move the skeleton, exactly the same way we used it to move the player. In this case, however, we also use its return value (a KinematicCollision2D object) to determine if there has been a collision with a body other than Player.
If this happens, the current direction of movement is rotated by a randomly generated angle obtained using the randf_range() function. This angle has a value between π/4 and π/2 radians (45° to 90°). In addition, an integer number between 2 and 5 is generated randomly (using the randi_range()) function, to be used as a countdown for the “bounce” on the obstacle.
Now that the A.I. is completed, instantiate some monsters in the scene dragging Skeleton.tscn on the Root node, and try running the game to test them. If you want, you can play with the values inside the previous functions, to fine-tune skeletons behavior.
Animating skeleton sprite
To animate the skeletons, we will use two functions that we will copy almost exactly from Player:
func get_animation_direction(direction: Vector2): var norm_direction = direction.normalized() if norm_direction.y >= 0.707: return "down" elif norm_direction.y <= -0.707: return "up" elif norm_direction.x <= -0.707: return "left" elif norm_direction.x >= 0.707: return "right" return "down" func animates_monster(direction: Vector2): if direction != Vector2.ZERO: last_direction = direction # Choose walk animation based on movement direction var animation = get_animation_direction(last_direction) + "_walk" # Play the walk animation $AnimatedSprite.play(animation) else: # Choose idle animation based on last movement direction and play it var animation = get_animation_direction(last_direction) + "_idle" $AnimatedSprite.play(animation)
The only modified function is animates_monster(), and there are only a few changes compared to the version used in Player:
- the name of the function.
- the name of the AnimatedSprite node.
- the removal of the line that sets the FPS of the animation based on the speed (skeletons move at a constant speed).
For details on how these functions work, please refer to 2D Sprite Animation tutorial.
There are other animations besides idle and walk, for example birth, which should not be interrupted by the execution of the animates_monster() function. In order not to interrupt them, we need a variable that tells us if one of these animations is playing:
# Animation variables var other_animation_playing = false
Finally, let’s add this code to _physics_process() to play walk and idle animations:
# Animate skeleton based on direction if not other_animation_playing: animates_monster(direction)
Run the game now to see skeletons idle and walk animations.
To test skeletons, we have manually added them to our scene. However, during the game we want to use a system that automatically generates the player’s enemies, so that they never fall below a certain number even after they have been killed. So, we are going to create a so-called spawner, i.e. a node that handles the instantiation of monsters when necessary.
First of all, remove all the skeletons you had manually added to the main scene.
Then, open the Skeleton.tscn scene, select the Timer node and in the Inspector disable Autostart. We will start the timer from the script when we instantiate the skeletons. We can’t have the timer active right away, because the skeletons would start moving before completing the spawning procedure.
Create a new scene for our spawner by clicking Scene → New Scene. Having the spawner in a separate scene will allow us, if we wish, to instantiate more than one spawners in the main scene.
The root node of this scene can be simply a Node2D, so in the Scene panel click on 2D Scene:
Rename this node to SkeletonSpawner and add a Timer node to it. With Timer selected, enable Autostart in the Inspector and leave Wait Time at 1 second.
Save the scene with the name SkeletonSpawner.tscn inside the Entities/SkeletonSpawner folder. Now attach a script on the SkeletonSpawner node and save it as SkeletonSpawner.gd in the same folder as before.
Let’s start by adding all the variables we’ll need:
# Nodes references var tilemap var tree_tilemap # Spawner variables export var spawn_area : Rect2 = Rect2(50, 50, 700, 700) export var max_skeletons = 40 export var start_skeletons = 10 var skeleton_count = 0 var skeleton_scene = preload("res://Entities/Skeleton/Skeleton.tscn") # Random number generator var rng = RandomNumberGenerator.new()
Here’s how we’ll use these variables:
- tilemap, tree_tilemap: in these variables we will store the references to the tilemaps. When we place the monsters randomly, we will use tilemaps to find out if there is an obstacle in the chosen position.
- spawn_area: it’s a rectangle that represents the area in which we want the monsters to appear.
- max_skeletons: it’s the maximum number of skeletons that can be on the map at the same time. Note that this value is for single spawner.
- start_skeletons: it’s the number of skeletons created at the beginning of the game. Note that this value is for single spawner.
- skeleton_count: it’s the number of skeletons (created by this spawner) currently present on the map.
- skeleton_scene: it’s the resource that represents the Skeleton scene. The preload() function returns a resource from the filesystem that is loaded during script parsing.
- rng: a RandomNumberGenerator object, used to randomly generate the positions where to place new skeletons.
The spawner will create new skeletons at two different times: at game start and at each timer timeout. Since we will need the code to instantiate skeletons on more than one occasion, we should write it down inside a function:
func instance_skeleton(): # Instance the skeleton scene and add it to the scene tree var skeleton = skeleton_scene.instance() add_child(skeleton) # Place the skeleton in a valid position var valid_position = false while not valid_position: skeleton.position.x = spawn_area.position.x + rng.randf_range(0, spawn_area.size.x) skeleton.position.y = spawn_area.position.y + rng.randf_range(0, spawn_area.size.y) valid_position = test_position(skeleton.position) # Play skeleton's birth animation skeleton.arise()
First, the function instantiates skeleton_scene using the instance() function. This function returns a scene of type Skeleton, which we add to the node hierarchy using the add_child() function.
Now, we need to place the skeleton at a random point within the spawn area. However, not all positions on the map are valid for the placement of a skeleton. For example, we cannot place the monster in the water or on an obstacle. If we get an invalid position, we need to regenerate it until we get a valid one.
To do this, we use a while loop. The while statement is a control flow statement that allows code to be executed repeatedly, based on a given boolean condition. In our case, we will repeat the code inside while as long as valid_position is false (and, consequently, the condition not valid_position is true). Inside the while loop, we generate a random position and check it with the function test_position(), which we will create shortly. If test_position() returns true, then the position is valid, otherwise it is not.
The last line of instance_skeleton() calls the arise() function of Skeleton. We will create this function later. It will simply play the skeleton’s birth animation.
Add the test_position() function to the script:
func test_position(position : Vector2): # Check if the cell type in this position is grass or sand var cell_coord = tilemap.world_to_map(position) var cell_type_id = tilemap.get_cellv(cell_coord) var grass_or_sand = (cell_type_id == tilemap.tile_set.find_tile_by_name("Grass")) || (cell_type_id == tilemap.tile_set.find_tile_by_name("Sand")) # Check if there's a tree in this position cell_coord = tree_tilemap.world_to_map(position) cell_type_id = tree_tilemap.get_cellv(cell_coord) var no_trees = (cell_type_id != tilemap.tile_set.find_tile_by_name("Tree")) # If the two conditions are true, the position is valid return grass_or_sand and no_trees
This function takes as argument the position of the map to be checked. tilemap‘s world_to_map() method returns the cell coordinates corresponding to the given local position. We use that coordinates as the argument of the get_cellv() function, which returns an index that identifies the type of tile in that cell. To find out the index corresponding to a certain type of tile, we use the find_tile_by_name() function of TileSet. We then compare the indexes to know if the cell is of type Grass or Sand (the only ones that are valid for positioning skeletons).
We repeat the same procedure with tree_tiles, this time to check that there are no trees in that position. If both tests are successful, the position is valid and the function returns true.
We said earlier that we need to instantiate skeletons on two occasions. The first is at startup, so we need to enter the instantiation code in the _ready() function, together with other initialization code:
func _ready(): # Get tilemaps references tilemap = get_tree().root.get_node("Root/TileMap") tree_tilemap = get_tree().root.get_node("Root/Tree TileMap") # Initialize random number generator rng.randomize() # Create skeletons for i in range(start_skeletons): instance_skeleton() skeleton_count = start_skeletons
The first two lines retrieve the references to the main scene tilemaps, used in the test_position() method. The following line initialize the random number generator.
The code immediately afterwards is the one that actually handles monsters instantiation. The for statement is a control flow statement used to iterate through a range. The range() function, when used with only one argument n, returns all the integers between 0 and n-1. So, in our case, the for loop executes the instance_skeleton() function a number of times equal to the value of start_skeletons, thus instantiating that number of skeletons.
Also, we want to create a new skeleton every second, unless the skeleton count is already equal to or greater than max_skeletons.
Go to the Node panel and connect SkeletonSpawner‘s Timer timeout() signal to the script. The function to handle the signal will be automatically added to the script. Replace the code with this:
func _on_Timer_timeout(): # Every second, check if we need to instantiate a skeleton if skeleton_count < max_skeletons: instance_skeleton() skeleton_count = skeleton_count + 1
Completing Skeleton script
Now that we’re done with the spawner, let’s go back to Skeleton and add the missing function arise() to the script:
func arise(): other_animation_playing = true $AnimatedSprite.play("birth")
If we were to add the spawner to the main scene and try the game now, we would see that, after reproducing the birth animation, the skeletons would remain stuck in its last frame. This is due to the fact that, at the end of the animation, we have to do two more things:
- start the timer to enable Artificial Intelligence
- set to false the other_animation_playing variable to allow walk and idle animations to play.
So, connect the animation_finished() signal of AnimatedSprite to the script and replace the automatically created function with this code:
func _on_AnimatedSprite_animation_finished(): if $AnimatedSprite.animation == "birth": $AnimatedSprite.animation = "down_idle" $Timer.start() other_animation_playing = false
Using the spawner
Open the main scene and drag SkeletonSpawner.tscn to the Root node. To observe more easily how the spawner works, with SkeletonSpawner selected, in the Inspector set the size of the Spawn Area to 200, 100.
Run the game to see the spawner in action.
When you have tested that everything is working, remember to reset Spawn Area to the default values, clicking on the reset icon:
In this tutorial we had learned a lot of useful things that you’ll often meet when developing games in Godot:
- How to instantiate scenes, either manually or by code.
- How to get nodes from the node hierarchy, using get_tree() and get_node() functions.
- How to use while and for loops
- How to generate random numbers
- How to use Timers
You can try what we’ve done so far by clicking here:
In the next tutorial we will add sword fighting to the game, so you can finally kill your enemies! But be careful, because they can kill you too!