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.)
ℹ️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:
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 thingsratio
: the relative weight the new state should be considered at, andinterpolate
: a convenience function for linear interpolationnearest
: 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:
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:
for (const key in state.players) {
const position = meta.interpolate(
state.players[key].position,
oldState.players[key].position
)
// use `position` for rendering
}
or even:
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:
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 inrender.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
:
const renderScreen: GameTypes['render'] = (state, oldState, meta) => {
// ...
}
and defining a new render
function which conditionally calls it:
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:
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
:
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:
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:
import { getBuffer } from 'gamewall'
const backgroundBuffer = getBuffer('background.jpg')
Additionally, for rendering without an external library, Gamewall exports the loadImage
convenience function:
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:
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()
, ashandle.renderOnDemand()
in the metadata of
setup
, asmeta.renderOnDemand()
Both renderOnDemand()
functions have the same signature:
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:
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
npm install phaser
and we'll create a phaser.ts
file to define a simple scene for our bouncy ball:
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:
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 (() => {}
) asrender
.