In Part 1, we created the dungeon as a large, empty room with walls around the edge. In this part, we'll modify our dungeon generation code to start by filling the entire map with walls and then carving out rooms and connecting them with tunnels.

Start by creating a structure we'll use to create our rooms. Add the following code to level.go:

type RectangularRoom struct {
    X1 int
    Y1 int
    X2 int
    Y2 int
}

// Create a new RectangularRoom structure.
func NewRectangularRoom(x int, y int, width int, height int) RectangularRoom {
    return RectangularRoom{
        X1: x,
        Y1: y,
        X2: x + width,
        Y2: y + height,
    }
}

The constructor takes the x and y coordinates of the top-level corner and computes the bottom right corner based on the width and height parameters.

In order to create tunnels between rooms, we'll also need a function to calculate the center of the room.

// Returns the tile coordinates of the center of the RectangularRoom.
func (r *RectangularRoom) Center() (int, int) {
    centerX := (r.X1 + r.X2) / 2
    centerY := (r.Y1 + r.Y2) / 2
    return centerX, centerY
}

To ensure that our rooms are surrounded by at least one layer of wall tile, we should create a function that returns the interior of the room:

// Returns the tile coordinates of the interior of the RectangularRoom.
func (r *RectangularRoom) Interior() (int, int, int, int) {
    return r.X1 + 1, r.X2 - 1, r.Y1 + 1, r.Y2 - 1
}

Using this, we can modify our createTiles() function, but we also want to keep track of our rooms, so we should add a slice of Rooms to our Level structure first.

type Level struct {
    Tiles []MapTile
    Rooms []RectangularRoom  // NEW
}

Then let's refactor createTiles() to make a pair of rooms. (Don't worry, we'll get to procedural generation later.)

func (level *Level) createTiles() {
    gd := NewGameData()
    tiles := make([]MapTile, gd.ScreenHeight*gd.ScreenWidth)

    // Fill with wall tiles
    for x := 0; x < gd.ScreenWidth; x++ {
        for y := 0; y < gd.ScreenHeight; y++ {
            idx := GetIndexFromCoords(x, y)
            wall, err := NewTile(x*gd.TileWidth, y*gd.TileHeight, TileWall)
            if err != nil {
                log.Fatal(err)
            }
            tiles[idx] = wall
        }
    }
    level.Tiles = tiles

    room1 := NewRectangularRoom(25, 15, 10, 15)
    room2 := NewRectangularRoom(40, 15, 10, 15)
    level.Rooms = append(level.Rooms, room1, room2)

    for _, room := range level.Rooms {
        x1, x2, y1, y2 := room.Interior()
        for x := x1; x <= x2; x++ {
            for y := y1; y <= y2; y++ {
                idx := GetIndexFromCoords(x, y)
                floor, err := NewTile(x*gd.TileWidth, y*gd.TileHeight, TileFloor)
                if err != nil {
                    log.Fatal(err)
                }
                level.Tiles[idx] = floor
            }
        }
    }
}

Our first nested loops now fill the entire level with wall tiles. We then create new rooms and add them to the level's list of rooms. Finally, we iterate through all of the level's rooms and carve out their interiors by making them floor tiles.

Unfortunately, our player isn't in a room anymore, so let's modify NewGame() in main.go to put the player in the center of the first room by changing the line where we create the player.

    startX, startY := g.CurrentLevel.Rooms[0].Center()
    player, err := NewEntity(startX, startY, "player")

Run the game and you'll find the player in the first of two rooms.

Tunneling Along

That's cool and all, but the player is trapped in the first room. That just won't do. We need to create tunnels between rooms.

Let's start be creating a pair of private helper functions that will create vertical and horizontal tunnels.

// Create a vertical tunnel.
func (level *Level) createVerticalTunnel(y1 int, y2 int, x int) {
    gd := NewGameData()
    for y := min(y1, y2); y < max(y1, y2)+1; y++ {
        idx := GetIndexFromCoords(x, y)

        if idx > 0 && idx < gd.ScreenHeight*gd.ScreenWidth {
            floor, err := NewTile(x*gd.TileWidth, y*gd.TileHeight, TileFloor)
            if err != nil {
                log.Fatal(err)
            }
            level.Tiles[idx] = floor
        }
    }
}

// Create a horizontal tunnel.
func (level *Level) createHorizontalTunnel(x1 int, x2 int, y int) {
    gd := NewGameData()
    for x := min(x1, x2); x < max(x1, x2)+1; x++ {
        idx := GetIndexFromCoords(x, y)

        if idx > 0 && idx < gd.ScreenHeight*gd.ScreenWidth {
            floor, err := NewTile(x*gd.TileWidth, y*gd.TileHeight, TileFloor)
            if err != nil {
                log.Fatal(err)
            }
            level.Tiles[idx] = floor
        }
    }
}

createVerticalTunnel() takes three arguments: a starting position on the Y-axis, an ending position on the Y-axis, and the X-coordinate. It then converts all tiles from x, y1 to x, y2 into floor tiles. createHorizontalTunnel() does the same thing, but on the X-axis instead of the Y.

We'll use both of these to tunnel from one room to another.

// Tunnel from this first room to second room.
func (level *Level) tunnelBetween(first *RectangularRoom, second *RectangularRoom) {
    startX, startY := first.Center()
    endX, endY := second.Center()

    if rand.Intn(2) == 0 {
        // Tunnel horizontally, then vertically
        level.createHorizontalTunnel(startX, endX, startY)
        level.createVerticalTunnel(startY, endY, endX)
    } else {
        // Tunnel vertically, then horizontally
        level.createVerticalTunnel(startY, endY, startX)
        level.createHorizontalTunnel(startX, endX, endY)
    }
}

This private function takes two RectangularRooms as arguments and tunnels from one to the next. It generates a random number (don't forget to add "math/rand" to your list of imports!) and uses that to determine whether to first tunnel vertically or horizontally. It then utilizes those helper functions to do the actual tunneling.

We can now use this function inside createTiles() to connect the two rooms we made.

    // Carve out rooms
    for roomNum, room := range level.Rooms {  // NEW
        x1, x2, y1, y2 := room.Interior()
        for x := x1; x <= x2; x++ {
            for y := y1; y <= y2; y++ {
                idx := GetIndexFromCoords(x, y)
                floor, err := NewTile(x*gd.TileWidth, y*gd.TileHeight, TileFloor)
                if err != nil {
                    log.Fatal(err)
                }
                level.Tiles[idx] = floor
            }
        }
        if roomNum > 0 { // NEW
            level.tunnelBetween(&level.Rooms[roomNum-1], &level.Rooms[roomNum])  // NEW
        }  // NEW
    }

We changed the start of the for loop from for _, room to for roomNum, room because we now need to know which room we're working on. After the room is carved, we then tunnel from this room, to the previous room (if there is a previous room).

If you run the game now, it should look like this.

More Rooms

Now that our room and tunnel functions work, it's time to move on to the actual dungeon generation. It'll be fairly simple: place rooms one at a time, make sure they don't overlap, then connect them with tunnels.

To do that, we'll need a function to determine if two rooms overlap.

// Determines if this room intersects with otherRoom.
func (r *RectangularRoom) IntersectsWith(otherRoom RectangularRoom) bool {
    return r.X1 <= otherRoom.X2 && r.X2 >= otherRoom.X1 && r.Y1 <= otherRoom.Y2 && r.Y2 >= otherRoom.Y1
}

We'll need a few more variables in GameData to determine the minimum and maximum size of the rooms as well as the maximum number of rooms one floor can have.

type GameData struct {
    ScreenWidth  int
    ScreenHeight int
    TileWidth    int
    TileHeight   int
    MaxRoomSize  int  // NEW
    MinRoomSize  int  // NEW
    MaxRooms     int  // NEW
}

// Creates a new instance of the static game data.
func NewGameData() GameData {
    gd := GameData{
        ScreenWidth:  80,
        ScreenHeight: 50,
        TileWidth:    16,
        TileHeight:   16,
        MaxRoomSize:  10,  // NEW
        MinRoomSize:  6,   // NEW
        MaxRooms:     30,  // NEW
    }
    return gd
}

With these variables in place, modify createTiles() replacing

    room1 := NewRectangularRoom(25, 15, 10, 15)
    room2 := NewRectangularRoom(40, 15, 10, 15)
    level.Rooms = append(level.Rooms, room1, room2)

with the following:

    for i := 0; i <= gd.MaxRooms; i++ {
        // generate width and height as random numbers between gd.MinRoomSize and gd.MaxRoomSize
        width := rand.Intn(gd.MaxRoomSize-gd.MinRoomSize+1) + gd.MinRoomSize
        height := rand.Intn(gd.MaxRoomSize-gd.MinRoomSize+1) + gd.MinRoomSize

        xPos := rand.Intn(gd.ScreenWidth - width)
        yPos := rand.Intn(gd.ScreenHeight - height)

        newRoom := NewRectangularRoom(xPos, yPos, width, height)

        isOkay := true
        for _, room := range level.Rooms {
            // check through all existing rooms to ensure newRoom doesn't intersect
            if newRoom.IntersectsWith(room) {
                isOkay = false
                break
            }
        }

        if isOkay {
            level.Rooms = append(level.Rooms, newRoom)
        }
    }

This one is a bit complicated, so let's break it apart.

width := rand.Intn(gd.MaxRoomSize-gd.MinRoomSize+1) + gd.MinRoomSize
height := rand.Intn(gd.MaxRoomSize-gd.MinRoomSize+1) + gd.MinRoomSize

This uses rand.Intn() to generate random widths and heights between gd.MinRoomSize (if rand.Intn() returns 0) and gd.MaxRoomSize (if rand.Intn() returns it's maximum value, which is gd.MaxRoomSize-gd.MinRoomSize in this case).

xPos := rand.Intn(gd.ScreenWidth - width)
yPos := rand.Intn(gd.ScreenHeight - height)

We then do a similar thing to grab the x- and y-coordinates of the top-left corner of the room, but ensure that our room doesn't extend off the edge of the map.

newRoom := NewRectangularRoom(xPos, yPos, width, height)

isOkay := true
for _, room := range level.Rooms {
    // check through all existing rooms to ensure newRoom doesn't intersect
    if newRoom.IntersectsWith(room) {
        isOkay = false
        break
    }
}

After creating a room with the new random specifications, we loop through all of the previously placed rooms and see if this new room intersects with them. If it does, we flag this room as not okay and stop the loop.

if isOkay {
    level.Rooms = append(level.Rooms, newRoom)
}

If we made it through the whole list without finding an intersection, we then add the room to the level's list of rooms.

Not too bad, right?

If you run the game now, you should see something similar to the following (but not the same, because it's random).

That's it! We have a functioning dungeon generation algorithm.

You can view the complete source code here and if you have any questions, please feel free to ask them in the comments.