Knight vs Trolls Part 3: Levels

In this tutorial we will implement our main gameplay loop. When our knight defeats the trolls in the level, more trolls will spawn and the game arena will become smaller making the game harder. The goal of our brave hero is to survive for as long as possible.

Level System

So far our game consisted of one level that we set up when the game boots. Here we will introduce a level system to switch between levels. We can do this in many ways. One way would be to create game.Scene instances and store them, either in the game source (hacky but sufficient in a game prototype) or in levels files (for example JSON dumps of game.Scene).

In this tutorial we will create a system that builds each level programmatically, similarly to what procedurally generated games do. Our levels will be created by the Level type defined as follows.

type Level struct {
    level       int
    tiles       int
    trolls      int
    trollsAlive int
    player      *Knight
    Scene       *game.Scene
}

Level keeps track of the current level (1,2,3 etc), the number of trolls in the level and the player. We convert this information into a game.Scene that runs in the engine. This is done in the makeAScene method.

func (l *Level) makeAScene() {
    scene := game.NewScene()
    if l.player == nil {
        l.player = NewKnight(math.Vector2[float32]{500, 500})
    }
    scene.AddGameObject(l.player)

    tilesize := float32(50)
    start := 150 + float32(15-l.tiles)*(tilesize/2)
    arenaSize := l.tiles * int(tilesize)

    scene.AddGameObject(NewArena(
        math.Vector2[float32]{start, start},
        l.tiles, l.tiles,
        math.Vector2[float32]{tilesize, tilesize}),
    )

    scoreText := NewFloatText(fmt.Sprint("Level ", l.level),
        math.Vector2[int]{300, 50}, math.Vector3[float32]{20, 950, 0})
    scene.AddGameObject(scoreText)

    // spawn trolls randomly
    for i := 0; i < l.trolls; i++ {
        posX := rand.Float32()*float32(arenaSize) + start
        posY := rand.Float32()*float32(arenaSize) + start

        troll := NewTroll(math.Vector2[float32]{posX, posY})
        scene.AddGameObject(troll)
    }
    l.trollsAlive = l.trolls
    // unload previous scene
    if l.Scene != nil {
        l.Scene.Destroy()
    }
    l.Scene = scene
}

This method does three things:

Changing Level

The rule for changing the level is simple. On odd levels we decrease the arena size. On even levels we increase the number of trolls.

func (l *Level) Next() {
    l.level = l.level + 1

    if l.level%2 != 0 {
        // odd levels decrease the arena
        l.tiles = l.tiles - 1
        l.makeAScene()

    } else {
        // even levels increase the number of trolls
        l.trolls = l.trolls + 1
        l.makeAScene()
    }
}

In both cases we recreate the scene using makeAScene.

Triggering a Level Change

A level change is triggered when all trolls in the scene die. We use the trollsAlive level attribute to keep track of this. When a troll dies we inform the level with the TrollKilled method:

func (l *Level) TrollKilled() {
    l.trollsAlive -= 1
    fmt.Println("trolls alive:", l.trollsAlive)
    if l.trollsAlive == 0 {
        l.Next()
    }
}
func (t *Troll) Update(dt time.Duration) {
    //...
        if OnGround(t.hurtbox) {
        t.Walker.Update(dt, t)
    } else {
        Fall(t, dt)
        if t.GetScale().Length() < 5 {
            Game.Level.TrollKilled()
            t.Destroy()
        }
    }
    //...
}

Once trollsAlive reaches zero, we trigger the transition to the next level.

This approach is fast and works well for our small demo game but for larger games it could become confusing to keep track of various level change conditions and where they are triggered. A more scalable approach would be to search the scene on each update and count the number of trolls. This wouldn’t require the implementation of Troll to worry about updating the level itself1. A possible downside is that searching the scene adds a small performance hit.

Emptying the Scene

When we swap one scene for another we want to make sure all game objects in the scene are destroyed. To ensure this, we call l.Scene.Destroy() at the end of makeAScene. This recursively calls Destroy on all game objects in the scene.

func (s *Scene) Destroy() {
    for _, v := range s.root.GetChildren() {
        v.Destroy()
    }
}

Running the Level

To run our levels we add it to the global game struct (this was a Scene in previous tutorials).

var Game struct {
    Atlas      *sprite.Atlas
    Shader     shaders.Shader
    Input      *platform.InputState
    Collisions game.CollisionManagerAABB
    Level      *Level
}

In our main loop we update and render the Level’s scene attribute:

func main() {
    //...
    Game.Level = NewLevel()
    //...
    for {
        // game loop 
        //...
        Game.Level.Scene.Update(dt)
        Game.Level.Scene.Render(renderer)
        renderer.Render()
    }
}

Since Level manages the scene internally, there is no need to code anything here. When the scene is swapped, the game loop will automatically update and render the new one.

Further Work

This concludes the Knights vs Trolls tutorial. Our goal was to showcase core building blocks of making a game such as rendering and controlling characters, collisions, gameplay mechanics and levels. We created a basis from which we can build incrementally more complex games. The following are some suggestions for further work.

Most of these suggestions shouldn’t be too hard to implement and building multiple would transform this little demo game into an actual game!


  1. A fancy way to put this is to say it reduces coupling.↩︎