Procedural Map Design
Procedural Generation and Roguelikes
Roguelikes are a relatively obscure genre of videogames with a long history. A biproduct of the early days of computing, they've had an outsized influence on modern games. One of their most interesting aspects is their heavy use of procedural generation. By introducing an element of randomness to aspects of the game, replayability is increased as well as challenge.
As a long-time fan of the Roguelike genre, I decided to try making my own with Godot.
Building a Map Generator
There are a handful of well-known map generation algorithms that are prevalent in Roguelikes. This blog post has a great overview of the most common. When I began writing my own algorithm implementations, I realized I needed a more convenient way to compose and test different methods on the fly. After looking at how others approached the task, I came across this repository. It's an older C# library called RogueElements that creates ASCII maps in a terminal.
The library makes use of three primary classes, MapGen, GenStep, and IGenContext.
The MapGen class takes an IGenContext and series of GenSteps, and applies the GenSteps sequentially to the IGenContext. Given my requirements, this seemed like a great approach. Rather than sticking with C#, I opted to write my own version in GDScript.
The Map Generator Framework
The main classes, as they are in the original, are relatively simple.
The Generator
The generator takes in a `BaseMap` configuration object, and a seed. By passing a seed in, generated outputs can be reproduced.
@tool
class_name MapGenerator extends RefCounted
var steps: Array[Step] = []
func generate(map: BaseMap, _seed: int = 0) -> MapContext:
var ctx: MapContext = MapContext.new(map, _seed)
_apply_steps(ctx, steps)
return ctx
func _apply_steps(ctx: MapContext, _steps: Array[Step]) -> void:
for step in _steps:
if step.enabled and step.can_apply(ctx):
step.apply((ctx))
The Context
The context holds all of the data about the map. In addition to coordinate data about tiles, it has a concept of pathing with the A* algorithm.
@tool
class_name MapContext extends Resource
@export var map: BaseMap
@export var theme: MapTheme = MapTheme.new()
var tiles: TileMapLayer = TileMapLayer.new()
var path: AStarGrid2D = AStarGrid2D.new()
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
var meta: Dictionary[StringName, Step] = {}
func _init(_map: BaseMap, _seed: int = 0) -> void:
map = _map
rng.seed = _seed
The Step
Steps override the `transform()` function from the base Step resource. They can be dependent on other steps, and enabled or disabled in the editor. This lets me retry outputs with the same seed while omitting certain effects.
@tool
class_name Step extends Resource
const EXAMPLE_STEP: StringName = &"EXAMPLE_STEP"
@export var enabled: bool = true
@export var name: StringName = EXAMPLE_STEP
@export var dependencies: Array[StringName] = []
func _transform(_ctx: MapContext) -> void:
push_error("you must override this function")
func can_apply(_ctx: MapContext) -> bool:
return dependencies.is_empty() or _ctx.meta.has_all(dependencies)
func apply(_ctx: MapContext) -> void:
if can_apply(_ctx):
_ctx.meta.set(name, self)
_transform.call(_ctx)
The Editor
All of these end up in a very simple scene file with the following script attached:
@tool
class_name MapEditor extends TileMapLayer
@export var _seed: int = 0
@export var theme: MapTheme
@export var base_map: BaseMap
@export var steps: Array[Step] = []
@export_tool_button("Randomize Seed")
var randomize_seed: Callable = _randomize_seed
@export_tool_button("Generate Map")
var generate: Callable = _generate
@export_tool_button("Clear Map")
var clear_map: Callable = _clear
func _randomize_seed() -> void:
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
_seed = _rng.randi()
Loggie.info("randomizing seed.")
func _generate() -> void:
if theme == null:
push_warning("missing theme.")
if base_map == null:
push_warning("missing base map.")
var _start: int = Time.get_ticks_msec()
var generator: Generator = Generator.new()
generator.steps.assign(steps)
var ctx: MapContext = generator.generate(base_map, _seed)
self.tile_map_data = ctx.tiles.tile_map_data
base_map.data = ctx.tiles.tile_map_data
var _end: int = Time.get_ticks_msec()
Loggie.info("generation complete. elapsed: %sms." % [_end - _start])
func _clear() -> void:
base_map.data = []
self.clear()
Loggie.info("map cleared.")
This gives me a small panel in the Godot editor that looks like this:
It's nothing fancy, but for my purposes it works well enough. For manual edits I'm relying on Godot's built-in editor.
Examples
Binary Split Partitioning
BSP starts by dividing an area into two rectangles, and then further subdividing
those up until a certain point. Then it goes back and places rooms that fit
within those subdivisions. Afterwards, the rooms are connected by walking back up
the tree of subdivided rectangles.
Directional Tunnels
These directional tunnels were implemented based off the description of an
algorithm described here on RogueBasin, a helpful source of information for
creating roguelikes.
Random Walk
Also known as Drunkard's Walk. This is an extremely simple algorithm that can fit into a single loop.
initialize all map cells to walls.
pick a map cell as the starting point.
turn the selected map cell into floor.
while insufficient cells have been turned into floor,
take one step in a random direction.
if the new map cell is wall,
turn the new map cell into floor and increment the count of floor tiles.
Cellular Automata
This method is particularly good for generating natural looking cave structures.
It uses a simple rule taken from Conway's Game of Life: a tile becomes a wall if
it was a wall and 4 or more of its eight neighbors were walls, or if it was not
a wall and 5 or more neighbors were.
Perlin Noise
Developed by Ken Perlin, it's a gradient noise that produces natural looking terail.
Directed Graphs
This was the most challenging and interesting to implement. It's based on an
algorithm originally described by the developer of TinyKeep. First It creates a
number of cells within the radius of a sphere, and then pushes them outward
until they no longer overlap. Next every room is linked using
Delaunay triangulation. We use this this graph as input to a
Minimum spanning tree, to reduce the number of connections while ensuring no
rooms are isolated.
GDScript Library
The tool works relatively well, but as you can see the steps and levels themselves are very bare bones. After some improvement, I plan to have a version of the library available online for others. Hopefully it can be of use to others looking to make their own Roguelike.