Procedural environment – The Backrooms

By Jordy Wolf

[Whitespace]

Table of contents

The plan

For the RnD I want to make an infinitely generating environment in the style of the backrooms. The reason I chose this style is because I like the way it looks, and it is a good environment to generate. This is because the environment is very bare, and the generated layout does not have to be logical and realistic.

Creating the aesthetic

Before I started working on any code, I wanted to use the Unity universal rendering pipeline and use its components to quickly and easily create a good-looking environment, that matches the vibe of the original. I created a new Unity project that uses the universal rendering pipeline template with Unity version 2022.1.14f1. I ended up having a problem where the camera would not render any objects, this problem was fixed by upgrading the unity version to 2022.2.9.f1. In the first day of working, I managed to create a good-looking environment using the URP and the Volume component, which fits the aesthetic of the backrooms.

The first prototype

The initial grid

For the first prototype of the infinite generation, I will make use of pre-made rooms that get generated in a grid. Then when the player moves in a direction, more rooms will be instantiated in the direction the player is moving. The rooms in the row that the player is moving away from will get deleted.

 public void GenerateWorld()
    {
        for (int i = 0; i < totalMapSize; i++)
        {
            if (currentPositionTracker == MapSize)
            {
                currentPosX = 0;
                currentPositionTracker = 0;
                currentPosZ += roomSize;
            }

            currentPos = new Vector3(currentPosX, 0, currentPosZ);

            GameObject Room = Instantiate(RoomPrefabs[Random.Range(0, roomPrefabs.Count)], currentPos, Quaternion.identity, worldGrid);
            Room.transform.eulerAngles = new Vector3(0, roomRotations[Random.Range(0, 4)], 0);

            RoomData roomData = new RoomData();
            roomData.Room = Room;
            roomData.RoomPosition = currentPos / roomSize;
            ActiveRooms.Add(roomData);

            currentPositionTracker++;
            currentPosX += roomSize;
        }
    }

For generating the initial grid of rooms, I keep track of the position where a room should be instantiated. Both the X and Z position are individually tracked by the floats currentPosX and currentPosZ. Every time a room gets instantiated, the size of the rooms gets added to currentPosX and another room is instantiated in that position and currentPositionTracker counts up by one. If currentPositionTracker is the same as the width of the grid (MapSize), currentPosX gets reset to zero and now the size of the rooms gets added to currentPosZ and the process of instantiating the rooms gets on the X axis gets repeated. This is done until the entire starting grid is instantiated.

This is a visual representation of the order the rooms get instantiated.

Procedural generation

For the procedural generation of the grid, I keep track of the position of the player. I divide the position of the player by the size of the rooms, round this down and then add 0,5 to both axes to make the position of the player change when it moves to a different room. If the 0,5 is not added, the position would change when the player crosses the middle of a room, instead of one of the borders. For an example of how the player position works, if the player would be standing anywhere inside the room marked with the 12 in the image above, its recalculated position would be x1, z2. I use this recalculated position of the player to make it easier for myself to determine if the player has moved to a different room and in which direction.

     void Update()
    {
        playerPosition = new Vector3(Mathf.Floor((player.transform.position.x / roomSize) + 0.5f), 0, Mathf.Floor((player.transform.position.z / roomSize) + 0.5f));

        if (playerPosition != playerPreviousPosition)
        {
            if (playerPosition.x > playerPreviousPosition.x) // player moved right
            {
                for (int i = 0; i < ActiveRooms.Count; i++)
                {
                    if (ActiveRooms[i].RoomPosition.x == (playerPosition.x - Mathf.Ceil(MapSize / 2) - 1))
                    {
                        MakeRoomInactiveX(i);
                    }
                }

                currentPosX = playerPosition.x + Mathf.Ceil(MapSize / 2);
                currentPosZ = playerPosition.z + Mathf.Ceil(MapSize / 2);

                for (int i = 0; i < MapSize; i++)
                {
                    currentPos = new Vector3(currentPosX, 0, currentPosZ);
                    currentPos *= roomSize;

                    GetRoomFromInactivePool();

                    currentPosZ--;
                }
            }

This is how the procedural generation works, the image above is how it works when the player moves to the right. The same code is used for the other directions, only some values that depend on the axis are different. The code also keeps track of what the recalculated player position was on the last frame. When the position of the last frame is different from the position on the current frame, we know the player has moved to a different room. We then determine the direction the player moved by some if statements checking both positions on the different axis. For instance, when the player moved to the right his new X position will be larger than it was in the last frame. For deleting the row of rooms that the player moved away from, each of the rooms gets checked if its X position is the same as the players x position minus half the size of the map, minus one. If this is true for the room, we know this room is on the far-left row and should be deleted. Then in similar fashion of generation the first grid, we instantiate new rooms in the direction of where the player moved. Because we instantiate new rooms in directions depending on the player, the list of rooms will be out of order after moving once. The order of rooms in the list is important as we use the order to determine what rooms need to get deleted on the Y axis by taking the first number of rooms depending on the size of the grid.

    public void SortRoomList()
    {
        ActiveRooms.Clear();

        GameObject[] GetRooms = GameObject.FindGameObjectsWithTag("Room");
        foreach (GameObject Room in GetRooms)
        {
            RoomData roomData = new RoomData();
            roomData.Room = Room;
            roomData.RoomPosition = Room.transform.position / roomSize;
            ActiveRooms.Add(roomData);
        }
        ActiveRooms.Sort(SortByPosition);
    }

    static int SortByPosition(RoomData pRoom1, RoomData pRoom2)
    {
        if (pRoom1.RoomPosition.z.CompareTo(pRoom2.RoomPosition.z) != 0)
        {
            return pRoom1.RoomPosition.z.CompareTo(pRoom2.RoomPosition.z);
        }
        else
        {
            return pRoom1.RoomPosition.x.CompareTo(pRoom2.RoomPosition.x);
        }
    }

So, every time the player moves a room, the list of rooms gets updated by removing the deleted rooms and adding the new rooms. And the list gets sorted depending on the X and Z position of the rooms.

Optimization with object pooling

This method of procedural generation works but creates lag spikes when new rooms get instantiated. This is an example of the lag spikes with a grid of 15×15, but with a grid as small as seven-by-seven these lag spikes occur. To fix this problem I added object pooling to the prototype.

    public void FillObjectPool()
    {
        for (int i = 0; i < MapSize * MapSize; i++)
        {
            foreach (GameObject Rooms in roomPrefabs)
            {
                GameObject Room = Instantiate(Rooms, InactiveRoomPool.position, Quaternion.identity, InactiveRoomPool);
                Room.SetActive(false);
            }
        }

        //Fill object pool with the empty rooms
        for (int i = 0; i < mapEmptiness * MapSize; i++)
        {
            foreach (GameObject EmptyRoom in emptyRooms)
            {
                GameObject Room = Instantiate(EmptyRoom, InactiveRoomPool.position, Quaternion.identity, InactiveRoomPool);
                Room.SetActive(false);
            }
        }
    }

In start FillObjectPool() gets called. This method instantiates all the rooms the game is going to need and sets them inactive.

    public void GetRoomFromInactivePool()
    {
        GameObject Room = InactiveRoomPool.GetChild(Random.Range(0, InactiveRoomPool.childCount)).gameObject;
        Room.transform.parent = worldGrid;
        Room.transform.position = currentPos;
        Room.transform.eulerAngles = new Vector3(0, roomRotations[Random.Range(0, 4)], 0);

        RoomData roomData = new RoomData();
        roomData.Room = Room;
        roomData.RoomPosition = currentPos / roomSize;
        ActiveRooms.Add(roomData);

        Room.SetActive(true);
    }

    public void MakeRoomInactiveX(int Index)
    {
        GameObject room = ActiveRooms[Index].Room;
        ActiveRooms.RemoveAt(Index);
        room.SetActive(false);
        room.transform.parent = InactiveRoomPool;
    }

    public void MakeRoomInactiveZ(RoomData pRoom)
    {
        ActiveRooms.Remove(pRoom);
        pRoom.Room.SetActive(false);
        pRoom.Room.transform.parent = InactiveRoomPool;
    }

Now instead of instantiating or deleting the rooms, they are just pulled out of the inactive rooms pool and set active if they are needed. If a room needs to be deleted, it is set inactive and put back in the inactive rooms pool. The reason there are two seperate “MakeRoomInactive” functions depending on the x and y axis, is because the way those rooms get selected works differently. Now it takes 20 to 30 seconds going into play mode, but there are no lag spikes when moving between rooms even with a grid of 15×15.

This prototype only has a couple of problems, the first one being that there is a chance of the player spawning inside a wall. The second problem that it is possible for the player spawn in a small room with no way out. Both these problems occur because there is no logic in spawning the different rooms. The last problem is that it is possible for the player to see the skybox and rooms spawning when there is a very long hallway. This last problem only really breaks the illusion of being stuck in an endless building, so is not really a priority for fixing. For the first two problems we can add logic to what rooms get generated. We can do this by using an algorithm called wave function collapse.

Wave Function Collapse algorithm

The wave function collapse algorithm is a procedural solver that takes a grid of cells each occupying a superposition containing all possible states for that cell, this is the wave function. Each potential state for a cell or tile comes with its own set of adjacency rules. So, a cell will only allow specific other cells adjacent to it, you could compare this to a sudoku puzzle. The wave function collapse algorithm will look for the cell with the lowest entropy (the lowest number of possible solutions for that cell) or pick one at random if entropy is equal among all the cells and collapse it to a single tile. It will then go through the process of propagating the consequences of this collapse to other cells. For example, in a 2D space, if a tile is made to be a ground tile the tiles below it will be restricted to only contain valid neighbours of that ground tile. So, tiles below it will now only be able to also become ground, as there should not be a tile of air under the ground. And now that its neighbouring cells have had their possibilities shrunk, the wave function collapse algorithm will need to continue propagating these new consequences neighbouring that cell and so on until all tiles invalidated by the collapse of our initial cell have been removed. This is a single iteration of the wave function collapse algorithm, and it will continue iterating over the wave function, starting again by finding a low entropy cell and collapsing it until all cells contain only a single tile. There is a tool that you can play around with, to help understand the wave function collapse algorithm.

Procedural generation using WFC

I could find many instances of people using the wave function collapse algorithm to create a type of environment. The perfect example of what I want to achieve is this article. specifically with the path constraint. Unfortunately, the article does not really go into how to code this. The developer has a unity asset, but it costs money, so it is not really an option. I ended up finding a unity project with a simple version of the algorithm. I decided to download this project so that I could try out the algorithm and look into the code. The way this version is coded is that each side of a cell gets assigned a value, based on that value it chooses which cells can be adjacent to it. I assigned numbers to each side of each pre-set room. Only walls that have the same value can connect to each other (I also made it so that wall 0 and wall 4 can connect, because it would have any connections except for with itself) I drew out the way the room connections work:

After adjusting my room models and changing settings so that they work in the premade unity project, I got this as the result.

While this looks pretty good, the algorithm might also create a not so good layout.

The algorithm also sometimes broke and generated many of just the same room, even though I set the contact restrictions specifically to not do that.

After running into these problems and trying to understand the code, I felt like even if it did work the generation would make the layout look not random enough. And with only two more weeks remaining I decided to look at different ways to generate the layout, that’s when I found this video for generating an infinite 3d maze. The tutorial even ends up making the maze look like the backrooms. I still find the wave function collapse algorithm very interesting, so I will probably return to it another time.

The second prototype

Maze generation algorithm

How this version of the generation works is that we instantiate a grid of rooms, with each room having four walls on each side. Then we let the algorithm walk around in the maze in random steps with each step removing the walls it went through. Then when the algorithm gets to a dead end, it goes back along its path to look for a different way to go and it will proceed from there. This will be done until all rooms have been visited.

public class MazeCell
{
    public bool visited;
    public int x, y;

    public bool topWall;
    public bool LeftWall;

    public Vector2Int position
    {
        get
        {
            return new Vector2Int(x, y);
        }
    }

    public MazeCell (int x, int y)
    {
        this.x = x;
        this.y = y;

        visited = false;

        topWall = LeftWall = true;
    }
}

First, we create a separate class that holds the data of each cell in the maze. Each cell keeps track of wether it has been visited, the position of the cell, and the top and left wall of the cell. We do not need to keep track of the right and lower walls, because these walls are just the top and left walls of other cells.

private void CarvePath (int x, int y)
    {
        if (x < 0 || y < 0 || x > mazeWidth - 1 || y > mazeHeight - 1)
        {
            x = y = 0;
            //Debug.LogWarning("Staring position is out of bounds, defaulting to 0, 0");
        }

        //Set current cell to the starting position we were passed
        currentCell = new Vector2Int(x, y);

        //A list to keep track of our current path
        List<Vector2Int> path = new List<Vector2Int>();

        //Loop until we encounter a dead end
        bool deadEnd = false;
        while (!deadEnd)
        {
            //Get the next cell to try
            Vector2Int nextCell = CheckNeigbours();

            //If that cell has no valid neigbours, set deadend to true so we break out of the loop
            if (nextCell == currentCell)
            {
                for (int i = path.Count - 1; i >= 0; i--)
                {
                    currentCell = path[i];          //Set currentcell to the next step back along our path
                    path.RemoveAt(i);               //Remove this step from the path
                    nextCell = CheckNeigbours();    //Check that cell to see if any other neighbours are valid

                    //If we find a valid neighbour, break out of the loop
                    if (nextCell != currentCell) break;
                }

                if (nextCell == currentCell) deadEnd = true;
            }
            else
            {
                BreakWalls(currentCell, nextCell);                  //Set wall flags on these two cells
                maze[currentCell.x, currentCell.y].visited = true;  //Set cell to visited before moving on
                currentCell = nextCell;                             //Set the current cell to the valid neighbour we found
                path.Add(currentCell);                              //Add this cell to our path
            }
        }
    }

For the algorithm, it first checks if the starting position is inside the bounds of the maze. Second, it sets the current cell to the staring position and create a list of positions to keep track of the current path. Next, we have a while loop that keeps going until the algorithm encounters a dead end. Then, it gets a random neighbouring cell and checks if it is valid. After that if the next cell is the same as the current cell (which means it encountered a dead end) it loops backwards through the path it took, checking the neighbours of the previous cells. If it finds a valid cell, the algorithm breaks out of the loop. If it still does not find a valid neighbour after looping back, deadEnd is set to true, which means the while loop stops and the maze is finished generating. If the algorithm does find a valid neighbour, it proceeds to line 153. There it breaks the walls between the two neighbours, it updates the cells it has visited, sets the current cell to that valid neighbour, and adds its position to the list of the path.

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

public class MazeRenderer : MonoBehaviour
{
    [SerializeField] MazeGenerator mazeGenerator;
    [SerializeField] GameObject MazeCellPrefab;

    public float CellSize = 10f;

    private void Start()
    {
        MazeCell[,] maze = mazeGenerator.GetMaze();

        for (int x = 0; x < mazeGenerator.mazeWidth; x++)
        {
            for (int y = 0; y < mazeGenerator.mazeHeight; y++)
            {
                GameObject newCell = Instantiate(MazeCellPrefab, new Vector3(x * CellSize, 0f, y * CellSize), Quaternion.identity, transform);

                MazeCellObject mazeCell = MazeCell.GetComponent<MazeCellObject>();

                bool top = maze[x, y].topWall;
                bool left = maze[x, y].LeftWall;

                bool right = false;
                bool bottom = false;

                if (x == mazeGenerator.mazeWidth - 1) right = true;
                if (y == 0) bottom = true;

                MazeCell.Init(top, bottom, right, left);
            }
        }
    }
}

Next to the script for the generation, we have a script for rendering the maze. Inside this script we instantiate all the cells and set their walls to the correct state. This script also makes sure there are walls along the edge of the maze. I actually do not want this, as I am going to place multiple mazes next to each other. To remove the edges, I changed line 30 and 31 to set the directions to false instead of true. And I added a copy of these lines that work for the left and top walls.

This is the result of the generation, which I am very pleased by. But there is only one flaw, there is a big chance the edges of the maze will be just a long straight hallway as you can see for the bottom and left edge of the maze. When placing multiple mazes next to each other, you might be able to see the void which breaks the illusion. To fix this we just make sure the edge of each maze gets generated.

[Whitespace]

A single maze generated with walls in the corners

[Whitespace]

Multiple mazes next to each other with the corner walls active

[Whitespace]

The selected back wall is placed by this extra measure to make sure you cannot see the void. As you can see, it works as intended.

Procedural generation

To make this version of the maze generation procedural, I use a similar technique to the one used in my first prototype. It is slightly different in the way that now it does not load and unload rows of rooms, but entire mazes. On start, nine mazes get generated and places in a three-by-three grid. Then when the player moves to a new maze, the three mazes behind the player get moved to the direction the player moved in, and a new maze layout is generated. I also ended up increasing the size of the walls on the corners of the maze to three cells wide, instead of just one.

    private void Update()
    {
        playerPosition = new Vector3(Mathf.Floor((player.transform.position.x + 5) / mazeSize), 0, Mathf.Floor((player.transform.position.z + 5) / mazeSize));

        if (playerPosition != playerPreviousPosition)
        {
            if (playerPosition.x > playerPreviousPosition.x) // player moved right
            {
                for (int i = 0; i < Mazes.Count; i++)
                {
                    if (Mazes[i].MazePosition.x == (playerPosition.x - (mazeWidth - 1)))
                    {
                        Mazes[i].Maze.transform.position = new Vector3(Mazes[i].Maze.transform.position.x + (mazeSize * mazeWidth), 0, Mazes[i].Maze.transform.position.z);
                        Mazes[i].mazeRenderer.GenerateNewMaze();
                    }
                }
            }

This is the code for when the player moved a maze to the right. As you can see it Is similar to what I used in the first prototype. The GenerateNewMaze function resets the walls of each cell in a maze, and then it generates a new path using the maze generation algorithm.

To fix the problem where the player sometimes can see the void, I added fog to mask the skybox. To solidify the illusion, I made the solid colour of the skybox the same colour of the fog.

Since the procedural generation is pretty much done and I still have a week until the deadline, I am going to introduce a monster into the maze. The monster will spawn in a random location inside the maze and will move towards the player. The monster only moves when out of sight, so when there are walls between him and the player. And, when the player is not looking in the direction of the monster. To make the monster be able to move inside the maze and find the path to the player I am going to make use of Unity navmesh. Navmeshes are pre calculated areas where agents can move and where it cannot. Normally you would bake these navmesh areas before playing, as the baking process is quite heavy for your computer. But, because my maze will be random each play and changes over the duration of playing the game, the baking process must be done in runtime. Unity currently does not support this on its own, but you can download a pretty stable package that will allow you to bake navmeshes on runtime.

First, I started trying to bake each individual cell of the maze, so about 729 cells being baked in start. This caused my startup to take at least 5 minutes which at that point I aborted unity and figured I should try a different approach. My second idea was to create one big invincible cube to be the floor for the navmesh to be baked onto. So, I would have an invisible floor where the monster would walk on, and then the original floors of the cells that the player walks on. This idea did bake quick on start, but the floor must move with the player when new parts of the maze get generated. Then, building the navmesh of the entire maze caused a significant lag spike. The game normally runs around 150-200 fps, but when building the navmesh it would spike to 15 fps. So, I would need to optimize. My next idea was to have each small maze have its own invisible floor, so that only a third of the entire maze had to rebuild. Because the full maze consists of a three-by-three grid of smaller mazes and only three move and regenerate at one time. So, I would only need to rebuild the three smaller mazes that moved and changed layout, but his still caused a significant lag spike. I learned from this that it is easier for Unity to build one big navmesh, rather than multiple smaller parts and stitching them together. My last idea was for only the middle maze to have its navmesh build, because the player is always in the middle smaller maze. Only this maze really matters when it comes to the navmesh. Building the navmesh of only one part of the maze with the invisible floor method still caused a lag spike, but it was only to around 60 fps. This lag spike was barely noticeable when playing, even when paying attention to it.

Creating the monster

            RaycastHit hit;
            if (Physics.Raycast(MonsterEyes.position, (player.position - transform.position), out hit))
            {
                //If there are no objects in the way and player is visible:
                if (hit.transform == player)
                {
                    Vector3 viewPos = mainCamera.WorldToViewportPoint(MonsterEyes.position);
                    if (viewPos.x >= 0 && viewPos.x <= 1 && viewPos.y >= 0 && viewPos.y <= 1 && viewPos.z > 0)
                    {
                        InPlayerVision = true;
                    }
                    else
                    {
                        InPlayerVision = false;
                    }
                }
                else
                {
                    InPlayerVision = false;
                }
            }

For the behaviour of the monster, its goal is set to the player position each frame. To check if the monster is in the view of the player, I use a raycast from the position of the monster’s eyes (just an empty object located in its head) to the position of the player. This raycast will return the transform of the first object it hit. If this is the player, we know there is a clear line of sight between the two. Then, to determine of the player is looking at the monster we do WorldToViewportPoint from the main camera to the monster’s eyes and we check if this vector3 is withing the bounding boxes of the camera. If both the conditions of the raycast and the camera check are met, we know the monster is in vision of the player. If the player moves to a different maze, the monster will no longer be able to get to him from his current location as the navmesh moves. So, we just teleport the monster to a random maze cell in the new middle part of the full maze. We only teleport the monster when he is out of sight, so that he does not just disappear in vision of the player.1

    public void SetMonsterInRandomPosition(MazeRenderer mazePart)
    {
        agent.enabled = false;

        MazeCellObject randomMazeCellObject = mazePart.mazeCells[Random.Range(0, mazePart.mazeCells.Count)];
        transform.position = new Vector3(randomMazeCellObject.transform.position.x, MonsterHeight, randomMazeCellObject.transform.position.z);

        RaycastHit hit;
        if (Physics.Raycast(transform.position, (player.position - transform.position), out hit))
        {
            //If there are no objects in the way and player is visible:
            if (hit.transform == player.transform)
            {
                SetMonsterInRandomPosition(mazePart);
            }
            else if (Vector3.Distance(transform.position, player.transform.position) <= MonsterMinimalDistance)
            {
                SetMonsterInRandomPosition(mazePart);
            }
        }

        ProceduralMaze.navMeshSurface.transform.position = ProceduralMaze.newNavMeshPosition;
        ProceduralMaze.navMeshSurface.BuildNavMesh();

        QueueTeleportation = false;
        agent.enabled = true;
    }

To teleport the monster, we get a random maze cell inside the new middle part of the full maze. Then, we do another raycast between the monster and the player to check of the monster will be spawned in view of the player. From the new position I also check the distance between the player and the monster because we do not want the monster to spawn around the first corner of the new maze. If it does spawn in vision or the distance is too small, we just call the function again. If it does not, we move the invisible floor to the new middle maze and start building the navmesh. To stop the monster from moving when it is in vision of the player, we just set its agent to isStopped = true, set its agents destination to its own position, and set the moving animation to an idle animation.

To add to the scariness and tension of the game, I found a good model along with animations I used for the monster. I added a heartbeat that beats faster depending on the distance between the player and the monster. And I had a friend help with some additional sounds for the monsters breathing and a sound that plays when you get caught.

Creating the build

When making the build I experienced problems with the navmesh. All the obstacles would show as just a cube instead of the object itself. To debug the build, I found a script online that makes a mesh out of the navmesh. I gave the mesh a pink color so that I was able to see the navmesh in the build.

The pink surface is the ground the enemy is able to walk over. In the correct navmesh you can see a space around all the walls and objects in the enviroment. This space is there so that the monster will not clip trough any objects as this space is calculated based on the width of the monster. In the incorrect navmesh, all the objects only have a small square cut out and not around the entire object. So the monster will walk trough the walls (except for the middle part where the navmesh did cut out).

Incorrect navmesh (in build)
Correct navmesh (in unity editor)

After debugging, I figured the external navmesh package was causing the problems. To fix this problem, I downgraded my unity version to the last long time support version the GitHub page recommended, which was version 2020.3.26f1. Of course, this broke some parts of the game, mainly the universal render pipeline. After fixing the problems the downgrade caused, the navmesh did work in the build.

Final Thoughts

The only thing missing making this demo an actual game is a winning condition. I did not add one on purpose, as the goal of the demo is for it to be endless. Having a win condition or end defeats the purpose of it being endless.

When I first started working on this idea, I assumed I would need to use more complicated algorithms. It ended up being fairly easy for me. I am happy with the result and with the fact that I was also able to add some additional features I wanted. I think the wave function collapse algorithm is super interesting and unfortunately it did not really work how I wanted it in this project, but I would like to revisit it in the future.

Thank you for reading!

Download project

Download the playable version of the second prototype here: https://jordywolf.itch.io/the-backrooms

Sources

Dobuski, M. (2022, November 5). The Backrooms: Horror storytelling goes online. ABC News. https://abcnews.go.com/US/backrooms-horror-storytelling-online/story?id=92623707

Developer Jake. (2022, August 28). Realistic Graphics – Backrooms Game Lab (Make a Backrooms Game) [Video]. YouTube. https://www.youtube.com/watch?v=z-HyCpOX5bI

Donald, M. (2020, July 31). Superpositions, Sudoku, the Wave Function Collapse algorithm. [Video]. YouTube. https://www.youtube.com/watch?v=2SuvO4Gi7uY

Wave Function Collapse – Mixed Initiative Demo by Martin Donald. (2020, July 31). itch.io. https://bolddunkley.itch.io/wfc-mixed

Mxgmn. (2022, August 26). GitHub – mxgmn/WaveFunctionCollapse: Bitmap & tilemap generation from a single example with the help of ideas from quantum mechanics. GitHub. https://github.com/mxgmn/WaveFunctionCollapse

Boris. (2021, November 12). Wave Function Collapse tips and tricks. BorisTheBrave.com. https://www.boristhebrave.com/2020/02/08/wave-function-collapse-tips-and-tricks/

Moskalev, A. (2023, January 24). Wave Function Collapse for procedural generation in Unity. pvs-studio.com. https://pvs-studio.com/en/blog/posts/csharp/1027/

Boris. (n.d.). Path Constraints Tutorial. https://www.boristhebrave.com/permanent/20/01/tessera_docs_2/articles/path.html

b3agz. (2022, August 31). MAKE A MAZE IN UNITY [Video]. YouTube. https://www.youtube.com/watch?v=TMOEYdV4Ot4

Assets used

WFC Example: https://pvs-studio.com/en/blog/posts/csharp/1027/

Navmesh in runtime package: https://github.com/Unity-Technologies/NavMeshComponents

Monster model and animations: https://www.youtube.com/watch?v=n3IH4qGf_ZA

Leave a Reply

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