How To Create a Procedurally Generated Infinite Game World With Godot 4
In this tutorial you'll learn how to create an infinitely expanding procedurally generated world in Godot 4. The principles are applicable in any engine. The player will be able to move around in this world and it'll expand forever.
Below you can see the components we need to create this. I'll go through them step by step as we build up this procedurally generated world. You can find the final code at Github. The sections in this guide are:
- the noise generator
-
translating noise into a representation in our 3d world
-
generate the world around the player
-
making the world infinite
-
next steps beyond the scope of this guide (chunk management, swapping between disk and ram, saving state)
If you prefer video format you can see the video version of this tutorial here:
the noise generator
To generate the world we'll leverage noise. We'll use a noise generator and map the noise (a numeric value) into attributes in our world. It's all up to us how this mapping should look.
We have a noise generator built into Godot 4 that we can leverage. It's called FastNoiseLite. It has several noise algorithms built in and for this tutorial we'll use Simplex Smooth Noise. I encourage you to read the docs and read further about the different types of algorithms and their characteristics. Given a coordinate in n-dimensional space the noise generator will give us back a noise value. Just to give you a visual representation, a 2d grayscale output of a noise algorithm can look like this:
translating noise into a representation in our 3d world
The possibilities are virtually endless for what we can do with the noise that we generate. In this tutorial I've chosen to create a ground where the height and color of the boxes are decided by the noise.
To implement this we need four components:
- translate the noise array into coordinates in our 3d world, so that given a coordinate we get back a noise value
-
map noise values into color values
-
map noise values into height values
- a component that creates boxes with the above attributes
translate the noise array into coordinates in our world, so that given a coordinate we get back a noise value
The Godot 4 class we're using to generate noise can take in a coordinate and gives back a noise value for that position. So we don't need to create anything here for this use case if we're using this library. Here's a code example:
var noise = FastNoiseLite.new() # creates our noise generation object
noise.get_noise_2d(x,y) # gives us a noise value for the x,y coordinate
map noise values to color values
With the chosen algorithm we'll have values ranging roughly between -0.4 and 0.4. For this tutorial I've chosen a very simple bucketing rule which just divides this range into five distinct buckets where any outliers go into the first and last buckets. Here is the GDScript code for that:
func get_color_from_noise(noise_value):
if noise_value <= -.4:
return Color(1,0,0,1)
elif noise_value <= -.2:
return Color(0,1,0,1)
elif noise_value <= 0:
return Color(0,0,1,1)
elif noise_value <= .2:
return Color(.5,.5,.5,1)
elif noise_value > .2:
return Color(.3,.8,.5,1)
map noise values into height values
This is quite straight forward. In this tutorial I'll simply be taking the raw float noise value and increase its amplitude by a constant. Like so:
const VERTICAL_AMPLITUDE = 10
cube_position = Vector3(x,generated_noise*VERTICAL_AMPLITUDE,z)
a component that creates boxes with a given position and color
Now when we have the ability to get a noise value at a given x,z-position and then map that value into a color and a vertical position let us write the code that can create a box with these attributes in the game 3d world.
func create_cube(position, color):
var box_size = Vector3(1,1,1)
var static_body = StaticBody3D.new()
var collision_shape_3d = CollisionShape3D.new()
collision_shape_3d.position = position
collision_shape_3d.shape = BoxShape3D.new()
collision_shape_3d.shape.size = box_size
var mesh = MeshInstance3D.new()
var boxmesh = BoxMesh.new()
boxmesh.size = box_size
var material = StandardMaterial3D.new()
material.albedo_color = color
boxmesh.material = material
mesh.set_mesh(boxmesh)
mesh.set_position(position)
static_body.add_child(mesh)
static_body.add_child(collision_shape_3d)
add_child(static_body)
Nothing crazy going on here. We're basically doing the same thing we would do through the UI to create a scene with a Static body, collision shape and a mesh but with code so that we can set its color and position dynamically.
generate the world around the player
Now we have the tools in place to map the noise function output into the way we decided to represent it in the 3d world. However so far there is nothing calling these tools. What we need to answer now is for how big part of the theoretically infinite world do we want to generate? And by which rules?
The approach I've chosen is the following. We'll set a constant number which is the distance in the y and z axes from the player that the world will be generated. To make the world infinite as the player moves around in the world the target of world generation will move with the player.
That is, these two components are needed:
- generate cubes up to a given distance from a certain position (e.g. origin / 0,0)
- make the player position the point from which the bounds of the world generation are decided
generate cubes up to a given distance from a certain position (e.g. origin / 0,0)
We can do this by creating a function that loops over all the positions up to the distance we decide. That function can look like this:
const GENERATION_BOUND_DISTANCE = 8 # the distance to generate world from the player
func generate_new_cubes_from_position(position):
for x in range(GENERATION_BOUND_DISTANCE*2):
# the next line makes us loop from -8 to 8
x += (position.x - GENERATION_BOUND_DISTANCE)
for z in range(GENERATION_BOUND_DISTANCE*2):
z += (position.z - GENERATION_BOUND_DISTANCE)
# code to create the cube
If we call this function with a (0,0) position from the _ready() function we'll have a procedurally generated world to enter when we boot the game! We can set the GENERATION_BOUND_DISTANCE to a very high value and the player will be able to explore for quite some time before reaching the end of the world. However there is a way we can make this world infinite.
making the world infinite - make the player position the point from which the bounds of the world generation are decided
If we simply insert the player position into the generate_cubes_from_position() function and call it from the _process() function on every frame the world will keep expanding as the player moves around.
However this is not performant and you will quickly see a drop in frame rate and your machine will probably run out of memory pretty fast. This is because on every frame the game world will be augmented with new cubes for every position in range from the player regardless of whether a cube already has been generated in that position or not.
The way I solved this (I'm sure there are many ways, a good exercise for the reader might be to explore other solutions as well) was to create a dictionary in which the game can keep track of the coordinates for which it has done cube generation. Then we create a function to check if a certain position has had a cube generated and another to record when a generation happens for a certain position:
var generated_cubes
func _ready():
generated_cubes={}
func has_cube_been_generated(x,z):
if x in generated_cubes and z in generated_cubes[x] and generated_cubes[x][z] == true:
return true
else:
return false
func register_cube_generation_at_coordinate(x,z):
if x in generated_cubes:
generated_cubes[x][z] = true
else:
generated_cubes[x] = {z: true}
This will allow us to call the generate_cubes_from_position() function on every frame as we will now only generate cubes when none have been generated there before. This vastly reduces memory footprint and workload in each frame and gives a smooth player experience. This is the code I've used:
func _process(delta):
generate_new_cubes_from_position(player.position)
# generate_new_cubes_from_position calls this function for every position it loops over
func generate_cube_if_new(x,z):
if !has_cube_been_generated(x,z):
var generated_noise = noise.get_noise_2d(x,z)
create_cube(Vector3(x,generated_noise*VERTICAL_AMPLITUDE,z), get_color_from_noise(generated_noise))
register_cube_generation_at_coordinate(x,z)
next steps beyond the scope of this tutorial
Congrats on creating your own procedurally generated infinite world! The world might look a bit bland and empty but now you have the tools to create something very interesting. The above is just a limited scope example and this is just the beginning. Some additional things you can do next that are outside of the scope of this guide can be found below.
playing around by letting randomness touch more attributes of our world
Try playing around with the randomness you get from noise in combination with the attributes of your world objects. Some examples of what you can try:
- Instead of bucketing the colors, have the noise to color translating function be continuous giving a wider range of colors.
- Give different attributes to the different buckets you translated the noise into for colors. For example they could represent different materials like earth, water, grass etc.
- Use noise to place scenes in the world like trees, characters, etc.
chunk management, swapping between disk and ram
I believe that at some point if the player explores a lot of the world and the game has generated a lot of world any machine will eventually run out of memory and take a performance hit. This limits the infinity aspect and vast sense of scope we might be after. At the same time it is probably not necessary for your game to hold hundred thousands or millions of cubes in memory as the zone of current interest to the player is much smaller than that.
To make the game feel more infinite there are performance hacks you can do to reduce memory footprint to whatever is "hot" right now (i.e. what the player needs to see and interact with) and offload the cold areas into e.g. disk.
This is a super interesting area of performance optimization which is outside of the scope of this tutorial. I might make a guide on in the future but for now I'd recommend you to google on how Minecraft does this.
Thank you so much for reading!