Making an RPG in Go: part 0.5

In the previous article we started getting acquainted with Ebitengine.

In this part, the structure of the game will be finalized and translated into scenes.

Part 0.5?

This is the second pre-1 part, in which a separate demo project is being developed.

It would be too difficult to start making an RPG from scratch: I want to use all my favorite libraries and practices as early as possible, and I couldn’t figure out a way to introduce all the components smoothly enough on a less artificial project.

Perhaps the next article will become the “real” first part, but for now let’s be patient and master the basic techniques of developing games in Go.

Controlling Gopher

Out of the box, Ebitengine has simple functions for processing player input. They work at the level of specific buttons.

// github.com/hajimehoshi/ebiten/v2
ebiten.IsKeyPressed(ebiten.KeyEnter)

// github.com/hajimehoshi/ebiten/v2/inpututil
inpututil.IsKeyJustPressed(ebiten.KeyEnter)

Through such an API it is impossible to abstract from specific buttons and input devices, so for my games I use the package ebitengine-input. The inspiration for this library was Godot actions.

Instead of button clicks, this library checks for action activations. We define possible actions in our game through constants.

In the demo game you will be able to move in four directions, so there will be at least four actions: MoveRight, MoveDown, MoveLeft, MoveUp.

I prefer to place Actions in a separate package controls:

mygame/
  cmd/mygame/main.go
  internal/
    assets/
      _data/images/gopher.png
    controls/actions.go
// internal/controls/actions.go

package controls

import (
    input "github.com/quasilyte/ebitengine-input"
)

const (
    ActionNone input.Action = iota

    ActionMoveRight
    ActionMoveDown
    ActionMoveLeft
    ActionMoveUp

    // Эти действия понадобятся позднее.
    ActionConfirm
    ActionRestart
)

Actions need to be associated with activation triggers. A trigger can be a button press on a keyboard, controller, mouse, touch screen, etc. I will call the collection of these mappings keymap.

Every game needs a default keymap. If desired, you can add support for external configs or remap specific actions within the game. For our demo game, a static keymap is sufficient.

// internal/controls/default_keymap.go

package controls

import (
    input "github.com/quasilyte/ebitengine-input"
)

var DefaultKeymap = input.Keymap{
    ActionMoveRight: {
        input.KeyRight,        // Кнопка [>] на клавиатуре
        input.KeyD,            // Кнопка [D] на клавиатуре
        input.KeyGamepadRight, // Кнопка [>] на крестовине контроллера
    },
    ActionMoveDown: {
        input.KeyDown,
        input.KeyS,
        input.KeyGamepadDown,
    },
    ActionMoveLeft: {
        input.KeyLeft,
        input.KeyA,
        input.KeyGamepadLeft,
    },
    ActionMoveUp: {
        input.KeyUp,
        input.KeyW,
        input.KeyGamepadUp,
    },

    ActionConfirm: {
        input.KeyEnter,
        input.KeyGamepadStart,
    },
    ActionRestart: {
        input.KeyWithModifier(input.KeyR, input.ModControl),
        input.KeyGamepadBack,
    },
}

All that remains is to create an input reading object bound to the given keymap. This object is created through input.Systemwhich is needed in a single copy for the entire game.

 type myGame struct {
    windowWidth  int
    windowHeight int

+   inputSystem input.System
    loader      *resource.Loader

    player *Player
 }

The input system requires a one-time initialization before starting the game:

g.inputSystem.Init(input.SystemConfig{
    DevicesEnabled: input.AnyDevice,
})

For each Update in the game you need to call the method of the same name in the input system:

 func (g *myGame) Update() error {
+   g.inputSystem.Update()
    g.player.pos.X += 16 * (1.0 / 60.0)
    return nil
 }

After integrating the system, you can create the same input reading objects. These objects in the library are called handlers. Each handler is tied to a player ID, which is especially important for games with the ability to connect several controllers at the same time.

For a demo game, only one handler with a zero ID is enough.

    inputSystem input.System
+   input       *input.Handler
    loader      *resource.Loader
g.input = g.inputSystem.NewHandler(0, controls.DefaultKeymap)

Now through g.input you can check the status of actions. We don't have any scenes yet, so all the logic will be focused mainly Update.

func (g *myGame) Update() error {
    g.inputSystem.Update()

    speed := 64.0 * (1.0 / 60)
    var v gmath.Vec
    if g.input.ActionIsPressed(controls.ActionMoveRight) {
        v.X += speed
    }
    if g.input.ActionIsPressed(controls.ActionMoveDown) {
        v.Y += speed
    }
    if g.input.ActionIsPressed(controls.ActionMoveLeft) {
        v.X -= speed
    }
    if g.input.ActionIsPressed(controls.ActionMoveUp) {
        v.Y -= speed
    }
    g.player.pos = g.player.pos.Add(v)

    return nil
}

This control will work with all activation methods that we set in the keymap: you can move using the arrows, WASD, and even through the controller.

Tag part0.5_controls contains the state of the demo game code after adding controls.

Refactoring

Before implementing scenes, it is worth refactoring.

First, I'll put the game context that exists between scenes into a package game. What will remain in the object myGamewill not be available for scenes directly.

 type myGame struct {
-   windowWidth  int
-   windowHeight int

    inputSystem input.System
-   input       *input.Handler
-   loader      *resource.Loader

    player *Player // Это вынесем позже, в сцену
 }
// internal/game/context.go

package game

import (
    input "github.com/quasilyte/ebitengine-input"
    resource "github.com/quasilyte/ebitengine-resource"
)

type Context struct {
    Input  *input.Handler
    Loader *resource.Loader

    WindowWidth  int
    WindowHeight int
}

What exactly falls into the game context depends heavily on the game and your preferences.

Tag part0.5_game_context — this is the repository after this refactoring.

Introduction to Scenes

To divide the game into separate parts, it is convenient to have the concept of a scene.

Earlier in the game there was already an implicit scene – the entire game. The game starts myGame performs Update+Draw cycle for this single scene. Explicit scenes change a lot and require additional code, but their benefits quickly justify the investment.

The transition to explicit scenes looks something like this:

type myGame struct {
    ctx *game.Context
}

func (g *myGame) Update() error {
    g.ctx.InputSystem.Update()
    g.ctx.CurrentScene().Update()
    return nil
}

func (g *myGame) Draw(screen *ebiten.Image) {
    g.ctx.CurrentScene().Draw(screen)
}

type Scene struct {
    // ...
}

func (s *Scene) Update() {
    for _, o := range s.objects {
        o.Update()
    }
}

func (s *Scene) Draw(screen *ebiten.Image) {
    for _, g := range s.graphics {
        o.Draw(screen)
    }
}

Please note that I store the current scene in game.Contextnot in the object myGame.

The transition from one scene to another occurs through replacement myGame.ctx.currentScene. The logic of a scene is contained in its objects (scene.objects), and all graphics are implemented by graphic objects (scene.graphics).

Library gscene implements exactly this model:

$ go get github.com/quasilyte/gscene

Objects and graphics (aka graphics objects) are interfaces.

type SceneObject interface {
    Init(*Scene)
    Update()
    IsDisposed() bool
}

type SceneGraphics interface {
    Draw(dst *ebiten.Image)
    IsDisposed() bool
}

Method IsDisposed will be needed to remove objects from the scene. Init Called on objects when they are added to the scene. Through the scene argument, these objects can add additional objects or graphics to the scene.

In my interpretation of scenes, it is very useful to have one special kind of object, one per scene – the controller. The scene contains objects and is their container, while the controller assigned to the scene is the main object of that scene. He also adds the first set of objects to the scene.

The controller implements the interface SceneObjectbut without method IsDisposed.

Scene objects can access the controller object. Everything will become clearer with an example.

Creating Scenes

We will have two scenes: a splash screen and a gameplay screen. The game starts on the splash screen, and after activating the confirm action, it goes to the gameplay scene.

A scene transition is the replacement of the current scene with a new one. The implementation of such a replacement might look like this:

// internal/game/context.go

// Реализуем как свободную функцию, потому что иначе не получится
// параметризовать функцию для разных T.
func ChangeScene[T any](ctx *Context, c gscene.Controller[T]) {
    s := gscene.NewRootScene[T](c)
    ctx.scene = s
}

// Заметим, что CurrentScene возвращает интерфейс GameRunner,
// а не сцену. Это позволяет унифицировать
// разные Scene[T] с точки зрения игрового цикла,
// ведь там достаточно иметь Update+Draw и ничего более.
func (ctx *Context) CurrentScene() gscene.GameRunner {
    return ctx.scene
}

I recommend storing all scenes in a bag scenes. For the simplest scenes, such as a splash screen, one file is enough inside scenesand for more complex cases it is worth creating nested packages, one for each similar scene.

Optionally, give these nested scenes a prefix scene*so that there are no conflicts with other packages (a quite common situation is to have a package battle for the scene and for some general gameplay definitions).

For logical objects scenes I prefer to add the suffix *node (both in file names and type names).

mygame/
  cmd/mygame/main.go
  internal/
    assets/
      _data/images/gopher.png
    controls/actions.go
    scenes/
      splash_controller.go
      walkscene/
        walkscene_controller.go
        gopher_node.go

The controller for the splash screen will be a stub, since it will not work out nicely to show the text on the screen (we will add the necessary library later). All it will do is switch to the main scene after processing the confirm activation.

// internal/scenes/splash_controller.go

package scenes

import (
    "github.com/quasilyte/ebitengine-hello-world/internal/controls"
    "github.com/quasilyte/ebitengine-hello-world/internal/game"
    "github.com/quasilyte/ebitengine-hello-world/internal/scenes/walkscene"
    "github.com/quasilyte/gscene"
)

type SplashController struct {
    ctx *game.Context
}

func NewSplashController(ctx *game.Context) *SplashController {
    return &SplashController{ctx: ctx}
}

func (c *SplashController) Init(s *gscene.SimpleRootScene) {
    // В заглушке никакого текста вроде "press [Enter] to continue"
    // мы показывать не будем. Вернёмся к этому немного позднее.
}

func (c *SplashController) Update(delta float64) {
    if c.ctx.Input.ActionIsJustPressed(controls.ActionConfirm) {
        game.ChangeScene(c.ctx, walkscene.NewWalksceneController(c.ctx))
    }
}
// internal/scenes/walkscene/walkscene_controller.go

package walkscene

import (
    "os"

    "github.com/quasilyte/ebitengine-hello-world/internal/game"
    "github.com/quasilyte/gscene"
)

type WalksceneController struct {
    ctx *game.Context
}

func NewWalksceneController(ctx *game.Context) *WalksceneController {
    return &WalksceneController{ctx: ctx}
}

func (c *WalksceneController) Init(s *gscene.SimpleRootScene) {
    os.Exit(0) // Пока что заглушка
}

func (c *WalksceneController) Update(delta float64) {
}

When the game starts, we will have a black screen (splash scene), and after processing confirm, the game will immediately close, going into the walkscene.

Installing Graphics

$ go get github.com/quasilyte/ebitengine-graphics

Most graphics constructors require passing an object *graphics.Cacheso for convenience this cache should be hidden inside game.Context. Many games will benefit from wrapping graphics constructors in context methods to reduce the number of arguments they call.

// internal/game/context.go

func NewContext() *Context {
    return &Context{
        graphicsCache: graphics.NewCache(),
    }
}

func (ctx *Context) NewLabel(id resource.FontID) *graphics.Label {
    fnt := ctx.Loader.LoadFont(id)
    return graphics.NewLabel(ctx.graphicsCache, fnt.Face)
}

func (ctx *Context) NewSprite(id resource.ImageID) *graphics.Sprite {
    s := graphics.NewSprite(ctx.graphicsCache)
    if id == 0 {
        return s
    }
    img := ctx.Loader.LoadImage(id)
    s.SetImage(img.Data)
    return s
}

Installing Fonts

To render text, you will need a font in ttf or otf format. Download DejavuSansMono.ttf and save it in internal/assets/_data/fonts.

By analogy with graphic resourcesfont resources need to be registered.

// internal/assets/fonts.go

package assets

import (
    resource "github.com/quasilyte/ebitengine-resource"
)

const (
    FontNone resource.FontID = iota
    FontNormal
    FontBig
)

func registerFontResources(loader *resource.Loader) {
    fontResources := map[resource.FontID]resource.FontInfo{
        FontNormal: {Path: "fonts/DejavuSansMono.ttf", Size: 10},
        FontBig:    {Path: "fonts/DejavuSansMono.ttf", Size: 14},
    }

    for id, res := range fontResources {
        loader.FontRegistry.Set(id, res)
        loader.LoadFont(id)
    }
}

IN RegisterResources call is added registerFontResources:

 func RegisterResources(loader *resource.Loader) {
    registerImageResources(loader)
+   registerFontResources(loader)
 }

Objects and Graphics

It's time to add a confirmation key prompt to your splash screen.

Inside a method SplashController.Init a label with the required text is added:

func (c *SplashController) Init(s *gscene.SimpleRootScene) {
    l := c.ctx.NewLabel(assets.FontBig)
    l.SetAlignHorizontal(graphics.AlignHorizontalCenter)
    l.SetAlignVertical(graphics.AlignVerticalCenter)
    l.SetSize(c.ctx.WindowWidth, c.ctx.WindowHeight)
    l.SetText("Press [Enter] to continue")
    s.AddGraphics(l)
}

Label is a graphic object, so it is added to the scene via AddGraphics.

There are two types of scenes – root and normal. The root is passed to the controller initializer. All other objects in the scene are passed the non-root scene.

The main reason for dividing into two types of scenes is to improve the API. The root scene is directly integrated into the game loop and has methods Update And Draw. The object scene does not have these methods.

The scene is always parameterized by the controller type (or interface to access it). If objects do not need access to the controller, then the parameter can be set any.

This binding helps any object to access the controller through the scene object. To avoid specifying a generic type within a package, you can set an alias:

// internal/scenes/walkscene/walkscene_controller.go

package walkscene

import "github.com/quasilyte/gscene"

// Этот псевдоним типа упростит сигнатуры внутри пакета.
type scene = gscene.Scene[*WalksceneController]

Gopher will become scene logical object:

// internal/scenes/walkscene/gopher_node.go

package walkscene

import (
    graphics "github.com/quasilyte/ebitengine-graphics"
    "github.com/quasilyte/ebitengine-hello-world/internal/assets"
    input "github.com/quasilyte/ebitengine-input"
    "github.com/quasilyte/gmath"
)

type gopherNode struct {
    input  *input.Handler
    pos    gmath.Vec
    sprite *graphics.Sprite
}

func newGopherNode(pos gmath.Vec) *gopherNode {
    return &gopherNode{pos: pos}
}

func (g *gopherNode) Init(s *scene) {
    // Controller() возвращает тип T, который связан со сценой.
    // В данном случае это WalksceneController.
    ctx := s.Controller().ctx

    g.input = ctx.Input

    g.sprite = ctx.NewSprite(assets.ImageGopher)
    g.sprite.Pos.Base = &g.pos
    s.AddGraphics(g.sprite)
}

func (g *gopherNode) IsDisposed() bool {
    return false
}

func (g *gopherNode) Update(delta float64) {
    // Здесь код, который раньше был в myGame Update.
}

The gopher sprite is its graphical component. Plastic bag graphics uses type Pos to bind the position of a graphic object to its owner. The gopher's position is part of the logic, and the sprite just looks at the value through the pointer.

type Pos struct {
    Base   *Vec
    Offset Vec
}

When creating your games, you will almost never have to create your own graphic types like Sprite.

A gopher is created and added to the scene inside Init controller method.

func (c *WalksceneController) Init(s *gscene.RootScene[*WalksceneController]) {
    g := newGopherNode(gmath.Vec{X: 64, Y: 64})
    s.AddObject(g)
}

Tag part0.5_scenes includes the changes described above.

There's still a lot of code ahead, so here's a meme for variety:

Adding Gameplay

In the demo project, the game mechanics will be very simple – collect squares and receive social rating points.

Since I will not discuss collisions and physics in this article, the squares will check their distance to the player and, if it is below the threshold, points will be awarded.

There are two options here: either store the gopher object directly in the controller and access it through the scene, or put it in an explicit shared state. I prefer the second option.

// internal/scenes/walkscene/scene_state.go

package walkscene

type sceneState struct {
    gopher *gopherNode
}

An object sceneState stored inside the controller and created during its initialization.

 type WalksceneController struct {
    ctx *game.Context

+   state *sceneState
+   scene *gscene.RootScene[*WalksceneController]
 }
 func (c *WalksceneController) Init(s *gscene.RootScene[*WalksceneController]) {
+   c.scene = s

    g := newGopherNode(gmath.Vec{X: 64, Y: 64})
    s.AddObject(g)

+   c.state = &sceneState{gopher: g}
 }

This state object can be accessed as across the stage, and explicitly passing the state object to the constructor. A matter of taste and religion.

It would be boring without a random number generator, so we add it to the context of the game.

 type Context struct {
+   Rand gmath.Rand
    ...

You can initialize random in main:

ctx.Rand.SetSeed(time.Now().Unix())

Let's add a picker object:

// internal/scenes/walkscene/pickup_node.go

package walkscene

import (
    graphics "github.com/quasilyte/ebitengine-graphics"
    "github.com/quasilyte/gmath"
    "github.com/quasilyte/gsignal"
)

type pickupNode struct {
    pos      gmath.Vec
    rect     *graphics.Rect
    scene    *scene
    score    int
    disposed bool

    EventDestroyed gsignal.Event[int]
}

func newPickupNode(pos gmath.Vec) *pickupNode {
    return &pickupNode{pos: pos}
}

func (n *pickupNode) Init(s *scene) {
    n.scene = s
    ctx := s.Controller().ctx

    // Количество очков-награды за подбор объекта
    // будет в случайном диапазоне от 5 до 10.
    n.score = ctx.Rand.IntRange(5, 10)

    n.rect = ctx.NewRect(16, 16)
    n.rect.Pos.Base = &n.pos
    n.rect.SetFillColorScale(graphics.ColorScaleFromRGBA(200, 200, 0, 255))
    s.AddGraphics(n.rect)
}

func (n *pickupNode) IsDisposed() bool {
    // Можно было бы использовать n.rect.IsDisposed(),
    // но я рекомендую не привязывать логику объектов
    // к состоянию графических компонентов.
    return n.disposed
}

func (n *pickupNode) Update(delta float64) {
    g := n.scene.Controller().state.gopher
    if g.pos.DistanceTo(n.pos) < 24 {
        n.pickUp()
    }
}

func (n *pickupNode) pickUp() {
    n.EventDestroyed.Emit(n.score)
    n.dispose()
}

func (n *pickupNode) dispose() {
    // Каждый объект должен вызывать методы Dispose
    // у своих компонентов в явном виде.
    n.rect.Dispose()
    n.disposed = true
}

Here I have added the package gsignalwhich implements something like the signals from Godot.

All that remains is to add the code for creating pick-up objects on the stage. This code is added to the controller.

 type WalksceneController struct {
+   scoreLabel *graphics.Label
+   score      int
    ...
// internal/scenes/walkscene/walkscene_controller.go

func (c *WalksceneController) createPickup() {
    p := newPickupNode(gmath.Vec{
        X: c.ctx.Rand.FloatRange(0, float64(c.ctx.WindowWidth)),
        Y: c.ctx.Rand.FloatRange(0, float64(c.ctx.WindowHeight)),
    })

    p.EventDestroyed.Connect(nil, func(score int) {
        c.addScore(score)
        c.createPickup()
    })

    c.scene.AddObject(p)
}

func (c *WalksceneController) addScore(score int) {
    c.score += score
    c.scoreLabel.SetText(fmt.Sprintf("score: %d", c.score))
}
 func (c *WalksceneController) Init(s *gscene.RootScene[*WalksceneController]) {
    ...

+   c.scoreLabel = c.ctx.NewLabel(assets.FontNormal)
+   c.scoreLabel.Pos.Offset = gmath.Vec{X: 4, Y: 4}
+   s.AddGraphics(c.scoreLabel)

+   c.createPickup()
+   c.addScore(0) // Установит текст у scoreLabel
 }

All code can be seen under the tag part0.5_pickups.

Polishing

Finally, let's add a few less significant features.

Let's start by turning the gopher sprite when moving to the left. This is done in a couple of lines:

 func (g *gopherNode) Update(delta float64) {
    ...

+   if !v.IsZero() {
+       g.sprite.SetHorizontalFlip(v.X < 0)
+   }

    g.pos = g.pos.Add(v)
 }

I want to demonstrate how easy it is to restart a scene with this framework:

// internal/scenes/walkscene/walkscene_controller.go

func (c *WalksceneController) Update(delta float64) {
    if c.ctx.Input.ActionIsJustPressed(controls.ActionRestart) {
        game.ChangeScene(c.ctx, NewWalksceneController(c.ctx))
    }
}

All we need to do is replace the current scene using a new controller instance.

The final version of the code can be found by tag part0.5_final.

Reinforcing what we have learned

Current project structure:

mygame/
  cmd/
    mygame/
    main.go
  internal/
    assets/
      _data/
        images/
          gopher.png
        fonts/
          DejavuSansMono.ttf
      assets.go
      fonts.go
      images.go
    controls/
      actions.go
      default_keymap.go
    game/
      context.go
    scenes/
      splash_controller.go
      walkscene/
        walkscene_controller.go
        scene_state.go
        gopher_node.go
        pickup_node.go

  • We use ebitengine-input to handle user input
  • The state shared between scenes is transferred to the context object
  • Using scenes gscene to divide the game into different “screens”
  • Each scene has a controller (the first logical object on the scene)
  • The controller works with RootSceneobjects – with Scene
  • The scene can be parameterized both by the controller itself and by the interface
  • We strictly separate graphic and logical scene objects
  • Convention Dispose: objects remove their components by calling them Dispose and so on
  • For graphic objects we use the package ebitengine-graphics
  • To link objects we use gsignal

Connect with us at telegram communityif the topic of game development on Go is interesting to you.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *