Let’s Learn Godot 4 by Making a Procedurally Generated Maze Game — Part 3: Procedural Level Generation #1 — Map Creation💣

Christine Coomans
18 min readSep 27, 2023

In this part, we’ll start getting to the fun part, which is procedurally generating our game’s map. This part might be a bit overwhelming because we’re going to be covering a lot, so take a breather when you have to and just take it step by step.

Figure 9: Overview of our game map.

Let’s first break down how the map in our game should work:

  • Unbreakable Tiles: The map should have a row of unbreakable walls at the borders of the map and in a grid pattern inside the map.
  • Breakable Tiles: The map should place breakable walls randomly throughout the map, avoiding the solid walls and “safe zones” near the corners where players spawn. The chance of a tile being breakable can increase based on the current level, up to a maximum of 50%. Breakable walls will be replaced with background tiles on bomb explosion and could spawn an explosion boost with a chance of 10%.
  • Background Tiles: The map should generate background tiles on the remaining empty cells (where breakables or solids aren’t placed), ensuring that the map is fully populated.
  • Spawn Points: The map should avoid placing breakable or solid walls in certain areas near the corners to ensure that players have room to move when they spawn.
  • Map Offset: The map should be shifted down a few rows to make space for our UI later on.

TILEMAP & TILESET SETUP

With your project workspace open, let’s create a new scene with a Node2D node as its root. Rename this scene root to “Level”, and save this scene underneath your Scenes folder.

In this scene, add a new TileMap node. This node will allow us to create our grid-based map above using different layers. We will use a TileSet resource to create the tiles that will be drawn on the TileMap node.

What is the difference between a TileMap and a TileSet?

The TileMap is the grid where you place tiles from the Tileset to actually build your level. A Tileset is a collection of tiles that you can use to build your level.

We can directly create the Tileset resource in the Inspector panel when clicking on the TileMap node, but we’re going to create the resource separately and then load the resource individually. By creating the resource separately we can better organize our project, especially if we plan to add to it later on. We can also reuse this resource across multiple TileMap nodes — which we won’t be doing.

In your Resources folder, right-click and navigate to Create New > Resource.

Search for the TileSet resource and select it. We want to save it as “Level_TileSet.tres”.

Double-click on your new resource to open the TileSet panel below. We can now drag in our tile sprites to create new sources. Navigate to your Assets/map_objects directory, and drag in ground_0.png, wall_0.png, and breakable_0.png into the sources panel.

This will create three new sources for our TileSet resource. If you don’t like the sprites later on (say you don’t like wall_0.png), you can just drag in a new image into the source’s Texture property.

We will want to draw these tiles on individual layers:

  • Background -> Layer 0
  • Breakable -> Layer 1
  • Unbreakable -> Layer 2

To do this, we will need to assign each tile source with a tile ID. When you’re working with a TileMap and a TileSet, each tile in the TileSet is assigned a unique identifier, commonly known as a Tile ID. The Tile ID serves as a link between the TileMap and the TileSet. It allows us to specify which tile should be drawn in each cell of the TileMap, and it enables us to manage complex scenes with multiple layers efficiently. Let’s rename our new tile sources and assign them these unique IDs.

Now we can load this TileSet resource in our TileMap node.

Click on this newly loaded resource and expand the menu to add a Physics Layer. This will allow us to add collisions to our tiles so that our entities are blocked by them when navigating through the map. Add a new Element to this Physics Layer.

Back in your TileSet panel, select the BREAKABLE_TILE source and select all the tiles on the image underneath “Base Tiles”. The color should be bright, and not faded after you’ve done this — which indicates that you’ve selected the textures which would be used to draw this tile. Alternative tiles allow for rotated/flipped versions of the base tile.

Then, go to your Paint Mode panel, and select the option “Physics Layer 0” underneath the Paint Properties dropdown. Draw in the collision blocks on each tile. Each block should look blue when you’re done — which indicates that a collision layer has been added to these tiles.

Figure 10: TileSet Mode’s overview.

Do the same for your UNBREAKABLE_TILE source — but not your BACKGROUND_TILE because this will block entities from moving.

Finally, select your BACKGROUND_TILE source and only select the first tile on the image underneath “Base Tiles”.

Now that we’ve added the IDs to our tile sources, we need to create the layers that they will be drawn on. Select your TileMap node and expand the Layers property. Add two new elements to this property. We’ll need to rename them and change their z-index. The z-index determines which element appears “on top” when multiple elements occupy the same space. Elements with a higher Z-index value are rendered on top of elements with a lower Z-index value. Rename the Layers and z-index of each as such:

Now we can start drawing these tiles on our TileMap on their respective layers. We won’t be doing any manual drawing though — no, the game will take care of the entire map creation for us. Let’s attach a new script to our Level scene.

At the top of our newly created script, let’s create a reference to our TileMap node.

### Level.gd
extends Node2D
# Node references
@onready var tilemap = $TileMap

Next, we’ll need to first do some calculations to determine our map’s initial dimensions. The initial width and height are the first map dimension values that will be loaded when our player starts a new game. Later on, we will change our map width and height depending on the level that the player is in, but if they start a new game, the width and height should always be equal to the initial width and height.

There are a few things we need to consider here:

  • Our screen size — which is 1152 x 648 (you can see this in your Project Settings window underneath Display > Window).
  • Our tile’s cell size — which is 16 (you can see this in your TileSet resource’s tile_size property).
  • Our camera zoom — which we will in the next part, but for now, know that the zoom value will be 2.
  • Final value — which needs to be an uneven number or else the solid tiles will not be placed equally on every second row.

What to keep in consideration for procedurally generated maps?

The considerations for procedurally generated maps depend on various factors, including gameplay requirements, performance considerations, and UI choices.

A good starting point is to consider the type of game you want to make. Then ask yourself what a good aspect ratio of your planned game is, and how you will draw the tiles to cover the overall map size. After that, you can add to the considerations how many entities will be on the map, how complex the map should be, and the general mechanics of the game.

In our game, we’ve determined our map size based on the screen dimensions, tile size, and an additional tile to keep the blocks even. The camera zoom is also included so that our tiles are closer to the player without making the map too big. The resulting map has an aspect ratio that is not square, differing from traditional Bomberman maps but it is big enough to allow us to have multiple players, enemies, and boosts on our map with enough space to move.

I’ve created the formula below for us to calculate the width and height. This formula takes into account the total width and height in pixels, the size of each tile in pixels, and the camera zoom level. It also adds 1 to each dimension to keep the map blocks even.

So, if we take our formula above, and we fill in our values, our map’s initial width/height should be:

In our Level script, let’s create a new constant that will hold the values for our map’s initial width and height.

### Level.gd
extends Node2D
# Node references
@onready var tilemap = $TileMap
# Randomizer & Dimension values ( make sure width & height is uneven)
const initial_width = 37
const initial_height = 21

Then, we will create variables that will hold our width and height. We’re redefining our variables so that we can change our width and height later on when we progress throughout our levels. We cannot change the value from our initial dimension constants, but we can change the value of our newly created variables.

### Level.gd
extends Node2D
# Node references
@onready var tilemap = $TileMap
# Randomizer & Dimension values ( make sure width & height is uneven)
const initial_width = 37
const initial_height = 21
var map_width = initial_width
var map_height = initial_height

We also need to define an offset value for our map which will shift our generated map four rows down. This will allow us to draw the UI bar at the top without changing the position (x, y) value of our TileMap node. The TileMap node’s position property must stay at (0, 0) — otherwise, our entities’ navigation and map generation will be off.

### Level.gd
extends Node2D
# Node references
@onready var tilemap = $TileMap
# Randomizer & Dimension values ( make sure width & height is uneven)
const initial_width = 37
const initial_height = 21
var map_width = initial_width
var map_height = initial_height
var map_offset = 4 #Shifts map four rows down for UI

We’ll also create constant references to our tile source’s TILE IDs and layers. This will allow us to reference these constants when we want to check the map’s layers for a tile with that ID so that we can add, remove, or change the tile. Make sure that you get these tile IDs and Layer IDs right. It has to match the IDs that we’ve assigned above.

### Level.gd
extends Node2D
# Node references
@onready var tilemap = $TileMap
# Randomizer & Dimension values ( make sure width & height is uneven)
const initial_width = 37
const initial_height = 21
var map_width = initial_width
var map_height = initial_height
var map_offset = 4 #Shifts map four rows down for UI
# Tilemap constants
const BACKGROUND_TILE_ID = 0
const BREAKABLE_TILE_ID = 1
const UNBREAKABLE_TILE_ID = 2
const BACKGROUND_TILE_LAYER = 0
const BREAKABLE_TILE_LAYER = 1
const UNBREAKABLE_TILE_LAYER = 2

GENERATING UNBREAKABLE TILES

Now, let’s create functions that will generate our tiles — starting with the UNBREAKABLE tiles. We want to add a row of unbreakable tiles around our map, and inside our map, we want to place an unbreakable tile at every second cell. To generate our border of unbreakable walls, we will use a for loop that will check if the current tile that the game is trying to place is at the left-most (x = 0) or right-most column (x = 37–1).

Then we will do the same with another loop that will check if the next current tile is trying to be placed is at the top-most (y = 0) or bottom-most row (y = 21- 1). We will then use the TileMap node’s set_cell() method, which will place the unbreakable_tile on the unbreakables layer at the tested coordinates.

How does the set_cell method work?

void set_cell (

int layer,

Vector2i coords,

int source_id=-1,

Vector2i atlas_coords=Vector2i(-1, -1),

int alternative_tile=0

)

void set_cell (

The layer where you want to draw the tile on,

The coordinates where you want to place the tile,

The ID of the tile source that you want to draw (-1 will erase the tile),

The atlas coordinates (we don’t use an atlas, so we leave it empty),

the alternative_tile (we use base tiles, so we leave it at a value of 0)

)

### Level.gd

#older code

func generate_unbreakables():
#--------------------------------- Ubreakables ------------------------------
# Generate unbreakable walls at the borders on Layer 2
for x in range(map_width):
for y in range(map_height):
if x == 0 or x == map_width - 1 or y == 0 or y == map_height - 1:
tilemap.set_cell(UNBREAKABLE_TILE_LAYER, Vector2i(x, y + map_offset), UNBREAKABLE_TILE_ID, Vector2i(0, 0), 0)

Take note that the above uses a Vector2i object instead of a Vector2 object. Vector2i represents a Vector2 object but with integers. Vector2 can sometimes create floating point coordinates, which can lead to precision errors for our tile’s placement — since you want to place the tile at (1, 1) and not (0.031231, 1.33144). Thus by using Vector2i objects, we can use integer components, making it suitable for precise grid-based and pixel-based calculations.

If you call your function in your _ready() function, and you run your scene, you’ll see that a border is drawn. You might need to change your Main scene to be your Level scene and not your Player scene.

### Level.gd

#older code

func _ready():
generate_unbreakables()

func generate_unbreakables():
#--------------------------------- Ubreakables ------------------------------
# Generate unbreakable walls at the borders on Layer 2
for x in range(map_width):
for y in range(map_height):
if x == 0 or x == map_width - 1 or y == 0 or y == map_height - 1:
tilemap.set_cell(UNBREAKABLE_TILE_LAYER, Vector2i(x, y + map_offset), UNBREAKABLE_TILE_ID, Vector2i(0, 0), 0)

Next, to generate the internal grid, we need to create another loop that will skip the first column and row starting from our border (x: 1, y: 1). Then it will place the tiles on every second block row-wise and column-wise. You’ll see below that we use ‘x % 2 == 0 and y % 2 == 0’ — this just checks if both x and y are even, meaning it will place unbreakable tiles in a grid pattern.

### Level.gd

#older code

func _ready():
generate_unbreakables()

func generate_unbreakables():
#--------------------------------- Ubreakables ------------------------------
# Generate unbreakable walls at the borders on Layer 2
for x in range(map_width):
for y in range(map_height):
if x == 0 or x == map_width - 1 or y == 0 or y == map_height - 1:
tilemap.set_cell(UNBREAKABLE_TILE_LAYER, Vector2i(x, y + map_offset), UNBREAKABLE_TILE_ID, Vector2i(0, 0), 0)

# Generate unbreakable walls in a grid on Layer 2, starting from (1, 1)
for x in range(1, map_width - 2): # Stop before the last column
for y in range(1, map_height - 2): # Stop before the last row
if x % 2 == 0 and y % 2 == 0: # Check if row and column are even
tilemap.set_cell(UNBREAKABLE_TILE_LAYER, Vector2i(x, y + map_offset), UNBREAKABLE_TILE_ID, Vector2i(0, 0), 0)

Now if you run your scene, you will see that unbreakable tiles have been placed on every second grid coordinate.

GENERATING BREAKABLE TILES

With our border and grid created, we can now randomly generate our breakable tiles in between the open cells. We’ll need to create a new function that will determine whether a cell on a particular layer is empty or not. We will do this via the get_cell_tile_data() method, which retrieves information about a tile at a specific cell position in a particular layer of a TileMap.

How does the get_cell_tile_data method work?

TileData get_cell_tile_data (

int layer,

Vector2i coords,

bool use_proxies=false

)

TileData get_cell_tile_data (

The layer on which you want information on,

The coordinates where you want to check for tiles

)

### Level.gd

#older code

# Checks if tiles at a specific coord on layer n are empty or not
func is_cell_empty(layer, coords):
var data = tilemap.get_cell_tile_data(layer, coords)
return data == null

Now we can use this custom utility function to check if the tiles are empty around the placed unbreakable tiles. We also need to prevent our breakables from spawning in the four corners of our map, because this is where the players will spawn, and we need to give them enough space to move without bombing themselves.

Let’s start by defining these spawn_zones:

  • The top-left corner, starting from (1, 1 + map_offset) and going down to (1, 3 + map_offset).
  • The top-right corner, starting from (map_width — 2, 1 + map_offset) and going down to (map_width — 2, 3 + map_offset).
  • The bottom-left corner, starting from (1, map_height — 2 + map_offset) and going up to (1, map_height — 4 + map_offset).
  • The bottom-right corner, starting from (map_width — 2, map_height — 2 + map_offset) and going up to (map_width — 2, map_height — 4 + map_offset).
func generate_breakables():
#--------------------------------- BREAKABLES ------------------------------
# Define an array for the spawn zones in the corners
var spawn_zones = [
# Near top-left corner
[Vector2i(1, 1 + map_offset), Vector2i(1, 2 + map_offset), Vector2i(1, 3 + map_offset)],
# Near top-right corner
[Vector2i(map_width - 2, 1 + map_offset), Vector2i(map_width - 2, 2 + map_offset), Vector2i(map_width - 2, 3 + map_offset)],
# Near bottom-left corner
[Vector2i(1, map_height - 2 + map_offset), Vector2i(1, map_height - 3 + map_offset), Vector2i(1, map_height - 4 + map_offset)],
# Near bottom-right corner
[Vector2i(map_width - 2, map_height - 2 + map_offset), Vector2i(map_width - 2, map_height - 3 + map_offset), Vector2i(map_width - 2, map_height - 4 + map_offset)]
]

These spawn_zones will now be excluded from the generation area for our breakables tiles. Next, we’ll need to use the RandomNumberGenerator to randomize the placement of breakable tiles so that it is different each time the function is called.

### Level.gd

# Randomizer & Dimension values ( make sure width & height is uneven)
const initial_width = 37
const initial_height = 21
var map_width = initial_width
var map_height = initial_height
var map_offset = 4 #Shifts map four rows down for UI
var rng = RandomNumberGenerator.new()

#older code

func generate_breakables():
#--------------------------------- BREAKABLES ------------------------------
# Define an array for the corners and their safe zones
var spawn_zones = [
# Near top-left corner
[Vector2i(1, 1 + map_offset), Vector2i(1, 2 + map_offset), Vector2i(1, 3 + map_offset)],
# Near top-right corner
[Vector2i(map_width - 2, 1 + map_offset), Vector2i(map_width - 2, 2 + map_offset), Vector2i(map_width - 2, 3 + map_offset)],
# Near bottom-left corner
[Vector2i(1, map_height - 2 + map_offset), Vector2i(1, map_height - 3 + map_offset), Vector2i(1, map_height - 4 + map_offset)],
# Near bottom-right corner
[Vector2i(map_width - 2, map_height - 2 + map_offset), Vector2i(map_width - 2, map_height - 3 + map_offset), Vector2i(map_width - 2, map_height - 4 + map_offset)]
]

# Randomly place breakable walls on Layer 1
rng.randomize()

Since our breakable tiles need to increase their spawn chance as we level up, we’ll need to define a new variable in our Global script which will hold our current level value. This won’t do anything now, but when we add level progression later our breakable tile count will increase the bigger the map gets and the more we level up.

### Global.gd

extends Node

# Color generation for player, ai_player
var color: Array = ["blue", "grey", "orange"]

# Level variables
var current_level = 1

Next, we will have to create a nested loop that loops through each cell in the map’s column (x) and row(y). Whilst we’re looping over each cell coordinate, we will calculate the chance of our breakable tile spawning at that cell coordinate. Our tile will have a base chance of 20% of spawning on an open cell. Each time we level up, this chance will increase by 1%. We’ll also cap it at a maximum chance of 50%.

### Level.gd

#older code

func generate_breakables():
#--------------------------------- BREAKABLES ------------------------------
# Spawn_zones array

# Randomly place breakable walls on Layer 1
rng.randomize()
for x in range(1, map_width - 1):
for y in range(1, map_height - 1):
var base_breakable_chance = 0.2 # default 20% chance
var level_chance_multiplier = 0.01 # increase by 1% per level
var breakable_spawn_chance = base_breakable_chance + (Global.current_level - 1) * level_chance_multiplier
breakable_spawn_chance = min(breakable_spawn_chance, 0.5) #max chance of 50%

Now we can create a new variable that will check if the grid placement is even, and if true, we will not place tiles there. We’ll also not place tiles in our spawn_zones.

### Level.gd

#older code

func generate_breakables():
#--------------------------------- BREAKABLES ------------------------------
# Spawn_zones array

# Randomly place breakable walls on Layer 1
rng.randomize()
for x in range(1, map_width - 1):
for y in range(1, map_height - 1):
var base_breakable_chance = 0.2 # default 20% chance
var level_chance_multiplier = 0.01 # increase by 1% per level
var breakable_spawn_chance = base_breakable_chance + (Global.current_level - 1) * level_chance_multiplier
breakable_spawn_chance = min(breakable_spawn_chance, 0.5) #max chance of 50%

var current_cell = Vector2i(x, y + map_offset)
var skip_current_cell = false
# Skip cells where solid tiles are placed
if x % 2 == 0 and y % 2 == 0:
skip_current_cell = true
# Skip cells in the spawn_zones
for corner in spawn_zones:
if current_cell in corner:
skip_current_cell = true
break
if skip_current_cell:
continue

If all of the above conditionals pass, we can then go ahead and place our tile on the current looped cell if the current cell is empty.

### Level.gd

#older code

func generate_breakables():
#--------------------------------- BREAKABLES ------------------------------
# Spawn_zones array

# Randomly place breakable walls on Layer 1
rng.randomize()
for x in range(1, map_width - 1):
for y in range(1, map_height - 1):
var base_breakable_chance = 0.2 # default 20% chance
var level_chance_multiplier = 0.01 # increase by 1% per level
var breakable_spawn_chance = base_breakable_chance + (Global.current_level - 1) * level_chance_multiplier
breakable_spawn_chance = min(breakable_spawn_chance, 0.5) #max of 50%
var current_cell = Vector2i(x, y + map_offset)
var skip_current_cell = false
# Skip cells where solid tiles are placed
if x % 2 == 0 and y % 2 == 0:
skip_current_cell = true
# Skip cells in the spawn_zones
for corner in spawn_zones:
if current_cell in corner:
skip_current_cell = true
break
if skip_current_cell:
continue
# Place breakables
if is_cell_empty(BREAKABLE_TILE_LAYER, current_cell):
if rng.randf() < breakable_spawn_chance:
tilemap.set_cell(BREAKABLE_TILE_LAYER, current_cell, BREAKABLE_TILE_ID, Vector2i(0, 0), 0)

Let’s organize our code and create a new singular function that will call all of our map generation functions, instead of calling each one in our _ready() function.

### Level.gd

#older code

func _ready():
generate_map()

# ---------------- Map Generation -------------------------------------
func generate_map():
generate_unbreakables()
generate_breakables()

Now each time you run your scene, your breakables should be randomly placed on your map.

GENERATING BACKGROUND TILES

The final tiles that we have to place are our background tiles. This will be the easiest function to create because we will just use our utility function from above to check if the coordinates on our breakables and unbreakables layer are empty, and if it is, we will place background tiles around these coordinates.

### Level.gd

#older code

func _ready():
generate_map()

# ---------------- Map Generation -------------------------------------
func generate_map():
generate_unbreakables()
generate_breakables()
generate_background()

#older code
func generate_background():
#--------------------------------- BACKGROUND ------------------------------
for x in range(map_width):
for y in range(map_height):
var cell_coords = Vector2i(x, y + map_offset)
if is_cell_empty(BREAKABLE_TILE_LAYER, cell_coords) and is_cell_empty(UNBREAKABLE_TILE_LAYER, cell_coords):
tilemap.set_cell(BACKGROUND_TILE_LAYER, cell_coords, BACKGROUND_TILE_ID, Vector2i(0, 0), 0)

If you run your scene now, you’ll see that the background layer gets filled in!

And that is it for now with our map creation! In the next parts, we will add player spawn points, enemies, pickups, and our gameplay logic. You can see that if you change the map width and height (to an unequal value), the map will automatically generate to fill the space.

Figure 11: Demonstration of different map dimensions & map generation.

Also, as a side note, we’ll be creating a bunch of functions, so make sure you make use of the functions navigation panel next to your Scripts workspace for easy navigation. In a real project, it would be good practice to segregate our functions in different script files, and then calling them from a central script such as our Level script, but for this project, we will only have one script per scene for simplicity sake.

Now would be a good time to save your project before moving on to the next part. Remember to make a backup of your project at this point.

The final source code for this part can be found here.

Unlock the Series!

If you like this series and would like to support me, you could donate any amount to my KoFi shop or you could purchase the offline PDF that has the entire series in one on-the-go booklet!

This PDF gives you lifelong access to the full, offline version of the “Learn Godot 4 by Making a Procedurally Generated Maze Game” series. This is a 387-page document that contains all the tutorials of this series in a sequenced format, plus you get dedicated help from me if you ever get stuck or need advice. This means you don’t have to wait for me to release the next part of the tutorial series on Dev.to or Medium. You can just move on and continue the tutorial at your own pace — anytime and anywhere!

This book will be updated continuously to fix newly discovered bugs, or to fix compatibility issues with newer versions of Godot 4.

--

--