It’s time for us to use everything we’ve learned so far to recreate another game. This time, it’s a game that I made a few years ago, called Bouncy Cars.
Bouncy Cars (2021)
Bouncy Cars is a local coop racing game where each course is procedurally generated. The aim of the game is to complete five laps without blowing up. Players can take damage if they collide with the race-way barriers or each other.
It’s the sixth game I made, yet it’s still one of the most polished I released. It includes robust procedural generation algorithm that makes each course unique and error-free.
Getting Set Up
Let’s get started by creating a new project. We want all the usual things, like a base Screen node inherited by menu and play screens.
Creating screen nodes
We also need a base CharacterBody2D which our different vehicles can inherit from. We can create their default behavior and then customize each vehicle.
Creatingplayer nodes
We need a way to switch between screens, like we did in Chapter 4. For this, create a global Screensnode and set it to autoload. We can use similar code to that of Chapter 4 to switch between different scenes:
We can define the various screen references in a Constantsglobal:
This is fromnodes/globals/constants.gd
extends Node
class_name Types
enum screens {
none,
menu,
new_game,
play,
}
@export var menu_scene: PackedScene
@export var new_game_scene: PackedScene
@export var play_scene: PackedScene
@onready var screen_scenes := {
screens.menu: menu_scene,
screens.new_game: new_game_scene,
screens.play: play_scene,
}
Now, we can add a few buttons to the screen nodes we’ve made so that we can switch back and forth between them. I’ll show you what this looks like for the main menu, and you can extrapolate from there for the remaining screens.
Mainmenu nodes
We’ve got the usual arrangement of CenterContainer, VBoxContainer, and Button nodes. This places three buttons in a vertical alignment in the center of the screen. We can attach listeners to these buttons so that screens change when we press a button:
This is fromnodes/screens/main_menu_screen.gd
extends GameScreen
func _on_new_game_pressed() -> void:
Screens.change_screen(Constants.screens.new_game)
func _on_quit_pressed() -> void:
get_tree().quit()
Not all platforms have this notion of quitting something. That’s very much a desktop PC thing. It would be cool if we can hide the quit button on platforms where someone can press a home button or close a tab:
This is fromnodes/screens/main_menu_screen.gd
@onready var _quit := $Center/Buttons/Quit
func _ready() -> void:
if OS.has_feature("HTML5") or OS.get_name() == "iOS" or OS.get_name() == "Android":
_quit.visible = false
Don’t forget, we need to configure the window size as we did before:
Changing the viewport size to 320 × 240
Changing the window override size to 1280 × 960
Changing the Stretch ➤ Mode to canvas_items
Changing the Stretch ➤ Aspect to expand
This will expand and center the interface, giving full weight to the pixel art.
Changingwindow size
Changingstretch settings
Creating a Seed Screen
We’re going to use some of the seed-based generation we learned about in the previous chapter. When selecting New Game, we’ll present players with a screen that shows them a random seed phrase. They can choose to keep or customize this phrase.
New Game screen
I expanded the width of the LineEdit to 200 pixels.
Let’s copy some of the code we created in the previous chapter, to select three words for our seed. We also need the text file of words we downloaded for this purpose:
This is fromnodes/globals/generation.gd
extends Node
@export_file("*.txt") var words_file
var generator : RandomNumberGenerator
func _ready() -> void:
generator = RandomNumberGenerator.new()
generator.randomize()
func get_three_words_phrase() -> String:
return " ".join(get_words(generator))
func get_words(generator : RandomNumberGenerator, number : int = 3) -> PackedStringArray:
This is like the experiment, but we’re now exporting the file reference instead of hard-coding the file path. This means we can move the file or global without the link between the two breaking. It also means we no longer have to check for the presence of the file before reading from it.
Remember to autoload the Generation node.
We can use this in the NewGamescreen to populate the phrase input:
This is fromnodes/screens/new_game_screen.gd
@onready var _phrase := $Center/Items/Seed/Phrase as LineEdit
This means the Phrase node will have a random phrase in it as soon as the NewGame screen loads. It is an editable control because we have to allow for the possibility that the user will change the seed.
Generating Maps
The question now is what to do with this seed phrase. One simple way to generate random maps is to create a set of “corners” that can be randomly selected and algorithmically modified. Here’s an example of what I mean:
Corner samples
Each of these corners is a potential layout for a quarter of the race track. To select from them, we need a method that can read the image data and convert it to a multidimensional array of cell or tile types. Let’s start with some constants for cell types and pixel colors:
This is fromnodes/globals/constants.gd
enum cells {
none,
grass,
road,
player_1_start,
player_2_start,
waypoint,
}
const cell_colors := {
cells.grass: "38a169",
cells.road: "4a5568",
cells.player_1_start: "f687b3",
cells.player_2_start: "f6ad55",
cells.waypoint: "4fd1c5",
}
const segment_width := 10
const segment_height := 7
enum segment_types {
top_left,
top_right,
bottom_left,
bottom_right,
}
const number_of_segments := 6
We can use pixel data to get a multidimensional array of cell types:
match image.get_pixel(x + (offset_segment * Constants.segment_width), y).to_html(false):
Types.cell_colors[Types.cells.grass]:
cell = Types.cells.grass
Types.cell_colors[Types.cells.road]:
cell = Types.cells.road
Types.cell_colors[Types.cells.player_1_start]:
cell = Types.cells.player_1_start
Types.cell_colors[Types.cells.player_2_start]:
cell = Types.cells.player_2_start
Types.cell_colors[Types.cells.waypoint]:
cell = Types.cells.waypoint
row.push_back(cell)
rows.push_back(row)
return rows
Since the corners image is a single row of corner designs, we can use an integer offset to fetch the pixel data for a complete corner. An offset of 1 means going a full segment_width (or, in this case, 10 pixels) to the right.
The rest of the code is straight out of Chapter 5, albeit with different cell types and colors. Feel free to go back to that chapter to brush up on this approach if you need to.
We can start to build up a complete race course map by fetching all the corners and picking a clockwise or anticlockwise direction:
if typeof(user_three_words_phrase) == TYPE_STRING:
three_words = user_three_words_phrase.split(" ")
else:
three_words = get_three_words_phrase().split(" ")
generator.seed = get_hash_from_words(three_words)
var clockwise = generator.randi() & 1
var top_left_offset = generator.randi() % Constants.number_of_segments
var top_right_offset = generator.randi() % Constants.number_of_segments
var bottom_left_offset = generator.randi() % Constants.number_of_segments
var bottom_right_offset = generator.randi() % Constants.number_of_segments
var segments_image = layout_texture.get_image()
var top_left_deep_corner = get_deep_corner_array(segments_image, top_left_offset)
var top_right_deep_corner = get_deep_corner_array(segments_image, top_right_offset)
var bottom_left_deep_corner = get_deep_corner_array(segments_image, bottom_left_offset)
var bottom_right_deep_corner = get_deep_corner_array(segments_image, bottom_right_offset)
return {
"three_words": three_words,
"clockwise": clockwise,
# ...
}
If we were to call this method and print the results, we’d see four arrays of cell types, all randomly selected. That’s a great start, but we need a way to create a loop from what would otherwise be four top-left corners.
Let’s add a method to flip the corners using code like what we had a couple chapters ago:
This method can flip vertically or horizontally; so we could turn a “top-left” corner into a “bottom-right” corner by flipping both directions. It’s a mirrored rotation.
We can extend our get_map method to flip the corners:
This is fromnodes/globals/generation.gd
var top_left_flipped_corner = get_flipped_corner_array(top_left_deep_corner, false, false)
var top_right_flipped_corner = get_flipped_corner_array(top_right_deep_corner, true, false)
var bottom_left_flipped_corner = get_flipped_corner_array(bottom_left_deep_corner, false, true)
var bottom_right_flipped_corner = get_flipped_corner_array(bottom_right_deep_corner, true, true)
We need to squash the cells into a simpler array (along with some extra metadata) so that they’re easier to draw. You’ve probably noticed code about “waypoints”. There’s going to be a bit more now, which we’ll go into more detail about in a bit.
if deep_corner_array[row][cell] == Types.cells.waypoint:
waypoints.push_back({
"y": row + offset_row,
"x": cell + offset_cell,
"segment_type": segment_type,
"index": i,
})
i += 1
return {
"cells": cells,
"waypoints": waypoints,
}
This new method takes a multidimensional array and squashes it into two one-dimensional arrays: one for waypoint cells and one for all cells. We’re almost ready to start drawing a map, but we still need to finish up the get_mapmethod:
Aside from the complete set of cells and waypoints, we also want to return the seed words, the clockwise/anticlockwise direction, and the generator. The seed words could be user-supplied but will usually be random. The generator is useful in case we need to generate anything in the caller that must be based on the same seed.
Drawing the Map
We’ve finished the code we need to tell Godot what to build, but now we need to write the code to tell Godot how to build it. That means taking these arrays and turning them into tiles and nodes!
We need to store the intended three words (or phrase) that the player has selected. This means creating a new global and interacting with it from the play screen:
This is fromnodes/globals/variables.gd
extends Node
var current_phrase : String
This will store the intended seed phrase so that we can reuse it in generation on later screens.
Remember to autoload the Generation node.
We need to set this when the seed screen is being used:
Don’t forget to attach the Play button’s pressed() signal to _on_play_pressed(). We can use the current_phrase variable when we’re drawing the map on the Play screen:
Drawing the map requires doing three distinct passes: static visuals, player nodes, and the waypoint system. We’re going to start with the static visuals. We’ve got the array of cells, so now we need to loop through it and draw each cell based on its type. Add a TileMap node to PlayScreen, called Tiles. We can draw on in the draw_map()function:
The set_cells_terrain_connect method takes an array of nodes to draw, with the desired terrain, and connects them all together using bit masks. It requires that we set up a TileMap and TileSet on PlayScreen, with a road terrain. I’m using this image as my road terrain:
Road tiles
This is the original artwork from Bouncy Cars; but we’re not going to use it all in this chapter. The important bits are the gray road sections. Go ahead and set up a terrain with these. Here’s what the bit masks look like for my terrain:
Bit masks
Notice how I have a blank tile in the terrain, which has no bits set. I arrived at this point through trial and error; so it’s likely you’ll need to experiment with the set_cells_terrain_connect method for your game.
For instance, the final parameter is the ignore_blank_tiles method, which defaults to true. I had to set this to false to get the road tiles to connect. That, in combination with a completely blank (in appearance and bit mask) tile, results in a neat result.
Drawing the Players
Next up, we need to place the players on the map. We haven’t actually made the players yet; so let’s do that. I’m using the following artwork, but we’ll only need parts of it:
Car sprites
It’s tricky to see here, but the second line actually has the cars outlined. This is useful for car selection, as I have implemented in Bouncy Cars:
Selected cars
You can make as many of these vehicles as you like; but I’m going to stick to three for now. The base Player node needs a few child nodes that all of them will inherit. Here’s what one of them looks like once it has a CollisionPolygon2D and Sprite2D:
The digger
We can then place a random vehicle for both players at a random start location. Let’s find a good starting position:
This is fromnodes/screens/play_screen.gd
func get_start_cells() -> Dictionary:
var start_cells = []
for cell in map.cells:
if cell.type == Types.cells.player_1_start:
start_cells.push_back(cell)
var player_1: Dictionary = start_cells[map.generator.randi() % start_cells.size()]
if player_1.x == player_2.x and (player_1.y == player_2.y - 1 or player_1.y == player_2.y + 1):
return true
if player_1.y == player_2.y and (player_1.x == player_2.x - 1 or player_1.x == player_2.x + 1):
return true
return false
These methods pick a random start cell for the first player and then find the closest start cell for the second player. The start positions need to be in the same row or column, or this code won’t find the second start cell.
We can use these in the draw_players method to draw both players and rotate them to face the correct way with a rotate_playersmethod:
This is fromnodes/screens/play_screen.gd
@export var digger_scene : PackedScene
@export var fire_truck_scene : PackedScene
@export var monster_truck_scene : PackedScene
var player_1_vehicle : GamePlayer
var player_2_vehicle : GamePlayer
func draw_players() -> Dictionary:
var start_cells := get_start_cells()
var vehicle_scenes := [digger_scene, fire_truck_scene, monster_truck_scene]
var player_1_tile_position : Vector2 = _tiles.map_to_local(Vector2(start_cells.player_1.x, start_cells.player_1.y))
var player_1_start_position : Vector2 = player_1_tile_position + _tiles.position
var player_1_vehicle_index : int = map.generator.randi() % vehicle_scenes.size()
if (player_1.x < 5 and map.clockwise) or (player_1.x > 15 and not map.clockwise):
degrees = -90
if (player_1.x < 5 and not map.clockwise) or (player_1.x > 15 and map.clockwise):
degrees = 90
if (player_1.y < 5 and map.clockwise) or (player_1.y > 15 and not map.clockwise):
degrees = 0
if (player_1.y < 5 and not map.clockwise) or (player_1.y > 15 and map.clockwise):
degrees = 180
player_1_vehicle.rotation = deg_to_rad(degrees)
player_2_vehicle.rotation = deg_to_rad(degrees)
return degrees
There’s a bunch happening, so let’s break it down:
1.
We start by picking a random vehicle for the first player using the generator instance returned by get_map.
2.
We remove this vehicle from the pool for the second player so that the players have unique vehicles.
3.
We calculate their position according to the start_position data.
4.
We add both players to PlayScreen and have their position set.
5.
We rotate the players based on the starting position of the first player. We assume that if they are close to the left- or right-hand side of the tile map, they need to face up or down.
6.
If they’re close to the top or the bottom of the tile map, then we face them left or right.
7.
The start positions and initial rotation of the vehicles are important for working out the correct direction for travel later on.
This means you have to position your starting positions within the first five pixels of the left or top edges of your pre-drawn segments. We could determine these margins algorithmically, but I don’t think it’s worth the hassle.
Drawing tiles and cars
Calculating Waypoints
Now we get to the part where we talk about waypoints: what they are and why we need them. Our segments have these light blue dots that we record as being waypoints.
In racing games like ours, where you can be facing either direction, it’s common for the game to tell the player when they’re going in the wrong direction.
In a game with generated maps, it can be tricky determining what the right way to go is. Let me describe how I hacked my way through it the first time so you can see my thought process.
Invisible waypoints in the map
I started out drawing these invisible waypoints in the map, lining up with the waypoint pixels in the pre-drawn corners. Taking the direction into account, I’d then shoot a bullet from the first waypoint in the direction the players were facing.
If the direction was clockwise, I’d rotate a few degrees to the left; and if the direction was anticlockwise, I’d rotate a few to the right. This gave me a starting direction to fire an invisible bullet toward.
In increments of five degrees, if I hadn’t hit anything with the invisible bullet, I’d rotate back toward the direction the cars should be facing, until I hit a waypoint.
Firing bullets
Once I hit a waypoint, I’d add it to the list and start firing bullets from it, using the last angle as the new starting angle to fire the bullet.
These invisible bullets were fast, but they can’t be too fast or they’ll overshoot the waypoints. Because of this delay, I added a countdown timer to the start of the race, which was necessary to hide the bullet scan delay. This ended up being a nice feature of the game.
The Right Way to Do This
Little did I know, at the time, that I already had a better solution. I had to determine the correct “first” waypoint. I did by working out which waypoint had the smallest distance to the first player’s starting position.
I just had to reuse the same logic to figure out what the next waypoint should be after that. Here’s what that code should look like:
We change the reset method to pass the starting positions and rotation to calculate_waypoints.
2.
This, in turn, creates a copy of the unordered waypoints so that we can change this array in place.
3.
While there are still unordered waypoints left, we loop through them and find the next nearest waypoint to the most recent ordered waypoint position.
4.
If it’s the first time we’re doing this check, there won’t be a last ordered waypoint; so we set this to the player’s position.
5.
If we’re calculating the first couple waypoint positions, we want to eliminate all waypoints that are behind the players from the check so that we establish the clear direction to the next waypoint.
6.
Once we calculate the nearest next waypoint, we add its position to the ordered waypoint position list and remove it from further checks.
This results in a list of ordered waypoint positions, where the first is the waypoint nearest in the direction the players should move and the last is the waypoint closest to their back.
Moving the Players
Let’s add the ability for the first player to move around the track. There are a whole bunch of different ways to model their movement, but I found an interesting take. It’s based on the physics surrounding a vehicle’s wheel base, steering angle, and drag:
This is fromnodes/players/player.gd
extends CharacterBody2D
class_name GamePlayer
@export var wheel_base := 30.0
@export var steering_angle := 90.0
@export var engine_power := 400.0
@export var friction := -0.9
@export var drag := -0.0015
@export var braking := -450.0
@export var max_speed_reverse := 150.0
var steer_angle: float
var acceleration := Vector2.ZERO
func _physics_process(delta: float) -> void:
acceleration = Vector2.ZERO
get_input()
apply_friction()
calculate_steering(delta)
velocity += acceleration * delta
var collided := move_and_slide()
func get_input():
var turn = 0
if Input.is_action_pressed("ui_left"):
turn -= 1
if Input.is_action_pressed("ui_right"):
turn += 1
steer_angle = turn * deg_to_rad(steering_angle)
if Input.is_action_pressed("ui_up"):
acceleration = transform.x * engine_power
if Input.is_action_pressed("ui_down"):
acceleration = transform.x * braking
func apply_friction():
if velocity.length() < 5:
velocity = Vector2.ZERO
var friction_force = velocity * friction
var drag_force = velocity * velocity.length() * drag
if velocity.length() < 100:
friction_force *= 3
acceleration += drag_force + friction_force
func calculate_steering(delta):
var rear_wheel = position - transform.x * wheel_base / 2.0
var front_wheel = position + transform.x * wheel_base / 2.0
Calculating the input that should affect the movement and increasing acceleration in response
2.
Applying friction to slow the vehicle down over time
3.
Applying a steering direction to the velocity
With this code in place, both vehicles move at the same time. That’s definitely not the best behavior, though it is entertaining for a short while. We should add a set of controls that affect movement upon player creation so that we can only respond to them on the player that they apply to:
This is fromnodes/players/player.gd
var controls := {
"left": "ui_left",
"right": "ui_right",
"accelerate": "ui_up",
"slow": "ui_down",
}
func get_input():
var turn = 0
if Input.is_action_pressed(controls.left):
turn -= 1
if Input.is_action_pressed(controls.right):
turn += 1
steer_angle = turn * deg_to_rad(steering_angle)
if Input.is_action_pressed(controls.accelerate):
acceleration = transform.x * engine_power
if Input.is_action_pressed(controls.slow):
acceleration = transform.x * braking
Then, we can disable the second player’s controls by providing a different set of controls when we create it:
Finally, we should use the waypoints we’ve calculated. Let’s show something to the player to let them know what direction they should be travelling in. The ideal thing to use for this is an arrow that points to the next waypoint, so they can re-orient themselves:
Thewaypoint arrow
We can reference this in the _physics_process method. We need to show the arrow (if required) and turn it toward the desired waypoint. This takes a bit of math and linear interpolation to look nice:
This is fromnodes/players/player.gd
var show_waypoint := false
var waypoint_position : Vector2
@onready var _arrow := $Arrow as Sprite2D
func _physics_process(delta: float) -> void:
if show_waypoint:
_arrow.rotation = lerp(_arrow.rotation, get_angle_to(waypoint_position) + PI / 2, 1)
All that we should need to show the arrow pointing to the right place is tell the player to show the arrow and give it a position to point toward.
Unfortunately, this will take a bunch of code to achieve. We need to track the next waypoint the player is moving toward. If they are too far away from it, then we should show the arrow. If they get too close to it, then we need to pick the next waypoint in the sequence to set as their next:
It would be a bit more efficient to do this with a timer, but the results would be the same.
Summary
This has been another huge chapter, putting our knowledge into practice. I’ve skimmed the surface of what it would take to produce a polished version of Bouncy Cars; but I’ve also shown you all the different parts of making a good procedural race track generator that you can base your own games on.
There are so many places you could take this project from here:
You could wire up the second player’s controls.
You could add collisions to the road tiles so that the players need to stay inside the lines to win.
You could add win/lose conditions and messaging.
You could add more vehicles and decorations.
I could fill the rest of this book with these refinements, but I’ll let you decide how much of that you would like to add to your game.
In the next chapter, we’re going to move on to navigating within a generated world, not using the keyboard as we did here, but things like pathfinding and click to move.