Labyrinth Procedural Dungeon Generator

OVERVIEW

// METRICS

Engine
Unity 6
My Roles
Gameplay Programmer
Platforms
PC

Procedural dungeon generation algorithm built in Unity/C# with the purpose of creating and linking seamlessly connected, thematically distinct areas into one contiguous map without teleporting or loading screens.

Most techniques used in the algorithm are fairly well known within the proc-gen community except for one feature. I first occupy grid space with marks I call "Blueprints" and then parse them later to create rooms of varying shape and size. This provides some variability to what can spawn between critical rooms, something I haven't seen in many rogue-like games that use similar techniques.

The algorithm was made in Unity 3D but can be rewritten to work in any game engine.

TECHNICAL FEATURES

Blueprints

Blueprints

Blueprints are the backbone of the algorithm. They must exist in order for any rooms to generate. Blueprints are simply single-celled marks on a grid that tell the algorithm "a room can eventually spawn here". When the blueprints are done generating, a second pass parses the blueprints and generates rooms based on a set of rules. Think of how Rule Tiles work in game engines.

// Blueprint
public class Blueprint
{
    public readonly string CellID;
    public readonly Vector3Int Position;    // Position of blueprint coords on grid
    public bool Available { get; set; }     // Prevents/allows parsing algorithm to use blueprint
    public bool[] EntryPointFlags { get; set; }

    // Constructor
    public Blueprint(Vector3Int position, string cellID = "Blueprint")
    {
        Available = true;
        CellID = cellID;
        Position = position;
        EntryPointFlags = new bool[6];      // A flag to mark which entrances should be open for a room
    }
}

To prevent blueprints from spawning on top of other blueprints, a Dictionary is used. A C# dictionary has O(1) lookup time, cannot contain duplicates, and can allocate space dynamically. To prevent rooms from spawning on top of each other, blueprints house a flag called Available that tells the room parser whether or not to include the blueprint in its check. Finally, EntryPointFlags tell the room which doorways it needs to open. These flags are determined during the blueprint pass of the algorithm.

Blueprint Operations are individual instruction-set style units of execution that can generate Blueprints in a specific pattern. This not only fosters a more organically catered dungeon but also makes the generation easy to debug by stepping through each operation individually one-by-one.

Blueprint Operations

As the algorithm grew larger, I began to worry about how hard it would be to debug in future iterations. At the time, the procedural generator executed all of its code in one go, but I wanted a way to step through each process individually, something akin to setting breakpoints in code. This is when I had the idea of applying concepts from assembly and instruction set architecture to my algorithm. Little did I know that building this debugger would end up making my procedural generator more dynamic and controllable than ever before.

To accomplish this, I needed to split each of the algorithm's processes into individual, executable components I call "Blueprint Operations." Each operation holds both input and output data, similar to registers in an assembly language. Operations are initialized and placed into a queue, then executed one by one.

// BlueprintOperation
public abstract class BlueprintOperation
{
    // DataID for is used strictly for debugging purposes.
    public string OperationID { get; protected set; }

    // Ports are the arguments and return values of operations.
    public List<string> InputPorts { get; protected set; }
    public List<string> OutputPorts { get; protected set; }

    protected MapGenerationContext _context;

    public BlueprintOperation(MapGenerationContext context)
    {
        InputPorts = new List<string>();
        OutputPorts = new List<string>();

        _context = context;
    }

    ...
}

Map Generation Context

Just like in assembly, the outputs of some operations need to be stored in memory so other operations can use them later. The map generation's context holds both the instruction/operation execution order and the data used in those operations.

// MapGenerationContext
public sealed class MapGenerationContext
{
    // Quick random access blueprint container; stores all blueprints generated in map
    private static Dictionary<Vector3Int, Blueprint> _blueprintDictionary;
    public static Dictionary<Vector3Int, Blueprint> BlueprintDictionary => _blueprintDictionary;

    public int OperationIDCounter { get; private set; } = 10000;
    public int MemoryIDCounter { get; private set; } = 20000;

    // Stores blueprint operations to be executed later
    public LinkedList<BlueprintOperation> OperationQueue { get; private set; }

    // Stores outputs from blueprint operations
    private Dictionary<string, object> _dataCache;

    public MapGenerationContext()
    {
        _dataCache = new();
        _blueprintDictionary = new();
        OperationQueue = new();
    }

    public BlueprintOperation OperationQueuePeek()
    {
        return OperationQueue?.First.Value;
    }

    public void OperationQueueEnqueue(BlueprintOperation op)
    {
        OperationQueue?.AddLast(op);
    }

    public void OperationQueueAddFront(BlueprintOperation op)
    {
        OperationQueue?.AddFirst(op);
    }

    public BlueprintOperation OperationQueueDequeue()
    {
        // Check to see if queue has at least one operation stored
        if (OperationQueue?.First is null)
            return null;

        BlueprintOperation op = OperationQueue?.First.Value;
        OperationQueue?.RemoveFirst();
        return op;
    }

    // Get data
    public bool TryGet<T>(string memoryID, out T value)
    {
        if (_dataCache is null)
        {
            value = default;
            return false;
        }

        if (_dataCache.TryGetValue(memoryID, out object obj) && obj is T castValue)
        {
            value = castValue;
            return true;
        }

        value = default;
        return false;
    }

    // Store data
    public void Malloc(string memoryID, object value)
    {
        if (_dataCache is null)
            return;

        _dataCache[memoryID] = value;
    }

    // Check for data
    public bool Contains(string memoryID)
    {
        if (_dataCache is null)
            return false;

        return _dataCache.ContainsKey(memoryID);
    }

    // Remove data
    public void Remove(string memoryID)
    {
        if (_dataCache is null)
            return;

        if (_dataCache.ContainsKey(memoryID))
            _dataCache.Remove(memoryID);
    }

    public int ConsumeOperationID()
    {
        return OperationIDCounter++;
    }

    public int ConsumeMemoryID()
    {
        return MemoryIDCounter++;
    }

    ...
}

THOUGHTS & REFLECTION

Backstory

I began building this algorithm back in 2023 for a club at my university. I was tasked with procedurally generating a 3D dungeon for an FPS we were making. It goes without saying that, between coursework and other obligations, a single semester wasn't enough time to finish a game of that scale. The game itself never came together, but it was enough to hook me on procedural generation. I picked the algorithm back up and kept developing it in 2024.

The procedural generation was simple at first, relying on a well known algorithm called The Drunkard's Walk that spawns rooms in random directions from the previous one. I later added Delaunay triangulation, A*, and Prim's algorithm to the pipeline.

What sets this algorithm apart from others?

Most of the algorithms the Labyrinth uses today are well known in the proc-gen community, but there's one feature I can genuinely call my own. Back in the club days, I had little knowledge of how procedural generation actually worked, so in a way, I had to get creative. Combining tools that I've learned over the years and from school, I came up with a feature I call "Blueprints" which are individual, single-celled components on a grid that combine to form an entire map. Using this blueprint method I then perform recursive descent parsing to generate rooms based on the patterns they create.

What's the ultimate goal for the project?

I've always admired how games like The Binding of Isaac, Enter the Gungeon, and Wizard of Legend generate their maps, but I equally admired the intricate, deliberate level design of games like Zelda and Dark Souls. Was there a way to randomize dungeon layouts while still producing levels that felt hand-crafted and made sense? Is it possible to link these levels together seamlessly so that different themed areas didn't need to be loaded in a different scene? That's the problem I set out to solve.