How to Procedurally Generate a Dungeon in Phaser – Part 2

In the previous tutorial we procedurally generated a dungeon with multiple rooms, allowing our hero to navigate through it. In this tutorial, we are going to populate those rooms with obstacles, enemies and add an exit, so the hero can leave the dungeon.

The following topics will be covered in this tutorial:

  • A strategy to procedurally populate rooms with objects
  • Populating the dungeon rooms with obstacle tiles
  • Populating the dungeon rooms with enemies
  • Adding an exit in one room so the player can leave the dungeon
  • Locking the room doors until all enemies have been defeated

To read this tutorial, it is important that you’re familiar with the following concepts:

  • Javascript and object-oriented concepts.
  • Basic Phaser concepts, such as: states, sprites, groups and arcade physics
  • Creating maps using Tiled

Learn Phaser by building 15 games

If you want to master Phaser and learn how to publish Phaser games as native games for iOS and Android feel free to check Zenva‘s online course The Complete Mobile Game Development Course – Build 15 Games.

Source code files

You can download the tutorial source code files here.

I modified some assets (images and maps), so I highly suggest you download them again even you have them from the previous tutorial.

Strategy to populate the rooms

Given an object and its dimensions (width and height) we need to find a free region in the room that fits the object. We do that by looking random regions with the object dimensions until we find one that is free. Since most of the room is free, this process is typically fast.

The “find_free_region” method below belongs to the Room class and is responsible for doing that. It runs a loop that starts by finding a random position that will be the center of the region. Then, it add the coordinates of the other positions that will be occupied by the object, according to its dimensions. Finally, it checks if the whole region is free. If so, we’re done. Otherwise, it keeps running the loop.

ProceduralGeneration.Room.prototype.find_free_region = function (size_in_tiles) {
    "use strict";
    var center_tile, region, x_coordinate, y_coordinate, initial_x_coordinate, initial_y_coordinate;
    do {
        // pick a random coordinate to be the center of the region
        center_tile = new Phaser.Point(this.game_state.game.rnd.between(2, (this.game_state.game.world.width / this.tile_dimensions.x) - 3),
                                    this.game_state.game.rnd.between(2, (this.game_state.game.world.height / this.tile_dimensions.y) - 3));
        region = [center_tile];
        initial_x_coordinate = center_tile.x - Math.floor(size_in_tiles.x / 2);
        initial_y_coordinate = center_tile.y - Math.floor(size_in_tiles.y / 2);
        // add all coordinates of the region, based in its size
        for (x_coordinate = initial_x_coordinate; x_coordinate < initial_x_coordinate + size_in_tiles.x; x_coordinate += 1) {
            for (y_coordinate = initial_y_coordinate; y_coordinate < initial_y_coordinate + size_in_tiles.y; y_coordinate += 1) {
                region.push(new Phaser.Point(x_coordinate, y_coordinate));
            }
        }
    } while (!this.is_free(region)); // stop if all the region is free
    return region;
};

ProceduralGeneration.Room.prototype.is_free = function (region) {
    "use strict";
    var coordinate_index, coordinate;
    for (coordinate_index = 0; coordinate_index < region.length; coordinate_index += 1) {
        coordinate = region[coordinate_index];
        // check if there is an object occupying this coordinate
        if (this.population[coordinate.y][coordinate.x]) {
            return false;
        }
    }
    return true;
};

The “is_free” method iterate through all coordinates and check the “population” property. This property starts as empty and is filled everytime an object is added to the room. So, if there is another object in that position, the method will return that the region is not free.

Population data

We will store the population in a JSON file like the one below. You can use the same, provided in the source code or create your own. The important thing is that this file describe two kinds of population: tiles and prefabs. For each one, you can also define different kinds (for example, you can add enemies and items in the prefabs section). To simplify the tutorial, I only added one type of tile and prefab. Finally, for each tile type you must define the layer it will use, the minimum and maximum number of objects that will be created, the possible sizes and possible tiles. On the other hand, for each prefab type you must define the minimum and maximum number of objects and the possible prefabs.

{
    "tiles": {
        "obstacles": {
            "layer": "collision",
            "number": {"min": 3, "max": 5},
            "sizes": [{"x": 1, "y": 1}, {"x": 1, "y": 2}, {"x": 1, "y": 3}, {"x": 2, "y": 1}, {"x": 3, "y": 1}],
            "possible_tiles": [10]
        }
    },
    "prefabs": {
        "enemies": {
            "number": {"min": 1, "max": 3},
            "possible_prefabs": [
                {
                    "prefab": "enemy",
                    "properties": {"texture": "enemy_image", "group": "enemies"}
                }
            ]
        }
    }
}

The population JSON file will be loaded in the DungeonState, since it will be used during the dungeon generation.

ProceduralGeneration.DungeonState.prototype.preload = function () {
    "use strict";
    // load the population JSON file
    this.load.text("population", "assets/levels/population.json");
};

Populating rooms with obstacle tiles

We will start by populating our rooms with obstacle tiles. Each obstacle will use a random tile index (from a list of tiles) and will have random dimensions (from a list of possible sizes).

The code below show the “populate_tiles” method. For each tile, it randomly picks a tile index, a size (available from the population data) and finds a free region. Then it adds all coordinates of this region to a “tiles” object and to the population. The “tiles” object will be read by the RoomState to add the tiles in the game.

ProceduralGeneration.Room.prototype.populate_tiles = function (number_of_tiles, layer, possible_tiles, possible_sizes) {
    "use strict";
    var index, tile, region_size, region, coordinate_index;
    for (index = 0; index < number_of_tiles; index += 1) {
        // pick a random tile index
        tile = this.game_state.game.rnd.pick(possible_tiles);
        // pick a random size
        region_size = this.game_state.game.rnd.pick(possible_sizes);
        // find a free region with the picked size
        region = this.find_free_region(region_size);
        // add all region coordinates to the tiles property
        for (coordinate_index = 0; coordinate_index < region.length; coordinate_index += 1) {
            this.tiles.push({layer: layer, tile: tile, position: region[coordinate_index]});
            this.population[region[coordinate_index].y][region[coordinate_index].x] = tile;
        }
    }
};

The “populate” method from the Room class is shown below. First, it initializes the “population” property as empty, so that all coordinates are initially free. Then, it iterates through all obstacles in the population data creating them. For each obstacle, it chooses a random number of obstacles and call the “populate_tiles” method.

ProceduralGeneration.Room.prototype.populate = function (population) {
    "use strict";
    var number_of_rows, number_of_columns, row_index, column_index, tile_type, number_of_tiles, prefab_type, number_of_prefabs;
    number_of_rows = this.game_state.game.world.height / this.tile_dimensions.y;
    number_of_columns = this.game_state.game.world.width / this.tile_dimensions.x;
    // initialize the population object as empty
    for (row_index = 0; row_index <= number_of_rows; row_index += 1) {
        this.population.push([]);
        for (column_index = 0; column_index <= number_of_columns; column_index += 1) {
            this.population[row_index][column_index] = null;
        }
    }

    // populate the room with tiles
    for (tile_type in population.tiles) {
        if (population.tiles.hasOwnProperty(tile_type)) {
            // pick a random number of tiles
            number_of_tiles = this.game_state.game.rnd.between(population.tiles[tile_type].number.min, population.tiles[tile_type].number.max);
            // create the tiles
            this.populate_tiles(number_of_tiles, population.tiles[tile_type].layer, population.tiles[tile_type].possible_tiles, population.tiles[tile_type].sizes);
        }
    }
};

We have to change the “generate_dungeon” method in the Dungeon class to load the population data and populate the rooms, as shown below.

// load the population data from the JSON file
    population = JSON.parse(this.game_state.game.cache.getText("population"));
    
    // iterate through rooms to connect and populate them
    created_rooms.forEach(function (room) {
        room.neighbor_coordinates().forEach(function (coordinate) {
            if (this.grid[coordinate.row][coordinate.column]) {
                room.connect(coordinate.direction, this.grid[coordinate.row][coordinate.column]);
            }
        }, this);
        // populate the room
        room.populate(population);
    }, this);

Now, we have to add the following code at the end of the “create” method in RoomState. This code will iterate through all tiles in the “tiles” object and call the “putTile” method from Phaser.Tilemap for each one. This method allows us to add new tiles to a previously loaded Tiled map (you can learn more in Phaser documentation). There is another very important change you have to make in this class. In the previous tutorial of this series, we set the collision of layers for only the tiles in that layers. Since now we are going to add new tiles to an already created layer, we have to change this code to set the collision for all tiles, as shown below.

// add tiles to the room
    this.room.tiles.forEach(function (tile) {
        this.map.putTile(tile.tile, tile.position.x, tile.position.y, tile.layer);
    }, this);
// create map layers
    this.layers = {};
    this.map.layers.forEach(function (layer) {
        this.layers[layer.name] = this.map.createLayer(layer.name);
        if (layer.properties.collision) { // collision layer
            this.map.setCollisionByExclusion([-1], true, layer.name);
        }
    }, this);

You can already try playing the demo with the obstacles.

obstacles

Populating rooms with enemies

Populating the rooms with enemies will be very similar to how we did with obstacles, but we must save the prefab information instead of tiles. Also, to simplify the code we will assume all prefabs occupy a single tile.

The code below shows the “populate_prefabs” method. Instead of picking a random tile index, it picks a random prefab, and it finds a random region with size of only one tile (which results in a random position). Then it adds the prefab name, type, position and properties to a “prefab” object, which will be read by RoomState as well.

ProceduralGeneration.Room.prototype.populate_prefabs = function (number_of_prefabs, possible_prefabs_data) {
    "use strict";
    var index, prefab_data, prefab, tile_position, position, properties;
    for (index = 0; index < number_of_prefabs; index += 1) {
        // pick a random prefab
        prefab_data = this.game_state.game.rnd.pick(possible_prefabs_data);
        prefab = prefab_data.prefab;
        // find a free region of size one
        tile_position = this.find_free_region({x: 1, y: 1});
        position = new Phaser.Point((tile_position[0].x * this.tile_dimensions.x) + (this.tile_dimensions.x / 2),
                                (tile_position[0].y * this.tile_dimensions.y) + (this.tile_dimensions.y / 2));
        properties = prefab_data.properties;
        // add the prefab to the prefabs property
        this.prefabs.push({name: prefab + index, prefab: prefab, position: position, properties: properties});
        this.population[tile_position[0].y][tile_position[0].x] = prefab;
    }
};

We must add in the “populate” method the code to add the prefabs. Similarly to what we did with the tiles, we iterate through all prefab population data, choose a random number of prefabs and call the “populate_prefabs” method.

    // populate the room with prefabs
    for (prefab_type in population.prefabs) {
        if (population.prefabs.hasOwnProperty(prefab_type)) {
            // pick a random number of prefabs
            number_of_prefabs = this.game_state.game.rnd.between(population.prefabs[prefab_type].number.min, population.prefabs[prefab_type].number.max);
            // create the prefabs
            this.populate_prefabs(number_of_prefabs, population.prefabs[prefab_type].possible_prefabs);
        }
    }

Finally, we add the following piece of code to the “create” method in RoomState, so it creates the prefabs after adding the obstacle tiles. This code simply goes through all added prefabs and create them using its “create_prefab” method.

// add prefabs to the room
    this.room.prefabs.forEach(function (prefab) {
        new_prefab = new this.prefab_classes[prefab.prefab](this, prefab.name, prefab.position, prefab.properties);
    }, this);

To be able to verify if everything is working by now, you have to create a Enemy prefab so the demo can run, as shown below. Remember that every time you create a new prefab you must add it to the “prefab_classes” property in RoomState. By now you can already try playing the demo with the enemies as well.

var ProceduralGeneration = ProceduralGeneration || {};

ProceduralGeneration.Enemy = function (game_state, name, position, properties) {
    "use strict";
    ProceduralGeneration.Prefab.call(this, game_state, name, position, properties);
    
    this.anchor.setTo(0.5);

    this.game_state.game.physics.arcade.enable(this);
    this.body.immovable = true;
};

ProceduralGeneration.Enemy.prototype = Object.create(ProceduralGeneration.Prefab.prototype);
ProceduralGeneration.Enemy.prototype.constructor = ProceduralGeneration.Enemy;

enemies

Adding the dungeon exit

Before adding the exit, we must create its prefab, as shown below. The Exit prefab simply checks for collisions with the hero and, if it detects one, it restarts the demo by calling the DungeonState again.

var ProceduralGeneration = ProceduralGeneration || {};

ProceduralGeneration.Exit = function (game_state, name, position, properties) {
    "use strict";
    ProceduralGeneration.Prefab.call(this, game_state, name, position, properties);
    
    this.anchor.setTo(0.5);
    
    this.direction = properties.direction;

    this.game_state.game.physics.arcade.enable(this);
    this.body.immovable = true;
};

ProceduralGeneration.Exit.prototype = Object.create(ProceduralGeneration.Prefab.prototype);
ProceduralGeneration.Exit.prototype.constructor = ProceduralGeneration.Exit;

ProceduralGeneration.Exit.prototype.update = function () {
    "use strict";
    this.game_state.game.physics.arcade.collide(this, this.game_state.groups.heroes, this.reach_exit, null, this);
};

ProceduralGeneration.Exit.prototype.reach_exit = function () {
    "use strict";
    if (this.game_state.groups.enemies.countLiving() === 0) {
        // restart the game
        this.game_state.game.state.start("DungeonState", true, false, 10);
    }
};

We will add the exit of the dungeon in the furthest room from the initial one. To do that, we must change the “generate_dungeon” method to keep track of the furthest room, as shown below. When populating a room, we calculate its distance to the initial room and save the coordinate of the furthest one. Then, after populating all rooms we add the exit to the room with the final room coordinate.

max_distance_to_initial_room = 0;
    // iterate through rooms to connect and populate them
    created_rooms.forEach(function (room) {
        room.neighbor_coordinates().forEach(function (coordinate) {
            if (this.grid[coordinate.row][coordinate.column]) {
                room.connect(coordinate.direction, this.grid[coordinate.row][coordinate.column]);
            }
        }, this);
        // populate the room
        room.populate(population);
        
        // check distance to the initial room
        distance_to_initial_room = Math.abs(room.coordinate.column - initial_room_coordinate.x) + Math.abs(room.coordinate.row - initial_room_coordinate.y);
        if (distance_to_initial_room > max_distance_to_initial_room) {
            final_room_coordinate.x = room.coordinate.column;
            final_room_coordinate.y = room.coordinate.row;
        }
    }, this);
    
    this.grid[final_room_coordinate.y][final_room_coordinate.x].populate_prefabs(1, [{prefab: "exit", properties: {texture: "exit_image", group: "exits"}}]);

Try playing the demo now and search for the exit.

exit

Fighting enemies

Our hero still can’t defeat any enemies, so you must add code to do that. Since this is not the focus of this tutorial, I will only make the hero kill the enemy when they overlap, as shown below. For an actual game you could add attack and defense stats and calculate the damage based on them. Feel free to improve this code in order to make something fun.

var ProceduralGeneration = ProceduralGeneration || {};

ProceduralGeneration.Enemy = function (game_state, name, position, properties) {
    "use strict";
    ProceduralGeneration.Prefab.call(this, game_state, name, position, properties);
    
    this.anchor.setTo(0.5);

    this.game_state.game.physics.arcade.enable(this);
    this.body.immovable = true;
};

ProceduralGeneration.Enemy.prototype = Object.create(ProceduralGeneration.Prefab.prototype);
ProceduralGeneration.Enemy.prototype.constructor = ProceduralGeneration.Enemy;

ProceduralGeneration.Enemy.prototype.update = function () {
    "use strict";
    this.game_state.game.physics.arcade.overlap(this, this.game_state.groups.heroes, this.kill, null, this);
};

Now that our hero can defeat enemies, we must lock the rooms (and the exit) until all enemies in that room have been defeated. To do that, we simply check if the enemies group has no alive objects in the “enter_door” and “reach_exit” methods, as shown below.

ProceduralGeneration.Door.prototype.enter_door = function () {
    "use strict";
    var next_room;
    if (this.game_state.groups.enemies.countLiving() === 0) {
        // find the next room using the door direction
        next_room = this.game_state.room.neighbors[this.direction];
        // start room state for the next room
        this.game_state.game.state.start("BootState", true, false, "assets/levels/room_level.json", "RoomState", {room: next_room});
    }
};
ProceduralGeneration.Exit.prototype.reach_exit = function () {
    "use strict";
    if (this.game_state.groups.enemies.countLiving() === 0) {
        // restart the game
        this.game_state.game.state.start("DungeonState", true, false, 10);
    }
};

Now, you can try playing the demo again and check if you can find the dungeon exit.

And that concludes our tutorial series about procedurally generated content. Let me know your opinions in the comments section!