maxresdefault 17

Most tutorials about procedural generation teach you one algorithm, usually random rooms with corridors, and stop there. Then you try to ship something and realize your generator makes maps that all feel the same. Or worse, maps that crash because they generate islands the player can’t reach.

I’ve built generators for three shipped roguelikes. Two of them I had to rewrite from scratch because I picked the wrong algorithm for the genre. So I want to save you that pain. This guide covers the algorithms that actually appear in shipped games. Not theory. The real stuff. We’ll go through what each one is good for, what it breaks on, and how to wire it up in Unity. By the end you’ll know which algorithm matches which game feel, and you’ll have working C# code for the most useful ones.

First, the question nobody asks (but should)

Before you pick an algorithm, answer this: what kind of space are you generating?

This is the part most tutorials skip. They show you one technique like it’s universal. It isn’t. Each algorithm produces a distinct feel.

  • Square rooms with corridors feels like a dungeon (Nethack, Brogue, classic D&D)
  • Curved organic shapes feels like a cave (Dwarf Fortress, Brogue’s caves)
  • Sprawling tunnels feels like a mine or worm-burrow (Cogmind)
  • Hand-built rooms connected procedurally feels like a designed level (Spelunky, Dead Cells, Binding of Isaac, Hades)
  • Open landscapes feels like overworld terrain (Minecraft surface, Don’t Starve)

Pick the feel first. Then pick the algorithm. Most beginners do it backwards and end up with maps that don’t match what they pictured.

Algorithm 1: BSP (Binary Space Partitioning)

This is what Nethack uses. It’s the closest thing to a “default” dungeon algorithm. You split the map in half. Then split each half. Then split those. Stop when partitions get small enough. Carve a room inside each partition. Connect siblings with corridors.

Why use it: clean rectangular rooms, no overlaps, guaranteed connectivity, easy to control room density.

Why not: maps look samey. Players who play a lot start to feel the grid underneath. Doesn’t work for organic spaces.

Here’s a working BSP generator. Drop this on a GameObject with a tilemap reference:

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;

public class BspDungeonGenerator : MonoBehaviour
{
    [SerializeField] private Tilemap floorMap;
    [SerializeField] private Tilemap wallMap;
    [SerializeField] private TileBase floorTile;
    [SerializeField] private TileBase wallTile;

    [SerializeField] private int mapWidth = 60;
    [SerializeField] private int mapHeight = 40;
    [SerializeField] private int minPartitionSize = 8;
    [SerializeField] private int maxIterations = 5;

    private List<RectInt> rooms = new List<RectInt>();

    private void Start()
    {
        Generate();
    }

    public void Generate()
    {
        rooms.Clear();
        floorMap.ClearAllTiles();
        wallMap.ClearAllTiles();

        RectInt root = new RectInt(0, 0, mapWidth, mapHeight);
        Partition(root, maxIterations);

        foreach (var room in rooms)
            CarveRoom(room);

        ConnectRooms();
        DrawWalls();
    }

    private void Partition(RectInt area, int depth)
    {
        if (depth == 0 || area.width < minPartitionSize * 2 || area.height < minPartitionSize * 2)
        {
            CreateRoomInside(area);
            return;
        }

        bool splitHorizontal = area.width > area.height
            ? false
            : Random.value > 0.5f;

        if (splitHorizontal)
        {
            int splitY = Random.Range(minPartitionSize, area.height - minPartitionSize);
            Partition(new RectInt(area.x, area.y, area.width, splitY), depth - 1);
            Partition(new RectInt(area.x, area.y + splitY, area.width, area.height - splitY), depth - 1);
        }
        else
        {
            int splitX = Random.Range(minPartitionSize, area.width - minPartitionSize);
            Partition(new RectInt(area.x, area.y, splitX, area.height), depth - 1);
            Partition(new RectInt(area.x + splitX, area.y, area.width - splitX, area.height), depth - 1);
        }
    }

    private void CreateRoomInside(RectInt area)
    {
        int padding = 1;
        int roomW = Random.Range(area.width / 2, area.width - padding * 2);
        int roomH = Random.Range(area.height / 2, area.height - padding * 2);
        int roomX = area.x + Random.Range(padding, area.width - roomW - padding);
        int roomY = area.y + Random.Range(padding, area.height - roomH - padding);

        rooms.Add(new RectInt(roomX, roomY, roomW, roomH));
    }

    private void CarveRoom(RectInt room)
    {
        for (int x = room.x; x < room.x + room.width; x++)
        for (int y = room.y; y < room.y + room.height; y++)
            floorMap.SetTile(new Vector3Int(x, y, 0), floorTile);
    }

    private void ConnectRooms()
    {
        for (int i = 0; i < rooms.Count - 1; i++)
        {
            Vector2Int a = new Vector2Int(
                rooms[i].x + rooms[i].width / 2,
                rooms[i].y + rooms[i].height / 2);
            Vector2Int b = new Vector2Int(
                rooms[i + 1].x + rooms[i + 1].width / 2,
                rooms[i + 1].y + rooms[i + 1].height / 2);

            CarveCorridor(a, b);
        }
    }

    private void CarveCorridor(Vector2Int from, Vector2Int to)
    {
        Vector2Int current = from;
        while (current.x != to.x)
        {
            floorMap.SetTile(new Vector3Int(current.x, current.y, 0), floorTile);
            current.x += current.x < to.x ? 1 : -1;
        }
        while (current.y != to.y)
        {
            floorMap.SetTile(new Vector3Int(current.x, current.y, 0), floorTile);
            current.y += current.y < to.y ? 1 : -1;
        }
    }

    private void DrawWalls()
    {
        BoundsInt bounds = floorMap.cellBounds;
        for (int x = bounds.xMin - 1; x <= bounds.xMax; x++)
        for (int y = bounds.yMin - 1; y <= bounds.yMax; y++)
        {
            Vector3Int pos = new Vector3Int(x, y, 0);
            if (floorMap.GetTile(pos) != null) continue;

            bool nearFloor = false;
            for (int dx = -1; dx <= 1 && !nearFloor; dx++)
            for (int dy = -1; dy <= 1 && !nearFloor; dy++)
                if (floorMap.GetTile(new Vector3Int(x + dx, y + dy, 0)) != null)
                    nearFloor = true;

            if (nearFloor)
                wallMap.SetTile(pos, wallTile);
        }
    }
}

A few practical notes from shipping this:

  • The corridor logic I used (L-shaped, always horizontal then vertical) is the simplest possible. It works but feels mechanical. For shipped games, store rooms in a BSP tree and connect siblings before connecting cousins. That gives a more natural hierarchy.
  • Random partition splits look more interesting than always splitting at center.
  • If area.width > area.height you should always split horizontally. Otherwise you get pancaked rooms.

Algorithm 2: Cellular Automata (caves)

This is the one for organic caves. The Brogue cave levels use this. So does Terraria for some underground areas.

The idea is dead simple. Start with random noise (each cell is floor or wall with some probability). Then run a rule like “if 4 or more neighbors are walls, become a wall, else become floor”. Run that 4 to 6 times. You get caves.

using UnityEngine;
using UnityEngine.Tilemaps;

public class CellularCaveGenerator : MonoBehaviour
{
    [SerializeField] private Tilemap floorMap;
    [SerializeField] private Tilemap wallMap;
    [SerializeField] private TileBase floorTile;
    [SerializeField] private TileBase wallTile;

    [SerializeField] private int width = 80;
    [SerializeField] private int height = 50;
    [SerializeField, Range(0f, 1f)] private float initialFillChance = 0.45f;
    [SerializeField] private int smoothingIterations = 5;
    [SerializeField] private int birthLimit = 4;
    [SerializeField] private int deathLimit = 3;

    private bool[,] map;

    private void Start()
    {
        Generate();
    }

    public void Generate()
    {
        map = new bool[width, height];
        RandomFill();

        for (int i = 0; i < smoothingIterations; i++)
            SmoothMap();

        DrawMap();
    }

    private void RandomFill()
    {
        for (int x = 0; x < width; x++)
        for (int y = 0; y < height; y++)
        {
            // Force walls at borders
            if (x == 0 || y == 0 || x == width - 1 || y == height - 1)
                map[x, y] = true;
            else
                map[x, y] = Random.value < initialFillChance;
        }
    }

    private void SmoothMap()
    {
        bool[,] next = new bool[width, height];
        for (int x = 0; x < width; x++)
        for (int y = 0; y < height; y++)
        {
            int walls = CountWallNeighbors(x, y);
            if (map[x, y])
                next[x, y] = walls >= deathLimit;
            else
                next[x, y] = walls > birthLimit;
        }
        map = next;
    }

    private int CountWallNeighbors(int cx, int cy)
    {
        int count = 0;
        for (int x = cx - 1; x <= cx + 1; x++)
        for (int y = cy - 1; y <= cy + 1; y++)
        {
            if (x == cx && y == cy) continue;
            if (x < 0 || y < 0 || x >= width || y >= height)
                count++;
            else if (map[x, y])
                count++;
        }
        return count;
    }

    private void DrawMap()
    {
        floorMap.ClearAllTiles();
        wallMap.ClearAllTiles();
        for (int x = 0; x < width; x++)
        for (int y = 0; y < height; y++)
        {
            var pos = new Vector3Int(x, y, 0);
            if (map[x, y]) wallMap.SetTile(pos, wallTile);
            else floorMap.SetTile(pos, floorTile);
        }
    }
}

The catch with cellular automata: it often creates disconnected pockets. You’ll get four cave regions that aren’t connected to each other. Your player spawns in one, the exit is in another, game broken.

You have two options:

  1. Detect and connect. Run a flood fill, find the largest connected region, throw away the rest. Or carve tunnels between regions.
  2. Detect and reject. Generate, check if it’s one connected region, if not generate again. Works but wasteful.

I always go with option 1. Flood fill is cheap. Here’s the cleanup pass:

private void EnsureConnected()
{
    var regions = FindRegions();
    if (regions.Count <= 1) return;

    regions.Sort((a, b) => b.Count.CompareTo(a.Count));
    var keep = regions[0];

    // Convert every cell not in 'keep' back to wall
    for (int x = 0; x < width; x++)
    for (int y = 0; y < height; y++)
        if (!map[x, y] && !keep.Contains(new Vector2Int(x, y)))
            map[x, y] = true;
}

private List<HashSet<Vector2Int>> FindRegions()
{
    var visited = new bool[width, height];
    var regions = new List<HashSet<Vector2Int>>();

    for (int x = 0; x < width; x++)
    for (int y = 0; y < height; y++)
    {
        if (visited[x, y] || map[x, y]) continue;

        var region = new HashSet<Vector2Int>();
        var queue = new Queue<Vector2Int>();
        queue.Enqueue(new Vector2Int(x, y));

        while (queue.Count > 0)
        {
            var p = queue.Dequeue();
            if (p.x < 0 || p.y < 0 || p.x >= width || p.y >= height) continue;
            if (visited[p.x, p.y] || map[p.x, p.y]) continue;
            visited[p.x, p.y] = true;
            region.Add(p);
            queue.Enqueue(new Vector2Int(p.x + 1, p.y));
            queue.Enqueue(new Vector2Int(p.x - 1, p.y));
            queue.Enqueue(new Vector2Int(p.x, p.y + 1));
            queue.Enqueue(new Vector2Int(p.x, p.y - 1));
        }
        regions.Add(region);
    }
    return regions;
}

Call EnsureConnected() after your smoothing loop.

Algorithm 3: Drunkard’s Walk (worm tunnels)

This is the simplest algorithm in the entire field. A “drunkard” stands at a random point, picks a random direction, carves a floor tile, and stumbles forward. Repeat until you’ve carved enough floor.

Cogmind uses a refined version of this for its tunneling pass.

public class DrunkardWalkGenerator : MonoBehaviour
{
    [SerializeField] private Tilemap floorMap;
    [SerializeField] private TileBase floorTile;

    [SerializeField] private int width = 60;
    [SerializeField] private int height = 40;
    [SerializeField, Range(0f, 1f)] private float fillPercent = 0.45f;

    public void Generate()
    {
        floorMap.ClearAllTiles();
        int target = (int)(width * height * fillPercent);
        int carved = 0;

        Vector2Int pos = new Vector2Int(width / 2, height / 2);

        while (carved < target)
        {
            if (floorMap.GetTile(new Vector3Int(pos.x, pos.y, 0)) == null)
            {
                floorMap.SetTile(new Vector3Int(pos.x, pos.y, 0), floorTile);
                carved++;
            }

            int dir = Random.Range(0, 4);
            switch (dir)
            {
                case 0: pos.x++; break;
                case 1: pos.x--; break;
                case 2: pos.y++; break;
                case 3: pos.y--; break;
            }

            pos.x = Mathf.Clamp(pos.x, 1, width - 2);
            pos.y = Mathf.Clamp(pos.y, 1, height - 2);
        }
    }
}

This always produces a connected map (the drunkard never teleports). But it tends to clump around the start. To fix, send out multiple drunkards from different points, or have your drunkard occasionally teleport to a random already-carved tile and continue from there.

For Cogmind-style tunneling that produces room-and-corridor results, give the drunkard a “momentum” so it keeps moving in the same direction with high probability, and occasionally have it stop and dig out a small rectangular room before continuing. That single change turns a chaotic walk into something that feels like a mining operation.

Algorithm 4: Room template system (the Spelunky / Dead Cells approach)

This is what serious roguelikes ship. Pure procedural generation looks random. Players notice. Hand-designed rooms feel intentional. The trick is to combine them: generate the layout procedurally, fill the rooms with hand-designed templates.

This is how Spelunky, Dead Cells, Binding of Isaac, Enter the Gungeon, and Hades all work. The exact recipe varies but the core idea is the same.

The Dead Cells approach in particular is worth knowing because they published it: hand-design a bunch of “tiles” (small room layouts) with marked entrances and exits. Build a level graph describing how rooms should connect (entrance > combat > combat > treasure > exit). The generator picks a template for each node that has compatible entrances and exits.

Here’s a minimal version. First, the room template:

[CreateAssetMenu(fileName = "RoomTemplate", menuName = "Roguelike/Room Template")]
public class RoomTemplate : ScriptableObject
{
    public int width;
    public int height;
    public RoomType type;
    public bool hasNorthExit;
    public bool hasSouthExit;
    public bool hasEastExit;
    public bool hasWestExit;
    public TileBase[] tiles; // flat array, width * height
}

public enum RoomType { Entrance, Combat, Treasure, Shop, Boss, Exit }

Build your templates in the editor by painting tiles into a small tilemap, then writing a tool that serializes the tilemap into a RoomTemplate asset. (Or use prefabs if you prefer GameObject-based rooms.)

Then the generator picks a grid of room slots and fills them:

public class TemplateRoomGenerator : MonoBehaviour
{
    [SerializeField] private RoomTemplate[] allTemplates;
    [SerializeField] private Tilemap output;
    [SerializeField] private int gridWidth = 5;
    [SerializeField] private int gridHeight = 5;
    [SerializeField] private int roomCellSize = 12;

    private RoomType[,] layout;

    public void Generate()
    {
        BuildLayout();
        PlaceRooms();
    }

    private void BuildLayout()
    {
        layout = new RoomType[gridWidth, gridHeight];

        // Walk a path from entrance to exit
        Vector2Int pos = new Vector2Int(0, gridHeight / 2);
        layout[pos.x, pos.y] = RoomType.Entrance;

        int pathLength = gridWidth + Random.Range(0, gridHeight);
        for (int i = 0; i < pathLength; i++)
        {
            // Prefer moving right, sometimes up/down
            int roll = Random.Range(0, 10);
            if (roll < 6 && pos.x < gridWidth - 1) pos.x++;
            else if (roll < 8 && pos.y < gridHeight - 1) pos.y++;
            else if (pos.y > 0) pos.y--;

            if (layout[pos.x, pos.y] == 0)
                layout[pos.x, pos.y] = RoomType.Combat;
        }

        layout[pos.x, pos.y] = RoomType.Exit;

        // Sprinkle one treasure room next to a random combat room
        AddSpecialRoom(RoomType.Treasure);
    }

    private void AddSpecialRoom(RoomType type)
    {
        for (int tries = 0; tries < 20; tries++)
        {
            int x = Random.Range(0, gridWidth);
            int y = Random.Range(0, gridHeight);
            if (layout[x, y] == RoomType.Combat)
            {
                layout[x, y] = type;
                return;
            }
        }
    }

    private void PlaceRooms()
    {
        for (int gx = 0; gx < gridWidth; gx++)
        for (int gy = 0; gy < gridHeight; gy++)
        {
            if (layout[gx, gy] == 0) continue;

            RoomTemplate t = PickTemplateFor(layout[gx, gy], gx, gy);
            if (t == null) continue;

            int originX = gx * roomCellSize;
            int originY = gy * roomCellSize;
            for (int x = 0; x < t.width; x++)
            for (int y = 0; y < t.height; y++)
                output.SetTile(
                    new Vector3Int(originX + x, originY + y, 0),
                    t.tiles[y * t.width + x]);
        }
    }

    private RoomTemplate PickTemplateFor(RoomType type, int gx, int gy)
    {
        // Filter templates that match type and have correct exits
        bool needNorth = gy < gridHeight - 1 && layout[gx, gy + 1] != 0;
        bool needSouth = gy > 0 && layout[gx, gy - 1] != 0;
        bool needEast = gx < gridWidth - 1 && layout[gx + 1, gy] != 0;
        bool needWest = gx > 0 && layout[gx - 1, gy] != 0;

        var candidates = new List<RoomTemplate>();
        foreach (var t in allTemplates)
        {
            if (t.type != type) continue;
            if (needNorth && !t.hasNorthExit) continue;
            if (needSouth && !t.hasSouthExit) continue;
            if (needEast && !t.hasEastExit) continue;
            if (needWest && !t.hasWestExit) continue;
            candidates.Add(t);
        }

        return candidates.Count == 0 ? null : candidates[Random.Range(0, candidates.Count)];
    }
}

This is the algorithm I reach for most often when building real games. Designers can author rooms in the editor. The generator just picks compatible ones. You get the replay value of procedural with the polish of hand-crafted.

Two important things that bite people:

  • Make sure you have enough templates for every (type, exit configuration) combination. If a slot needs north+east exits and you have zero templates with that, your generator fails. Either author more templates or have a fallback.
  • Test seeds. A specific random seed should always produce the same level. This is critical for daily challenges, debugging, and speedruns.

Algorithm 5: Wave Function Collapse

This is the fancy one that gets a lot of attention. It’s based on constraint propagation. You give it a small example image with tile-adjacency rules, and it generates a larger image that follows the same local rules.

Honest take after shipping with it: WFC is amazing for specific use cases (terrain, sprawling architecture, alien-looking spaces) and overkill for normal dungeons. It’s also slow and can fail to generate (contradiction state) which means you need restart logic.

If you want it, don’t write it yourself for production. Use a library. Selina Dev’s WFC implementation or the original mxgmn implementation are both ported to C#. For Unity specifically, there’s a maintained package called “MarkovJunior for Unity” that handles WFC and similar constraint-based generation.

I’d only recommend WFC if you specifically want that “looks coherent, locally rule-based” feel. For most roguelikes, the template approach above gets you 90% of the benefit with 10% of the complexity.

The pieces nobody talks about

The algorithm is maybe 40% of a real generator. The rest is the boring stuff:

Validation. Every generated map needs to be checked. Is the exit reachable from the entrance? Run a pathfinder. Are there any one-tile choke points that block large enemies? Check. Is any monster placed inside a wall? Check. Validation is what separates a tutorial generator from a shipped one.

Dijkstra maps. Once you have a layout, run a Dijkstra distance map from the entrance. Now you know the “depth” of every tile. Use it to place stronger enemies further from start, place the boss at the furthest point, place keys before locked doors, and so on. Brogue famously uses Dijkstra maps everywhere.

public int[,] BuildDijkstraMap(bool[,] walkable, Vector2Int start)
{
    int w = walkable.GetLength(0), h = walkable.GetLength(1);
    int[,] dist = new int[w, h];
    for (int x = 0; x < w; x++) for (int y = 0; y < h; y++) dist[x, y] = int.MaxValue;

    var queue = new Queue<Vector2Int>();
    queue.Enqueue(start);
    dist[start.x, start.y] = 0;

    while (queue.Count > 0)
    {
        var p = queue.Dequeue();
        Vector2Int[] dirs = { new(1,0), new(-1,0), new(0,1), new(0,-1) };
        foreach (var d in dirs)
        {
            int nx = p.x + d.x, ny = p.y + d.y;
            if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
            if (!walkable[nx, ny]) continue;
            if (dist[nx, ny] != int.MaxValue) continue;
            dist[nx, ny] = dist[p.x, p.y] + 1;
            queue.Enqueue(new Vector2Int(nx, ny));
        }
    }
    return dist;
}

Seeding properly. Use Random.InitState(seed) at the start of generation. Don’t use UnityEngine.Random mixed with System.Random because they’re separate generators and you’ll get inconsistent results. Pick one and stick to it. I usually use System.Random because I can pass it around to functions explicitly, which makes parallel generation safe.

Generation time. A 60×40 map should take well under 100ms. If yours takes longer, you’re probably allocating too many lists per frame or running too many smoothing passes. Profile before you optimize. Don’t pre-optimize generation code, you’ll just make it harder to read.

Which one should you actually pick?

Match the algorithm to the game feel you want:

  • Classic D&D dungeon? BSP, then improve room shapes later.
  • Cave / mine / underwater? Cellular Automata with a connectivity pass.
  • Sprawling industrial complex? Drunkard’s walk with momentum and room punches.
  • Spelunky-style hand-crafted feel? Room templates on a grid.
  • Truly weird locally-coherent spaces? WFC, with patience.

For 90% of indie roguelikes I’d start with room templates on a grid. It gives the best ratio of (player perceived quality) to (code complexity).

What to do next

Pick the algorithm that matches your game. Build the simplest version of it. Generate 50 levels in a row and look at them. You’ll spot patterns and problems instantly that you’d never catch testing one at a time.

Then add validation. Then add Dijkstra maps for smart content placement. Then layer in templates over your procedural layout for set-piece moments.

That progression has worked for every roguelike I’ve shipped. Algorithm first, then placement, then polish. Skip steps and you’ll fight your own code for months.

Now go make some levels.

ABOUT NIPSAPP

NipsApp Game Studios is a full-cycle game development company founded in 2010, based in Trivandrum, India. With expertise in Unity, Unreal Engine, VR, mobile, and blockchain game development, NipsApp serves startups and enterprises across 25+ countries.

🚀 3,000+ Projects Delivered 121 Verified Clutch Reviews 🌍 25+ Countries Served 🎮 Since 2010

SERVICES

GAME GRAPHICS DESIGN

VR/XR SIMULATION

TALENT OUTSOURCING

RESOURCES

WORK SAMPLES

CONTACT US

India Office:

Viddhya Bhavan, Panniyode Road, Vattappara, Trivandrum, Kerala, India

Email: [email protected]

Phone: +91 62384 72255

Apple Maps Icon View on Apple Maps Google Maps Icon View on Google Maps

UAE Office:

Office No: 102, Near Siemens Building, Masdar Free Zone, Masdar City, Abu Dhabi, UAE

COPYRIGHT © 2025 NipsApp Game Studios | Privacy Policy | Terms & Conditions | Refund Policy | Privacy Policy Product |
Facebook Twitter LinkedIn Instagram YouTube Clutch
TABLE OF CONTENT