Invasion is a game I recently built, inspired by world events. It’s a strategy game, where you try to lead survivors through a war zone.
Invasion (2022)
It has a surprising amount of depth, despite being capable of running on a phone or tablet. That’s thanks to the click-to-move navigation and solid content generation code.
In this chapter, we’re going to rebuild this game, putting into practice everything we’ve learned so far.
Getting Set Up
- 1.
We need to set up a folder structure that supports our screens, globals, scenery, and actors.
- 2.
Our screens should extend a base scene so that we can add screen switching, transitions, and mobile support.
- 3.
We should base our level generation on pixel art, though we can forgo a seeding system.
- 4.
The scenery code should account for collective nodes, which we can see in the preceding screenshot.
I’ll go through each of these, with screenshots.
Screens
Head over to Project ➤ Project Settings, and adjust the Viewport, Override, and Stretch settings:
Adjusting screen size
With this done, I set up the usual MarginContainer-based screen system:
Screens for the game
I’ve added a new CanvasLayer → ColorRect node combination. We’re going to use this for a fancy screen transition, but first, we need to add the screen switching and menu buttons.
As before, we need to define the list of screens and their scene files:
We can reference these in the standard screen switching code we’ve written a few times:
We can connect to this code from our MenuScreen buttons. Speaking of which, I want to add a quit button to our main menu.
Only this time, I want to explore a new way of finding the button, so we can hide it when we export the game to platforms that don’t support quitting.
The most common way we’ve referenced nodes is with the @onready var _button := $Path/To/Button notation. There’s another that doesn’t hard-code the relationships between the button and its parent nodes. If we right-click on the button node and select Access as Scene Unique Name, we can reference it using this new %Syntax.
Using scene-unique names
Our menu code looks like this:
Transitions
I had to make the Cover node invisible to see what I was doing with the menu nodes. Let’s add the screen transition code so we can hide Cover with a fancy shader.
I like to implement transitions with signals and hooks. Hooks are methods with special names that our code can call automatically. The flow can be quite confusing at first:
Signals and actions when switching screens
This requires adding signals to the screens we’re going to switch between, which is where the common parent class comes in handy:
We need to call these from our screen switching code. We should prepare the screens for showing and hiding and only free the old screen when all the transitions have happened.
The methods we’ve defined on the Screen node immediately emit their related signals, so there aren’t any animations. We’re going to change that shortly. For now, let’s call the appropriate Screen methods:
We can await custom signals in the same way as we await signals from built-in classes and methods. We can shuffle these calls and awaits around if we wanted the operations to happen in parallel.
The animation we’re going for only makes sense if they show and hide in sequence, though.
Adding Shaders
Let’s make the ColorRect appear and disappear with a fancy shader. I’m by no means a shader expert; but I have put together a simple effect that will work for us.
Selecting the ColorRect node, we can go to Material ➤ New ShaderMaterial ➤ New Shader. Save the resulting file anywhere, and then switch to the Shader Editor tab (on the bottom of the window).
We can use the following shader code:
In short, this shader says that as the amount reaches 1.0, more of the screen should be covered in the black banding:
Adjusting shader amount in the property inspector
We can tween this amount property during the screen transitions. When we’re hiding a screen, we should increase the amount to 1.0, and when we’re showing a screen, we should do the opposite.
This is an interesting combination of lambda syntax and procedural tweening. We prepare to show Cover by setting its amount parameter to 0.0, and then we tween this value to 1.0 as we hide the screen.
Planning Room Generation
Each Invasion level has some connected rooms. We can use a lot of the same code we created for Bouncy Cars.
Layouts for rooms
Purple pixels are paths.
Red pixels are houses.
Green pixels are trees.
Gray pixels are gravestones.
- 1.
Convert pixel data into cell data.
- 2.
Combine clusters of pixels into single cells.
- 3.
Create node instances for each cell.
The added complication is that we want to generate adjoining “rooms” that the player can travel between. The player should spawn in one of the rooms. This starting room should also contain the “safe house” to lead survivors to.
A different room should contain the safe house, which acts as a way for the player to complete the level.
We can visualize the rooms like this:
Connected rooms
Let’s start by creating a single room so that we can get the code for pixels → nodes out of the way. Here are the nodes I have in my Room scene:
We need to do quite a bit of setup for the rooms to function. I’m going to show you a hectic screenshot and then break down each part in sections:
Room scene
You can save this scene to the nodes folder and attach an empty script to it. I have the grid showing and the snap positions set to 12 × 12 pixels. The guides are at 66 pixels on each axis. The exit area colliders are 11 grid cells wide, and their center is on 66 pixels.
Snapping to the grid
Tile Map
I'm basing this tile set on a kenney.nl asset pack that I’ve extracted and modified various tiles from:
Room sprites
Each of these modified sprites is 12 × 12 pixels; so I’ve set up a tile set sprite for those dimensions and created the TileSet to have a road terrain. I had planned to use more of these tiles in the pixel layouts, but I found these too noisy in the final game.
Feel free to use them in yours, as long as you can set up new pixel colors and adjust the generation code to match.
12 × 12 pixel tiles, in an 11 × 11 unit layout, means each room has a size of 132 × 132 pixels. This is smaller than our 180 × 180 screen size, but we’ll center the rooms and the Aspect settings will scale them up to fill the center of the screen.
Exits
As the player moves to the edges of the screen, we need to transition them into the adjacent room if this exists. I’ve set up four Area2D nodes, with CollisionShape2D children, to be able to detect this transition.
These can be on the edges of the level so that walking to the edge will start the transition. We still need to code that part, though.
Sanctuaries
When the level begins, the player should start in a room that has a sanctuary in it. This is where the player needs to bring survivors to rescue them.
We also need a visible sanctuary in the room through which the player exits. These can look different, but I decided to make them look the same in my build of the game.
They’re blue ColorRect nodes in the screenshot, but you’re welcome to make them custom nodes if you so choose. Keeping things simple to focus on the important parts of this chapter.
Arrows
If there are adjacent rooms, we want to indicate to the player that they can travel in the direction of the adjacent room. Rooms won’t have an adjacent room on every side, so the idea is to hide the arrows if they aren’t present on that edge of the room.
These overlapping nodes might look strange, but we’ll hide them by default and show them when nothing else is in that position.
Spawns
We need to show where the player can spawn when the level starts and when they move from room to room. This can be hard-coded in a script, but I prefer a visual indicator.
That’s why I’ve created a Spawns Node2D to hold four Marker2D nodes. Our scripts can use the position of these visual indicators to work out where the player should enter a room, or where they should spawn.
It’s best that these positions do not intersect any doodads in our pixel art layout image. Otherwise, the player might be stuck in the position they spawn in. Plan your layout images so there is open space for these markers.
The Remaining Nodes
All the remaining nodes are placeholders for where we’ll add doodads, soldiers, and survivors. We could add them all to the same parent node; but that makes runtime debugging a bit harder.
Generating One Room
Now that we’ve taken care of the structure for each room, we need to write some code to handle drawing the tiles and doodads into each room. This code should take a random room layout and work out which pixels are nodes and which are tiles.
The layouts in the pixel art image are already horizontally flipped. I don’t want to vertically flip them because it would complicate the buildings.
First, we need to set up some constants:
This is all quite like Chapter 7, but we’re also adding nodes into the mix. We need to make a scene for each of those exports.
All the drawables extend on this node and code:
Normally, we’d use the property syntax to define this kind of setter. It’s simpler, instead, to use a method that we can override it in the child classes that need to.
The tree, grave, and grass scenes are all similar and simple. Let me show you what one of them looks like, and you can follow this pattern for the others.
Showing a random tree sprite
We followed a similar approach in Chapter 2. This gives roughly a 1/10 chance for the one tree design to show, a 4/10 chance for the next, and a 7/10 chance for the third.
The graves and grass follow the same approach, with different sprites and percentages. I use low probability for those, so the room isn't too noisy.
Here’s that code in a more readable format:
The houses are a bit more complicated, but they follow the approach we learned about in the previous chapter:
Showing variable-size houses
I went ahead and created a TileMap node in each of the sizes our layouts support. There are nine of them, most of which we need to hide via script. We should only be showing the one required by the size given during the generation phase. Here’s how we do that:
We can call the set_drawable_size method, on the parent class, with the super keyword. Next, we can plan an instance of the Room in the PlayScreen:
Adding a test Room instance to PlayScreen
Notice how I’ve put the Room at -66 × -66 pixels. This is half the width and height, so it is in the center of the screen.
If we launch the game, we should see the sanctuaries and arrows. Let’s work on the code that draws doodads and tiles.
Generating a random room layout from the pixel art layout file
Drawing TileMap road tiles
Drawing drawables for everything else
The layout method looks much like the ones we’ve made before:
This gives us a 2D array of tile and node types. We can pass to the other methods we need to make. First up, the TileMap drawing method:
This is like the one we made for Bouncy Cars. We can pair it with a method that creates and places drawable nodes:
We loop through the rows and columns (y → x), ignoring all TileMap types and pixels we’ve already accounted for as part of a cluster.
- We do a bit more processing for each type in the drawable_groups array.
We loop from 0 → 4 to see if there are matching cell types to the right.
We loop from 0 → 4 to see if there are matching cell types downward.
We combine these into a new drawable_size variable and add individual cells to the ignore list.
Once we’ve gone through all the cells, we create new nodes and assign the drawable_size to each.
With these methods in place, we can adjust the Room script so that it generates itself:
The room is now responsible for adding its own tiles and nodes. The results are quite lovely:
Rooms that draw themselves
Feel free to add as many other decorations as you like. One thing I like about our houses is that we can create any number of variations. These could include houses that have second floors and yards. The tile set has some lovely decorations to achieve this.
Generating Many Rooms
The starting room must have a rescue sanctuary in it.
There should be a limited number of rooms, branching out from it.
The final room should have an exit sanctuary in it.
The arrows should only be visible on edges where there is an adjacent room present.
Let’s build this function in stages, starting with the code to generate the first room:
This code begins by resetting variables in a new global: Variables. Here’s what the script for that global looks like:
These arrays are typed to only allow values of the defined types. The make_rooms method continues by creating a new instance of the room scene. The Constants script also gets a few new properties:
Don’t forget to reference the room scene through the property inspector or you’ll get a nasty error message when running this code, something like Nonexistent function ‘instantiate’ in base ‘Nil’.
Once we create and position the first room, we can get all the potential neighbors. This requires a few methods in the Room script:
When we’re generating the grid of rooms and need to figure out the available spots
When we’re showing or hiding arrows and sanctuaries
That first part looks like this:
We find all the neighbor positions and add them to the list from which we’ll generate the next room. Before we do that, we remove one of the potential neighbor positions. We want to have a sanctuary in the first room. This will be on the “free side.”
Now, we need to build the rest of the rooms. Each room follows a similar creation process, though they’re positioned off-screen.
Can you guess why we need to store the available room positions and created rooms? We need these in the Room helper methods, or we could have used local variables. We’re also setting the types of most of the rooms to other and the last one to last.
The final bit of code needs to set the sanctuary side of the first and last rooms and fetch any new potential room positions:
Be sure to check out the sample project code for this full listing, since it’s too large for me to include here. We can now remove the instance of Room we manually placed in PlayScreen and call the make_rooms method to dynamically place rooms:
The only way to see this code in action, before the player can walk around in them, is to open the remote debugger. If you run the game and then look above the node tree, you’ll see a Remote and a Local button.
When you’re designing, then you should be looking at the Local view. If the game is running and you’d like to see the nodes and their values, then you can click Remote and inspect things.
Inspecting rooms in the remote view
Hiding Invalid Arrows and Sanctuaries
Before we add player movement, let’s clean up the look of the rooms by hiding invalid sanctuaries and arrows. Let’s define a new Room method for this:
It’s interesting that we can use square-bracket syntax on enums, giving us the ability to use a dynamic string. We need to call this method in the generation code:
Moving Around in the Rooms
We’re going to add click-to-move navigation. This means putting into practice some things we learned in Chapter 8.
Let’s create a player character, based on some of what we learned:
Setting up the player
It is a CharacterBody2D node.
It has a Sprite2D node for visuals.
It has a CollisionShape2D node to work out collisions.
It has a NavigationAgent2D node to work out pathing.
The code for it differs from the code we wrote previously. We’re using the NavigationAgent2D node to calculate a path, and we’re using that path in a different way. It still works as expected, but now we have a bit more control over what we do with the path information.
Here’s the code in a format that’s easier to read:
Go through each of the scenes and make sure that all the nodes with green icons have their Mouse Filter settings to Ignore. This is so that the _unhandled_input method is called on the GamePlayer class, without the other controls intercepting it.
Another important thing to note is that we must call the get_next_location method at least once so that the NavigationAgent2D node can determine whether the navigation is finished. We continue to call it inside the _physics_process method so that the path can update if colliders move.
Remember to check the Avoidance Enabled check box so that the velocity_computed signal is emitted.
We also need to change the Room scene so that it has navigation data and injects a player into the first room:
Adding navigation mesh data to Room
This is a NavigationRegion2D node, with a custom rectangle of navigation data drawn into the Room. Chapter 8 explains this in a bit more detail, but the gist is that this area is used to calculate where NavigationAgent2D nodes can navigate.
We can inject the GamePlayer node through the PlayScreen scene:
This depends on an exported player_scene reference, so don’t forget to also set that up. This code adds it to the first room. The player is also stored in a variable on the Variables global so that we can get it from within the rooms.
Launch the game and click around a bit. You should see the step layer move to your cursor.
Transitioning to Neighboring Rooms
It’s time we added the ability to move between rooms. In the beginning of the chapter, we added Area2D nodes that would serve as the doorways between rooms. We’re now going to put them to use.
When the player’s body collides with the exits, we want to start the transition into another room. The trouble is that they can’t just be added to the new room in the same position, or they’ll trigger the transition in that room as well.
We need to disable all the colliders so that only one transition happens. Let’s add the colliders to groups so that we can disable them without a lot of traversal code:
Adding nodes to groups for bulk actions
Next, let’s build a function to move the player from their current room to the new room:
The purpose of this function is to take the player from the current room they’re in to the next room. We can use the call_group method to run a method on every node in the exit_colliders group. It runs asynchronously, which means we need to wait for a short time for all the colliders to be disabled.
Godot 3 had a SceneTree.idle_frame signal, but I cannot find a suitable substitute for it in Godot 4. This code is the simplest way I could come up with to perform the scene transition.
The only downside is that waiting for a timer is generally not considered a good practice because it opens the code up to potential race conditions. I’m not as concerned about this because I know there are at most eight colliders that we need to disable. It's a quick process.
We could reduce the timeout to something far smaller, and this solution would still work.
If the player is already in a room, then we remove them from it and move it off-screen. We follow this up by adding them to the next room and position it in the center of the screen. We need to connect the body_entered signals of each Area2D to the listener methods we've defined here.
We also need to define those reposition and get_spawn_position methods, so let’s start with the latter:
This method is a shortcut for finding the named Spawns → Marker2D node and returning its position. Here’s what the reposition method looks like:
We could expose the _agent variable so that other classes could set these properties manually. But the NavigationAgent2D’s target is linked to the GamePlayer’s position, so this helper makes sense.
Since we have the add_player method, we can use it when we add the player to the first room:
The player will now spawn at the same position as the Top Marker2D node. When they move transition to a room above their current one, they’ll be positioned at the Bottom Marker2D node.
Spawning Survivors
A game about rescuing survivors needs survivors to rescue. Let’s make a little survivor node and then spawn it into some of the rooms. It's like the player’s character:
Setting up the Survivor node
We need another Area2D node to act as the acquisition radius. When the player enters this radius, the survivor will attach themselves to the player. They'll continue to follow the player until they reach the sanctuary.
The Room node can be responsible for spawning these survivors. The best place to spawn them would be on top of a grass node, since these should always be navigable. Alternatively, you could create a new pixel color for the spots survivors should spawn.
We can also start the Survivor script off with most of the code in the Player script:
To allow the survivors to spawn on grass drawables, we need to make the layout available to other methods:
The spawn_survivors method we follow this up with needs to randomly select a grass tile for the survivor spawn:
Constants.minimum_survivors_in_room
Constants.maximum_survivors_in_room
Constants.survivor_scene
I’ll leave it to you to set these up. After creating the GameSurvivor instance, we keep attempting to select an unoccupied position for them. Once found, we can spawn the survivor.
Rescuing Survivors
They should be able to follow the player.
We should allow them to move between rooms.
The player should be able to drop them off at the sanctuary.
GameSurvivor already has an acquisition Area2D node, so we can tie into the signal emitted when a body enters it:
I’ve attached a signal listener to the Survivor’s Acquisition node, in which I check if the body entering is a player. If so, I set the following variable to the player instance.
You’ll also notice I created a timer, called FollowTimer. The timeout signal is useful for updating the survivor’s NavigationAgent2D target location. The timer is set to Autostart and is not set as a One Shot. This means it will start automatically and keep timing out.
Don’t forget to add a survivors array to the player class, where we can store references to survivors following the player.
When we launch the game, we’re greeted by an unfortunate side effect of the current NavigationAgent2D system. Nested Area2D and CollisionShape2D nodes will be included in the avoidance detection logic.
This means that we cannot actually get inside the acquisition radius to acquire the survivor.
Nested CollisionShape2D nodes interfering with collision avoidance
I could have skipped over this part by going straight to the alternative. I chose, instead, to highlight this issue because it’s likely to cause you a lot of headaches if you don’t know it’s there.
One solution to this problem is not to use an Area2D node to detect acquisition. We can use the distance from the player’s position to the survivor’s position:
We can delete the Acquisition Area2D node, since we don’t need it anymore.
I also spent some time fiddling with the Survivor → Agent Path Desired Distance and Target Desired Distance. I arrived at 5 pixels being a good setting for both of these. Since we’re not controlling the survivors, it’s ok if they aren’t as responsible or accurate as the player.
These increased values mean their movement will be smoother and their targeting more forgiving.
Additionally, I saw that the survivors were sometimes hidden behind the doodads. I moved the Doodads node above the Survivors node so that they display above the doodads.
We can make the survivors move to different rooms in the same method we use to allow the player to transition to different rooms:
We need to remove the survivors from their parent before we can add them to the new room. Remember, they’re children of the Survivors node, so we can’t remove them from the room. If we added them to a group, then we can use the call_group method instead of needing to find their parents.
Once the player is in the new room, we can add the survivors to the new room. Since we can now take survivors back into our starting room, we can rescue them:
This looks like a lot of code, but that’s only because there is some repetition for the different Area2D signals. The gist of it is that we have some special functionality if the current room is the first room.
If the player is in the first room and they are on the sanctuary side, then all the survivors following them are rescued.
We could collapse this code even further by using a common signal method, but that’s a bit more complicated than I want to make this code.
Taking Things Further
- 1.
Have a visual indicator that appears when you are close to a survivor, for how close you need to be to get them. In my version, I used a yellow circle, so the player can see how close they must get to the survivor.
- 2.
Show how many survivors there are to rescue, somewhere on the screen, and track how many have already been rescued.
- 3.
Spawn soldiers, and have them patrol between random road drawables. They can have a similar indicator for how close you can get to them before they start to chase you.
- 4.
Add a dialog system to display conversations between the player, soldiers, and survivors.
- 5.
Have a hope counter, which continually drains but can be increased when you rescue a survivor.
- 6.
If soldiers catch you while you have no following survivors, decrease the player’s hope.
- 7.
If soldiers catch you while you have following survivors, make them detain the survivors. You can decide what the hope penalty is for these, so it might be more favorable to be caught alone or with company.
- 8.
Add an exit transition, and populate the summary screen with data about the current state of hope.
- 9.
Add a game-over transition for when hope has completely run out.
Summary
This has been an ambitious chapter, wherein we built the majority of the functionality for Invasion. It’s not exactly what I released, but it includes everything fundamental. It gives you a good jumping-off point for customization and novel mechanics of your own.
I am so proud of what we’ve covered in this chapter and the book as a whole. This is our third game, and it showcases the majority of what I’d consider practical procedural content generation. I encourage you to take your time with this chapter and project. If you feel like there are topics that you don’t have a handle on, spend some time going over the code and researching specifics in the Godot community.
If you can produce a game like Invasion, you’re ready to use these skills in your own games.
In the following chapter, we’re going to look at generating and adhering to stricter paths of movement. This will be useful for games where you want richer NPC movement or less free player movement.