Grogue: A Roguelike Tutorial in Go (Part 1)

thecal714

Sean Callaway

Posted on October 10, 2023

Grogue: A Roguelike Tutorial in Go (Part 1)

Welcome to part one of this series which will help you create your a roguelike game written in Go! This is based largely on the Roguebasin libtcod tutorial, which has proven very helpful in getting fledgling roguelike-devs off the ground.

If you haven't already completed the steps outlined in Part 0, please go back and do that now.

With our dependencies installed and validated, let's grab a few neat tiles created by by Jere Sikstus. Download wall.png and floor.png and place them in a folder called assets/ inside our project folder.

Humble Beginnings

Let's refactor our Hello, World from Part 0 into something we can continue to use.

Go ahead and remove the contents of the Draw() and add a new function just beneath the definition of the Game type.

// Creates a new Game object and initializes the data.
func NewGame() *Game {
    g := &Game{}
    return g
}
Enter fullscreen mode Exit fullscreen mode

This constructor will create a new Game object for us, which is currently empty, but will be expanded as we continue along.

Now change the contents of our main function:

func main() {
    g := NewGame()
    ebiten.SetWindowTitle("Grogue")
    ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)

    if err := ebiten.RunGame(g); err != nil {
        log.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

This code should be fairly self-explanitory: create a new game, set the window title to "Grogue," allow the window to be resized, then run the game and log any crashes.

Run your code with go run . and you should see the following:

The game window

The Simplest of Maps

We've created an empty window. It's a start, but we still have more to do in this part.

Let's create a new file called level.go, which will hold our code related to the game's level. Add the following to the file:

package main

import (
    "log"

    "github.com/hajimehoshi/ebiten/v2"
    "github.com/hajimehoshi/ebiten/v2/ebitenutil"
)
Enter fullscreen mode Exit fullscreen mode

This should look similar to the top of main.go in part 0.

Create a struct for holding our static game data:

type GameData struct {
    ScreenWidth int
    ScreenHeight int
    TileWidth int
    TileHeight int
}
Enter fullscreen mode Exit fullscreen mode

and create a constructor to go with it:

// Creates a new instance of the static game data.
func NewGameData() GameData {
    gd := GameData{
        ScreenWidth: 80,
        ScreenHeight: 50,
        TileWidth: 16,
        TileHeight: 16,
    }
    return gd
}
Enter fullscreen mode Exit fullscreen mode

Here we set that our tiles are 16x16 and that our screen is 80 tiles wide and 50 tiles high. However, if you multiply these values, you'll notice that it doesn't match the screen size we set in the Layout() function in main.go. So, let's fix that.

func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
    gd := NewGameData()
    return gd.TileWidth * gd.ScreenWidth, gd.TileHeight * gd.ScreenHeight
}
Enter fullscreen mode Exit fullscreen mode

This will utilize that GameData struct we just created to calculate the size of the window, so we shouldn't need to change this again if we decided to modify the values inside NewGameData().

Since most roguelikes are tile-based, we should probably create a structure to hold our individual tiles on the map.

type MapTile struct {
    PixelX int
    PixelY int
    Blocked bool
    Opaque bool
    Image *ebiten.Image
}
Enter fullscreen mode Exit fullscreen mode

This hold the X and Y values (PixelX and PixelY, respectively) of the upper left corner of the tile, whether or not this tile blocks creatures' movement, whether or not the tile can been seen through, and a pointer to the ebiten.Image object storing the actual graphic for the tile.

Since we will be storing each of our 4,000 (80 columns x 50 rows) tiles in one of these, we should optimize their storage which means storing them in a single slice instead of a two dimensional array. We'll then create a quick helper function to return the index of the tile based on X and Y coordinates.

// Returns the logical index of the map slice from given X and Y tile coordinates.
func GetIndexFromCoords(x int, y int) int {
    gd := NewGameData()
    return (y * gd.ScreenWidth) + x
}
Enter fullscreen mode Exit fullscreen mode

Since we'll be creating a lot of tiles, we should create a MapTile constructor.

const (
    TileFloor string = "floor"
    TileWall  string = "wall"
)

// Creates a MapTile of a given type at pixels x, y.
//
//  Supported types include 'Floor' and 'Wall'
func NewTile(x int, y int, tileType string) (MapTile, error) {
    blocked := true
    opaque := true

    image, _, err := ebitenutil.NewImageFromFile("assets/" + tileType + ".png")
    if err != nil {
        return MapTile{}, err
    }

    if tileType == TileFloor {
        blocked = false
        opaque = false
    }

    tile := MapTile{
        PixelX:  x,
        PixelY:  y,
        Blocked: blocked,
        Opaque:  opaque,
        Image:   image,
    }
    return tile, nil
}
Enter fullscreen mode Exit fullscreen mode

This defines a set of constants for the types of tiles we have and makes it easier to extend later when we want to add doors or stairs.

Then, we utilize ebitenutil.NewImageFromFile() to load one of the tiles we added, set the properties of the tile, and return it. Since we're using PNG images, we'll need to add _ "image/png" to the import section, making it look like this:

import (
    _ "image/png"
    "log"

    "github.com/hajimehoshi/ebiten/v2"
    "github.com/hajimehoshi/ebiten/v2/ebitenutil"
)
Enter fullscreen mode Exit fullscreen mode

Since we are going to be creating a lot of tiles, we should probably have something better than a simple slice to store them. Create a new structure called Level and give it a constructor.

type Level struct {
    Tiles []MapTile
}

// Creates a new Level object
func NewLevel() Level {
    l := Level{}
    return l
}
Enter fullscreen mode Exit fullscreen mode

Once we have those in place, let's create a private function that builds the initial map: all floor tiles bordered by a wall.

// Creates a simple map in level.Tiles
func (level *Level) createTiles() {
    gd := NewGameData()
    tiles := make([]MapTile, gd.ScreenHeight*gd.ScreenWidth)

    for x := 0; x < gd.ScreenWidth; x++ {
        for y := 0; y < gd.ScreenHeight; y++ {
            idx := GetIndexFromCoords(x, y)
            if x == 0 || x == gd.ScreenWidth-1 || y == 0 || y == gd.ScreenHeight-1 {
                wall, err := NewTile(x*gd.TileWidth, y*gd.TileHeight, TileWall)
                if err != nil {
                    log.Fatal(err)
                }
                tiles[idx] = wall
            } else {
                floor, err := NewTile(x*gd.TileWidth, y*gd.TileHeight, TileFloor)
                if err != nil {
                    log.Fatal(err)
                }
                tiles[idx] = floor
            }
        }
    }
    level.Tiles = tiles
}
Enter fullscreen mode Exit fullscreen mode

Now update the Level constructor to call CreateTiles(), adding the following line before the return call:

    l.createTiles()
Enter fullscreen mode Exit fullscreen mode

We should also create a Draw() function that draws the current level.

// Draw the current level to 'screen'.
func (level *Level) Draw(screen *ebiten.Image) {
    gd := NewGameData()
    for x := 0; x < gd.ScreenWidth; x++ {
        for y := 0; y < gd.ScreenHeight; y++ {
            tile := level.Tiles[GetIndexFromCoords(x, y)]
            op := &ebiten.DrawImageOptions{}
            op.GeoM.Translate(float64(tile.PixelX), float64(tile.PixelY))
            screen.DrawImage(tile.Image, op)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

With that complete, we're finished with level.go for this part. Save the file and return to main.go.

In our Game struct, add the following:

    Levels []Level
Enter fullscreen mode Exit fullscreen mode

and add the following line to the NewGame() function just above the return statement:

    g.Levels = append(g.Levels, NewLevel())
Enter fullscreen mode Exit fullscreen mode

Now the game will load our map as the first level.

Finally, we need to go into the Draw() function and have it actually draw the map.

    gd := NewGameData()
    level := g.Levels[0]
    for x := 0; x < gd.ScreenWidth; x++ {
        for y := 0; y < gd.ScreenHeight; y++ {
            tile := level.Tiles[GetIndexFromCoords(x, y)]
            op := &ebiten.DrawImageOptions{}
            op.GeoM.Translate(float64(tile.PixelX), float64(tile.PixelY))
            screen.DrawImage(tile.Image, op)
        }
    }
Enter fullscreen mode Exit fullscreen mode

Now if you run the game (go run .), you should see the following:

The simple map

That's it for this part. We've added the basic structures to hold a map, a very simple map creation function, and have rendered it to the screen.

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

💖 💪 🙅 🚩
thecal714
Sean Callaway

Posted on October 10, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related