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:
- You only want to render once per game loop.
- 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 Map
type 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.
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.