Procedural Generation Of Mazes With Unity

Create a procedurally generated maze from scratch with Unity! By Joseph Hocking.

Leave a rating/review
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

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.