log 012

Game Dev Log: Game Loop

I'm building a Tactical RPG, think Final Fantasy Tactics or XCOM, and I'm writing it from scratch in Gleam.

The first thing that I need to build to make this happen is a game loop. Okay actually the first thing I needed to do was learn what a game loop was. Here is what I learned.

A game loop is a continuous time based loop used to advance the game’s state based on user input. It is then responsible for rendering those updates on the client.

Learning this made me realize two things.

  1. The Game Loop is actually two loops.
  2. I need to nail down the client I want to render my game to.

A game loop is the combination of a logical loop and a render loop. The logical loop takes any events that have happened and the amount of time that has elapsed since the last loop and updates the current state. The render loop takes the updated state and renders it as graphics the players see.

We will dig deeper into both of those loops in a future log, but let's talk through the second realization and jump into the example of how I implemented the game loop in Gleam.

The answer to number two was easy. I am a web guy. I may dabble in a little bit of everything from time to time, but I wanted to use at least one thing I was familiar with, the canvas API in the browser. This meant I will be using Gleam compiled to JavaScript, and it also led me to my next hurdle.

Game loops are time based loops. Okay cool, what does that actually mean? Well, have you ever heard of the term 60 FPS? FPS aka frames per second is the speed that your game loop is able to finish an update and render those changes to the screen.

The hurdle? I need to decide what browser based solution for time based loops do we use, There are two realistic options.

  • requestAnimationFrame
  • setInterval

I will be going with requestAnimationFrame for three reasons.

  1. It updates with the browser rendering cycle. This gives me the best performance for updates to the canvas vs setInterval which can have frame drops or stutters if the interval does not sync with the browsers rendering.
  2. The internal timers for setInterval can drift over longer periods. Normally? Not a problem. For a game engine? Big problem when the expectation is extended use in a single session.
  3. Resource efficiency. The browser throttles requestAnimationFramecalls on inactive tabs, and setTimeout doesn't. This means if someone leaves the game up and continues to something elserequestAnimationFrame will conserve CPU and battery as opposed to setInterval.

Enough talk let's get to the implementation.

Stateful frontends in Gleam can be built using a wonderful library called Lustre. It is a sibling of Elm, and follows the Model-View-Update (MVU) pattern for managing state and rendering. Here is the skeleton that I started with to get a simple canvas rendered to the browser.

// MAIN ------------------------------------------------------------------------

pub fn main() {
  let app = lustre.application(init, update, view)
  let assert Ok(_) = lustre.start(app, "#app", Nil)

  Nil
}

// MODEL -----------------------------------------------------------------------

type Model {
  Idle
}

fn init(_) -> #(Model, Effect(Msg)) {
  #(Idle, effect.none())
}

// UPDATE ----------------------------------------------------------------------

type Msg {
  ToDo
}

fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
  case msg {
      _ -> #(model, effect.none())
    }
  }
}

// VIEW ------------------------------------------------------------------------

fn view(model: Model) -> Element(Msg) {
  html.div([], [
    html.canvas([]),
  ])
}

I am not going to dig into details of how MVU works, but the TL;DR is a unidirectional data flow where each concept has an isolated and predictable job:

Model:

Immutable representation of the entire state of the application.

View:

A pure function that takes the Model and returns the UI

Messages (Msg):

All possible actions that can be taken within the system to update the Model. These are the bridge for how you and the user can influence the state of the world.

Update:

A function that takes a Msg and the current Model, then returns an updated Model.

So if we are building a game loop how can we introduce this into the MVU skeleton we see above?

  • Need to store time of last frame (Model)
  • Need to update state based on current_time and events (Update)
  • Need to schedule the next frame in the loop (??)

First thing to do was update the Model so it could hold some game state.

type Model {
  Idle
  Ready(previous_time: Float)
}

There are now two states the game can be in. Idle for when you first load the page, and Ready for when we have initialized the game and have a loop running with the data we need to track.

Next thing was to write an update function for the game state in each loop. Update requires two pieces. The Msg telling us what is happening and the actual update logic for that Msg.

fn init(_) -> #(Model, Effect(Msg)) {
  #(Idle, schedule_next_frame())
}

// ...

fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
  case msg {
      Tick(current_time) -> #(Ready(previous_time: current_time), schedule_next_tick())
    }
  }
}

fn schedule_next_frame() { 
  effect.from(fn(dispatch) {
    engine.request_animation_frame(fn(timestamp) {
      dispatch(Tick(timestamp))
    })
  })
}

Okay, we have our state, we have the way we handle updating our state with the current frame time….but how do we get the current time and send it in a Msg to the update function?

A great question curious reader! As we covered earlier we know that the way we will get our timestamps is calling requestAnimationFrame. Let's introduce a new concept and explain how calling requestAnimationFrame fits into MVU.

Our update function is pure. That means when you pass in the same arguments you get the same results. This may be setting off warning bells for you now.

Update can't be pure if we call requestAnimationFrame, the animation timestamp is different every time it is called.

You're right! Wow, so proud of you for realizing that on your own.

Say hello to Effects. In functional terminology things like calls torequestAnimationFrame, or DOM requests (aka all of our canvas stuff we will be covering later) are called side effects. They are impure actions where the results of calling them multiple times with the same arguments can result in vastly different results depending on some system outside of our control.

With requestAnimationFrame every call the timestamp is different because time is the greatest side effect of them all. So, how do we handle that in our update function?

You have noticed in our code so far that update (and init) return a#(Model, Effect(Msg)) tuple. If an update requires us to call a side effect we wrap it in an Effect function that returns aMsg. This allows us to go off and run our impure code and send the result wrapped in Msg back through the update function. Everyone is happy! We get to go run processes that need to hook into ever changing sources and the pure update function will get a Msg with stable data based on the result.

With that dynamic in mind let's add the final pieces to our game loop.

type Msg {
  Tick(current_time: Float)
}

fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
  case msg {
      Tick(current_time) -> #(Ready(previous_time: current_time), effect.none())
    }
  }
}

If you want to validate the loop is working as expected we can add some extra data to the Model and render more than just a canvas:

type Model {
  Idle
  Ready(previous_time: Float, fps: Float)
}

fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
  case msg {
    Tick(current_time) -> { 
      let frame_time = calc_frame_time(model, current_time) 
      
      // Since division by 0 is illegal the division function
      // returns a Result type we need to pattern match on.
      let fps = case float.divide(1000.0, frame_time) {
        Ok(fps) -> fps
        _ -> 60.0
      } 
      
      #(Ready(previous_time: current_time, fps: fps), schedule_next_tick())
    } 
  }
}

fn calc_frame_time(model: Model, current_time: Float) { 
  case model { 
    Ready(previous_time, ..) -> float.subtract(current_time, previous_time)
    _ -> 0.0
  }
}

fn view(model: Model) -> Element(Msg) {
  html.div([], [
    html.canvas([]),
    html.div([], render_debugger(model)),
  ])
}

fn render_debugger(model: Model) { 
  case model { 
    Idle -> html.p([],[html.text("Initializing")])
    Ready(previous_time, fps) -> {
      html.p([],[
        float.to_string(previous_time) |> html.text
        html.text(" • "),
        float.to_string(fps) |> html.text
      ]) 
    }
  }
}

I've got a looooong way to go, but it is off to a fun start!

I hope you enjoyed

There is a lot more coming...

If you want to get updates when I publish new guides, demos, and more just put your email in below.