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.System
which 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 myGame
will 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.Context
not in the objectmyGame
.
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 SceneObject
but 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 scenes
and 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.Cache
so 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
RootScene
objects – withScene
- 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 themDispose
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.