Roguelike C# in Unity (3/13): Generating a dungeon

Welcome to the 3rd part of the tutorial! The original Python tutorial can be found here. Previous posts for C# in Unity:

This is starting to get complex, in this tutorial we’ll keep working in our GridGenerator class, but move away entirely from the FloorSetup() and WallSetup() methods set previously to something entirely different: Creating a procedurally generated dungeon.

What we need?

  • Differentiate between Walkable and non-walkable tiles, in the future we can be more granular with this as walkable or non-walkable does not mean walls and floors necessarily, as a lava tile is walkable, but you’d rather prefer not to walk on it and does not block vision, however a mist tile is walkable, but blocks vision. For the moment we’ll go only with floors and walls for simplicity.
  • From the original tutorial we can see that the dungeon dungeon is generated by from a complete walled-off room, then we “digging” out sections to create the floor, therefore all tiles are blocked by default. In our case we’ll do it differently for 2 reasons: 1) We’re using TileMaps and these act as layer of tiles, so we won’t be digging anything into a TileMap but superposing different TileMaps with different setups to fit together. 2) We’ll be using a different algorithm, while the Python tutorial spawn rooms until there’s no space left and then connect rooms, we’ll use the Drunk Walker algorithm to create the playable scenario first, and wall the rest later.
  • Instead of relying in an unique CreateMap() method, we’ll split each part of the process in different methods, so we have a more granular control over what’s happening, these will:
  • 1) Create the initial setup, and create the floors: ProcGenFloorWalkersSetup()
  • 2) Create the walls where there’s no floors: ProcGenWallSetup()
  • 3) Fix the gaps the Walkers may have created (like crossing through non-playable area): ProcGenWallFixtures()
  • 4) Place our entities randomly across the map, only on valid coordinates (floor tiles): PlaceEntities() and PlaceItems()
  • 5) As a bonus, we’ll implement map seeding, so despite having procedural map generation and random placed entities, we can replicate this setup exactly as long as we use the same seed, this is great for debugging, improving the algorithm, fixing bugs, etc, …

Creating floors :

This is a bit long but bear with me, ProcGenFloorWalkersSetup() will get help from some other methods, and a new class:

First we’ll create a simple class for our Walkers, so each one can store and update independently both a position and a direction:

    class Walker
    {
        public Vector2 walkerPosition;
        public Vector2 walkerDirection;
    }

We’ll generate a bunch of initial Walkers in the center of the map, and then let them go crazy by looping through WalkerBehaviour() a certain number of times, I chose 10 initial Walkers and 1000 iterations but feel free to experiment with both the number of walkers and iterations for different map effects:

    /* Sets up the Walkers that will construct the floor grid later on */
    public void ProcGenFloorWalkersSetup()
    {
        initialNumberOfWalkers = 10; // How many walkers we'll initially create
        initialNumberOfWalkeriterations = 1000;

        // Create walkers
        for (int i = 0; i < initialNumberOfWalkers; i++)
        {
            Walker _walker = new Walker();
            _walker.walkerPosition = new Vector2((int)mapWidthX/2, (int)mapHeightY/2); // Spawn them at the center of the map
            listOfWalkers.Add(_walker);
        }

        // Runs WalkerBehaviour() a fixed number of times. Each time this functions run, each walker of the list moves, replicates, or die, building a path of floors in each movement
        for (int i = 0; i < initialNumberOfWalkeriterations; i++) 
        {
            WalkerBehaviour(listOfWalkers);
        }

    }

WalkerBehaviour() which will decide move each Walker on a different random direction, and open a new floor while doing it, and avoiding to pass the map boundaries. Each time this functions run, each walker of the list moves, replicates, or die, building a path of floor tiles in each new movement.

Check how we’re affecting 2 different TileMaps at the same time, we set tiles as floors, but at the same time we set to null the walls on that same position. This is because the wall TileMap is on top of the floors TileMap, if we don’t unset these wall tiles then the floors beneath it wouldn’t be visible. This is a little details that we must take into account in future iterations, as what may look like “not working” may be just “working but not visible”:

    void WalkerBehaviour(List<Walker> _inputListOfWalkers) {

        foreach (var _walker in _inputListOfWalkers)
        {

            _walker.walkerPosition = RandomDirection(_walker.walkerPosition); // Walker new position will be a new position in a random direction RandomDirection()
            wallMap.SetTile(new Vector3Int((int)_walker.walkerPosition.x, (int)_walker.walkerPosition.y, 0), null);
            floorMap.SetTile(new Vector3Int((int)_walker.walkerPosition.x, (int)_walker.walkerPosition.y, 0), floorTile);
            listOfFloorTiles.Add(_walker.walkerPosition);


            int _rand = Random.Range(0, 100);
            if (_rand <= 30) // 30% chance of new walker being born
            {
                Walker _newWalker = new Walker();
                _newWalker.walkerPosition = _walker.walkerPosition; // New walker position is the same as the old walker position
                listOfFloorTiles.Add(_walker.walkerPosition);
                _inputListOfWalkers.Add(_newWalker); // Problem: this will not execute here because we're still within the loop, and the LIST listOfWalkers cannot be modified by adding a new item while is running. That's why we add break to get out of the loop.
                break;
            }
        }
    }

RandomDirection() moves the Walker in one of four directions, and turns the Walker around if happens to cross through the map limits.

    Vector2 RandomDirection(Vector2 currentWalkerPosition) {

        float _rand = (int)Random.Range(0, 3.99f); // 0, 1, 2, 3
        switch (_rand)
        {
            case 0: //up x=0 y++
                currentWalkerPosition.y += 1.0f;
                break;
            case 1: //down x=0 y--
                currentWalkerPosition.y -= 1.0f;
                break;
            case 2: //left x-- y=0
                currentWalkerPosition.x -= 1.0f;
                break;
            case 3://right x++ y=0
                currentWalkerPosition.x += 1.0f;
                break;
            default:
                break;

        }

        /* Boundaries:
        BottomLeft: 0,0
        BottomRight: mapWidthX, 0
        TopLeft: 0, mapHeightY
        TopRight: mapWidthX, mapHeightY
        */
        if (currentWalkerPosition.x < 0) // Checks X-- and turns the walker around
        {
            currentWalkerPosition.x = currentWalkerPosition.x * (-1);

        } else if (currentWalkerPosition.y < 0) // Checks Y-- and turns the walker around
        {
            currentWalkerPosition.y = currentWalkerPosition.y * (-1);

        } 

        return new Vector2(currentWalkerPosition.x, currentWalkerPosition.y);

    }

This is a slow motion of what happens within a couple of frames:

This was long, but now we have a new procedurally generated map map each time we run the game:

This is a map without the boundaries behaviour on RandomDirection() , is not necessarily bad and could be solved in different ways. I’d rather locked this for simplicity.
And this is an example of a map being generated from (0,0) instead of the middle of the map, neither a bad solution, just needs different rework. Again I avoided this for simplicity and generated the map from the middle of the playable zone.

Creating walls:

ProcGenWallSetup() will be much much more simpler than the floors part, we’ll just check which tiles in the floor TileMap are null, and draw a wall in the wall TileMap for these positions:

    public void ProcGenWallSetup() {
    
        int width = mapWidthX;
        int height = mapHeightY;
        for (int x = 0; x < width; x++)
        {
            for (int y = 0; y < height; y++)
            {
                if (floorMap.GetTile(new Vector3Int(x, y, 0)) == null)
                {
                        Vector2 _wallTileLocation = new Vector2(x, y);
                        wallMap.SetTile(new Vector3Int((int)_wallTileLocation.x, (int)_wallTileLocation.y, 0), wallTile);
                        listOfWallTiles.Add(_wallTileLocation);
                }
            }
        }
    }

Fixing the gaps:

Via ProcGenWallFixtures() I’ll fix the gaps that the walkers left into our map, that’s fill with walls these openings that let our player move outside of the playable area. Why is this set to Color.black? Because by default will be undiscovered tiles, if we paint them normally these will be visible even if the player has not visited yet that part of the map. Then in the future once the player reaches that zone and the tiles enter in its Field Of Vision (FOV) these will be re-painted as normal.

    void ProcGenWallFixtures(int width, int height)
    {
        // Fills with walls the bottom and ceiling map openings from the walkers
        for (int i = 0; i < width; i++)
        {
            if (floorMap.GetTile(new Vector3Int(i, 0, 0)) != null)
            {
                floorMap.SetTile(new Vector3Int(i, 0, 0), null); // Clear potential previous floors
                wallMap.SetTile(new Vector3Int(i, 0, 0), wallTile);
                wallMap.SetColor(new Vector3Int(i, 0, 0), Color.black);


                // Add to our list of walls:
                Vector2 _wallTileLocation = new Vector2(i, 0);
                listOfWallTiles.Add(_wallTileLocation); // + list
                listOfFloorTiles.Remove(_wallTileLocation); // - list
            }
            if (floorMap.GetTile(new Vector3Int(i, height-1, 0)) != null)
            {
                floorMap.SetTile(new Vector3Int(i, height - 1, 0), null); // Clear potential previous floors
                wallMap.SetTile(new Vector3Int(i, height-1, 0), wallTile);
                wallMap.SetColor(new Vector3Int(i, height-1, 0), Color.black);

                // Add to our list of walls:
                Vector2 _wallTileLocation = new Vector2(i, height-1);
                listOfWallTiles.Add(_wallTileLocation);// + list
                listOfFloorTiles.Remove(_wallTileLocation); // - list
            }
        }
        // Fills with walls the right and left map openings from the walkers
        for (int i = 0; i < height; i++)
        {
            if (floorMap.GetTile(new Vector3Int(0, i, 0)) != null)
            {
                wallMap.SetTile(new Vector3Int(0, i, 0), wallTile);
                wallMap.SetColor(new Vector3Int(0, i, 0), Color.black);
                // Add to our list of walls:
                Vector2 _wallTileLocation = new Vector2(0, i);


                floorMap.SetTile(new Vector3Int(0, i, 0), null); // Clear potential previous floors
                listOfWallTiles.Add(_wallTileLocation);
                listOfFloorTiles.Remove(_wallTileLocation); // Remove them for the list as well so we can use flood algo
            }
            if (floorMap.GetTile(new Vector3Int(i, height - 1, 0)) != null)
            {
                wallMap.SetTile(new Vector3Int(height, i, 0), wallTile);
                wallMap.SetColor(new Vector3Int(height, i, 0), Color.black);
                // Add to our list of walls:
                Vector2 _wallTileLocation = new Vector2(height, i);

                floorMap.SetTile(new Vector3Int(height, i, 0), null); // Clear potential previous floors
                listOfWallTiles.Add(_wallTileLocation);
                listOfFloorTiles.Remove(_wallTileLocation); // Remove them for the list as well so we can use flood algo
            }
        }
    }
I set those to green instead of black so we can easily see which tiles were fixed by ProcGenWallFixtures() . This is just the walls, the floors is an iteration to the algorithm that we’ll deal with in the future.

Placing our entities properly:

These should appear in random and valid floor tiles (not occupied by other items, enemies, the player, or walls) instead of in specific hardcoded places, this will be easy as we have been adding the vectors of the floor tiles to a listOfFloorTiles List, so we only have to pick random vectors from that list.

    private void PlaceEntities()
    {
        int _numberOfEnemyEntities = 10;

        GameObject enemyObjectPrefab = Resources.Load<GameObject>("Prefabs/Enemy");
         
        // For each entity to be created, find a suitable spawning place and Instantiate an enemy
        for (int i = 0; i < _numberOfEnemyEntities; i++)
        {
            int _randomIndex = Random.Range(1, listOfFloorTiles.Count);
            Vector2 _randomVector = listOfFloorTiles[_randomIndex];
            Entity npcInstance = new Entity((int)_randomVector.x, (int)_randomVector.y, "Enemy", enemyObjectPrefab, new Vector3(_randomVector.x, _randomVector.y, 0));
            Instantiate(npcInstance.entityGameObject, npcInstance.entityLocation, Quaternion.identity);
            listOfEnemyEntities.Add(npcInstance);

        }
    }

We can pass the Player through this function as well:

GameObject player = Resources.Load<GameObject>("Prefabs/Player");
Entity playerInstance = new Entity((int)_randomVector.x, (int)_randomVector.y, "Player", player, new Vector3(_randomVector.x, _randomVector.y, 0));
Instantiate(playerInstance.entityGameObject, playerInstance.entityLocation, Quaternion.identity);

Or if you prefer, just spawn it in the middle of the map:


GameObject player = Resources.Load<GameObject>("Prefabs/Player");
int playerX = 106/2;
int playerY = 106/2;
Entity playerInstance = new Entity(playerX, playerY, "Player", player, new Vector3(playerX, playerY, 0));

Map seed

This can be modified to be more advanced in the future, for example by setting a hash code instead, but for the moment a random number 1-100 is good enough.

    public int seed;
    public bool useRandomSeed;

        if (useRandomSeed)
        {
            seed = Random.Range(1, 100);
        }
        Random.InitState(seed);
        Debug.Log("Generating Map: Using seed " + seed);
Note that this is not a looped GIF but both runs of the same map are equal thanks to the pseudo-random generation via a pre-defined seed

In a nutshell, with this we’re using pseudo-random generation instead of random generation, as the “jumps” between each random value will be the same each time we run the random functions, the results will still be random, but will be replicable as long as we use the same seed, this results in the same map, same enemy position, same event handling, etc, …

Run the project now and you should be placed in a procedurally generated dungeon! By feeding the GridGenerator a specific seed you’ll generate the same level over and over again, a good way to start debugging other features could be to run a few random maps, and once you find one that you like, use that seed going forward.

Join 227 other subscribers

Leave a Reply