DEV Community

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

Posted on

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

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.

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.

Learn Godot 4

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.

Learn Godot 4


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.

Learn Godot 4

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

Learn Godot 4

Learn Godot 4

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.

Learn Godot 4

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.

Learn Godot 4

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.

Learn Godot 4

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

Learn Godot 4

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.

Learn Godot 4

Learn Godot 4

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.

Learn Godot 4

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.

TileSet Mode’s overview.

Learn Godot 4

Learn Godot 4

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

Learn Godot 4

Learn Godot 4

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

Learn Godot 4

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:

Learn Godot 4

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.

Learn Godot 4

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
Enter fullscreen mode Exit fullscreen mode

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.

Learn Godot 4

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

Learn Godot 4

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Learn Godot 4

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)
Enter fullscreen mode Exit fullscreen mode

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

Learn Godot 4

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
Enter fullscreen mode Exit fullscreen mode

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)]
        ]
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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%
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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

Learn Godot 4

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)
Enter fullscreen mode Exit fullscreen mode

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

Learn Godot 4

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.

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.

Learn Godot 4

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!

Learn Godot 4

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

Top comments (0)