Skip to content

Rendering

The Gamewall Engine runs a fixed-interval tick loop and provides a best-effort (frame interval) render loop. It also allows the developer to ignore this render loop and define their own, or harness another game engine's render loop. This inevitably results in a lack of synchronization between the tick loop and rendering, necessitating the ability to render intermediate states between ticks for a smooth experience.

The engine solves this by providing two states each time a frame is rendered: the most recent state and the one before; along with a ratio defining the relative weight of the two game states. (A ratio of 1.0 means the new state should be fully in effect, a ratio of 0.0 means we should fully skew toward the old state.)

gamewall render timing

ℹ️Noteℹ️

Technically, this method always lags behind by one tick interval (not illustrated), since we have no information on future ticks, only past ones. Keep the tickRate in mind when calculating render and input delays.

The engine provides two main methods for rendering: using the provided render loop, or on-demand rendering.

Using The Render Loop

The simplest way to render a game (without the use of third-party libraries) is to implement all rendering logic inside the render function, which has the following signature:

typescript
type render = (state: State, oldState: State, meta: {
    game: Metadata,
    env: GamewallEnvironment,
    ratio: number,
    interpolate: <T>(newValue: T, oldValue: T) => T,
    nearest: <T>(newValue: T, oldValue: T) => T,
    tick: number,
}) => void

This gives us easy access to all required render parameters, and the engine will call the function for us at every frame with the current data.

The parameters given are

  • state: the current latest game state,

  • oldState: the game state exactly one tick before,

  • meta: a render metadata, which includes, among other things

    • ratio: the relative weight the new state should be considered at, and

    • interpolate: a convenience function for linear interpolation

    • nearest: a convenience function for nearest-neighbor selection

Interpolation is left up to the game developer, because not all parts of the game state may need or benefit from interpolation. The provided convenience functions, meta.interpolate() and meta.nearest(), can deal with complex objects if needed, so hypothetically it is possible to just start render with:

typescript
const interpolatedState = meta.interpolate(state, oldState)

However, for complex games this can prove to be a constraint. It's usually easier to make interpolations such as:

typescript
for (const key in state.players) {
    const position = meta.interpolate(
        state.players[key].position,
        oldState.players[key].position
    )
    // use `position` for rendering
}

or even:

typescript
for (const player of meta.interpolate(state.players, oldState.players)) {
    // use `player.position` for rendering
}

For our bouncy ball example, we are going to use a little more granularity than needed, to demonstrate a more typical render function, even though our simplistic game state could just be interpolated in one go. That way, our function can look something like this:

typescript
import { getContext } from './setup'

const BALL_RADIUS = 10 // this could go in the metadata
const PI_2 = Math.PI * 2

export const render: GameTypes['render'] = (state, oldState, meta) => {
    const ctx = getContext()

    // resize the canvas to clear it and follow window size changes
    ctx.canvas.width = window.innerWidth
    ctx.canvas.height = window.innerHeight

    // draw a background
    ctx.fillStyle = '#014'
    ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)

    // calculate the ball's position
    const altitude = meta.interpolate(state.altitude, oldState.altitude)
    const y = ctx.canvas.height - altitude - BALL_RADIUS
    const x = ctx.canvas.width / 2

    // draw the ball
    ctx.fillStyle = '#def'
    ctx.beginPath()
    ctx.arc(x, y, BALL_RADIUS, 0, PI_2, false)
    ctx.fill()
}

ℹ️Noteℹ️

In the default boilerplate, the render function resides in render.ts

If we did everything right, we should see something like this:

[insert picture of bouncy ball]

However, there's a slight problem: our controller has a copy of the game running on it, as opposed to a proper controller screen. To fix that, we'll need to watch the meta.env.role variable to see which screen we have to render.

Rendering multiple screen types

In our example, in the manifest we set up two screens: screen and controller. This is the default and most common setup. If we'd like to create a game for a complex deployment, or offer the flexibility of multiple renderers and controller setups, we can always add more potential screens in the manifest.

But first, let's handle the two default options. The easiest way to do so is to split the render function into a number of specific functions. We'll start by renaming our existing render function to renderScreen:

typescript
const renderScreen: GameTypes['render'] = (state, oldState, meta) => {
    // ...
}

and defining a new render function which conditionally calls it:

typescript
export const render: GameTypes['render'] = (state, oldState, meta) => {
    switch (meta.env.role) {
        case 'screen': return renderScreen(state, oldState, meta)
    }
}

Aaand success...ish. This change should have removed the game screen from the controller, but now we see nothing there. To rectify that, we need to define a controller-specific renderer:

typescript
import { getHolding } from './input.ts'

const renderController: GameTypes['render'] = (state, oldState, meta) => {
    const ctx = getContext()
    const holding = getHolding()

    // resize the canvas to clear it and follow window size changes
    ctx.canvas.width = window.innerWidth
    ctx.canvas.height = window.innerHeight

    // draw a background
    ctx.fillStyle = holding ? '#def' : '#014'
    ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)

    // draw a tooltip
    ctx.fillStyle = holding ? '#014' : '#def'
    ctx.font = '30px sans-serif'
    ctx.textAlign = 'center'
    ctx.textBaseline = 'middle'
    ctx.fillText('hold here', ctx.canvas.width / 2, ctx.canvas.height / 2)
}

And add it to render:

typescript
export const render: GameTypes['render'] = (state, oldState, meta) => {
    switch (meta.env.role) {
        case 'screen': return renderScreen(state, oldState, meta)
        case 'controller': return renderController(state, oldState, meta)
    }
}

This way, we get the correct render function called on the correct screen at all times.

This logic is useful for more complex scenarios as well. For example, if we're using an existing engine with its own render loop, we'll usually want to use on-demand rendering and forgo the render function for the main screen entirely, but we might want a more interactive controller. Conversely, if the game we're developing requires a DOM-based controller, we might want to use the render loop for the main screen only. In more complex deployment setups, this could get even more complicated, but by organizing each role into its own render function, we can easily handle them separately as needed.

Asset Storage

Many games use quite a few resources beyond just the code of the game: textures, map files, 3D models, and so on. The Gamewall Engine provides an asset manager system for these: any file saved into the assets directory will be unpacked and loaded into the sandbox at runtime. These files can be accessed with the getUrl function:

typescript
import { getUrl } from 'gamewall'

// long example with getUrl
const background = new Image()
background.src = getUrl('background.jpg')

The URL-based loading method maximizes compatibility with external libraries. If needed, assets are also available synchronously in Buffer form:

typescript
import { getBuffer } from 'gamewall'

const backgroundBuffer = getBuffer('background.jpg')

Additionally, for rendering without an external library, Gamewall exports the loadImage convenience function:

typescript
import { loadImage } from 'gamewall'

// short example with loadImage
const ball = loadImage('ball.png')

This is equivalent to creating a new image with new Image() and setting its .src property.

ℹ️Noteℹ️

You can find an example background.png and ball.png in the assets folder in this repository.

Rendering Images

With the asset images now loaded, we can render them out the same way we'd render any image on a javascript canvas:

typescript
const renderScreen: GameTypes['render'] = (state, oldState, meta) => {
    const ctx = getContext()

    // resize the canvas to clear it and follow window size changes
    ctx.canvas.width = window.innerWidth
    ctx.canvas.height = window.innerHeight

    // draw a background
    if (background.complete) {
        const scaleFactor = Math.max(
            ctx.canvas.width / background.width,
            ctx.canvas.height / background.height
        )
        const offsetX = (ctx.canvas.width - background.width * scaleFactor) / 2
        const offsetY = (ctx.canvas.height - background.height * scaleFactor) / 2
        ctx.drawImage(
            background,
            offsetX, offsetY,
            background.width * scaleFactor, background.height * scaleFactor
        )
    }

    // calculate the ball's position
    const altitude = meta.interpolate(state.altitude, oldState.altitude)
    const y = ctx.canvas.height - altitude - BALL_RADIUS
    const x = ctx.canvas.width / 2

    // draw the ball
    if (ball.complete) {
        ctx.drawImage(
            ball,
            x - BALL_RADIUS, y - BALL_RADIUS,
            BALL_RADIUS * 2, BALL_RADIUS * 2
        )
    }
}

For background we did a bit of fancy math to emulate the behavior of backgrond-size: cover in CSS, while for ball, we're using a square asset and rendering it to a square, so we don't need to worry about aspect ratio. In both cases, we use the .complete property of the image to avoid rendering while the image hasn't finished loading yet -- realistically, both images are loading from memory, so we're just avoiding an edge case here.

With a fully-fledged rendering engine such as Phaser, these problems can be abstracted away from us.

On-Demand Rendering

In addition to the Gamewall Engine calling the render function proactively, we can also request a renderable state with the renderOnDemand function whenever needed. This function is available in two places:

  • in the game handle returned by gamewall.register(), as handle.renderOnDemand()

  • in the metadata of setup, as meta.renderOnDemand()

Both renderOnDemand() functions have the same signature:

typescript
type renderOnDemand = (delay?: number) => {
    state: State,
    oldState: State,
    meta: RenderMetadata
}

Where RenderMetadata is identical to the meta parameter of the render function, as documented above. The delay parameter can be used to request a millisecond delay for rendering, which can be used to smooth out microstutters caused by late calculations of game states. This is automatically managed when using the render function.

We're going to show setup as an example, but using handle should be very similar. First, we'll start by creating a new screen in the manifest:

yaml
screensAvailable: # an array of screens your game can show
  - screen
  - controller
  - screen_phaser

For development, we can set this as default, but this is selectable in the simulator. For deployments, it is up to the operator to select which of the available screens they'd like to use.

Then, we'll install Phaser

bash
npm install phaser

and we'll create a phaser.ts file to define a simple scene for our bouncy ball:

typescript
import { getUrl } from 'gamewall'
import * as Phaser from 'phaser'

import { GameTypes } from './types'

class BouncyBallScene extends Phaser.Scene {
    ball?: Phaser.GameObjects.Image | null = null
    background?: Phaser.GameObjects.Image | null = null

    preload() {
        this.load.image('ball', getUrl('ball.png'))
        this.load.image('background', getUrl('background.png'))
    }
    create() {
        this.background = this.add.image(300, 300, 'background')
        this.background.setScale(
            this.scale.width / this.background.width,
            this.scale.height / this.background.height
        )
        this.ball = this.add.image(300, 300, 'ball')
        this.ball.setScale(100 / this.ball.width)
    }
}

let game: Phaser.Game | null = null
let scene: BouncyBallScene | null = null

export const initPhaser = (canvas: HTMLCanvasElement): Phaser.Game => {
    game = new Phaser.Game({
        type: Phaser.WEBGL,
        width: 600,
        height: 600,
        scale: {
            mode: Phaser.Scale.HEIGHT_CONTROLS_WIDTH,
            autoCenter: Phaser.Scale.CENTER_BOTH
        },
        scene: [],
        canvas: canvas,
    })
    scene = new BouncyBallScene()
    game.scene.add('main', scene, true)

    return game
}

export const renderPhaser: GameTypes['render'] = (state, oldState, meta) => {
    if (!game || !scene) return

    const altitude = meta.interpolate(state.altitude, oldState.altitude)
    scene.ball?.setPosition(300, 550 - altitude)
}

ℹ️Noteℹ️

See the Phaser docs for an in-depth guide on Phaser

Then we'll modify our setup function in setup.ts, to conditionally set up Phaser:

typescript
import { initPhaser, renderPhaser } from './phaser'

export const setup: GameTypes['setup'] = (meta) => {
    // initialize the canvas
    switch (meta.env.role) {
        case 'screen':
        case 'controller':
            context = canvas.getContext('2d')
            break
        case 'screen_phaser':
            const game = initPhaser(canvas)
            game.events.on('prerender', () => {
                const { state, oldState, meta: renderMeta } = meta.renderOnDemand()
                renderPhaser(state, oldState, renderMeta)
            })
            break
    }

    // inject the dom
    meta.env.container.appendChild(canvas)

    // return the game metadata (we can modify it here, if needed)
    return meta.game
}

This snippet does two important things:

  • it conditionally initializes the external engine

  • it uses the external engine's render loop to synchronize renderable states

We have abstracted these two tasks away into initPhaser and renderPhaser, respectively. Much of the code is specific to Phaser, which is beyond the scope of this documentation, but the underlying principle is the same: we use the external engine's own render loop for timing, retrieving render state with renderOnDemand(), as opposed to relying on the Gamewall Engine for timing.

ℹ️Noteℹ️

The render function still has to be provided even if we exclusively use on-demand rendering. However, it is not required to do anything, so for example if our input is entirely DOM-based and we use an external engine for rendering, we can just pass an empty function (() => {}) as render.

Made by Mondriaan with ❤️