One of the most rewarding milestones in game development is watching a world construct itself from sheer mathematical logic. In roguelikes, action-RPGs, and dungeon crawlers, procedural level generation is the engine that drives infinite replayability. It transforms static architecture into a dynamic labyrinth where neither player nor designer knows what lurks around the next bend.

When prototyping my catacombs RPG shooter in Godot, I needed a dungeon generation algorithm that was lightweight, predictable, and easy to debug. You don’t always need hyper-complex wave function collapse or Voronoi noise fields right out of the gate. A clean, modular implementation based on Random Room Placement, L-Shaped Hallway Carving, and Weighted Entity Spawning delivers fantastic results in just a few lines of GDScript.

In this guide, we will implement a complete, production-ready procedural dungeon generator in Godot 4 using typed GDScript.


The Generation Pipeline: Step-by-Step Overview

Before writing code, let’s visualize the pipeline. Our generator follows four distinct phases:

[ 1. Grid & Seed Setup ] 
         ↓
[ 2. Room Generation & Overlap Rejection ]  ──> Place non-overlapping Rect2i rooms
         ↓
[ 3. Corridor Carving ]                      ──> Connect room centers with L-shaped paths
         ↓
[ 4. Tile Painting & Entity Spawning ]       ──> Paint TileMapLayer & spawn enemies/chests
  1. Room Generation: Generate random rectangles across the map canvas. If a new room intersects an existing room (with optional padding), reject it.
  2. Corridor Carving: Sort or chain the surviving rooms and connect their center points using orthogonal horizontal and vertical hallways.
  3. Tilemap Assignment: Mark grid coordinates as either FLOOR or WALL, then apply them to Godot 4’s TileMapLayer.
  4. Entity Distribution: Spawn the player in Room 0, place the exit ladder in the furthest room, and scatter enemies and loot across intermediate chambers using safety radii.

1. Defining Data Structures and Grid Representation

In Godot 4, 2D coordinates and bounding boxes are cleanly represented by Vector2i and Rect2i. Using integer math avoids floating-point precision issues when indexing grid tiles.

Let’s define our tile types and constants:

class_name DungeonGenerator
extends Node2D

enum TileType { EMPTY = -1, FLOOR = 0, WALL = 1 }

@export_group("Dungeon Dimensions")
@export var map_width: int = 80
@export var map_height: int = 50
@export var min_room_size: int = 6
@export var max_room_size: int = 14
@export var max_rooms: int = 25
@export var room_padding: int = 1

@export_group("Entities & Spawning")
@export var min_enemies_per_room: int = 1
@export var max_enemies_per_room: int = 3
@export var enemy_scene: PackedScene
@export var player_scene: PackedScene
@export var exit_ladder_scene: PackedScene

# Internal storage
var rooms: Array[Rect2i] = []
var grid: Dictionary = {} # Stores Vector2i -> TileType

Using a Dictionary for the grid (grid[Vector2i(x, y)] = TileType.FLOOR) allows sparse lookups without allocating a giant 2D matrix in memory.


2. Generating Non-Overlapping Rooms

To generate natural-looking dungeons, we iteratively pick random coordinates and dimensions. We expand each proposed room rectangle by room_padding to prevent rooms from merging directly into one another without walls:

func generate_rooms() -> void:
	rooms.clear()
	
	for i in range(max_rooms):
		# Random width and height within limits
		var w: int = randi_range(min_room_size, max_room_size)
		var h: int = randi_range(min_room_size, max_room_size)
		
		# Keep rooms safely inside map borders
		var x: int = randi_range(2, map_width - w - 2)
		var y: int = randi_range(2, map_height - h - 2)
		
		var new_room := Rect2i(x, y, w, h)
		
		# Check overlap against all existing rooms (with padding)
		var padded_room := new_room.grow(room_padding)
		var overlaps: bool = false
		
		for existing_room in rooms:
			if padded_room.intersects(existing_room.grow(room_padding)):
				overlaps = true
				break
		
		if not overlaps:
			rooms.append(new_room)
			carve_room(new_room)

The carve_room helper simply marks every cell within the bounds as TileType.FLOOR:

func carve_room(room: Rect2i) -> void:
	for x in range(room.position.x, room.end.x):
		for y in range(room.position.y, room.end.y):
			grid[Vector2i(x, y)] = TileType.FLOOR

3. Carving Corridors Between Rooms

Once rooms are placed, we need to ensure every room is accessible. The simplest and most dependable approach is the L-Shaped Corridor algorithm:

  1. Connect Room i to Room i - 1.
  2. Find the centers of both rooms (Vector2i(room.get_center())).
  3. Roll a 50/50 chance to either carve horizontally first, then vertically—or vice versa.
func connect_rooms() -> void:
	for i in range(1, rooms.size()):
		var prev_center: Vector2i = Vector2i(rooms[i - 1].get_center())
		var curr_center: Vector2i = Vector2i(rooms[i].get_center())
		
		if randf() < 0.5:
			# Horizontal then Vertical
			carve_horizontal_corridor(prev_center.x, curr_center.x, prev_center.y)
			carve_vertical_corridor(prev_center.y, curr_center.y, curr_center.x)
		else:
			# Vertical then Horizontal
			carve_vertical_corridor(prev_center.y, curr_center.y, prev_center.x)
			carve_horizontal_corridor(prev_center.x, curr_center.x, curr_center.y)

func carve_horizontal_corridor(x1: int, x2: int, y: int) -> void:
	var start_x: int = mini(x1, x2)
	var end_x: int = maxi(x1, x2)
	for x in range(start_x, end_x + 1):
		grid[Vector2i(x, y)] = TileType.FLOOR

func carve_vertical_corridor(y1: int, y2: int, x: int) -> void:
	var start_y: int = mini(y1, y2)
	var end_y: int = maxi(y1, y2)
	for y in range(start_y, end_y + 1):
		grid[Vector2i(x, y)] = TileType.FLOOR

By connecting room $i$ to room $i-1$, we form a spanning tree, guaranteeing that every carved room is reachable.


4. Automatic Wall Outlining and TileMap Painting

In a dungeon crawler, players shouldn’t see empty voids bordering walkable tiles. Every floor tile needs surrounding wall boundaries:

func place_walls() -> void:
	var floor_cells: Array = grid.keys()
	var neighbor_offsets: Array[Vector2i] = [
		Vector2i(-1, -1), Vector2i(0, -1), Vector2i(1, -1),
		Vector2i(-1,  0),                  Vector2i(1,  0),
		Vector2i(-1,  1), Vector2i(0,  1), Vector2i(1,  1)
	]
	
	for cell in floor_cells:
		if grid.get(cell) == TileType.FLOOR:
			for offset in neighbor_offsets:
				var neighbor: Vector2i = cell + offset
				if not grid.has(neighbor):
					grid[neighbor] = TileType.WALL

Painting with Godot 4’s TileMapLayer

In Godot 4.3+, TileMapLayer is the recommended way to draw tiles programmatically. We loop through our grid and paint cells:

@onready var ground_layer: TileMapLayer = $GroundLayer
@onready var wall_layer: TileMapLayer = $WallLayer

func render_to_tilemap() -> void:
	ground_layer.clear()
	wall_layer.clear()
	
	for cell in grid.keys():
		var type: TileType = grid[cell]
		if type == TileType.FLOOR:
			# Source ID 0, Atlas coordinate (0, 0) for floor
			ground_layer.set_cell(cell, 0, Vector2i(0, 0))
		elif type == TileType.WALL:
			# Source ID 0, Atlas coordinate (1, 0) for wall
			wall_layer.set_cell(cell, 0, Vector2i(1, 0))

5. Procedural Entity Placement: Safety & Rules

Spawning enemies purely at random leads to frustrating gameplay: players might spawn on top of an elite skeleton, or enemies might block narrow single-tile corridors.

To solve this, implement procedural safety rules:

  1. Player Safe Zone: The player spawns in the center of rooms[0]. No enemies are allowed in rooms[0].
  2. Objective Placement: The dungeon exit (or boss) spawns in rooms[-1] (the last generated room).
  3. Interior Margins: Enemies spawn strictly inside room interiors, at least 1 tile away from room edges, keeping doorways unobstructed.
  4. Anti-Clustering: Check distances between newly spawned entities to prevent stacked collisions.
func spawn_entities() -> void:
	if rooms.is_empty():
		return
	
	# 1. Spawn Player in Room 0
	var player_spawn_pos: Vector2 = ground_layer.map_to_local(rooms[0].get_center())
	if player_scene:
		var player = player_scene.instantiate()
		player.position = player_spawn_pos
		add_child(player)
	
	# 2. Spawn Exit Ladder in Last Room
	var exit_pos: Vector2 = ground_layer.map_to_local(rooms[-1].get_center())
	if exit_ladder_scene:
		var ladder = exit_ladder_scene.instantiate()
		ladder.position = exit_pos
		add_child(ladder)
	
	# 3. Spawn Enemies in Intermediate Rooms (Rooms 1 through N-1)
	if not enemy_scene:
		return

	for i in range(1, rooms.size()):
		var room: Rect2i = rooms[i]
		var enemy_count: int = randi_range(min_enemies_per_room, max_enemies_per_room)
		var spawned_positions: Array[Vector2i] = []
		
		for e in range(enemy_count):
			# 1-tile margin from walls to avoid clogging doorway entries
			var spawn_tile := Vector2i(
				randi_range(room.position.x + 1, room.end.x - 2),
				randi_range(room.position.y + 1, room.end.y - 2)
			)
			
			if spawn_tile in spawned_positions:
				continue # Avoid stacking on the same tile
			
			spawned_positions.append(spawn_tile)
			
			var enemy = enemy_scene.instantiate()
			enemy.position = ground_layer.map_to_local(spawn_tile)
			add_child(enemy)

Complete Script: dungeon_generator.gd

Here is the complete, self-contained script you can attach to a Node2D in Godot 4:

class_name DungeonGenerator
extends Node2D

enum TileType { EMPTY = -1, FLOOR = 0, WALL = 1 }

@export_group("Map Settings")
@export var seed_value: int = 0
@export var map_width: int = 80
@export var map_height: int = 50
@export var min_room_size: int = 6
@export var max_room_size: int = 14
@export var max_rooms: int = 20
@export var room_padding: int = 1

@export_group("Entity Scenes")
@export var player_scene: PackedScene
@export var enemy_scene: PackedScene
@export var exit_scene: PackedScene

@onready var ground_layer: TileMapLayer = $GroundLayer
@onready var wall_layer: TileMapLayer = $WallLayer

var rooms: Array[Rect2i] = []
var grid: Dictionary = {}

func _ready() -> void:
	if seed_value != 0:
		seed(seed_value)
	else:
		randomize()
		
	generate_dungeon()

func generate_dungeon() -> void:
	grid.clear()
	rooms.clear()
	
	generate_rooms()
	connect_rooms()
	place_walls()
	render_to_tilemap()
	spawn_entities()

func generate_rooms() -> void:
	for i in range(max_rooms):
		var w: int = randi_range(min_room_size, max_room_size)
		var h: int = randi_range(min_room_size, max_room_size)
		var x: int = randi_range(2, map_width - w - 2)
		var y: int = randi_range(2, map_height - h - 2)
		
		var new_room := Rect2i(x, y, w, h)
		var overlaps: bool = false
		
		for existing in rooms:
			if new_room.grow(room_padding).intersects(existing.grow(room_padding)):
				overlaps = true
				break
		
		if not overlaps:
			rooms.append(new_room)
			for rx in range(new_room.position.x, new_room.end.x):
				for ry in range(new_room.position.y, new_room.end.y):
					grid[Vector2i(rx, ry)] = TileType.FLOOR

func connect_rooms() -> void:
	for i in range(1, rooms.size()):
		var prev: Vector2i = Vector2i(rooms[i - 1].get_center())
		var curr: Vector2i = Vector2i(rooms[i].get_center())
		
		if randf() < 0.5:
			carve_horiz(prev.x, curr.x, prev.y)
			carve_vert(prev.y, curr.y, curr.x)
		else:
			carve_vert(prev.y, curr.y, prev.x)
			carve_horiz(prev.x, curr.x, curr.y)

func carve_horiz(x1: int, x2: int, y: int) -> void:
	for x in range(mini(x1, x2), maxi(x1, x2) + 1):
		grid[Vector2i(x, y)] = TileType.FLOOR

func carve_vert(y1: int, y2: int, x: int) -> void:
	for y in range(mini(y1, y2), maxi(y1, y2) + 1):
		grid[Vector2i(x, y)] = TileType.FLOOR

func place_walls() -> void:
	var offsets: Array[Vector2i] = [
		Vector2i(-1,-1), Vector2i(0,-1), Vector2i(1,-1),
		Vector2i(-1, 0),                 Vector2i(1, 0),
		Vector2i(-1, 1), Vector2i(0, 1), Vector2i(1, 1)
	]
	for cell in grid.keys():
		if grid[cell] == TileType.FLOOR:
			for o in offsets:
				var n: Vector2i = cell + o
				if not grid.has(n):
					grid[n] = TileType.WALL

func render_to_tilemap() -> void:
	ground_layer.clear()
	wall_layer.clear()
	for cell in grid:
		if grid[cell] == TileType.FLOOR:
			ground_layer.set_cell(cell, 0, Vector2i(0, 0))
		elif grid[cell] == TileType.WALL:
			wall_layer.set_cell(cell, 0, Vector2i(1, 0))

func spawn_entities() -> void:
	if rooms.is_empty():
		return
	if player_scene:
		var p = player_scene.instantiate()
		p.position = ground_layer.map_to_local(rooms[0].get_center())
		add_child(p)
	if exit_scene:
		var exit = exit_scene.instantiate()
		exit.position = ground_layer.map_to_local(rooms[-1].get_center())
		add_child(exit)
	if not enemy_scene:
		return
	for i in range(1, rooms.size()):
		var room: Rect2i = rooms[i]
		var count: int = randi_range(min_enemies_per_room, max_enemies_per_room)
		for _e in range(count):
			var tile := Vector2i(
				randi_range(room.position.x + 1, room.end.x - 2),
				randi_range(room.position.y + 1, room.end.y - 2)
			)
			var enemy = enemy_scene.instantiate()
			enemy.position = ground_layer.map_to_local(tile)
			add_child(enemy)

Performance and Design Tips

  1. Deterministic Seeds: Notice the seed_value property. Calling seed(seed_value) ensures that typing 1337 generates the exact same dungeon layout every time—essential for Daily Run challenges or bug debugging.
  2. Sub-millisecond Execution: On an 80×50 grid with 20 rooms, this algorithm executes in under 4 milliseconds, making it fast enough to run during gameplay transitions or instant resets.
  3. Collision Detection: In Godot 4, ensure your wall_layer tiles have a collision polygon configured in the TileSet resource. The generator handles placement; the physics engine handles navigation blocking automatically.
  4. Expanding to 3D: This identical 2D grid logic translates directly into 3D. Replace ground_layer.set_cell() with GridMap.set_cell_item() to place 3D low-poly floor and wall meshes in a modular dungeon crawler!

Conclusion

Procedural generation doesn’t need to be intimidating. By breaking the problem down into distinct, logical steps—generating rooms, connecting centers, resolving boundaries, and populating contents—you get a reliable, clean dungeon generator ready to power your next roguelike or action RPG.