Skip to content

Implement Mazes in Unity2D

Implement Mazes in Unity2D

In this tutorial, we will take you through the step-by-step process of generating mazes in Unity2D, from setting up your project to implementing the backtracking algorithm for maze generation. By the end of this tutorial, you will have the skills and knowledge to create captivating mazes that will keep your players engaged and entertained.

Contact me

Find the GitHub repository of this tutorial at https://github.com/shamim-akhtar/tutorial-maze.

View the Tutorial on YouTube

Introduction to Mazes

Not too far ago, mazes were somewhat central to video games. Arguably, the original first-person shooter game, literally named Maze, was created by students on Imlac computers at a NASA laboratory in 1974 – source Wikipedia.

Mazes have long been a staple in video games, offering players a challenging and immersive experience as they navigate through winding pathways and hidden corridors. And now, with Unity, you can bring these intricate mazes to life in your own projects. 

Mazes have been captivating human imagination for centuries, serving as enigmatic puzzles, architectural wonders, and even symbolic representations in various cultures worldwide. Defined by intricate pathways, walls, and dead ends, mazes challenge our spatial reasoning, problem-solving abilities, and perseverance.

In the realm of gaming, mazes hold a special place, offering players a thrilling journey of exploration and discovery. From ancient text-based adventures to modern virtual realities, mazes have been a recurring motif, testing the wit and agility of players as they navigate through twists and turns, seeking escape or uncovering hidden treasures.

In this tutorial, we will learn how to implement the backtracking algorithm and generate mazes dynamically.

The Unity Project

Go ahead and create a new Unity2D project, naming the project UnityMazeGen. Once Unity is loaded, rename the default sample scene as Scene_MazeGeneration.

The Room Prefab

We will first create a room prefab. We will use this prefab as a unit cell in the 2D grid. For this tutorial, we will keep things simple. We will create a room that is made up of four walls. 

Right-click on the scene hierarchy and create a new empty game object. Rename it to Room. Select this game object and then add a new 2d square sprite. Resize it to have a length of 5 units and a height of 1 unit. Change the transform’s position value with x as 0 and y as 3.

Rename this sprite to Top. Now, duplicate this sprite and change the position to y as -3. This change will relocate it to the bottom. Rename the sprite to Down.

Similarly, now add another 2d square sprite. This time, resize it to have a length of 1 unit and a height of 5 units. Change the transform’s position value with x as 3 and y as 0. Rename this sprite to Right. Now, duplicate this sprite and change the position to x as -3. This change will relocate it to the left. Rename the sprite to Left. 

After that, create four more 2d square sprites for the four corners, each of size 1 unit. Rename these four sprites to corner underscore left top, corner underscore right top, corner underscore right bottom, and corner underscore left bottom. Change their positions to x as -3 and y as 3, x as 3 and y as 3, x as 3 and y as -3 and x as -3 and y as -3, respectively.   

We will now add the floor of the room by creating another 2D square sprite, resizing it to 7 and 7 and setting the z value to 1. Change the RGB colour of the sprite to 150, 150 and 150.

Select all the wall and corner sprites and change the RGB colour to 200, 200 and 200.

Go to the project window and create a new folder called Scripts. Add a new script called Room in this folder. Add this script component to the Room game object. 

Double-click the Room script and open it in Visual Studio or your favourite editor.

The Room Script

This script’s primary purpose is to control and manage the visibility of walls in different directions and enable the creation of a continuous walkway for the maze.

We start with defining an enumeration called Directions. This enumeration will help us identify the different directions our walls can face: top, right, bottom, and left.

  public enum Directions
  {
    TOP,
    RIGHT,
    BOTTOM,
    LEFT,
    NONE,
  }
Code language: C# (cs)

We then declare some serialised fields of type GameObject to represent the walls in each direction. These fields will be assigned in the Unity Editor.

  [SerializeField]
  GameObject topWall;
  [SerializeField]
  GameObject rightWall;
  [SerializeField]
  GameObject bottomWall;
  [SerializeField]
  GameObject leftWall;
Code language: C# (cs)

Next, we create a dictionary called walls, which maps each direction to its corresponding wall GameObject.

  Dictionary<Directions, GameObject> walls =
    new Dictionary<Directions, GameObject>();
Code language: HTML, XML (xml)

We then implement a property called Index of type Vector2Int. This property will store the index of the room in the game grid.

  public Vector2Int Index
  {
    get;
    set;
  }
Code language: C# (cs)

We also have a boolean property called visited, which indicates whether this wall has been visited or not. By default, it’s set to false.

public bool visited { get; set; } = false;
Code language: C# (cs)

Another dictionary called direction flag is used to store the flag indicating whether each direction’s wall is active or not.

Dictionary<Directions, bool> dirflags =
    new Dictionary<Directions, bool>();
Code language: C# (cs)

In the Start method, we populate our walls dictionary with the assigned GameObjects for each direction.

This allows us to easily access and manipulate the visibility of walls in different directions throughout our game environment, providing a convenient way to control the state of individual walls based on their direction.

  private void Start()
  {
    walls[Directions.TOP] = topWall;
    walls[Directions.RIGHT] = rightWall;
    walls[Directions.BOTTOM] = bottomWall;
    walls[Directions.LEFT] = leftWall;
  }
Code language: C# (cs)

We then define a private method called SetActive, which takes a direction and a boolean flag indicating whether to activate or deactivate the wall in that direction.

  private void SetActive(Directions dir, bool flag)
  {
    walls[dir].SetActive(flag);
  }
Code language: C# (cs)

Finally, we implement a public method called SetDirectionFlag, which allows us to set the flag for a specific direction’s wall and update its active state accordingly. This method will be called by the maze generator later when we implement it.

  public void SetDirFlag(Directions dir, bool flag)
  {
    dirflags[dir] = flag;
    SetActive(dir, flag);
  }Code language: JavaScript (javascript)

Now, go to the Unity editor and select the Room GameObject. After that, associate the wall GameObjects with the correct script wall field in the inspector.

Go to the project window and create a new folder called Resources in the assets folder. Go inside the resources folder and create a new folder called the prefabs. Drag and drop the room GameObject inside this prefabs folder to make the room a prefab.

The Generate Maze Script

We shall now start with the maze generation process. For this, create an empty GameObject and name it GenerateMaze. Create a new script called GenerateMaze in the scripts folder. Drag and drop this new script into the GenerateMaze GameObject. Double-click the script and open it in Visual Studio or your favourite editor.

The Member Variables

First, we add the necessary variables to facilitate the generation and management of a maze within the Unity environment.

We add a variable called the roomPrefab. This variable will hold the prefab of a single room in our maze. We will instantiate copies of this prefab to create the individual rooms in our maze grid.

  [SerializeField]
  GameObject roomPrefab;

Code language: C# (cs)

We then add the 2D array of rooms. This variable will represent the grid of rooms where our maze will be constructed. Each element in the array will correspond to a specific room in the maze.

  // The grid.
  Room[,] rooms = null;
Code language: C# (cs)

Then, we add the variables that will determine the number of rooms along the X and Y axes of the maze grid, respectively. They will define the size of our maze grid.

  [SerializeField]
  int numX = 10;
  [SerializeField]
  int numY = 10;
Code language: C# (cs)

After that, we add two float variables, roomWidth and roomHeight. These variables will store the width and height of the template room in the maze, defined by the roomPrefab. We will calculate these values based on the dimensions of the room prefab.

  // The room width and height.
  float roomWidth;
  float roomHeight;
Code language: PHP (php)

Now, we add a stack data structure that we will use for backtracking during maze generation. 

  // The stack for backtracking.
  Stack<Room> stack = new Stack<Room>();
Code language: C# (cs)

Finally, we add a boolean variable called generating that will indicate whether maze generation is currently in progress. It will be used to prevent the initiation of a new maze generation process while one is already underway.

  bool generating = false;
Code language: C# (cs)

Get Room Size Function

We will now implement the GetRoomSize method.

  private void GetRoomSize()
  {
    SpriteRenderer[] spriteRenderers =
      roomPrefab.GetComponentsInChildren<SpriteRenderer>();

    Vector3 minBounds = Vector3.positiveInfinity;
    Vector3 maxBounds = Vector3.negativeInfinity;

    foreach(SpriteRenderer ren in spriteRenderers)
    {
      minBounds = Vector3.Min(
        minBounds,
        ren.bounds.min);

      maxBounds = Vector3.Max(
        maxBounds,
        ren.bounds.max);
    }

    roomWidth = maxBounds.x - minBounds.x;
    roomHeight = maxBounds.y - minBounds.y;
  }
Code language: C# (cs)

This method is responsible for determining the width and height of the individual room prefab that will be used to construct the maze. We begin by finding all the child sprites of the room Prefab using the GetComponentsInChildren method. 

We then initialise two Vector3 variables, minBounds and maxBounds, to positive and negative infinity, respectively. These variables will be used to store the minimum and maximum bounds of the room prefab.

Next, we iterate through each child sprite obtained earlier using a “foreach” loop. For each sprite renderer, we update the minBounds and maxBounds variables using Vector3.Min and Vector3.Max functions to ensure they encapsulate the entire bounds of the room prefab.

After iterating through all child sprites, we calculate the width and height of the room prefab by subtracting the minimum bounds from the maximum bounds along the X and Y axes, respectively. These values are stored in the roomWidth and roomHeight variables.

Set Camera Position Function

Next, we implement the SetCamera method.

  private void SetCamera()
  {
    Camera.main.transform.position = new Vector3(
      numX * (roomWidth - 1) / 2,
      numY * (roomHeight - 1) / 2,
      -100.0f);

    float min_value = Mathf.Min(
      numX * (roomWidth - 1), 
      numY * (roomHeight - 1));
    Camera.main.orthographicSize = min_value * 0.75f;
  }
Code language: C# (cs)

This method is responsible for positioning the main camera in a way that ensures the entire maze is visible within its view. First, it calculates the position based on the dimensions of the maze grid and the size of each room. It positions the camera at the centre of the maze grid, slightly adjusted to ensure that it captures the entire maze. 

After that, it determines the minimum dimension of the maze (either the total width or height) and sets the orthographic size of the camera accordingly. Doing so ensures that the entire maze fits within the camera’s view, with a little extra padding around the edges for better aesthetics.

The Start Function

After that, we implement the Start method. This method is called when the script is initialised, typically at the beginning of the game or when the object containing the script is enabled.

  private void Start()
  {
    GetRoomSize();

    rooms = new Room[numX, numY];

    for(int i = 0; i < numX; ++i)
    {
      for(int j = 0; j < numY; ++j)
      {
        GameObject room = Instantiate(roomPrefab,
          new Vector3(i * roomWidth, j * roomHeight, 0.0f),
          Quaternion.identity);

        room.name = "Room_" + i.ToString() + "_" + j.ToString();
        rooms[i, j] = room.GetComponent<Room>();
        rooms[i, j].Index = new Vector2Int(i, j);
      }
    }

    SetCamera();
  }
Code language: C# (cs)

We first call GetRoomSize to calculate the size of each room in the maze based on the dimensions of the room’s sprites.

Then, we initialise the 2D array rooms to store references to each room in the maze.

Next, we use a nested loop to iterate through each grid position in the maze (with numX rows and numY columns).

Inside the loop, we instantiate a room GameObject at the calculated position based on the grid index (i and j) and the size of each room (line 11-13).

We set the name of each room GameObject to indicate its position in the grid (line 15).

We retrieve the Room component attached to each instantiated room GameObject and store it in the rooms array at the corresponding grid position (line 17).

Finally, we call SetCamera (line 21) to position and adjust the main camera to ensure the entire maze is visible within its view.

The Remove Wall Function

We now implement the RemoveRoomWall method. This method is responsible for removing a wall of a given direction from a specified room in the maze.

In this method, we first set the flag of the wall in the specified direction of the room at position (x, y) to false, indicating that the wall should be removed. We only set false if the Room.Direction enum type is not NONE.

  private void RemoveRoomWall(
    int x,
    int y,
    Room.Directions dir)
  {
    if (dir != Room.Directions.NONE)
    {
      rooms[x, y].SetDirFlag(dir, false);
    }Code language: C# (cs)

Then, based on the direction provided, we determine the opposite direction from which we will remove the corresponding wall in the adjacent room.

The left square is the current cell. From the current cell, we are moving right. Meaning we will need to remove the wall with the RIGHT direction from the current cell, and subsequently, we will need to remove the LEFT wall from the neighbouring cell.
A corridor is formed after removing the two walls.
    Room.Directions opp = Room.Directions.NONE;

Using a switch statement, we check the provided direction and update the opposite direction accordingly, considering the boundary conditions to avoid accessing out-of-bounds array elements.

    switch(dir)
    {Code language: C# (cs)

For case TOP,  we check if the given room is not at the topmost row of the maze. If true, we set the opposite direction to BOTTOM and increment the y.

      case Room.Directions.TOP:
        if(y < numY - 1)
        {
          opp = Room.Directions.BOTTOM;
          ++y;
        }
        break;Code language: C# (cs)

For case RIGHT, we check if the given room is not at the rightmost column of the maze. If true, we set the opposite direction to LEFT and increment the x.

      case Room.Directions.RIGHT:
        if (x < numX - 1)
        {
          opp = Room.Directions.LEFT;
          ++x;
        }
        break;Code language: C# (cs)

For case BOTTOM, we check if the given room is not at the bottom-most row of the maze. If true, we set the opposite direction to TOP and decrement the y.

      case Room.Directions.BOTTOM:
        if (y > 0)
        {
          opp = Room.Directions.TOP;
          --y;
        }
        break;Code language: JavaScript (javascript)

For case LEFT, we check if the given room is not at the leftmost column of the maze. If true, we set the opposite direction to RIGHT and decrement the x.

      case Room.Directions.LEFT:
        if (x > 0)
        {
          opp = Room.Directions.RIGHT;
          --x;
        }
        break;
    }Code language: C# (cs)

Finally, we call the SetDirectionFlag method of the adjacent room to set the flag of the wall in the opposite direction to false, effectively removing the wall between the current room and its adjoining room in the specified direction.

    if (opp != Room.Directions.NONE)
    {
      rooms[x, y].SetDirFlag(opp, false);
    }
  }Code language: C# (cs)

This method could be very error-prone. Pay special attention to the boundary conditions.

The Get Neighbours Not Visited Function

Now, we shall implement the function GetNeighboursNotVisited, which is responsible for finding neighbouring rooms that have not been visited yet.

The figure shows the neighbour cells of the current cell in dark blue. In this case, all four neighbour cells are not visited yet.
The figure shows the neighbour cells of the current cell in dark blue. In this case, 1, 3, and 4 cells are neighbours that are not visited. cell 2 to the right is marked visited and so is not added to the list of neighbours.

This function takes in two parameters, cx and cy, representing the x and y coordinates of the current room.

  public List<Tuple<Room.Directions, Room>> GetNeighboursNotVisited(
    int cx, int cy)
  {Code language: C# (cs)

Next, we initialise an empty list named neighbours to store the neighbouring rooms that have not been visited. We then loop over each direction using a foreach loop and the Enum.GetValues method to get all possible values of the Directions enum.

Inside the loop, we declare variables x and y to represent the coordinates of the neighbouring room, initially set to the current room’s coordinates. For each direction, we use a switch statement to handle the different cases. 

    List<Tuple<Room.Directions, Room>> neighbours =
      new List<Tuple<Room.Directions, Room>>();
    foreach(Room.Directions dir in Enum.GetValues(
      typeof(Room.Directions)))
    {
      int x = cx;
      int y = cy;
      switch(dir)
      {Code language: C# (cs)

In each case, we calculate the coordinates of the neighbouring room based on the current room’s coordinates and then check if the adjoining room is within the bounds of the maze and whether it has been visited or not.

If the neighbouring room meets the criteria, we create a tuple containing the direction and the room and add it to the neighbours list. We do this for all the cases. 

      switch(dir)
      {
        case Room.Directions.TOP:
          if(y < numY - 1)
          {
            ++y;
            if (!rooms[x,y].visited)
            {
              neighbours.Add(new Tuple<Room.Directions, Room>(
                Room.Directions.TOP, 
                rooms[x, y]));
            }
          }
          break;
        case Room.Directions.RIGHT:
          if(x < numX - 1)
          {
            ++x;
            if (!rooms[x, y].visited)
            {
              neighbours.Add(new Tuple<Room.Directions, Room>(
                Room.Directions.RIGHT,
                rooms[x, y]));
            }
          }
          break;
        case Room.Directions.BOTTOM:
          if (y > 0)
          {
            --y;
            if (!rooms[x, y].visited)
            {
              neighbours.Add(new Tuple<Room.Directions, Room>(
                Room.Directions.BOTTOM,
                rooms[x, y]));
            }
          }
          break;
        case Room.Directions.LEFT:
          if (x > 0)
          {
            --x;
            if (!rooms[x, y].visited)
            {
              neighbours.Add(new Tuple<Room.Directions, Room>(
                Room.Directions.LEFT,
                rooms[x, y]));
            }
          }
          break;
      }Code language: C# (cs)

Finally, we return the list of neighbouring rooms that have not been visited yet.

    }
    return neighbours;
  }Code language: C# (cs)

The Generate Step Function

We will now move on to implement the GenerateStep method, which plays a pivotal role in maze generation. This method simulates the process of exploring and carving out paths in the maze.

Within the method, we start by checking if the stack of rooms is empty. If it is, it means that we have explored all possible paths, and we return true to indicate that maze generation is complete. 

  private bool GenerateStep()
  {
    if (stack.Count == 0) return true;Code language: C# (cs)

We then peek at the top room in the stack to examine its neighbouring rooms.

    Room r = stack.Peek();Code language: C# (cs)

Next, we call the GetNeighboursNotVisited method to find neighbouring rooms that haven’t been visited yet.

    var neighbours = GetNeighboursNotVisited(r.Index.x, r.Index.y);Code language: C# (cs)

If there are unvisited neighbouring rooms, we randomly select one of them. We marked the selected neighbouring room as visited and removed the wall between it and the current room. Finally, we push the selected neighbouring room onto the stack for further exploration.

    if(neighbours.Count != 0)
    {
      var index = 0;
      if(neighbours.Count > 1)
      {
        index = UnityEngine.Random.Range(0, neighbours.Count);
      }
      var item = neighbours[index];
      Room neighbour = item.Item2;
      neighbour.visited = true;
      RemoveRoomWall(r.Index.x, r.Index.y, item.Item1);
      stack.Push(neighbour);
    }Code language: C# (cs)

If there are no unvisited neighbouring rooms, we backtrack by popping the current room from the stack. At the end of the method, we return false to indicate that maze generation is still in progress.

    else
    {
      stack.Pop();
    }
    return false;
  }Code language: C# (cs)

The Create Maze Method

After this, we will implement the CreateMaze method. Inside the method, we begin by checking if maze generation is already in progress. If it is, we simply return without doing anything. Next, we call the Reset method to reset the maze’s state. This involves marking all rooms as unvisited and resetting their walls to their default state. We have not yet implemented this method.

  public void CreateMaze()
  {
    if (generating) return;
    Reset();Code language: C# (cs)

We will then remove the bottom wall of the bottom-left room to ensure there is an entrance point to the maze. Similarly, we will remove the right wall of the top-right room to ensure there’s an exit point from the maze. 

    RemoveRoomWall(0, 0, Room.Directions.BOTTOM);
    RemoveRoomWall(numX - 1, numY - 1, Room.Directions.RIGHT);Code language: C# (cs)

We then push the bottom-left room onto the stack to be the first cell in the maze, and finally, we start the maze generation process by invoking the Coroutine_Generate method as a coroutine. This allows us to visualise the maze generation process step by step.

    stack.Push(rooms[0, 0]);
    StartCoroutine(Coroutine_Generate());
  }Code language: C# (cs)

The Maze Generation Coroutine

We now move on to implement the coroutine.

  IEnumerator Coroutine_Generate()
  {
    generating = true;
    bool flag = false;
    while(!flag)
    {
      flag = GenerateStep();
      yield return new WaitForSeconds(0.05f);
    }
    generating = false;
  }Code language: C# (cs)

We start by setting the generating flag to true, indicating that maze generation is currently in progress. We initialize a boolean variable flag to false. This flag will be used to determine whether maze generation is complete. Inside a while loop, we continuously call the GenerateStep method until the flag becomes true, indicating that maze generation is complete. After each iteration, we yield WaitForSeconds with 0.05 seconds. This pauses the coroutine for a short duration, allowing us to visualise the maze generation process step by step. Once maze generation is complete, we set the generating flag back to false.

To show the animation of the maze creation process, I have set the yield return for the coroutine to a specific duration. You may change this duration, or you may yield return null.

The Reset Function

We shall now implement the Reset method.

  private void Reset()
  {
    for(int i = 0; i < numX; ++i)
    {
      for(int j = 0; j < numY; ++j)
      {
        rooms[i, j].SetDirFlag(Room.Directions.TOP, true);
        rooms[i, j].SetDirFlag(Room.Directions.RIGHT, true);
        rooms[i, j].SetDirFlag(Room.Directions.BOTTOM, true);
        rooms[i, j].SetDirFlag(Room.Directions.LEFT, true);
        rooms[i, j].visited = false;
      }
    }
  }Code language: C# (cs)

In this method, we iterate through the nested for loop and set the flags of all four walls (top, right, bottom, and left) to true, indicating that they are intact. Additionally, we also reset the visited flag of each room to false, indicating that the room has not been visited during maze generation.

The Update Function

Finally, we implement the Update method, where we check if the spacebar key is pressed. If the spacebar is pressed and the maze generation is not currently in progress, call the CreateMaze method.

This allows the user to trigger maze generation by pressing the spacebar key while ensuring that maze generation does not overlap if it’s already underway.

  private void Update()
  {
    if(Input.GetKeyDown(KeyCode.Space))
    {
      if(!generating)
      {
        CreateMaze();
      }
    }
  }Code language: SAS (sas)

Test Maze Generation

Now, go to the Unity Editor, select GenerateMaze GameObject from the hierarchy. Go to the inspector and associate the Room Prefab field by dragging and dropping the room prefab from the prefabs folder. Key in Num X as 16 and Num Y as 9.

Click Play and press the space bar. See the maze generation in action. 

Press the space bar again to recreate the maze.

And that’s it. We have implemented our dynamic maze generation in Unity2d using the backpropagation algorithm. In the next tutorial, we will enhance the maze generation with some more visual representations and then apply pathfinding within the maze. Till then, happy coding!

Read My Other Tutorials

  1. Reusable Finite State Machine using C++
  2. Flocking and Boids Simulation in Unity2D
  3. Runtime Depth Sorting of Sprites in a Layer
  4. Implement Constant Size Sprite in Unity2D
  5. Implement Camera Pan and Zoom Controls in Unity2D
  6. Implement Drag and Drop Item in Unity
  7. Graph-Based Pathfinding Using C# in Unity
  8. 2D Grid-Based Pathfinding Using C# and Unity
  9. 8-Puzzle Problem Using A* in C# and Unity
  10. Create a Jigsaw Puzzle Game in Unity
  11. Implement a Generic Pathfinder in Unity using C#
  12. Create a Jigsaw Puzzle Game in Unity
  13. Generic Finite State Machine Using C#
  14. Implement Bezier Curve using C# in Unity
  15. Create a Jigsaw Tile from an Existing Image
  16. Create a Jigsaw Board from an Existing Image
  17. Solving 8 puzzle problem using A* star search
  18. A Configurable Third-Person Camera in Unity
  19. Player Controls With Finite State Machine Using C# in Unity
  20. Finite State Machine Using C# Delegates in Unity
  21. Implementing a Finite State Machine Using C# in Unity
  22. Solving 8 puzzle problem using A* star search in C++

Leave a Reply

Your email address will not be published. Required fields are marked *