Skip to content

Input

The input system maps physical device state onto the named actions a game declares. A game declares what it can do, its actions, its action contexts, and its bindings, and InputManager holds all three and runs them: capturing device state, deciding which bindings fire, and broadcasting each fired action as an event every subscribed entity hears.

typescript
const inputManager = engine.managers.inputManager;

InputManager

MemberMeaning
actionsThe ActionRegistry
contextsThe ActionContextRegistry
deviceState(type, index)The state currently captured for one device
lastActiveDeviceTypeThe device type behind the most recent deliberate input, or undefined
getBindingsForAction(action, deviceType?, context?)Every binding registered for the action, filtered when a caller gives a device type or a context
registerCapture(capture)Feeds an InputCapture every deliberate candidate. Answers with a function that unregisters it.
routeCapturedInput(context)The routing loop. GameManager calls this once a frame.

Actions

An action is digital or analog. A digital action carries a boolean; an analog action carries a number.

typescript
interface DigitalAction
{
    type : 'digital';
    name : string;
    label ?: string;
}

interface AnalogAction
{
    type : 'analog';
    name : string;
    label ?: string;
    minValue ?: number;
    maxValue ?: number;
}

type Action = DigitalAction | AnalogAction;

The name is what everything else keys on, and the label is what a settings screen shows in its place. The declaration type lives in @skewedaspect/sage-core and is re-exported by @skewedaspect/sage.

ActionRegistry

MethodMeaning
register(action)Registers by name. A name already registered throws, and an analog minValue above maxValue throws.
get(name)The action, or undefined
list()Every registered action
typescript
inputManager.actions.register({ type: 'analog', name: 'moveForward', minValue: -1, maxValue: 1 });
inputManager.actions.register({ type: 'digital', name: 'jump', label: 'Jump' });

A game registers every action before anything binds to its name.

How an action arrives

In core an action is an action:<name> event in the event map, broadcast to every entity subscribed to it. The payload is the value the binding computed, a boolean for a digital action and a number for an analog one. A game declares each action's event type:

typescript
declare module '@skewedaspect/sage-core'
{
    interface EventMap
    {
        'action:moveForward' : number;
        'action:jump' : boolean;
    }
}

A behavior subscribes and reads the payload in onEvent. Deciding which entities answer is game logic. See Events.

Bindings

A binding connects one device input to one action. There are four kinds.

KindFires
triggerOnce when its input changes state, on the rising edge, the falling edge, or both
toggleAlternates each time its input is pressed, holding its state between presses
valueContinuously, passing its input through. It contributes to its action's value rather than delivering on its own.
deltaOnce for a one-shot sample its input reports, such as a wheel notch, whenever the sample computes nonzero

BindingDefinition

A binding is written as a definition object, JSON-compatible:

typescript
interface BindingDefinition
{
    kind : 'trigger' | 'toggle' | 'value' | 'delta';
    action : string;
    input : BindingInput;
    context ?: string;
    options ?: Record<string, unknown>;
}

interface BindingInput
{
    device : { type : 'keyboard' | 'mouse' | 'gamepad'; index : number };
    sourceType : string;
    sourceKey : string;
}

options is the kind's own options bag.

Trigger options

OptionDefaultMeaning
edge'rising''rising', 'falling', or 'both'
thresholdnoneAn analog input at or above this reads as pressed. Without one, any nonzero value reads as pressed.

A trigger bound to an analog action passes the raw value through in place of a boolean.

Toggle options

OptionDefaultMeaning
onValuetrueThe value sent when toggled on
offValuefalseThe value sent when toggled off
initialStatenoneThe starting on/off state
thresholdnoneAs for a trigger
invertfalseAlternate on release instead of press

Value options

Applied in order: scale, offset, clamp, invert, then deadzone.

OptionDefaultMeaning
scale1Multiplier
offset0Added after scaling
min, maxnoneClamp bounds
invertfalseNegate
deadzone0A magnitude inside it is sent as exactly zero

Delta options

Applied in order: scale, invert, then deadzone. A delta applies neither offset nor clamp. It has no held value to compare against, so it fires whenever a fresh sample computes nonzero and stays silent otherwise.

OptionDefault
scale1
invertfalse
deadzone0

Binding

A resolved binding:

typescript
interface Binding<State>
{
    readonly kind : BindingKind;
    readonly action : Action;
    readonly device : DeviceID;
    readonly context ?: string;

    process(state : State) : BindingFire | undefined;
    prime(state : State) : void;
    toDefinition() : BindingDefinition;
}

process computes whether the binding fires for the given device state, and the value it would send. prime seeds the binding's internal state from the current physical input, so an input already held when a context activates does not read as a fresh press. It is for the bindings that read edges: a value binding has no edge to miss, and its prime does nothing. toDefinition answers with the JSON definition, options included, for export.

resolveBinding

typescript
function resolveBinding<State>(
    definition : BindingDefinition,
    action : Action,
    reader : DeviceReader<State>
) : Binding<State>

Builds the live binding a definition describes. The kind picks TriggerBinding, ToggleBinding, ValueBinding, or DeltaBinding; the caller has already looked the action up and built the reader.

Device readers

A device reader pulls one value out of one device's state:

typescript
interface DeviceReader<State>
{
    readonly sourceType : string;
    readonly sourceKey : string;
    getValue(state : State) : boolean | number | undefined;
}
ReaderSource typeSource keyState
KeyboardReader(sourceKey)keyA DOM KeyboardEvent.code: KeyW, Space, ShiftLeft, ArrowUpKeyboardState
MouseReader('button', sourceKey)button'0' left, '1' middle, '2' rightMouseState
MouseReader('position', sourceKey)position'x' or 'y', the cursor's client positionMouseState
MouseReader('wheel', sourceKey)wheel'deltaX', 'deltaY', or 'deltaZ'MouseState
GamepadReader('button', sourceKey)buttonThe button index as a string, following the standard gamepad mappingGamepadState
GamepadReader('axis', sourceKey)axisThe axis index as a stringGamepadState

Devices are identified by type and index, so a second gamepad is { type: 'gamepad', index: 1 } reached by the same reader definitions.

A reader is typed to its device's state shape, so resolving a definition means picking the reader for its device type:

typescript
import { GamepadReader, KeyboardReader, MouseReader, resolveBinding } from '@skewedaspect/sage';
import type { Binding, BindingDefinition, InputManager } from '@skewedaspect/sage';

function resolve(inputManager : InputManager, definition : BindingDefinition) : Binding<unknown>
{
    const action = inputManager.actions.get(definition.action);

    if(action === undefined)
    {
        throw new Error(`No action registered under the name "${ definition.action }".`);
    }

    const { device, sourceType, sourceKey } = definition.input;

    if(device.type === 'keyboard')
    {
        return resolveBinding(definition, action, new KeyboardReader(sourceKey));
    }

    if(device.type === 'mouse')
    {
        if(sourceType === 'button')
        {
            return resolveBinding(definition, action, new MouseReader('button', sourceKey));
        }

        if(sourceType === 'position')
        {
            return resolveBinding(definition, action, new MouseReader('position', sourceKey));
        }

        return resolveBinding(definition, action, new MouseReader('wheel', sourceKey));
    }

    if(sourceType === 'axis')
    {
        return resolveBinding(definition, action, new GamepadReader('axis', sourceKey));
    }

    return resolveBinding(definition, action, new GamepadReader('button', sourceKey));
}

Gamepad mapping

Buttons and axes are keyed by plain index, the same numbering Gamepad.buttons and Gamepad.axes use. Under the standard mapping: buttons 0 to 3 are the face buttons (A/Cross, B/Circle, X/Square, Y/Triangle), 4 and 5 the bumpers, 6 and 7 the triggers, 8 and 9 Back and Start, 10 and 11 the stick clicks, and 12 to 15 the D-pad. Axes 0 and 1 are the left stick, 2 and 3 the right stick. Stick up reads negative.

Action contexts

An action context is a named group of bindings that activate and deactivate together. A binding names at most one context, and a binding that names none is always live.

A context is exclusive by default, and activating an exclusive context deactivates every other exclusive one. A context declared non-exclusive stacks on whatever is already active. Activating a context primes its bindings against the current physical input. An axis already held when its context arrives reports its value on the next pass rather than waiting for it to change.

ActionContextRegistry

MethodMeaning
registerContext(name, exclusive?)Registers a context. Exclusive unless false is passed.
activateContext(name, currentState)Activates it, deactivating other exclusive contexts, and primes each member binding with the device state currentState(binding) answers
deactivateContext(name)Deactivates it
isActive(name), isRegistered(name)Queries
addBinding(binding)Adds a binding under its own named context, or under none
removeBinding(binding)Retires it. The routing loop stops delivering through it and the next activation stops priming it. Idempotent.
list()Every binding, in the order added
isLive(binding)True when the binding names no context, or names an active one

activateContext takes the function that reads a binding's device state because deviceState is overloaded per device type:

typescript
export function activateContext(inputManager : InputManager, name : string) : void
{
    inputManager.contexts.activateContext(name, (binding) =>
    {
        if(binding.device.type === 'keyboard')
        {
            return inputManager.deviceState('keyboard', binding.device.index);
        }

        if(binding.device.type === 'mouse')
        {
            return inputManager.deviceState('mouse', binding.device.index);
        }

        return inputManager.deviceState('gamepad', binding.device.index);
    });
}

Device state capture

InputManager captures device state itself: it listens for keyboard and mouse events on window and polls every connected gamepad once a rendered frame. A game never listens for a device event or polls a gamepad on its own account.

deviceState(type, index) answers with the state currently captured for one device:

typescript
interface KeyboardState
{
    readonly keys : Readonly<Record<string, boolean>>;
}

interface MouseState
{
    readonly buttons : Readonly<Record<string, boolean>>;
    readonly position : Readonly<{ x : number; y : number }>;
    readonly wheel : Readonly<{ deltaX : number; deltaY : number; deltaZ : number }>;
}

interface GamepadState
{
    readonly buttons : Readonly<Record<string, number>>;
    readonly axes : Readonly<Record<string, number>>;
}

A game reads it directly for anything the routing loop itself does not cover, such as highlighting which input is currently held on a rebinding screen.

The wheel deltas reset to zero once the routing loop has read them for the frame, so a still wheel reports nothing on the next frame. Held state, a key, a button, an axis, or the cursor position, is never reset this way.

lastActiveDeviceType names the device type behind the most recent deliberate input: a key or button pressed, a wheel notch, or a gamepad button or axis away from zero. It is undefined before any device has reported one.

The routing loop

Each rendered frame, GameManager's frame loop calls inputManager.routeCapturedInput(simulation) immediately before runStepsForElapsedTime. It polls the gamepads, then for every trigger, toggle, and delta binding contexts holds live, hands that binding's device's captured state to deliverAction, which calls the binding's process and, when it fires, broadcasts the result as an action:<name> event. The live value bindings are summed by action first (see Value actions).

Every action the loop delivers is a broadcast, carrying the sender ROUTING_LOOP_SENDER_ID, an ID naming the routing loop itself and no entity. It runs once a rendered frame whether or not that frame's elapsed time completes a fixed step, and it does not run until a simulation exists.

Input captured for one frame reaches onEvent at that frame's drain, and onUpdate acts on whatever onEvent wrote at the next fixed step: one fixed step of latency between a captured input and a behavior updating from it.

deliverAction

typescript
function deliverAction<State>(
    binding : Binding<State>,
    state : State,
    context : GameSimulationContext,
    senderID : string
) : void

Computes a binding's fire from device state and, when it fires, broadcasts it with the sender the caller gives. It stays available outside the routing loop for anything else that wants to deliver a binding's fire with a sender of its own choosing: an NPC's own bindings, or a server replaying what a client's binding fired.

deliverValueAction

typescript
function deliverValueAction(
    action : Action,
    value : number,
    context : GameSimulationContext,
    senderID : string
) : void

Broadcasts a value action with the value the routing loop computed for it. No one binding decides that value, so this takes the action and the value rather than a binding. Otherwise it delivers exactly as deliverAction does.

Value actions

An action driven by value bindings has one current value on each pass of the routing loop: the sum of what its live value bindings compute, clamped to the action's minValue and maxValue. Opposing inputs cancel, a key and a stick axis on the same action add, and no one binding's value can mask another's.

typescript
// Hold W and the action reads 1. Tap and release S while W is still held and it reads 1 again, not 0.
{ kind: 'value', action: 'moveForward', context: 'gameplay', input: keyboardInput('KeyW'), options: { scale: 1.0 } },
{ kind: 'value', action: 'moveForward', context: 'gameplay', input: keyboardInput('KeyS'), options: { scale: -1.0 } },

The loop delivers that value on every pass while it is away from rest, and once more on the pass it returns to rest. A held axis re-announces itself every pass, so an entity created while an input is held has the current value on its next step without asking for it, and an action every binding has let go of settles at rest exactly once rather than repeating it. Rest is zero, clamped into the action's bounds like any other value.

A behavior that assigns the payload (this.state.forward = event.payload) needs no change for this. One that accumulates per event now accumulates every frame the input is held.

Trigger, toggle, and delta bindings are untouched by this: a press, a state change, and a one-shot sample are each events in their own right, and each delivers as it happens.

Input capture

InputCapture waits for the next deliberate input and answers with the BindingInput that would bind it.

typescript
interface CaptureOptions
{
    deviceTypes ?: readonly DeviceType[];
    sourceTypes ?: readonly string[];
    signal ?: AbortSignal;
}
MethodMeaning
wait()A promise for the next deliberate input that passes the filters
feed(candidate)Offers a candidate. InputManager.registerCapture calls this.

A deliberate input is a key or button pressed, or a number away from zero. Aborting the signal rejects the pending wait.

typescript
import { InputCapture } from '@skewedaspect/sage';

const controller = new AbortController();
const capture = new InputCapture({ deviceTypes: [ 'keyboard' ], sourceTypes: [ 'key' ], signal: controller.signal });
const unregister = inputManager.registerCapture(capture);

try
{
    const input = await capture.wait();
    console.log(input.sourceKey);
}
finally
{
    unregister();
}

Registering costs nothing while a capture is not waiting. Gameplay bindings keep delivering while a capture waits. A rebind that replaces a binding retires the old one with removeBinding before adding its replacement; see the Input System guide.

Configuration

The whole set of actions, bindings, and contexts exports and imports as one JSON-compatible object:

typescript
interface Configuration
{
    actions : readonly Action[];
    bindings : readonly BindingDefinition[];
    contexts : readonly ContextDefinition[];
}

interface ContextDefinition
{
    name : string;
    exclusive : boolean;
}
FunctionMeaning
exportConfiguration(actions, bindings, contexts)Builds a Configuration from the registered actions, the live bindings' definitions, and the context definitions the caller supplies
importConfiguration(config, actions, contexts)Registers every action and context the configuration names, and hands back the binding definitions for the caller to resolve
typescript
import { exportConfiguration, importConfiguration } from '@skewedaspect/sage';

const contextDefinitions : ContextDefinition[] = [ { name: 'gameplay', exclusive: true } ];

const config = exportConfiguration(
    inputManager.actions.list(),
    inputManager.contexts.list(),
    contextDefinitions
);
localStorage.setItem('controls', JSON.stringify(config));

const saved = localStorage.getItem('controls');

if(saved !== null)
{
    const definitions = importConfiguration(JSON.parse(saved), inputManager.actions, inputManager.contexts);

    for(const definition of definitions)
    {
        inputManager.contexts.addBinding(resolve(inputManager, definition));
    }
}

ActionContextRegistry does not enumerate its contexts, so the game keeps its own list of ContextDefinition for export. Importing into a registry that already holds an action or context of the same name throws for the action; register into a fresh manager or track what is already registered.

Entity actions

NodeEntity.actions and a definition's actions carry the input actions a class's entities declare, in the Action shape. Nothing in sage reads that list to register actions when an entity spawns; the list is where a game finds them.

Not in this release

Binding modifiers, a combination requiring other inputs to be held, a repeat that re-fires a held trigger, and a sensitivity curve over a value binding, are designed and not built.

Released under the MIT License.