Processing Inputs
Gamewall exposes two main paradigms for submitting inputs: polling and manual input. When using input polling, the Gamewall Engine queries the game at a predictable rate, while manual input involves calling the sendInput
function whenever the game deems necessary. We recommend using polling for most games using continuous inputs. However, for some game types, like card games and most board games, or games using exclusively instantaneous "tap" and no sustained "hold" style inputs, the sparse pattern of sendInput
might work better with a well-designed anticipate
function.
Input Polling
To use the built-in polling functionality, we simply need to define the poll
function:
type poll = (previousInputs: Input[], meta: {
game: Metadata,
env: GamewallEnvironment,
seed: number,
random: PRNG,
}) => Array<{ input: Input, player: number | null }>
This function is automatically called at a regular tick interval, intended to collect the current state of the inputs to fill a dense chart. Gamewall does expect the developer to keep track of their own physical buttons, so a simple input might look something like this:
import { canvas } from './setup'
let holding = false
export const getHolding = () => holding // for future use in render.ts
document.body.addEventListener('keydown', (event) => {
if (event.key === ' ') holding = true
})
document.body.addEventListener('keyup', (event) => {
if (event.key === ' ') holding = false
})
canvas.addEventListener('touchstart', () => holding = true)
canvas.addEventListener('touchend', () => holding = false)
canvas.addEventListener('mousedown', () => holding = true)
canvas.addEventListener('mouseup', () => holding = false)
export const poll: GameTypes['poll'] = (_previousInputs, meta) => {
return meta.env.playersControlled.map(player => ({
input: { raise: holding },
player,
}))
}
ℹ️Noteℹ️
In the default boilerplate, the
poll
function resides ininput.ts
By default, any inputs we don't control are rejected at multiple levels (the game engine, the netcode, even the backing Gamewall service), but it's still best practice to loop through meta.env.playersControlled
so that we only give inputs to the players we're expected to control.
If we do not need access to the previous inputs, or any other player-specific detail, we can also set the player
parameter of the returned value to null
. This broadcasts the same input to all controlled players, cutting out an unnecessary loop:
export const poll: GameTypes['poll'] = (_previousInputs, meta) => {
return [{
input: { raise: holding },
player: null,
}]
}
Whether or not this is useful depends on the game's nature and architecture. For the simple bouncy ball demo, this does make things easier, but if we would like to run filtering functions on inputs, it can be better to stick to looping through meta.env.playersControlled
.
Manual Sending
To send manual inputs, we need to capture the game handle when we register the game:
const handle = gamewall.register({
// game method definitions here
})
document.body.addEventListener('click', () => {
handle.sendInput({ raise: true }, 0)
})
ℹ️Noteℹ️
In the default boilerplate,
gamewall.register()
is called inindex.ts
Where handle.sendInput()
has the following signature:
type sendInput = (input: Input, player: number | null, options?: {
tick?: number,
delay?: number
}) => void
ℹ️Noteℹ️
The
player
parameter inhandle.sendInput()
is nullable. Sendingnull
is equivalent to sending a polled input tonull
, the engine will broadcast the input to all players controlled by the current instance.
This allows us to manually control the tick
and delay
parameters, which the engine normally takes control of. The function of these is to set the input timing:
tick
sets the absolute tick for the input (allowed to be fractional)delay
sets a millisecond delay on the input (allowed to be negative)
For example, since using a single tick action would be infeasible for our bouncy ball, we can manually elongate it:
document.body.addEventListener('click', () => {
const tick = handle.currentFractionalTick
for (let i = 0; i < 10; i++) {
handle.sendInput({ raise: true }, null, { tick: tick + i })
}
})
ℹ️Noteℹ️
We can use both
handle.currentTick
andhandle.currentFractionalTick
to request timings, but there is a subtle difference between the two:currentFractionalTick
always follows the clock at the highest resolution available, whilecurrentTick
reflects the current status of the game calculation. In edge cases, these might desynchronize briefly. Inputs are submitted according tocurrentFractionalTick
by default.
When using sendInput
, please keep in mind that the engine is only able to process the first input for each tick. Subsequent inputs sent at overlapping ticks will be silently ignored. If your game uses manual sending and necessitates each input to be manually sent, we recommend specifying both an exact tick
number and a delay
of 0, as well as keeping track of the next empty tick.
When neither tick
nor delay
is specified, the engine automatically sets tick
to currentFractionalTick
and sets the delay according to the input delay setting of the environment. With polling, tick
is set directly to the polled tick's number, while the input delay setting is automatically used.
Final input tick numbers are calculated in-engine and preserved through the netcode. An input sent to a final tick of 42
on a user's device is going to be sent to tick 42
on the big screen or screens and all other devices as well.
Input Anticipation
It is typical for a Gamewall game to not have access to the true inputs of the final few ticks at any given time. For this reason, the Gamewall Engine requires the developer to specify an anticipate
function in the following format:
type anticipate = (input: Input, state: State, meta: {
game: Metadata,
env: GamewallEnvironment,
seed: number,
random: PRNG,
}) => Input
ℹ️Noteℹ️
In the default boilerplate, the
anticipate
function resides ininput.ts
The purpose of anticipate
is to generate an approximation of an as-yet-unknown player input. This anticipated input will then be used in place of the actual input in running loop
, then, if the true input is ever received, the game state will be rewound to the appropriate time and loop
will be reran with the correct inputs.
The simplest two input anticipation strategies are to either:
presume no input, or
presume a continuation of the last input
Which of these makes more sense varies on a case-by-case basis. Typically, continuous inputs such as directional joysticks play well with a continuation pattern, while with instantaneous actions it's generally better to assume no input. But it is also important to take into account the game's input pattern: sparse inputs with sendInput
will behave very differently from a dense input stream of polling where anticipation merely fills the gaps.
In the bouncy ball example, raise
is a continuous input, so if we use polling, the best implementation of anticipate
might look like this:
export const anticipate: GameTypes['anticipate'] = (previous) => {
return { raise: previous.raise }
}
In this case, if we miss an input, we'll simply presume the user kept doing what they were doing before: if they were raising the ball, we assume they kept raising it, while if they let it fall, we assume they haven't pressed the button again. This will be corrected when (and if) we receive the true input for any given tick.
On the other hand, using the above anticipation function with the sparse input would result in a permanently stuck input. In that case, we need to switch to this implementation:
export const anticipate: GameTypes['anticipate'] = (previous) => {
return { raise: false }
}
Which, at any point when we miss an input, presumes the user let go of the button. Since in the sparse input example we only get the inputs where the user doesn't press the button, this is not only ideal, it is necessary.
Please note that the anticipate
function does have access to the game state of the most recent tick at the time of the input, and is consequently recalculated each time the engine backtracks. This allows for richer, context-aware input anticipation mechanisms, but it also requires this function to be similarly deterministic as loop
.
In theory, by this point in the tutorial, whichever path we took for the inputs, we should already have a fully complete simulation. In practice though, we cannot see any of it yet, so next we need to set up our render functionality.