An intro to Procedural Generation using Godot

2024-02-25 by Gabriel C. Lins

An intro to Procedural Generation using Godot

From The Elder Scrolls: Arena to Dwarf Fortress to Minecraft, procedural generation is a staple in gaming. Even when it's not part of the main game loop, it's still used for a diversity of effects, whether it's particles, water textures or even determining NPC actions. In this article we'll dive into procedural generation using Godot Engine, but you should be able to attempt the same thing in your preferred environment.

What is procedural generation exactly?

If you're here, you probably already know what procedural means, but just so we're on the same page: procedural means generated algorithmically. It is not strictly necessary that it is generated at run time, although one of its bigger benefits is being able to do so to provide pseudo-random values.

We can use procedural generation for basically anything, and it's up to the developer to determine what should or should not be random. We will not focus on any particular use of this tool, but we'll be using the concept of terrain generation (think Minecraft or No Man's Sky) to illustrate our results.

How does it work?

Everything in a computer is deterministic. While there obviously are things that are effectively random to a given program (such as the current time, or the user's inputs), it's impossible to effectively "roll a die" inside a CPU — it's always about input and output.

Pseudorandom number generators (PRNG) are programs that execute special mathematical functions that are designed to have wildly erratic results. They are widely used in cryptography and also useful in gaming. The idea is that, given a function with multiple parameters, changing one of these parameters by a relatively small amount may result in a completely different output. These parameters, or inputs. can be geographical coordinates, time or any other type of "positioning".

Different functions have different behaviours, and some are designed to provide "smoother" results, creating pockets of similar values at various scales. They all have drawbacks in the form of lower performance, more predictability, or a combination of these when used in higher-dimensional contexts.

Here is a comparison of Perlin noise, a common gradient noise generator, and value noise, which is essentially random noise with an easing function applied to average out the results, courtesy of Wikimedia Commons's contributors:

Perlin noise Value noise
Perlin noise Value noise

The parameters to the function in this situation are an unknown random seed and the coordinates of the pixels in the pictures (X and Y). By having a seed (also called a key in cryptography), we can both ensure that it's possible to replicate results and that we'll always get different results for our coordinates as long as there are different seeds.

It's not just about terrain

Terrain generation is cool, but there are many other uses for procedural generation:

Minecraft's biomes

We've talked about Minecraft a lot — it's inevitable, since everything about it is essentially random. What makes it so smart is that although it is random, it uses many blending and easing techniques and different noise functions to merge many layers of randomness and create believable landscapes.

Minecraft's world generation consists of many layers of noise maps, each of which determine something different: temperature, altitude, carving caves and ravines out of the stone, structure spawning and much more. By using multiple noise maps related to environment, the game creates biome borders that somewhat make sense: you don't (usually) see warm deserts bordering icebergs, or jungles suddenly transitioning into tundra.

Roguelikes

Roguelikes and roguelites make use of procedural generation to create environments. It's one of the defining traits of the genre, tracing back to Rogue in 1980. There, randomness is used to generate rooms, items, item effects and enemies, and in modern interpretations of the genre there are even more applications – terrain, waves of enemies, card draws, loot and whatever you can imagine.

Slay the Spire, one of my recent favourites, uses procgen to create the rooms and paths in its overworld map.

Slay the Spire's overworld map

This example clearly illustrates the kinds of things you can determine using RNG: encounter types, room connections, the presence of the rooms themselves, etc. Being a card game, there's even more randomness involving card draws, rewards after each room, and many other things.

Textures

Procedural textures are a concept you're probably familiar with, even if you haven't heard about it by name – water, sky and fog textures in basically any game, as well as those old screensavers and audio visualisers have all made use of procedural texturing. It's used in more subtle ways in modern shaders as a way to provide variance as well.

Making a terrain generator

I've made a terrain generator using Godot when I was learning about these things myself, and it's available on itch.io. For this tutorial, we'll make something slightly different, but the principle is the same, and the result should be fairly similar.

Creating our project

We'll be using Godot 4.2.1. You can download the editor on the official website to follow these steps yourself.

First, we'll create a new project. It's very simple so we can create it in Compatibility Mode, especially since I want to include it in this article in the end:

Godot project creation

Now, we make a new 2D scene named World, and add a script to it.

The scene itself doesn't need any children just yet, as we'll have to set up our script before we do any graphical work.

The script will contain a few things:

  • Two constants that will make configuration easy:
    • A TILE_SIZE constant, set to 32: the size of our tiles, which you can customise to see more tiles at once;
    • A SEA_LEVEL constant used in world generation (more on that later);
  • Two inputs:
    • A FastNoiseLite instance, which will contain our noise generator;
    • A PackedScene to be instantiated when a tile is generated;
  • A generate_terrain_tile(x, y) method that spawns a Sprite2D with the correct value for the noise coordinates at the x, y coordinates.

Here's a stub of what it's going to look like:

extends Node2D
class_name World

const TILE_SIZE = 32
const SEA_LEVEL = 0.2

@export var altitude_noise: FastNoiseLite
@export var tile: PackedScene

func generate_terrain_tile(x: int, y: int):
    pass

In our example, generate_terrain_tile is going to be called by the camera as it moves about, so that we preemptively load more tiles as the camera pans around.

The logic for the first noise layer is going to be simple: if the value at a certain tile is above our SEA_LEVEL threshold, it generates as land; otherwise, it generates as sea. Sea and land could be different objects in a more complex setup, but for this example, our single tile scene will have properties that toggle it between these two.

Now, we'll create a basic Tile scene with a tile_type input, which can be set to SEA or LAND.

Start by creating the scene and a child Sprite2D node.

Tile object & sprite creation

Now, attach a script to Tile. This script should do the following:

  • Declare the input tile_type which World is going to use;
  • On ready, change its sprite to match the correct tile_type.

Because we're creating a basic app, we won't bother with sprites for now, but you can use whatever you like. In the tutorial we'll simply use Godot's PlaceholderTexture2D and set the sprite's modulate property to edit its colour.

extends Node2D
class_name Tile

enum TileType {
    SEA,
    LAND
}

@export var tile_type: TileType

@onready var sprite := $Sprite2D

func _ready():
    sprite.modulate = Color(0, 1, 1, 1) if tile_type == TileType.SEA else Color.WHITE

Because Godot's default placeholder sprite is pink for some reason, we use modulate to shut down the red channel to make the sea look blue, while we can keep the land pink for now. This is enough to be able to tell which is which.

Now, back on the World scene, let's spawn a single Tile to make sure we did it right: first, we wire both scenes correctly by setting World's tile input to the scene we made and also creating a new FastNoiseLite for it:

World attributes

Back at the script, let's add an instantiation to the _ready lifecycle hook:

func _ready():
    var test_tile = tile.instantiate()
    add_child(test_tile)

With this, you should be able to see a quarter of a blue tile at the top-left corner of the screen when previewing the World scene. Adding a line that changes its tile_type to Tile.TileType.LAND should turn it pink on the next preview:

  test_tile.tile_type = Tile.TileType.LAND

Important: this line should be added before the add_child call. Otherwise, _ready will get called first on Tile, because Godot processes updates from child to parent; this will make it always blue, because TileType.SEA is the first value in the enum and therefore the default.

If you change the default or manually switch the value that's saved with the scene, you will see that instead, however it still wouldn't be controlled by the World scene.

Now that we've verified that it works, we can start spawning tiles with the correct color according to what our noise map tells us.

Getting values for each coordinate

Now we can update our generate_terrain_tile method to actually do what it's supposed to by moving the responsibility of spawning tiles to it. We'll also make it so it uses the x and y parameters to position the tiles. To make the code more readable, we'll make a separate method to get the tile value called altitude_value.

Lastly, we'll change the assignment of tile.tile_type to use the noise map we passed to World:

func _ready():
    generate_terrain_tile(0, 0)

func generate_terrain_tile(x: int, y: int):
    var tile = tile.instantiate()
    tile.tile_type = altitude_value(x, y)
    tile.position = Vector2(x, y) * TILE_SIZE
    add_child(tile)

func altitude_value(x: int, y: int) -> Tile.TileType:
    var value = altitude_noise.get_noise_2d(x, y)
    
    if value >= SEA_LEVEL:
        return Tile.TileType.LAND
        
    return Tile.TileType.SEA

This should result on the exact same render, except that the tile should now repect the threshold we provided for determining land versus sea. Of course, it'll make no difference to us until we add more tiles around it, so let's get to that by generating tiles around the center.

Rendering more tiles

Let's create a new constant called RENDER_DISTANCE and set it to 16, then update _ready to generate a few more tiles:

const RENDER_DISTANCE = 16.0

# ...

func _ready():
    for n in RENDER_DISTANCE:
        # We divide by two so that half the tiles
        # generate left/above center and half right/below
        var x = n - RENDER_DISTANCE / 2.0
        
        for m in RENDER_DISTANCE:
            var y = m - RENDER_DISTANCE / 2.0
            
            generate_terrain_tile(x, y)

If you run the scene now, there's a high chance you're only seeing sea tiles. That's because land is rarer and our resolution is very low. To find more land, we can lower SEA_LEVEL to 0.0, and that should give you a substantial increase in land space, as the chances of generating sea or land become exactly 50%.

However, because we are rendering around the [0, 0] point, we are only seeing a quarter of all the tiles. Add a Camera2D node under World to correct that. Now, the node tree and rendered scene should look something like this:

Camera2D added & Render Distance adjusted

Fine-tuning

You can now play around with the values in World's Altitude Noise property through the editor and see as the land and sea shapes change when you re-render the scene.

FastNoiseLite editor

For example, if you increase Frequency you should start seeing more granular spots of land due to the values varying more rapidly. If you change Seed, you will get different land shapes with the same configuration. This is what we'll use to make our worlds random when this project is finished. Under Fractal you have ways to tune the fractal generation, which is a complex topic to explain, so I'll let you look that up yourself or learn by experimentation.

Conclusion

After the previous steps, your code should look like this:

# world.gd

extends Node2D
class_name World

const TILE_SIZE = 32
const SEA_LEVEL = 0.0
const RENDER_DISTANCE = 16.0

@export var altitude_noise: FastNoiseLite
@export var tile: PackedScene

func _ready():
    for n in RENDER_DISTANCE:
        # We divide by two so that half the tiles
        # generate left/above center and half right/below
        var x = n - RENDER_DISTANCE / 2.0
        
        for m in RENDER_DISTANCE:
            var y = m - RENDER_DISTANCE / 2.0
            
            generate_terrain_tile(x, y)

func generate_terrain_tile(x: int, y: int):
    var tile = tile.instantiate()
    tile.tile_type = altitude_value(x, y)
    tile.position = Vector2(x, y) * TILE_SIZE
    add_child(tile)

func altitude_value(x: int, y: int) -> Tile.TileType:
    var value = altitude_noise.get_noise_2d(x, y)
    
    if value >= SEA_LEVEL:
        return Tile.TileType.LAND
        
    return Tile.TileType.SEA

With this, you've got a working project that is capable of generating rough coastlines at a very low resolution, as well as customising the generation of the land by tweaking the noise texture in the editor.

In the next installment of this series, we'll implement a simple moving camera, render more tiles as we move, add extra layers to compose the landscape with more properties and use time to affect our generator functions.