Procedural Cave Generation in Godot (2D)

procedural cave generation

This tutorial will teach you how to create a procedural cave generation system in Godot that guarantees a path between start and end points. The system uses a combination of random generation, cellular automata for smoothing, and pathfinding using A-star to ensure playability.

Prerequisites: Basic understanding of GDScript, Godot 4.x, a tileset with at least one wall tile.

Project Setup

  1. Create a new Godot project
  2. Create a new scene with a TileMap node as the root
  3. Set up a basic tileset with at least one wall tile
  4. Attach a new script to the TileMap

Basic Structure and Configuration

First, let’s set up variables and constants. Add this code at the top of your script:

extends TileMap

# Configuration parameters
@export var fill_percent := 45  # Initial random fill percentage
@export var smoothing_iterations := 4  # How many times to smooth the cave
@export var min_cave_size := 50  # Minimum size of cave regions to keep
@export var room_radius := 4  # Radius of open spaces around start/end

# Map settings
@export var map_width := 40
@export var map_height := 40
@export var max_generation_attempts := 16  # Maximum attempts to generate a valid map

@export var should_be_closed := true

# Tile IDs
const WALL_TILE := 0
const EMPTY := -1

var _map := {}  # Dictionary to store our map data
var _start_point: Vector2
var _end_point: Vector2

Main Generation Function

This is the primary function that starts the cave generation process:

func generate_level(start: Vector2, end: Vector2) -> void:
    _start_point = start
    _end_point = end
    
    var attempt := 0
    var valid_map := false
    
    while !valid_map and attempt < max_generation_attempts:
        attempt += 1
        
        # Clear existing tiles
        clear()
        _map.clear()
        
        # Generate a new map
        _initialize_map(attempt)  # Pass attempt as seed
        
        # Smooth the map multiple times using cellular automata
        for i in smoothing_iterations:
            _smooth_map()
        
        # Create rooms at start and end points
        _create_room(_start_point, room_radius)
        _create_room(_end_point, room_radius)
        
        # Validate path existence
        valid_map = _check_path_exists()
    
    if !valid_map:
        push_error("Failed to generate a valid map after %d attempts" % max_generation_attempts)
    
    # Force walls on the map edges
    if should_be_closed:
        _force_walls()
    
    # Apply the final map to tilemap
    _apply_to_tilemap()

Above function calls _initialize_map(), _smooth_map() and other functions to generate map and process them in sequence. Finally, _apply_to_tilemap() is called to actually view the canges in TileMap.

Map Initialization

This function creates the initial random map:

func _initialize_map(seed_value: int) -> void:
    seed(seed_value)  # Set the random seed for reproducibility
    for x in range(map_width):
        for y in range(map_height):
            var pos := Vector2(x, y)
            # Fill edges with walls
            if x == 0 || x == map_width - 1 || y == 0 || y == map_height - 1:
                _map[pos] = true
            else:
                _map[pos] = randf() * 100 < fill_percent

Cellular Automata Smoothing

These functions handle the smoothing process using cellular automata rules:

func _smooth_map() -> void:
    var new_map := {}
    for x in range(map_width):
        for y in range(map_height):
            var pos := Vector2(x, y)
            var wall_count := _get_surrounding_wall_count(pos)
            
            # Apply cellular automata rules
            if wall_count > 4:
                new_map[pos] = true
            elif wall_count < 4:
                new_map[pos] = false
            else:
                new_map[pos] = _map[pos]
    
    _map = new_map

func _get_surrounding_wall_count(pos: Vector2) -> int:
    var wall_count := 0
    for x in range(-1, 2):
        for y in range(-1, 2):
            var check_pos := Vector2(pos.x + x, pos.y + y)
            if check_pos != pos:
                if _map.get(check_pos, true):  # Default to wall if outside bounds
                    wall_count += 1
    return wall_count

For current cell, if more than4 of its neighbors are walls, then make it a wall. And if neighboring walls are less than 4 (meaning, fewer or no walls around itself), then make this too, empty.

To get wall-cells around current cell, we use indices of those cells, in loop. If current cell if (0, 0), then the cell above it will be (0, 1) and cell on right of it will be (1, 0), and so on for all other neighbors.

Room Creation

This function creates open areas at the start and end points:

func _create_room(center: Vector2, radius: int) -> void:
    for x in range(-radius, radius + 1):
        for y in range(-radius, radius + 1):
            var pos := Vector2(center.x + x, center.y + y)
            if pos.x >= 0 && pos.x < map_width && pos.y >= 0 && pos.y < map_height:
                if Vector2(x, y).length() <= radius:
                    _map[pos] = false

Path Validation

These functions ensure there’s a valid path between start and end points. We use A* algorithm to confirm this:

func _check_path_exists() -> bool:
    var astar := AStar2D.new()
    
    # Add all empty points to AStar
    for x in range(map_width):
        for y in range(map_height):
            var pos := Vector2(x, y)
            if !_map[pos]:  # If it's an empty space
                var point_id := _get_point_id(pos)
                astar.add_point(point_id, pos)
    
    # Connect neighboring points
    for x in range(map_width):
        for y in range(map_height):
            var pos := Vector2(x, y)
            if !_map[pos]:
                var point_id := _get_point_id(pos)
                
                # Connect to orthogonal neighbors
                for neighbor in [Vector2(1, 0), Vector2(-1, 0), Vector2(0, 1), Vector2(0, -1)]:
                    var next_pos :Vector2 = pos + neighbor
                    if _is_valid_empty_pos(next_pos):
                        var next_id := _get_point_id(next_pos)
                        if !astar.are_points_connected(point_id, next_id):
                            astar.connect_points(point_id, next_id)
    
    # Check if path exists between start and end
    var start_id := _get_point_id(_start_point)
    var end_id := _get_point_id(_end_point)
    
    return astar.has_point(start_id) && astar.has_point(end_id) && \
           astar.get_point_path(start_id, end_id).size() > 0

func _is_valid_empty_pos(pos: Vector2) -> bool:
    return pos.x >= 0 && pos.x < map_width && \
           pos.y >= 0 && pos.y < map_height && \
           !_map[pos]

func _get_point_id(pos: Vector2) -> int:
    return int(pos.x + pos.y * map_width)

If path doesn’t exist between start/end points, i.e., if _check_path_exists() return false, then we re-start the entire process until we get the map in which the path exists (see above in generate_level() method).

Final Map Processing

These functions handle the final processing and application of the map:

func _force_walls() -> void:
    if not should_be_closed:
        return
        
    # Create walls on top and bottom edges
    for x in range(map_width):
        _map[Vector2(x, 0)] = true              # Top wall
        _map[Vector2(x, map_height - 1)] = true # Bottom wall
    
    # Create walls on left and right edges
    for y in range(map_height):
        _map[Vector2(0, y)] = true              # Left wall
        _map[Vector2(map_width - 1, y)] = true  # Right wall

func _apply_to_tilemap() -> void:
    for x in range(map_width):
        for y in range(map_height):
            var pos := Vector2(x, y)
            if _map[pos]:
                set_cell(0, pos, 0, Vector2i(0, randi_range(0, 1)))  # Set wall tile
            else:
                set_cell(0, pos, -1)  # Clear tile (empty space)

How to Use

@onready var tilemap = $TileMap
func _ready():
    var start = Vector2(5, 5) # start point
    var end = Vector2(35, 35) # end point
    tilemap.generate_level(start, end)

I used a combination of, random initial generation, cellular automata for cave smoothing, A* pathfinding to make sure that start/end points connect, room creation.

The system will make multiple attempts to generate a valid map if necessary, ensuring there’s always a path between the start and end points.

Thank you for reading <3

Leave a Reply

Your email address will not be published. Required fields are marked *