DEV Community

Cover image for Grogue: A Roguelike Tutorial in Go (Part 3)
Sean Callaway
Sean Callaway

Posted on

Grogue: A Roguelike Tutorial in Go (Part 3)

In Part 1, we create 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,
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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

We have 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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
Enter fullscreen mode Exit fullscreen mode

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.

Secret Tunnel!

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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).

Random Dungeon

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.

Top comments (2)

Collapse
 
tylerlang profile image
Tyler Lang

As a fellow roguelike lover, I am loving this series! Please keep it up!

Collapse
 
thecal714 profile image
Sean Callaway

Glad to hear you're enjoying it. Part 4 is being finished up right now.