log 015

Game Dev Log: Render Loop

This is the final log covering the game loop I have built in Gleam. I covered the foundational loop, the update loop, and now we are going to break down the render loop.

If you remember from the last log we talked about the update loop and render loop having some core principles. Here were the render loop’s:

  1. You only want to render once per game loop.
  2. Rendering should not be responsible for any state mutation.

The reason these two are core to the concept of a rendering loop is that your render loop (geez how many times am I going to type that) is the most performance intensive piece of your game engine.

We ended our last log having implemented a cursor in our game state, and now we need to render it and a map onto our client, which in our case is HTML Canvas. In the same way that we are using requestAnimationFrame to trigger our game loop using the Tick(Msg)we will need to use an Effect for our render loop.

Why? Besides the call to requestAnimationFrame, we will be making DOM API calls to check if our canvas exists so that we can render to it. Okay…what makes that an effect? Well, you can call document.getElementById(GAME_ID) multiple times without changing any of the arguments and if something has changed in the browser environment and the canvas no longer exists you will get a different result. So, our first job is to setup an Effect that pulls our rendering context from the browser and then schedule our rendering inside of a requestAnimationFrame callback to optimize its performance with the browser.

fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
  case msg {
    Tick(current_time) -> {
      case model {
        Ready(game_state) -> {
          let updated_state = 
            game_state 
              |> update_time(current_time)
              |> apply_events()
              |> update_entities()
      
          #(Ready(updated_state), effect.batch([render(), schedule_next_tick())
      } 
      Idle -> #(Ready(init_game_state(current_time)), schedule_next_frame())
    } 
  }
}

fn render(game_state: GameState) -> Effect(Msg) {
  effect.from(fn(dispatch) {
    case render.with_context() {
      Ok(render.RenderContext(canvas, context)) -> { 
        engine.request_animation_frame(fn (_) { 
           // Render here
        })
      }
      _ -> dispatch(RendererMissingCanvas)
    })
  })
}

fn view(model: Model) -> Element(Msg) {
  html.div([], [
    html.canvas([
      attribute.id(render.render_target_id),
      attribute.width(640),
      attribute.height(360),
      attribute.style([
        #("image-rendering", "pixelated"),
        #("border", "1px solid black"),
      ]),
    ]),
  ])
}

// Render Module

pub type RenderContext {
  RenderContext(Canvas, CanvasRenderingContext2D)
}

pub const render_target_id = "__render_target"

pub fn with_context() {
  let canvas_result = canvas.get_canvas_by_id(render_target_id)
  let context_result = result.try(canvas_result, context.get_context_2d)
  case canvas_result, context_result {
    Ok(can), Ok(con) -> Ok(RenderContext(can, con))
    _, _ -> Error("Failed to find context.")
  }
}

There is an underlying FFI implementation for canvas and the canvas context, but I left it out due to it being just a direct copy of the JavaScript APIs. You can check out the repo here to dig into it.

Now that we have a loop let's talk about rendering an isometric map. There are some requirements for the game engine I am building that need to be understood based on the fact that we are rending a “3D” map into a 2D context using canvas.

- Tiles must be rendered from back to front - Rendering happens on coordinate position at a time - Render order matters especially within a coordinate space with many entities.

Because we are working in a 2D canvas space there is not concept of z-index and layering by some parameterized approach. The order that you render things in to the canvas is the way they get layered. So if you render the tiles without thinking about order you do not get the result you're expecting.

In the conceptual 3D space the gray and blue tiles are farther away so the other tiles should show on top. In canvas that means we have to render lower layers first thus the rule is back to front. The same goes for multiple entities on a single coordinate space

The first thing we will need to render anything is to update our Maptype to hold our tiles, their coordinates, data, and a method for looping through each tile based on our rules from above.

pub type Map { 
  Map( 
    tiles: Dict(Coord, Tile)
  ) 
}

pub type Tile {
  Tile(tileset: Tileset, passability: Passability)
}

pub type Tileset {
  Demo(DemoVariant)
}

pub type DemoVariant {
  Base
  Variant1
  Variant2
  Variant3
  Variant4
  Variant5
  Variant6
}

pub type Passability {
  Passable
  Impassable
}

We are using a Dict to store our tiles because we want O(1) lookups. I started with an Array, buuut maps could be shaped like these examples:

And a sparse map is better defined by a Dict than by an array with blank indices. Not to mention with a Dict I can use a custom type as a key meaning we can store not only the location of the tile on the grid, but its elevation too.

The Tileset and *Variant type that you're seeing here represents how each map can have different tile options to render. For example our demo map has 7 options that look like below:

The Passability will continue to get more complex to factor in tile variants like water, mud, ice etc, that will affect the movement calculations, but for now we are keeping it simple by saying the tile is either passable or not.

The next thing we need is a method for looping through each tile and rendering the tile that exists at a specific coordinate. Also, we need to keep in mind that for each tile we will need to check for other entities that need to be rendered at that coordinate as well. Say for instance our cursor.

To do this I wrote a iterator function that accepts a callback that has access to the current coordinates and tile data.

pub fn each_tile(map: Map, f: fn(coord.Coord, tile.Tile) -> Nil) -> Nil {
  map.tiles
  |> dict.to_list
  |> list.sort(fn(a, b) { coord.compare(pair.first(a), pair.first(b)) })
  |> list.each(fn(tile_pair) {
    let coords = pair.first(tile_pair)
    let tile = pair.second(tile_pair)
    f(coords, tile)
  })
}

This approach means wherever we call this function they can have full control of what happens at that Coord based on the Tile data.

Before we get into spritesheets and how we can convert our Tileset(Variant) to spritesheet locations let's get the tile loop setup in our render function.

fn render(game_state: GameState) -> Effect(Msg) {
  effect.from(fn(dispatch) {
    case render.with_context() {
      Ok(render.RenderContext(canvas, context)) -> { 
        engine.request_animation_frame(fn (_) { 
           game_state.map
           |> map.each_tile(fn(coords, tile) {
                // Render tiles here
              })
        })
      }
      _ -> dispatch(RendererMissingCanvas)
    })
  })
}

Well, three logs in and halfway in and we are finally getting to our games graphics! Let's cover some basics and terminology we will need to know going into building out our system for managing all the artwork for this game.

Graphics are stored as sprite sheets. A sprite sheet is a way of conserving performance by putting all related versions of a graphic in a single image defined by a common grid size. That way when you need to use that graphic in your render, for instance all the tiles from a map, or a character with attack animation frames you load only a single (still small since we're working with pixel art) image vs potentially hundreds of tiny images.

Okay, with that in mind we need to add the sprite sheet data to our map for our tiles to pull from.

pub type Map {
  Map(
    sprite_sheet: sprite.SpriteSheet,
  )
}

pub type SpriteCoord {
  SpriteCoord(x: Int, y: Int)
}

pub type SpriteSheet {
  SpriteSheet(
    asset: asset.Asset, // Opaque Type representing a loaded Image object
    grid: Int,
    sprites: Array(SpriteCoord),
  )
}

Our data type holds the loaded asset for our canvas, their grid size used in this sprite sheet. I think most graphics will be the same grid size, but I don't know if that may be different for attacks, or buildings, so I want to have that available just in case. Finally, all of our sprites will be stored as an array of (x,y) coordinates in the loaded asset. This will allow us to create a transformer for our *Variant to an index in that array for lookup.

We now have our two concepts defined.

The Tileset, that holds all of our game specific data and our SpriteSheet which holds all of our graphic specific data.

As of right now I am hardcoding tiles for the map. Even later on I think there will be plenty of maps I do this for. I think all core maps in the storyline will be custom and consistent vs procedural like other maps may be for random quest lines or wild run ins.

Let's go ahead and define a demo map, and the code we need to tie it to a spritesheet.

Here is our map:

pub fn new() -> map.Map {
  let tiles =
    dict.from_list([
      // Row 1
      #(coord.at(0, 0, 0), new_tile(demo.Base)),
      #(coord.at(1, 0, 0), new_tile(demo.Variant1)),
      #(coord.at(2, 0, 0), new_tile(demo.Variant6)),
      #(coord.at(3, 0, 0), new_tile(demo.Base)),
      #(coord.at(4, 0, 0), new_tile(demo.Variant4)),
      #(coord.at(5, 0, 0), new_tile(demo.Variant5)),
      #(coord.at(6, 0, 0), new_tile(demo.Variant2)),
      #(coord.at(7, 0, 0), new_tile(demo.Base)),
      // Row 2
      #(coord.at(0, 1, 0), new_tile(demo.Variant3)),
      #(coord.at(1, 1, 0), new_tile(demo.Variant4)),
      #(coord.at(2, 1, 0), new_tile(demo.Base)),
      #(coord.at(3, 1, 0), new_tile(demo.Variant5)),
      #(coord.at(4, 1, 0), new_tile(demo.Variant1)),
      #(coord.at(5, 1, 0), new_tile(demo.Base)),
      #(coord.at(6, 1, 0), new_tile(demo.Variant6)),
      #(coord.at(7, 1, 0), new_tile(demo.Variant2)),
      // Row 3
      #(coord.at(0, 2, 0), new_tile(demo.Variant2)),
      #(coord.at(1, 2, 0), new_tile(demo.Base)),
      #(coord.at(2, 2, 0), new_tile(demo.Base)),
      #(coord.at(3, 2, 0), new_tile(demo.Variant1)),
      #(coord.at(4, 2, 0), new_tile(demo.Variant1)),
      #(coord.at(5, 2, 0), new_tile(demo.Base)),
      #(coord.at(6, 2, 0), new_tile(demo.Variant3)),
      #(coord.at(7, 2, 0), new_tile(demo.Base)),
      // Row 4
      #(coord.at(0, 3, 0), new_tile(demo.Base)),
      #(coord.at(1, 3, 0), new_tile(demo.Variant4)),
      #(coord.at(2, 3, 0), new_tile(demo.Base)),
      #(coord.at(3, 3, 2), new_tile(demo.Variant1)),
      #(coord.at(4, 3, 2), new_tile(demo.Variant3)),
      #(coord.at(5, 3, 0), new_tile(demo.Base)),
      #(coord.at(6, 3, 0), new_tile(demo.Base)),
      #(coord.at(7, 3, 0), new_tile(demo.Variant2)),
      // Row 5
      #(coord.at(0, 4, 0), new_tile(demo.Variant3)),
      #(coord.at(1, 4, 0), new_tile(demo.Base)),
      #(coord.at(2, 4, 0), new_tile(demo.Variant4)),
      #(coord.at(3, 4, 2), new_tile(demo.Base)),
      #(coord.at(4, 4, 2), new_tile(demo.Variant5)),
      #(coord.at(5, 4, 0), new_tile(demo.Base)),
      #(coord.at(6, 4, 0), new_tile(demo.Base)),
      #(coord.at(7, 4, 0), new_tile(demo.Variant1)),
      // Row 6
      #(coord.at(0, 5, 0), new_tile(demo.Base)),
      #(coord.at(1, 5, 0), new_tile(demo.Variant1)),
      #(coord.at(2, 5, 0), new_tile(demo.Base)),
      #(coord.at(3, 5, 0), new_tile(demo.Variant3)),
      #(coord.at(4, 5, 0), new_tile(demo.Base)),
      #(coord.at(5, 5, 0), new_tile(demo.Variant2)),
      #(coord.at(6, 5, 0), new_tile(demo.Variant5)),
      #(coord.at(7, 5, 0), new_tile(demo.Base)),
      // Row 7
      #(coord.at(0, 6, 0), new_tile(demo.Variant4)),
      #(coord.at(1, 6, 0), new_tile(demo.Variant2)),
      #(coord.at(2, 6, 0), new_tile(demo.Base)),
      #(coord.at(3, 6, 0), new_tile(demo.Base)),
      #(coord.at(4, 6, 0), new_tile(demo.Variant5)),
      #(coord.at(5, 6, 0), new_tile(demo.Base)),
      #(coord.at(6, 6, 0), new_tile(demo.Variant6)),
      #(coord.at(7, 6, 0), new_tile(demo.Base)),
      // Row 8
      #(coord.at(0, 7, 0), new_tile(demo.Variant6)),
      #(coord.at(1, 7, 0), new_tile(demo.Base)),
      #(coord.at(2, 7, 0), new_tile(demo.Variant1)),
      #(coord.at(3, 7, 0), new_tile(demo.Variant4)),
      #(coord.at(4, 7, 0), new_tile(demo.Base)),
      #(coord.at(5, 7, 0), new_tile(demo.Base)),
      #(coord.at(6, 7, 0), new_tile(demo.Base)),
      #(coord.at(7, 7, 0), new_tile(demo.Variant2)),
    ])
  map.Map(sprite_sheet: demo.sprite_sheet(), tiles: tiles)
}

fn new_tile(variant: demo.DemoVariant) -> tile.Tile {
  tile.Tile(tileset: tile.Demo(variant), passability: tile.Passable)
}

Here is our spritesheet:

pub type DemoVariant {
  Base
  Variant1
  Variant2
  Variant3
  Variant4
  Variant5
  Variant6
}

pub fn get_sprite_key(variant: DemoVariant) {
  case variant {
    Base -> 0
    Variant1 -> 1
    Variant2 -> 2
    Variant3 -> 3
    Variant4 -> 4
    Variant5 -> 5
    Variant6 -> 6
  }
}

const sprites = [
  sprite.SpriteRegion(0, 0),
  sprite.SpriteRegion(1, 0),
  sprite.SpriteRegion(2, 0)),
  sprite.SpriteRegion(3, 0)),
  sprite.SpriteRegion(4, 0)),
  sprite.SpriteRegion(5, 0)),
  sprite.SpriteRegion(6, 0)),
]

pub fn sprite_sheet() -> sprite.SpriteSheet {
  sprite.SpriteSheet(
    asset: asset.load_image(
      "https://our-sprite-sheet.com/demo.png",
    ),
    grid: 32,
    sprites: array.from_list(sprites),
  )
}

Now that we have these defined let's see how we coordinate the two.

pub fn get_sprite(
  sprite_sheet: sprite.SpriteSheet,
  tileset: Tileset,
) -> Result(sprite.SpriteCoord, Nil) {
  let sprite_key = case tileset {
    Demo(variant) -> Ok(demo.get_sprite_key(variant))
    Blank -> Error(Nil)
  }

  sprite_key
  |> result.try(array.get(sprite_sheet.sprites, _))
}

We pattern match our Tileset and extract the variant for the current tile. We then use our transformer function we wrote before to get the sprite key that we use to fetch our SpriteCoord. That is all we need to get the right asset from our spritesheet to render it to the canvas.

This is getting long, but we are in the final stretch. Now that we have a way to get the spritesheet information we need, we need to render the graphic to the correct location on the canvas based on our 3D Coord. To do this we need a conversion function that will map 3D Coords to our 2D vector location.

Calculate camera focus offset

Our system allows the map to overflow the camera viewport. This means where a tile is rendered inside of the canvas can change from render to render.

let dx = coord.x - focus_coord.x
let dy = coord.y - focus_coord.y

The delta x and delta y represent how many grid steps we are from the focus tile.

Convert linear coordinates to isometric

In a standard top down grid the x moves left and right and y move up and down. However, in our isometric projection of our coordinates we need to rotate that concept.

let screen_x = (dx - dy) * half_width
let screen_y = (dx + dy) * half_height

For our screen_x subtracting y from x rotates the coordinate to move (NW ↔ SE) and doing the opposite for screen_y rotates it (NE ↔ SW). Then by multiplying by our half of the tile sizes in each direction gives us the steepness of our isometric angle.

Start offset from canvas center

So far we have gotten all of our data, but it is still being done from a (0, 0) origin (the top left of canvas) we need that calculation to be based off of the center of our canvas.

let center_x = canvas_width / 2
let center_y = canvas_height / 2

let screen_x = center_x + (dx - dy) * half_width
let screen_y = center_y + (dx + dy) * half_height

Factor in z-offset

Our tiles can be of different elevation from the true bottom of the map. To handle how this effects our 2D coordinate we need to adjust our y axis by moving it up (in the negative axis direction) by half the tile height times our z value.

let z_offset = z * half_height

let screen_y = center_y + (dx + dy) * half_height - z_offset

There is one final note as we put it all together we below. The vectors will need to be converted to a Float to fit our definition of what a canvas 2D Vector is in the engine.

const half_width = 16
const tile_height = 16
const half_height = 8

pub fn to_vector(
  coord: Coord,
  focus_coord: Coord,  // the tile you consider your center
  canvas_width: Int
) -> vector.Vector {
  let dx = coords.x - camera.focus.x
  let dy = coords.y - camera.focus.y
  let center_x = camera.width / 2
  let center_y = camera.height / 2 - half_height

  let screen_x = center_x + { dx - dy } * half_width

  let screen_y = center_y + { dx + dy } * half_height

  at(
    screen_x |> int.to_float,
    { screen_y - coords.z * half_height } |> int.to_float,
  )
}

Annnd finally function to take alllll of this data we have been calculating and put it on the canvas. To do this we need to use the full parameter version of drawImage which we have setup using FFI.

A quick explainer of the drawImage function signature since there are a lot of arguments:

drawImage(
  asset, // The sprite sheet we are getting our graphic from
  assetX, // The x coordinate for the sprite in the sheet  
  assetY, // The same as above but for y
  assetWidth, // The width of the sprite in the sheet
  assetHeight, // The height of the sprite in the sheet
  canvasX, // The x location for drawing the cropped image above
  canvasY, // The same but for y
  canvasWidth, // The width to draw the sprite on the canvas
  canvasHeight // The same but for height
)

We use it to define a render function in our sprite module, and allow the function to scale the asset using the final argument based on how much screen real estate we have in the client.

// lib/sprite.gleam

pub fn x(sr: SpriteRegion, grid: Int) {
  sr.x * grid
}

pub fn y(sr: SpriteRegion, grid: Int) {
  sr.y * grid
}

pub fn render(
  context: context_impl.CanvasRenderingContext2D,
  sheet: SpriteSheet,
  sprite_region: SpriteRegion,
  at: vector.Vector,
  scale: math.Scale,
) {
  context_impl.draw_image_cropped(
    context,
    sheet.asset,
    x(sprite_region, sheet.grid) |> int.to_float,
    y(sprite_region, sheet.grid) |> int.to_float,
    sheet.grid,
    sheet.grid,
    vector.x(at) |> math.scale(scale),
    vector.y(at) |> math.scale(scale),
    32.0 |> math.scale(scale),
    32.0 |> math.scale(scale),
  )
}

And if you have made it this far….bless you. We finally use the sprite render function in our render loop to draw all of our tiles onto the canvas based on the rules we defined at the beginning of this log.

fn render(game_state: GameState) -> Effect(Msg) {
  effect.from(fn(dispatch) {
    case render.with_context() {
      Ok(render.RenderContext(canvas, context)) -> { 
        engine.request_animation_frame(fn (_) { 
           game_state.map
           |> map.each_tile(fn(coords, tile) {
                let sprite_region = tile.get_sprite(game_state.map.sprite_sheet, tile.tileset)

                case sprite_region {
                  Ok(region) -> {
                    sprite.render(
                      context,
                      game_state.map.sprite_sheet,
                      region,
                      coord.to_vector(coords),
                      game_state.scale,
                    )
                    Nil
                  }
                  _ -> Nil
                }
              })
        })
      }
      _ -> dispatch(RendererMissingCanvas)
    })
  })
}

You will notice we haven't even covered the cursor rendering yet, buuuut I'm tired of writing so we can cover that later.

See ya next time! 👋

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.