Welcome to the blog of

Josh Deprez

⬅️ Previous postNext post ➡️ Permalink link

Published 6 September 2021

Making a Game in Go, part 2

In Part 1 of this series of posts, I sketched a simple game engine written in Go. The proposed engine was overly simplistic, but could conceivably be used as a starting point for simple 2D games.

In this post, I want to discuss a critical design decision that affects how easy it is to make more complicated 2D games.

Structure

Recall from the previous post that the main type for the overly-simple game engine looked like this:

1
2
3
type Game struct {
    Components []interface{}
}

And, when time came to update the game state or draw the components, Game delegated the relevant work to each component that had either of those behaviours.

The flat nature of the Components slice implies that all the components in the game sit at some equal level in a flat hierarchy, with one special component (Game) delegating work. It’s kind of like the false utopia of the theoretical “Valve Organizational Chart”:

Valve Organizational Charts (as envisioned by employees)
From Valve Handbook For New Employees (p. 5), Valve Corporation, 2012, Valve Press, accessed 6 Sep 2021.

Whatever your opinion on company org-charts, game design often benefits from additional structure. Each component might itself consist of some other components. Compare this screenshot from Commander Keen 4, and a hypothetical hierarchy of components that might be driving the game under the hood:

Commander Keen 4 screenshot
From Commander Keen 4: Secret of the Oracle, Id Software Inc., 1992, Apogee.
  • Level 1 (Border Village)
    • Background
      • Forest
      • House
      • Background tile
      • Background tile
    • Midground tilemap
      • Ground tile
      • Ground tile
    • Sprites
      • Mimrock
      • Billy Blaze (Keen)
      • Bounder
        • Circling stars
      • Poison Slug
      • Lifewater droplet
      • Lifewater droplet
    • Foreground tilemap
      • Tile
      • Tile
    • Scorecard
      • Score
        • Digit
        • Digit
      • Lives count
        • Helmet icon
        • Digit
        • Digit
      • Ammo count
        • Blaster icon
        • Digit
        • Digit

It seems clear that a game tree is a more natural way of describing a game than flattening all the components into a single list.

Fork in the road

There are two ways to adapt the game engine to handle more complicated game structures. These are:

  1. Don’t. Make every component responsible for calling Update and Draw on all its direct subcomponents. In turn, each subcomponent calls those methods on their direct subcomponents, and so on.
  2. Alternatively, each component and subcomponent (and so on) registers with Game, and Game does some book-keeping to track them all and the relationships between them. Then Game figures out when to call Update and Draw on all components.

The rest of this post will be about Approach #1. I will write about Approach #2 in the next post.

Commander Keen 4 looks like the kind of game that could be implemented easily with approach #1. It is a platformer which could be described as a “tilemap sandwich”: background tiles, midground tiles and sprites, and foreground tiles. This makes the top layer of the game tree “fixed”; there is never a need to reorder the layers.

Scenery

Approach #1 has a number of benefits for the engine programmer. In particular, the work is delegated from the top level Game object down through each subcomponent, so there is minimal book-keeping in the top-level object. Also, each component can easily influence its subcomponents.

Let’s implement a component that might be useful in such an engine. Here’s one called Scene, which is intended to be a bare-bones container of other components:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Scene struct {
    Components []interface{}
}

func (s *Scene) Draw(screen *ebiten.Image) {
    for _, c := range s.Components {
        if d, ok := c.(Drawer); ok {
            d.Draw(screen)
        }
    }
}

func (s *Scene) Update() error {
    for _, c := range s.Components {
        if u, ok := c.(Updater); ok {
            if err := u.Update(); err != nil {
                return err
            }
        }
    }
    return nil
}

Note that *Scene implements both the Drawer and Updater interfaces itself, so when it is nested in Game or another Scene (e.g. the below), its methods get called as intended, and in turn delegates work to its subcomponents:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
game := &Game{
    Components: []interface{}{
        &Scene{
            Components: []interface{}{
                &Scene{
                    Components: []interface{}{
                        ...
                    }
                }
            }
        }
    }
}

ebiten.RunGame(game)

But wait! Scene is almost exactly the same as Game! It’s doing the same things: loop over the subcomponents, and delegate work to those that have the applicable behaviour. It would even make sense to copy the draw-order sorting in Update from Game into Scene too, because a Scene could contain subcomponents that change draw order over time.

This suggests a refactor of Game:

1
2
3
type Game struct {
    Scene
}

By embedding Scene, Game now has the methods of Scene (Draw and Update), and calling these is equivalent to calling them on game.Scene. The only other thing from ebiten.Game is Layout, and for now, that isn’t useful for Scene to implement (it could be, though), so Game remains useful as a separate type.

Let’s make Scene more useful, though. Some things we might want to do during a game are:

  • Hide everything in a Scene, e.g. hide level 1 and show level 2
  • Stop updates to everything in a Scene, e.g. pause the game

This is easy to do in the Approach #1 paradigm. This could be implemented in Scene as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
type Scene {
    Components []interface{}
    Hidden bool
    Disabled bool
}

func (s *Scene) Draw(screen *ebiten.Image) {
    if s.Hidden {
        // Skip drawing
        return
    }
    // loop over Components and draw the Drawers
}

func (s *Scene) Update() error {
    if s.Disabled {
        // Skip updating
        return nil
    }
    // loop over Components and update the Updaters
}

Drawing stuff and relative positioning

To draw a sprite onto the screen in ebiten, one typically needs code like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var opts ebiten.DrawImageOpts

// The destination position is set by translating the geometry matrix
opts.GeoM.Translate(pos.X, pos.Y)

// The source image might be a sprite sheet containing a particular subimage
// needed at this moment
sx := frameSize.X * index
src := sourceImage.SubImage(image.Rect(sx, 0, sx+frameSize.X, frameSize.Y))

// Draw src onto screen at pos
screen.DrawImage(src, &opts)

Given the Scene implemented above, it would be inconvenient if, say, we wanted to translate everything in a Scene by the same offset - each component would need its position updated. This in turn means storing two different positions for each component: its draw position and its real position, and keeping them in sync somehow. This is a mess.

A similar but related problem would be to recolour all the components in a Scene, e.g. suppose we want to use a ColorM to adjust the colouring in a consistent way.

Relative positioning and recolouring and all sorts of fun is unblocked if we alter the Drawer interface:

1
2
3
type Drawer interface {
    Draw(screen *ebiten.Image, opts ebiten.DrawImageOpts)
}

This diverges from ebiten.Game. Game would be the logical place to interop between ebiten.Game and Drawer, by supply the initial opts value to Scene’s Draw method:

1
2
3
4
func (g *Game) Draw(screen *ebiten.Image) {  // implements ebiten.Game, not Drawer
    // Pass the zero value for opts
    g.Scene.Draw(scene, ebiten.DrawImageOpts{})
}

Each parent component such as Scene can modify opts before delegating drawing to its subcomponents:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
type Point struct {
    X, Y float64
}

type Scene struct {
    Offset Point
    // ... other stuff ...
}

func (s *Scene) Draw(screen *ebiten.Image, opts ebiten.DrawImageOpts) {
    if s.Hidden {
        return
    }

    // Copy opts
    newOpts := opts
    
    // Reset GeoM to an identity matrix
    newOpts.GeoM.Reset()
    
    // Translate scenespace to worldspace (translate by s.Offset)
    newOpts.GeoM.Translate(s.Offset.X, s.Offset.Y)
    
    // Reapply the original opts.GeoM to translate into screenspace or whatever
    newOpts.GeoM.Concat(opts.GeoM)
    
    // Draw the subcomponents
    for _, c := range s.Components {
        if d, ok := c.(Drawer); ok {
            d.Draw(screen, newOpts)
        }
    }
}

And each component can use the opts provided from its parent component in basically the same way:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func (s *Sprite) Draw(screen *ebiten.Image, opts ebiten.DrawImageOpts) {
    // Copy opts
    newOpts := opts
    
    // Reset GeoM to an identity matrix
    newOpts.GeoM.Reset()

    // Translate from spritespace to worldspace
    newOpts.GeoM.Translate(s.pos.X, s.pos.Y)
    
    // Reapply opts.GeoM to translate from worldspace to screenspace via
    // whatever geometry matrix was supplied by the parent
    newOpts.GeoM.Concat(opts.GeoM)
    
    // Draw!
    src := ...
    screen.DrawImage(src, &newOpts)
}

Problems

Approach #1 gets a long way, especially for platformers and other tilemap sandwiches, because it is straightforward and the logical structure matches the draw order. There are a few well-defined layers, drawn in a fixed order, and each component in the hierarchy can affect its subcomponents directly because it is responsible for delegating work to them.

The limits of the approach are hit as soon as subcomponents in different parts of the game tree need to be drawn in different orders. Suppose we are implementing a top-down game with walls or other obstacles with “height”. They must partially obscure objects behind them, and are partially obscured by objects in front of them. (Spoilers: you are now making a 3D game.)

Awakeman walks around a column
A sprite (Awakeman) walks around a column, and is alternately partially obscured by the column, and partially obscures the column.

Logically, all the “wall parts” might belong as subcomponents of a single “wall” component, especially if the parent component holds common properties of each part like “size” and “sprite sheet”.

But having all the wall parts be subcomponents of one wall would mean all the wall parts are drawn in one layer: the “wall layer”. The wall parts now can’t intermingle with other components in a dynamic way, because draw ordering happens for each layer, not across layers. If the draw ordering is fixed (i.e. the game consists of nothing but fixed walls) then there is hardly a problem, but this prohibits having a sprite that sometimes walks in front of walls and sometimes walks behind them, because the ordering between them cannot change.

This necessitates a compromise: the wall parts must be immediate subcomponents of all the other components they need to be sorted amongst. The parent Scene can then sort them as the draw ordering changes. Great!

Unfortunately this sucks, because now there is no single “wall”, just lots of horrible little pieces that are separate from one another, and if we need to change some common property of them, we must either:

  1. Find them all and change each one individually
  2. Do book-keeping to ensure each one has a pointer to some shared struct, and update that
  3. There is no 3.

Compromise #2 is the more sensible option, but it becomes gets harder if the game tree needs to be serialised and deserialised. If the pointer to the shared struct is serialised along with the wall part, then the deserialiser might decide to allocate each wall part its own copy of the “shared” struct! If the pointer is not serialised along with the wall part, then each wall part has to somehow be told about, or independently locate, the shared struct to use after being inflated.

The benefits of a hierarchy are lost. It seems some amount of book-keeping is actually necessary, so why not go back to the beginning and implement Approach #2? Let’s rip the bandaid off and make complex 2D games easier! Approach #2 will be written about in Part 3 of this series.