© The Author(s), under exclusive license to APress Media, LLC, part of Springer Nature 2023
C. PittProcedural Generation in Godothttps://doi.org/10.1007/978-1-4842-8795-8_7

7. Recreating Bouncy Cars

Christopher Pitt1  
(1)
Durbanville, South Africa
 

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.

A window depicts 2 bouncing vehicles on tracks for debugging purposes.

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.

A screenshot of a drop-down menu for screens that offer options for a menu screen, a new game screen, a play screen, and a screen.

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.

A screenshot of a drop-down menu of nodes under the file system option in the player.

Creating player nodes

We need a way to switch between screens, like we did in Chapter 4. For this, create a global Screens node and set it to autoload. We can use similar code to that of Chapter 4 to switch between different scenes:

This is from nodes/globals/screens.gd
extends Node
var root = null
var current_screen : Types.screens
var current_screen_node : GameScreen
var is_changing_screen := false
func _ready() -> void:
    root = get_tree().get_root()
    current_screen_node = root.get_children().back()
func change_screen(new_screen: Types.screens) -> void:
    if is_changing_screen:
        return
    is_changing_screen = true
    var new_screen_node : GameScreen = Constants.screen_scenes[new_screen].instantiate()
    load_new_screen(new_screen_node, new_screen)
func load_new_screen(new_screen_node: GameScreen, new_screen: Types.screens) -> void:
    current_screen_node.queue_free()
    root.add_child(new_screen_node)
    current_screen = new_screen
    current_screen_node = new_screen_node
    is_changing_screen = false

We can define the various screen references in a Constants global:

This is from nodes/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.

A screenshot of a dialog box of a scene with a drop-down menu for the menu screen followed by center then buttons which have the options new game and quit.

Main menu 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 from nodes/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 from nodes/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.

A screenshot of a window of project settings. The general settings include configuration, display, audio, editor, and a few options to customize the settings in the display window.

Changing window size

A screenshot of a window of project settings. The general settings include configuration, display, audio, editor, and a few options to customize the settings in the display window.

Changing stretch 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.

A screenshot of a window for creating a new game screen has features for viewing with drop-down options like phrase and play.

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 from nodes/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:
    var words := get_all_words()
    var size := words.size()
    var chosen := []
    for i in range(3):
        chosen.append(words[generator.randi() % size])
    return PackedStringArray(chosen)
func get_all_words() -> PackedStringArray:
    var file = File.new()
    file.open(words_file, File.READ)
    var content = file.get_as_text()
    file.close()
    return content.split(" ", false)
func get_hash_from_words(words : PackedStringArray) -> int:
    var complete = ""
    for word in words:
        complete += word.trim_prefix(" ").trim_suffix(" ").to_lower()
    return complete.hash()

A screenshot of a window with the scene, generation, inspector, and film system columns. The programming codes are written in the generation section.

Exporting file variables with extension filter

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 NewGame screen to populate the phrase input:

This is from nodes/screens/new_game_screen.gd
@onready var _phrase := $Center/Items/Seed/Phrase as LineEdit
func _on_back_pressed() -> void:
    Screens.change_screen(Types.screens.main_menu)
func _ready() -> void:
    _phrase.text = Generation.get_three_words_phrase()

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:

An illustration of corner samples for the track. It depicts in blurred formed in squares.

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 from nodes/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:

This is from nodes/globals/generation.gd
func get_deep_corner_array(image: Image, offset_segment: int) -> Array:
    var rows = []
    for y in Constants.segment_height:
        var row = []
        for x in Constants.segment_width:
            var cell = Types.cells.none
            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:

This is from nodes/globals/generation.gd
@export var layout_texture : Texture2D
func get_map(user_three_words_phrase = null) -> Dictionary:
    var three_words = null
    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 is from nodes/globals/generation.gd
func get_flipped_corner_array(deep_corner_array: Array, should_flip_x: bool = false, should_flip_y: bool = false) -> Array:
    var new_rows = []
    for row in deep_corner_array:
        var new_row = []
        for cell in row:
            if should_flip_x:
                new_row.push_front(cell)
            else:
                new_row.push_back(cell)
        if should_flip_y:
            new_rows.push_front(new_row)
        else:
            new_rows.push_back(new_row)
    return new_rows

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 from nodes/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.

This is from nodes/globals/generation.gd
func get_shallow_corner_array(deep_corner_array: Array, offset_row: int, offset_cell: int, segment_type: int) -> Dictionary:
    var cells = []
    var waypoints = []
    var i = 0
    for row in deep_corner_array.size():
        for cell in deep_corner_array[row].size():
            cells.push_back({
                "y": row + offset_row,
                "x": cell + offset_cell,
                "type": deep_corner_array[row][cell],
            })
            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_map method:

This is from nodes/globals/generation.gd
func get_map(user_three_words_phrase = null) -> Dictionary:
    var three_words = null
    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)
    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)
    var top_left_shallow_corner = get_shallow_corner_array(top_left_flipped_corner, 0, 0, Types.segment_types.top_left)
    var top_right_shallow_corner = get_shallow_corner_array(top_right_flipped_corner, 0, Constants.segment_width, Types.segment_types.top_right)
    var bottom_left_shallow_corner = get_shallow_corner_array(bottom_left_flipped_corner, Constants.segment_height, 0, Types.segment_types.bottom_left)
    var bottom_right_shallow_corner = get_shallow_corner_array(bottom_right_flipped_corner, Constants.segment_height, Constants.segment_width, Types.segment_types.bottom_right)
    var cells = []
    cells += top_left_shallow_corner.cells
    cells += top_right_shallow_corner.cells
    cells += bottom_left_shallow_corner.cells
    cells += bottom_right_shallow_corner.cells
    var waypoints = []
    waypoints += top_left_shallow_corner.waypoints
    waypoints += top_right_shallow_corner.waypoints
    waypoints += bottom_left_shallow_corner.waypoints
    waypoints += bottom_right_shallow_corner.waypoints
    return {
        "cells": cells,
        "waypoints": waypoints,
        "generator": generator,
        "three_words": three_words,
        "clockwise": clockwise,
    }

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 from nodes/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:

This is from nodes/screens/new_game_screen.gd
func _ready() -> void:
    var phrase = Generation.get_three_words_phrase()
    Variables.current_phrase = phrase
    _phrase.text = phrase
func _on_phrase_text_changed(new_text: String) -> void:
    Variables.current_phrase = _phrase.text
func _on_play_pressed() -> void:
    Screens.change_screen(Types.screens.play)

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:

This is from nodes/screens/play_screen.gd
extends GameScreen
var map : Dictionary
func _ready() -> void:
    reset()
func reset() -> void:
    map = Generation.get_map(Variables.current_phrase)
    draw_map()
    draw_players()
    calculate_waypoints()
func draw_map() -> void:
    pass
func draw_players() -> void:
    pass
func calculate_waypoints() -> void:
    pass

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:

This is from nodes/screens/play_screen.gd
@onready var _tiles := $Tiles as TileMap
func draw_map() -> void:
    for cell in map.cells:
        var roads : Array[Vector2i] = []
        if [
            Types.cells.road,
            Types.cells.player_1_start,
            Types.cells.player_2_start,
            Types.cells.waypoint
        ].has(cell.type):
            roads.append(Vector2i(cell.x, cell.y))
        _tiles.set_cells_terrain_connect(0, roads, 0, 0, false)

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:

An image of various types of road tiles samples for setting up the interface of the game.

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:

A screenshot of a window with bit markings for the tile template installation utilizing paint properties.

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:

An overview of the numerous car kinds that will be used as interface examples in the game.

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:

An overview of the selected cars by player 1 and player 2.

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 scene window where the digger player features have a drop-down menu for sprite and collider adjustments.

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 from nodes/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()]
    var player_2: Dictionary
    for cell in map.cells:
        if cell.type == Types.cells.player_2_start:
            if cells_are_close(player_1, cell):
                player_2 = cell
                break
    return {
        "player_1": player_1,
        "player_2": player_2,
    }
func cells_are_close(player_1: Dictionary, player_2: Dictionary) -> bool:
    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_players method:

This is from nodes/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()
    player_1_vehicle = vehicle_scenes[player_1_vehicle_index].instantiate()
    add_child(player_1_vehicle)
    player_1_vehicle.position = player_1_start_position
    vehicle_scenes.remove_at(player_1_vehicle_index)
    var player_2_tile_position : Vector2 = _tiles.map_to_local(Vector2(start_cells.player_2.x, start_cells.player_2.y))
    var player_2_start_position : Vector2 = player_2_tile_position + _tiles.position
    player_2_vehicle = vehicle_scenes[map.generator.randi() % vehicle_scenes.size()].instantiate()
    add_child(player_2_vehicle)
    player_2_vehicle.position = player_2_start_position
    var degrees := rotate_players(start_cells.player_1)
    return {
        "start_cells": start_cells,
        "degrees": degrees,
    }
func rotate_players(player_1 : Dictionary) -> int:
    var degrees := 0
    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. 1.

    We start by picking a random vehicle for the first player using the generator instance returned by get_map.

     
  2. 2.

    We remove this vehicle from the pool for the second player so that the players have unique vehicles.

     
  3. 3.

    We calculate their position according to the start_position data.

     
  4. 4.

    We add both players to PlayScreen and have their position set.

     
  5. 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. 6.

    If they’re close to the top or the bottom of the tile map, then we face them left or right.

     
  7. 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.

The track and vehicles positioned for the bouncy car interaction are visible in a debug window.

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.

A map representation of the approximate track exhibits a starting point and numerous waypoints.

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.

A representation of a section of the map with 4 sites for shooting bullets marked on it.

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:

This is from nodes/screens/play_screen.gd
func reset() -> void:
    map = Generation.get_map(Variables.current_phrase)
    draw_map()
    var player_positions := draw_players()
    calculate_waypoints(player_positions)
var ordered_waypoint_positions : Array[Vector2] = []
func calculate_waypoints(player_positions: Dictionary) -> void:
    var unordered_waypoints = map.waypoints.duplicate() as Array
    var start_position : Vector2
    while unordered_waypoints.size() > 0:
        if ordered_waypoint_positions.size() == 0:
            start_position = Vector2(player_positions.start_cells.player_1.x, player_positions.start_cells.player_1.y)
        else:
            start_position = ordered_waypoint_positions[ordered_waypoint_positions.size() - 1]
        var nearest_waypoint = unordered_waypoints[0]
        var nearest_waypoint_position = Vector2(nearest_waypoint.x, nearest_waypoint.y)
        for waypoint in unordered_waypoints:
            if ordered_waypoint_positions.size() < 2:
                if player_positions.degrees == 0 and waypoint.x < player_positions.start_cells.player_1.x:
                    continue
                if player_positions.degrees == 90 and waypoint.y < player_positions.start_cells.player_1.y:
                    continue
                if player_positions.degrees == 180 and waypoint.x > player_positions.start_cells.player_1.x:
                    continue
                if player_positions.degrees == -90 and waypoint.y > player_positions.start_cells.player_1.y:
                    continue
            var waypoint_position = Vector2(waypoint.x, waypoint.y)
            if waypoint_position.distance_squared_to(start_position) < nearest_waypoint_position.distance_squared_to(start_position):
                nearest_waypoint = waypoint
                nearest_waypoint_position = waypoint_position
        ordered_waypoint_positions.append(nearest_waypoint_position)
        unordered_waypoints.erase(nearest_waypoint)
Another huge method! Let’s break it down:
  1. 1.

    We change the reset method to pass the starting positions and rotation to calculate_waypoints.

     
  2. 2.

    This, in turn, creates a copy of the unordered waypoints so that we can change this array in place.

     
  3. 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. 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. 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. 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 from nodes/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
    rear_wheel += velocity * delta
    front_wheel += velocity.rotated(steer_angle) * delta
    var new_heading = (front_wheel - rear_wheel).normalized()
    var d = new_heading.dot(velocity.normalized())
    if d > 0:
        velocity = new_heading * velocity.length()
    if d < 0:
        velocity = -new_heading * min(velocity.length(), max_speed_reverse)
    rotation = new_heading.angle()
The movement happens in three stages:
  1. 1.

    Calculating the input that should affect the movement and increasing acceleration in response

     
  2. 2.

    Applying friction to slow the vehicle down over time

     
  3. 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 from nodes/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:

This is from nodes/screens/play_screen.gd
player_2_vehicle = vehicle_scenes[map.generator.randi() % vehicle_scenes.size()].instantiate()
player_2_vehicle.controls = {
    "left": "ui_cancel",
    "right": "ui_cancel",
    "accelerate": "ui_cancel",
    "slow": "ui_cancel"
}
add_child(player_2_vehicle)

Warning the Players About Directions

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:

The scene window has a waypoint arrow option under the player drop-down list that can be used to indicate the directions in which the automobiles will be moving.

The waypoint 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 from nodes/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)
        _arrow.global_position = global_position.move_toward(waypoint_position, 20)
        _arrow.visible = true
    else:
        _arrow.visible = false
    acceleration = Vector2.ZERO
    get_input()
    apply_friction()
    calculate_steering(delta)
    velocity += acceleration * delta
    var collided := move_and_slide()

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:

This is from nodes/screens/play_screen.gd
var player_1_next_waypoint_index : int
func reset() -> void:
    map = Generation.get_map(Variables.current_phrase)
    draw_map()
    var player_positions := draw_players()
    calculate_waypoints(player_positions)
    player_1_next_waypoint_index = 0
func _physics_process(delta: float) -> void:
    var player_1_next_waypoint = ordered_waypoint_positions[player_1_next_waypoint_index]
    var player_1_next_waypoint_position := _tiles.map_to_local(Vector2(player_1_next_waypoint.x, player_1_next_waypoint.y))
    if player_1_vehicle.global_position.distance_to(player_1_next_waypoint_position) < 100:
        player_1_vehicle.show_waypoint = false
        if player_1_vehicle.global_position.distance_to(player_1_next_waypoint_position) < 50:
            player_1_next_waypoint_index += 1
            if player_1_next_waypoint_index >= ordered_waypoint_positions.size():
                player_1_next_waypoint_index = 0
            return
    else:
        player_1_vehicle.show_waypoint = true
        player_1_vehicle.waypoint_position = player_1_next_waypoint_position

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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset