Skip to content

The Game Loop

The Gamewall Engine is a multiplayer-first system designed on the principle of efficient engine-level state synchronization. Delayed inputs and game states, and synchronization problems are abstracted away, and the game developer only needs to concern themselves with two discrete steps:

  • the initialize function, generating the first game state, and

  • the loop function, advancing the game state by one tick

The engine will call these functions whenever necessary. Typically, the loop function is going to be executed multiple times per clock tick, backtracking to prior game states, applying delayed inputs, and recalculating the game state up to the current clock tick, with anticipated inputs if necessary, whenever true inputs are not yet available.

Because of this, it is imperative that loop is a pure function -- that is, a function which returns the same outputs for the same inputs, no matter how many times it is called. It must be deterministic and able to be executed multiple times, on multiple devices if needed, yielding the same result.

In exchange, the developer needs not concern themselves with synchronization problems. loop can be (and must be) a naive function, operating solely on the inputs it receives and treating them as factual. It is guaranteed that it will receive inputs for all players at all times, thus allowing for multiplayer game development that is no more complex than developing for singleplayer.

The loop Function

loop has the following signature:

typescript
type loop = (state: State, inputs: Input[], meta: {
    game: Metadata,
    env: GamewallEnvironment,
    seed: number,
    random: PRNG,
    tick: number,
}) => State | void

Where State, Input, and Metadata are the specific types set up for the game in the previous chapter.

There are two ways a game loop can function:

  • it can return a new game state, or
  • it can modify the existing game state.

IMPORTANT: the loop cannot both modify the game state and also return a new one. If a game state is modified, the game loop must either return nothing, or return the same state by reference.

ℹ️Noteℹ️

Gamewall uses immer internally for the purposes of managing the game state. This requirement directly mirrors what's found in an immer producer.

If forceDeepClone is enabled, breaking this rule will not throw an error, the game will silently favor the returned state and drop the modified one.

For most purposes, modifying the existing game state is a simpler operation. Returning a new one is usually useful for scene changes, if applicable. For many games, this might never be necessary, because of the specifics of the Gamewall execution environment.

For our bouncy ball demo, we are going to opt for a simple modification to the game state:

typescript
export const loop: GameTypes['loop'] = (state, inputs, meta) => {
    // see if anyone is raising the ball
    const raising = inputs.some(input => input.raise)

    // set the ball's speed either according to the raise or gravity
    state.vario = raising
        ? meta.game.raiseSpeed
        : state.vario - meta.game.gravity

    // move the ball one time step
    state.altitude += state.vario

    // process bounces, if any
    if (state.altitude < 0) {
        state.altitude *= -meta.game.bounciness
        state.vario *= -meta.game.bounciness
    }
}

ℹ️Noteℹ️

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

The Gamewall Engine will then call this function whenever it needs to advance a tick. For most intents and purposes, we can think of this as something that runs once per clock tick (as a reminder, we set a tickRate of 128 in the previous chapter, meaning one tick will encompass 1/128th of a second) -- backtracking is handled automatically in-engine by providing a previous state and its corresponding inputs to this function.

A note on randomness

For Gamewall to function properly, it is imperative that loop is deterministic, and that its results are reproducible both within a single device, and between multiple devices.

The most common reason to want to break this determinism in a game is random elements, usually created either with Math.random() or a custom random generator. However, both of these would create a non-deterministic result.

To keep the loops deterministic without giving up functionality, the meta parameter exposes the following two parameters:

  • meta.seed: a pseudorandom signed 32-bit integer, deterministically chosen for the tick in question, intended as a seed for custom random generators

  • meta.random(): a pre-seeded pseudorandom generator which produces a deterministic sequence of random values in the range of 0-1, available as a drop-in replacement to Math.random().

When using meta.random() please note that execution order matters: the pseudorandom generator will always output the same sequence of numbers. In theory, by simply not introducing any non-deterministic element (for example by replacing all your Math.random() calls with meta.random()), execution order is guaranteed, which makes the PRNG applicable in a wide range of use cases. However, if you notice any weird jumps in-game, this could be a source of them -- for example, if you're looping through players based on a first to last in-game position, and there's an overtake in a previous tick, it might swap the random numbers assigned to different players. We recommend keeping loop orders as predictable as feasible.

ℹ️Noteℹ️

meta.random is not only a function, it is also a seedrandom PRNG instance, which allows for calling meta.random.int32() to quickly get a 32-bit signed integer. In fact, such a call is used to generate meta.seed internally.

The initialize function

You might have noticed that while we have a default metadata and input, we do not have a default game state. The reason for that is simple: every new round might be different, and given that the only other function modifying the state is loop, a default state might get extremely limiting.

initialize has a simple signature:

typescript
type initialize = (meta: {
    game: Metadata,
    env: GamewallEnvironment,
    seed: number,
    random: PRNG,
}) => State

The game state generated by initialize will be the first state passed to the first loop function, starting the cycle. The same random functionality is added here too, which, if used, will allow a game to be replayed deterministically solely from a source seed.

For the bouncy ball example, we're simply going to put the ball to a fixed height with a random upward motion:

typescript
export const initialize: GameTypes['initialize'] = (meta) => {
    const state: GameState = {
        altitude: 100, // could be another metadata field
        vario: meta.random() * 10, // likewise
    }

    return state
}

ℹ️Noteℹ️

In the default boilerplate, the initialize function resides in loop.ts

We chose to define state as a GameState type rather than return it directly, to give us some leeway to modify it between these two statements, allowing for a more complex initialization logic.

With these two functions defined, our should be able to calculate the next state. However, we'll have problems controlling the ball, because we still need to define the inputs. Let's do that next.

Made by Mondriaan with ❤️