Procedural Generation Of Mazes With Unity

Joseph Hocking

Note: This tutorial was written using Unity 2017.1.0 and is aimed at advanced users. It assumes you are already comfortable programming games in Unity. Feel free to start with our basic Unity tutorials before doing this one.

Procedurally Generated Maze in Unity

As a Unity developer, you’re probably quite skilled at constructing levels manually. But have you ever wanted to generate levels on-the-fly? Procedural generation of meshes for floors and walls, as opposed to simply laying out preconstructed models, offers a lot of flexibility and interesting replay value.

In this tutorial, you’ll learn how to:

  • Procedurally generate levels by making a maze-running game.
  • Generate maze data.
  • Use maze data to build a mesh.

Getting Started

Most algorithms you can find (such as the ones here and here) produce “perfect”, dense mazes; that is, ones with only one correct path and no loops. They’re much like the ones you would find in a newspaper’s puzzle section.

Perfect maze

Image courtesy of Wikipedia

However, most games play better with mazes that are both imperfect, with looping paths, and sparse, made from open spaces instead of tight twisty corridors. This is especially true of the rogue-like genre, where procedural levels aren’t so much “mazes” as they are dungeons.

Roguelike dungeon

Image courtesy of Bob Nystrom

In this tutorial, you’re going to implement one of the simplest maze algorithms around, described here. The reason for this choice is simply to get mazes into your game with the least amount of effort. This simple approach works well for the classic games at the above link, so you’ll use that same algorithm to create the mazes in a game called Speedy Treasure Thief.

In this game, every level is a new maze that includes a treasure chest somewhere in the level. However, you don’t have much time to find it and get out before the guards come back! Each level has a time limit, and you can keep playing until you get caught. Your score is based on how much treasure you snag.

Screenshot of finished game

To start off, create a new empty project in Unity.

Now download the starter package, unzip it and import **proc-mazes-starter.unitypackage** into your new project. The starter package includes the following:

  1. A Graphics folder, which contains all the graphics required for the game.
  2. The Scene scene, which is the initial scene for this tutorial and contains the player and the UI.
  3. A folder called Scripts, which contains two helper scripts. You’ll write the rest during this tutorial.

That’s enough to get you started. You’ll go into each of these areas in detail later.

Establishing the Code Architecture

Start by adding an empty object to the scene. Select GameObject ▸ Create Empty, name it Controller and position it at (X:0, Y:0, Z:0). This object is simply an attachment point for the scripts that control the game.

In the project’s Scripts directory, create a C# script named GameController, then create another script and name it MazeConstructor. The first script will manage the overall game, while the second will specifically handle the maze generation.

Replace everything in GameController with the following:

using System;
using UnityEngine;

[RequireComponent(typeof(MazeConstructor))]               // 1

public class GameController : MonoBehaviour
{
    private MazeConstructor generator;

    void Start()
    {
        generator = GetComponent<MazeConstructor>();      // 2
    }
}

Here’s a quick summary of what you have just created:

  1. The RequireComponent attribute ensures that a MazeConstructor component will also be added when you add this script to a GameObject.
  2. A private variable that stores a reference returned by the GetComponent().

Add this script into the scene: drag the GameController script from the Project window and drop it on Controller GameObject in the Hierarchy window.

Notice that MazeConstructor was also added to Controller; that happened automatically because of the RequireComponent attribute.

Now in MazeConstructor, replace everything with the following:

using UnityEngine;

public class MazeConstructor : MonoBehaviour
{
    //1
    public bool showDebug;
    
    [SerializeField] private Material mazeMat1;
    [SerializeField] private Material mazeMat2;
    [SerializeField] private Material startMat;
    [SerializeField] private Material treasureMat;

    //2
    public int[,] data
    {
        get; private set;
    }

    //3
    void Awake()
    {
        // default to walls surrounding a single empty cell
        data = new int[,]
        {
            {1, 1, 1},
            {1, 0, 1},
            {1, 1, 1}
        };
    }
    
    public void GenerateNewMaze(int sizeRows, int sizeCols)
    {
        // stub to fill in
    }
}

Here’s what’s going on above:

  1. These fields are all available to you in the Inspector. showDebug will toggle debug displays, while the various Material references are materials for generated models. Incidentally, the SerializeField attribute displays a field in the Inspector, even though the variable is private as far as code access is concerned.
  2. Next is the data property. The access declarations (i.e. declaring the property as public but then assigning private set) makes it read-only outside this class. Thus, maze data can’t be modified from outside.
  3. As for the data type, the maze boils down to a grid of cells. Thus the maze data is simply a two-dimensional array that’s either 0 or 1 (to represent open or blocked) for every space. It’s that simple!

  4. The last bit of interesting code is in Awake(). This initializes data with a 3 by 3 array of ones surrounding zero. 1 means “wall” while 0 means “empty”, so this default grid is simply a walled-in room.

That’s a fair amount of groundwork for the code, but nothing is visible yet!

To display the maze data and verify how it looks, add the following method to MazeConstructor:

void OnGUI()
{
    //1
    if (!showDebug)
    {
        return;
    }

    //2
    int[,] maze = data;
    int rMax = maze.GetUpperBound(0);
    int cMax = maze.GetUpperBound(1);

    string msg = "";

    //3
    for (int i = rMax; i >= 0; i--)
    {
        for (int j = 0; j <= cMax; j++)
        {
            if (maze[i, j] == 0)
            {
                msg += "....";
            }
            else
            {
                msg += "==";
            }
        }
        msg += "\n";
    }

    //4
    GUI.Label(new Rect(20, 20, 500, 500), msg);
}

Taking each commented section in turn:

  1. This code checks if debug displays are enabled.
  2. Initialize several local variables: a local copy of the stored maze, the maximum row and column, and a string to build up.
  3. Two nested loops iterate over the rows and columns of the two-dimensional array. For each row/column of the array, the code checks the stored value and appends either "...." or "==" depending on if the value is zero. The code also appends a newline after iterating through all the columns in a row, so that each row is a new line.
  4. Finally, GUI.Label() prints out the built-up string. This project uses the newer GUI system for displays seen by the player, but the older system is simpler for creating quick debug displays.

Remember to turn on Show Debug on the MazeConstructor component. Hit Play, and the stored maze data (which is just the default maze for now) will be displayed:

Default procedural mazes

Displaying the stored data is a good start! However, the code isn't actually generating a maze yet. The next section explains how to handle that task.

Generating the Maze Data

Note that MazeConstructor.GenerateNewMaze() is currently empty; for now it's simply a stub to fill in later. In the GameController script, add the following line to the end of the Start() method. This will call that stub method:

    generator.GenerateNewMaze(13, 15);

The "magic" numbers 13 and 15 in the method's parameters dictate how large to make the maze. While they aren't being used quite yet, these size parameters determine the number of rows and columns in the grid respectively.

At this point you can start generating the data for the maze. Create a new script named MazeDataGenerator; this class will encapsulate the data generation logic, and will be used by MazeConstructor. Open the new script and replace everything with:

using System.Collections.Generic;
using UnityEngine;

public class MazeDataGenerator
{
    public float placementThreshold;    // chance of empty space

    public MazeDataGenerator()
    {
        placementThreshold = .1f;                               // 1
    }

    public int[,] FromDimensions(int sizeRows, int sizeCols)    // 2
    {
        int[,] maze = new int[sizeRows, sizeCols];
        // stub to fill in
        return maze;
    }
}

Note that this class doesn't inherit from MonoBehaviour. It won't be directly used as a component, only from within MazeConstructor, so it doesn't need MonoBehaviour's functionality.

Meanwhile:

  1. placementThreshold will be used by the data generation algorithm to determine whether a space is empty. This variable is assigned a default value in the class constructor, but it's made public so that other code can tune the generated maze.
  2. Once again, one method (FromDimensions() in this case) is currently simply a stub to call and will be filled in shortly.

Next add some sections of code to MazeConstructor to enable it to call the stub method. First add a private variable to store the data generator:

private MazeDataGenerator dataGenerator;

Then instantiate it in Awake(), storing the generator in the new variable by adding the following line to the top of the Awake() method.

    dataGenerator = new MazeDataGenerator();

Finally, call FromDimensions() in GenerateNewMaze(), passing along the grid size and storing the resulting data. Find the line containing // stub to fill in in GenerateNewMaze() and replace it with the following:

    if (sizeRows % 2 == 0 && sizeCols % 2 == 0)
    {
        Debug.LogError("Odd numbers work better for dungeon size.");
    }

    data = dataGenerator.FromDimensions(sizeRows, sizeCols);

Note the warning about odd numbers working better for size; that's because the generated maze will be surrounded by walls.

Play the game to see the correctly sized but otherwise empty maze data:

Empty maze

Great! Everything's in place to store and display the maze data! Time to implement the maze generation algorithm inside FromDimensions().

The algorithm described earlier iterates over every other space in the grid (no, not every single space!) to both place a wall and choose an adjacent space to block as well. The algorithm programmed here is a slight modification that also decides if the space should simply be skipped, resulting in open spaces to vary the maze. Since the algorithm doesn't need to store much, or know anything about the rest of the maze, such as a list of branch points to iterate over, the code becomes very simple.

To implement this maze generation algorithm, add the following code to FromDimensions() in MazeDataGenerator replacing the line that reads // stub to fill in.

    int rMax = maze.GetUpperBound(0);
    int cMax = maze.GetUpperBound(1);

    for (int i = 0; i <= rMax; i++)
    {
        for (int j = 0; j <= cMax; j++)
        {
            //1
            if (i == 0 || j == 0 || i == rMax || j == cMax)
            {
                maze[i, j] = 1;
            }

            //2
            else if (i % 2 == 0 && j % 2 == 0)
            {
                if (Random.value > placementThreshold)
                {
                    //3
                    maze[i, j] = 1;

                    int a = Random.value < .5 ? 0 : (Random.value < .5 ? -1 : 1);
                    int b = a != 0 ? 0 : (Random.value < .5 ? -1 : 1);
                    maze[i+a, j+b] = 1;
                }
            }
        }
    }

As you can see, the code gets the boundaries of the 2D array and then iterates through it:

  1. For every grid cell, the code first checks if the current cell is on the outside of the grid (that is, if either index is on the array boundaries). If so, assign 1 for wall.
  2. The code next checks if the coordinates are evenly divisible by 2 in order to operate on every other cell. There is a further check against the placementThreshold value described earlier, to randomly skip this cell and continue iterating through the array.
  3. Finally, the code assigns 1 to both the current cell and a randomly chosen adjacent cell. The code uses a series of ternary operators to randomly add 0, 1, or -1 to the array index, thereby getting the index of an adjacent cell.

Display the maze data again in order to verify how the generated maze looks:

Maze data

Restart the game to see that the maze data is different each time. Pretty cool!

The next big task is to generate a 3D mesh from the 2D maze data.

Generating the Maze Mesh

Now that the underlying data for the maze is being generated properly, you can construct the mesh based on that data.

Create another new script named MazeMeshGenerator. Much like MazeDataGenerator encapsulated the data generation logic, MazeMeshGenerator will contain the mesh generation logic and will be used by MazeConstructor to handle that step in generating the maze.

Or rather, it will eventually contain the mesh generation logic. First, you'll simply create a textured quad for demonstration purposes and then modify that code to generate the entire maze. In order to do that, you'll need to make a few minor adjustments within Unity's editor before diving into the code.

First, you need to link the materials that will be applied to the generated mesh.

Select the Graphics folder down in the Project window, then select Controller up in the Hierarchy window in order to see its Maze Constructor component in the Inspector.

Drag the materials from the Graphics folder over to the material slots in Maze Constructor. Use floor-mat for Material 1 and wall-mat for Material 2, while start and treasure go in the according slots.

Since you're already working in the Inspector, also add a tag called Generated: click on the Tag menu at the top of the Inspector and select Add Tag. When you generate meshes, you'll assign that tag in order to identify them.

Now that the needed adjustments have all been made in Unity's editor, open the new script and replace everything with:

using System.Collections.Generic;
using UnityEngine;

public class MazeMeshGenerator
{    
    // generator params
    public float width;     // how wide are hallways
    public float height;    // how tall are hallways

    public MazeMeshGenerator()
    {
        width = 3.75f;
        height = 3.5f;
    }

    public Mesh FromData(int[,] data)
    {
        Mesh maze = new Mesh();

        //1
        List<Vector3> newVertices = new List<Vector3>();
        List<Vector2> newUVs = new List<Vector2>();
        List<int> newTriangles = new List<int>();
        
        // corners of quad
        Vector3 vert1 = new Vector3(-.5f, -.5f, 0);
        Vector3 vert2 = new Vector3(-.5f, .5f, 0);
        Vector3 vert3 = new Vector3(.5f, .5f, 0);
        Vector3 vert4 = new Vector3(.5f, -.5f, 0);

        //2
        newVertices.Add(vert1);
        newVertices.Add(vert2);
        newVertices.Add(vert3);
        newVertices.Add(vert4);

        //3
        newUVs.Add(new Vector2(1, 0));
        newUVs.Add(new Vector2(1, 1));
        newUVs.Add(new Vector2(0, 1));
        newUVs.Add(new Vector2(0, 0));

        //4
        newTriangles.Add(2);
        newTriangles.Add(1);
        newTriangles.Add(0);

        //5
        newTriangles.Add(3);
        newTriangles.Add(2);
        newTriangles.Add(0);

        maze.vertices = newVertices.ToArray();
        maze.uv = newUVs.ToArray();
        maze.triangles = newTriangles.ToArray();

        return maze;
    }
}

The two fields at the top of the class, width and height, are just like placementThreshold from MazeDataGenerator: values, set with to default in the constructor, that are used by the mesh generation code.

The majority of the interesting code is inside FromData(); that's the method MazeConstructor calls to generate a mesh. At the moment the code just creates a single quad, in order to demonstrate how that works. You'll expand that to an entire level shortly.

This illustration shows what a quad is made of:

This code is long but fairly repetitive, with minor variations:

  1. A mesh is comprised of three lists: the vertices, the UV coordinates and the triangles.
  2. The list of vertices stores the position of each vertex...
  3. The UV coordinates listed go with the corresponding vertex in that list...
  4. And the triangles are indexes in the list of vertices (i.e. "this triangle is made from vertices 0, 1, and 2").
  5. Note that two triangles were created; a quad is made of two triangles. Note also that List data types were used (in order to append to the list) but ultimately Mesh needs Arrays.

MazeConstructor needs to instantiate MazeMeshGenerator and then call the mesh generation method. Meanwhile it also needs to display the mesh, so here are the pieces of code to add:

First add a private field to store the mesh generator.

private MazeMeshGenerator meshGenerator;

Instantiate it in Awake(), storing the mesh generator in the new field by adding the following line to the top of the Awake() method:

    meshGenerator = new MazeMeshGenerator();

Next add the DisplayMaze() method:

private void DisplayMaze()
{
    GameObject go = new GameObject();
    go.transform.position = Vector3.zero;
    go.name = "Procedural Maze";
    go.tag = "Generated";

    MeshFilter mf = go.AddComponent<MeshFilter>();
    mf.mesh = meshGenerator.FromData(data);
    
    MeshCollider mc = go.AddComponent<MeshCollider>();
    mc.sharedMesh = mf.mesh;

    MeshRenderer mr = go.AddComponent<MeshRenderer>();
    mr.materials = new Material[2] {mazeMat1, mazeMat2};
}

Finally, in order to call DisplayMaze(), add the following line to the end of GenerateNewMaze():

    DisplayMaze();

By itself, a Mesh is simply data. It isn't visible until assigned to an object (more specifically, the object's MeshFilter) in the scene. Thus DisplayMaze() doesn't only call MazeMeshGenerator.FromData(), but rather inserts that call in the middle of instantiating a new GameObject, setting the Generated tag, adding MeshFilter and the generated mesh, adding MeshCollider for colliding with the maze, and finally adding MeshRenderer and materials.

Having programmed the MazeMeshGenerator class, and instantiated it in MazeConstructor, hit Play now:

Single quad

You’ve built a textured quad completely from code! That's an exciting and important beginning, so pause here to review your work up to this point if you don't completely understand how it works.

Next up is a rather significant refactor of FromData(); replace it entirely with:

public Mesh FromData(int[,] data)
{
    Mesh maze = new Mesh();

    //3
    List<Vector3> newVertices = new List<Vector3>();
    List<Vector2> newUVs = new List<Vector2>();

    maze.subMeshCount = 2;
    List<int> floorTriangles = new List<int>();
    List<int> wallTriangles = new List<int>();

    int rMax = data.GetUpperBound(0);
    int cMax = data.GetUpperBound(1);
    float halfH = height * .5f;

    //4
    for (int i = 0; i <= rMax; i++)
    {
        for (int j = 0; j <= cMax; j++)
        {
            if (data[i, j] != 1)
            {
                // floor
                AddQuad(Matrix4x4.TRS(
                    new Vector3(j * width, 0, i * width),
                    Quaternion.LookRotation(Vector3.up),
                    new Vector3(width, width, 1)
                ), ref newVertices, ref newUVs, ref floorTriangles);

                // ceiling
                AddQuad(Matrix4x4.TRS(
                    new Vector3(j * width, height, i * width),
                    Quaternion.LookRotation(Vector3.down),
                    new Vector3(width, width, 1)
                ), ref newVertices, ref newUVs, ref floorTriangles);


                // walls on sides next to blocked grid cells

                if (i - 1 < 0 || data[i-1, j] == 1)
                {
                    AddQuad(Matrix4x4.TRS(
                        new Vector3(j * width, halfH, (i-.5f) * width),
                        Quaternion.LookRotation(Vector3.forward),
                        new Vector3(width, height, 1)
                    ), ref newVertices, ref newUVs, ref wallTriangles);
                }

                if (j + 1 > cMax || data[i, j+1] == 1)
                {
                    AddQuad(Matrix4x4.TRS(
                        new Vector3((j+.5f) * width, halfH, i * width),
                        Quaternion.LookRotation(Vector3.left),
                        new Vector3(width, height, 1)
                    ), ref newVertices, ref newUVs, ref wallTriangles);
                }

                if (j - 1 < 0 || data[i, j-1] == 1)
                {
                    AddQuad(Matrix4x4.TRS(
                        new Vector3((j-.5f) * width, halfH, i * width),
                        Quaternion.LookRotation(Vector3.right),
                        new Vector3(width, height, 1)
                    ), ref newVertices, ref newUVs, ref wallTriangles);
                }

                if (i + 1 > rMax || data[i+1, j] == 1)
                {
                    AddQuad(Matrix4x4.TRS(
                        new Vector3(j * width, halfH, (i+.5f) * width),
                        Quaternion.LookRotation(Vector3.back),
                        new Vector3(width, height, 1)
                    ), ref newVertices, ref newUVs, ref wallTriangles);
                }
            }
        }
    }

    maze.vertices = newVertices.ToArray();
    maze.uv = newUVs.ToArray();
    
    maze.SetTriangles(floorTriangles.ToArray(), 0);
    maze.SetTriangles(wallTriangles.ToArray(), 1);

    //5
    maze.RecalculateNormals();

    return maze;
}

//1, 2
private void AddQuad(Matrix4x4 matrix, ref List<Vector3> newVertices,
    ref List<Vector2> newUVs, ref List<int> newTriangles)
{
    int index = newVertices.Count;

    // corners before transforming
    Vector3 vert1 = new Vector3(-.5f, -.5f, 0);
    Vector3 vert2 = new Vector3(-.5f, .5f, 0);
    Vector3 vert3 = new Vector3(.5f, .5f, 0);
    Vector3 vert4 = new Vector3(.5f, -.5f, 0);

    newVertices.Add(matrix.MultiplyPoint3x4(vert1));
    newVertices.Add(matrix.MultiplyPoint3x4(vert2));
    newVertices.Add(matrix.MultiplyPoint3x4(vert3));
    newVertices.Add(matrix.MultiplyPoint3x4(vert4));

    newUVs.Add(new Vector2(1, 0));
    newUVs.Add(new Vector2(1, 1));
    newUVs.Add(new Vector2(0, 1));
    newUVs.Add(new Vector2(0, 0));

    newTriangles.Add(index+2);
    newTriangles.Add(index+1);
    newTriangles.Add(index);

    newTriangles.Add(index+3);
    newTriangles.Add(index+2);
    newTriangles.Add(index);
}

Whew — that last batch of code was long! But again, it’s pretty much the same thing over and over with a few numbers changed. In particular, the code to generate a quad was moved into a separate AddQuad() method in order to call that repeatedly for the floor, ceiling, and walls of each grid cell.

  1. The final three parameters to AddQuad() are the same list of vertices, UVs, and triangles to append to. Meanwhile, the first line of the method gets the index to start from; as more quads are appended, the index will increase.
  2. However, the first AddQuad() parameter is a transformation matrix, and that part may be confusing. Essentially, a position/rotation/scale can be stored in a matrix, and then applied to the vertices. That's what the MultiplyPoint3x4() calls are doing. That way, the exact same code to generate a quad can be used for floors, walls, etc. You only need to vary the transformation matrix being used!
  3. Going back to FromData(), lists for vertices, UVs, and triangles are created at the top. This time, there are two lists of triangles. Unity's Mesh object can have multiple sub-meshes with a different material on each, so each list of triangles is a different sub-mesh. You declare two sub-meshes so that you can assign different materials to the floor and walls.
  4. After that, you iterate through the 2D array and build quads for floor, ceiling, and walls at every grid cell. While every cell needs a floor and ceiling, there are checks of adjacent cells to see which walls are needed. Note how AddQuad() is being called repeatedly, but with a different transform matrix each time, and with different triangle lists used for floors and walls. Also note that width and height are used to determine where quads are positioned and how big they are.
  5. Oh and one other subtle addition: RecalculateNormals() prepares the mesh for lighting.

Press Play to see the full maze mesh generated:

Game & Scene views

Congratulations; that was the entirety of maze generation, and the majority of the programming needed for Speedy Treasure Thief! You'll wrap up the rest of the game in the next section.

Finishing the Game

You need to make some more additions and changes in the code, but first let's go over what was provided by the starter package. As mentioned in the introduction, the starter package included two scripts, a scene with the player and UI, and all the graphics for the maze game. The FpsMovement script is simply a one-script version of the character controller from my book, while TriggerEventRouter is a utility that's handy for triggers in the game.

The scene has the player already set up, including an FpsMovement component and a spotlight attached to the camera. The skybox and environment lighting are also turned off in the Lighting Settings window. Finally, the scene has a UI canvas, with labels for score and time already placed.

That summarizes what the starter package provided. Now you’ll write the remaining code for this game.

First up is MazeConstructor. First, add the following properties to store sizes and coordinates:

public float hallWidth
{
    get; private set;
}
public float hallHeight
{
    get; private set;
}

public int startRow
{
    get; private set;
}
public int startCol
{
    get; private set;
}

public int goalRow
{
    get; private set;
}
public int goalCol
{
    get; private set;
}

Now to add some new methods. The first one is DisposeOldMaze(); as the name implies, this deletes any existing maze. The code finds all objects with the Generated tag and destroys them.

public void DisposeOldMaze()
{
    GameObject[] objects = GameObject.FindGameObjectsWithTag("Generated");
    foreach (GameObject go in objects) {
        Destroy(go);
    }
}

The next method to add is FindStartPosition(). This code starts at 0,0 and iterates through the maze data until it finds an open space. Then those coordinates are stored as the maze's start position.

private void FindStartPosition()
{
    int[,] maze = data;
    int rMax = maze.GetUpperBound(0);
    int cMax = maze.GetUpperBound(1);

    for (int i = 0; i <= rMax; i++)
    {
        for (int j = 0; j <= cMax; j++)
        {
            if (maze[i, j] == 0)
            {
                startRow = i;
                startCol = j;
                return;
            }
        }
    }
}

Similarly, FindGoalPosition() does essentially the same thing, only starting with max values and counting down. Add this method as well.

private void FindGoalPosition()
{
    int[,] maze = data;
    int rMax = maze.GetUpperBound(0);
    int cMax = maze.GetUpperBound(1);

    // loop top to bottom, right to left
    for (int i = rMax; i >= 0; i--)
    {
        for (int j = cMax; j >= 0; j--)
        {
            if (maze[i, j] == 0)
            {
                goalRow = i;
                goalCol = j;
                return;
            }
        }
    }
}

PlaceStartTrigger() and PlaceGoalTrigger() place objects in the scene at the start and goal positions. Their collider is set to be a trigger, the appropriate material is applied, and then TriggerEventRouter (from the starter package) is added. This component takes a callback function, to call when something enters the trigger volume. Add these two methods as well.

private void PlaceStartTrigger(TriggerEventHandler callback)
{
    GameObject go = GameObject.CreatePrimitive(PrimitiveType.Cube);
    go.transform.position = new Vector3(startCol * hallWidth, .5f, startRow * hallWidth);
    go.name = "Start Trigger";
    go.tag = "Generated";

    go.GetComponent<BoxCollider>().isTrigger = true;
    go.GetComponent<MeshRenderer>().sharedMaterial = startMat;

    TriggerEventRouter tc = go.AddComponent<TriggerEventRouter>();
    tc.callback = callback;
}

private void PlaceGoalTrigger(TriggerEventHandler callback)
{
    GameObject go = GameObject.CreatePrimitive(PrimitiveType.Cube);
    go.transform.position = new Vector3(goalCol * hallWidth, .5f, goalRow * hallWidth);
    go.name = "Treasure";
    go.tag = "Generated";

    go.GetComponent<BoxCollider>().isTrigger = true;
    go.GetComponent<MeshRenderer>().sharedMaterial = treasureMat;

    TriggerEventRouter tc = go.AddComponent<TriggerEventRouter>();
    tc.callback = callback;
}

Finally replace the entire GenerateNewMaze() method with the following:

public void GenerateNewMaze(int sizeRows, int sizeCols,
    TriggerEventHandler startCallback=null, TriggerEventHandler goalCallback=null)
{
    if (sizeRows % 2 == 0 && sizeCols % 2 == 0)
    {
        Debug.LogError("Odd numbers work better for dungeon size.");
    }

    DisposeOldMaze();

    data = dataGenerator.FromDimensions(sizeRows, sizeCols);

    FindStartPosition();
    FindGoalPosition();

    // store values used to generate this mesh
    hallWidth = meshGenerator.width;
    hallHeight = meshGenerator.height;

    DisplayMaze();

    PlaceStartTrigger(startCallback);
    PlaceGoalTrigger(goalCallback);
}

The rewritten GenerateNewMaze() calls the new methods you just added for things like disposing the old mesh and placing triggers.

You've added a lot to MazeConstructor! Well done. Fortunately you’re done with that class now. Just one more set of code to go.

Now add some additional code in GameController. Replace the entire contents of the file with the following:

using System;
using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(MazeConstructor))]

public class GameController : MonoBehaviour
{
    //1
    [SerializeField] private FpsMovement player;
    [SerializeField] private Text timeLabel;
    [SerializeField] private Text scoreLabel;

    private MazeConstructor generator;

    //2
    private DateTime startTime;
    private int timeLimit;
    private int reduceLimitBy;

    private int score;
    private bool goalReached;

    //3
    void Start() {
        generator = GetComponent<MazeConstructor>();
        StartNewGame();
    }

    //4
    private void StartNewGame()
    {
        timeLimit = 80;
        reduceLimitBy = 5;
        startTime = DateTime.Now;

        score = 0;
        scoreLabel.text = score.ToString();

        StartNewMaze();
    }

    //5
    private void StartNewMaze()
    {
        generator.GenerateNewMaze(13, 15, OnStartTrigger, OnGoalTrigger);

        float x = generator.startCol * generator.hallWidth;
        float y = 1;
        float z = generator.startRow * generator.hallWidth;
        player.transform.position = new Vector3(x, y, z);

        goalReached = false;
        player.enabled = true;

        // restart timer
        timeLimit -= reduceLimitBy;
        startTime = DateTime.Now;
    }

    //6
    void Update()
    {
        if (!player.enabled)
        {
            return;
        }

        int timeUsed = (int)(DateTime.Now - startTime).TotalSeconds;
        int timeLeft = timeLimit - timeUsed;

        if (timeLeft > 0)
        {
            timeLabel.text = timeLeft.ToString();
        }
        else
        {
            timeLabel.text = "TIME UP";
            player.enabled = false;

            Invoke("StartNewGame", 4);
        }
    }

    //7
    private void OnGoalTrigger(GameObject trigger, GameObject other)
    {
        Debug.Log("Goal!");
        goalReached = true;

        score += 1;
        scoreLabel.text = score.ToString();

        Destroy(trigger);
    }

    private void OnStartTrigger(GameObject trigger, GameObject other)
    {
        if (goalReached)
        {
            Debug.Log("Finish!");
            player.enabled = false;

            Invoke("StartNewMaze", 4);
        }
    }
}
  1. The first things added are serialized fields for objects in the scene.
  2. Several private variables were added to keep track of the game's timer, score, and if the maze's goal was found yet.
  3. MazeConstructor is initialized just like before, but now Start() uses new methods that do more than just calling GenerateNewMaze().
  4. StartNewGame() is used to start the entire game from the beginning, as opposed to switching levels within a game. The timer is set to starting values, score is reset, and then a maze is created.
  5. StartNewMaze() progresses to next level without starting the entire game over. Besides generating a new maze, this method places the player at the start, resets the goal, and reduces the time limit.
  6. Update() checks if the player is active, and then updates time remaining to complete the level. Once time is up, the player is deactivated and a new game is started.
  7. OnGoalTrigger() and OnStartTrigger() are callbacks passed to TriggerEventRouter in MazeConstructor. OnGoalTrigger() records that the goal was found, and then increments the score. OnStartTrigger() checks if the goal was found, then deactivates the player and starts a new maze.

That's all of the code. Turn your attention back to the scene in Unity. First, select the Canvas in the Hierarchy window and enable it in the Inspector. The Canvas was turned off so as not to interfere with the debug display while building the maze code. Remember that serialized fields were added, so drop those scene objects (Player, Time label on Canvas, and Score label) onto the slots in the Inspector. You probably also want to turn off Show Debug, then hit Play:

Speedy Treasure Thief

Great job! Procedurally generating mazes can be tricky, but they result in engaging and dynamic gameplay.

Where To Go From Here?

If you've been following along, you should have created the complete game by now. If you want, feel free to download the finished Unity project from this tutorial here.

Going forward, you could explore other maze generation algorithms, replacing the code in FromDimensions(). You could also try generating other environments; start by looking up cave generation using cellular automata.
Randomly generating items and enemies around the map may also prove to be a lot of fun!

I hope you enjoyed this tutorial, and if you have any questions or comments, please join the discussion below!

Team

Each tutorial at www.raywenderlich.com is created by a team of dedicated developers so that it meets our high quality standards. The team members who worked on this tutorial are:

Joseph Hocking

Professional Game Programmer, Author of Unity in Action

Joe's a software engineer living in Chicago, specializing in interactive media development. He builds games and apps for both mobile and web using technologies like C#/Unity and JavaScript/HTML5. Besides his professional work, he maintains a dev log about his personal projects.

Other Items of Interest

Save time.
Learn more with our video courses.

raywenderlich.com Weekly

Sign up to receive the latest tutorials from raywenderlich.com each week, and receive a free epic-length tutorial as a bonus!

Advertise with Us!

PragmaConf 2016 Come check out Alt U

Our Books

Our Team

Video Team

... 27 total!

iOS Team

... 83 total!

Android Team

... 47 total!

Unity Team

... 16 total!

Articles Team

... 4 total!

Resident Authors Team

... 32 total!

Podcast Team

... 4 total!

Recruitment Team

... 8 total!

Illustration Team

... 4 total!