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.
const inputManager = engine.managers.inputManager;InputManager
| Member | Meaning |
|---|---|
actions | The ActionRegistry |
contexts | The ActionContextRegistry |
deviceState(type, index) | The state currently captured for one device |
lastActiveDeviceType | The 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.
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
| Method | Meaning |
|---|---|
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 |
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:
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.
| Kind | Fires |
|---|---|
trigger | Once when its input changes state, on the rising edge, the falling edge, or both |
toggle | Alternates each time its input is pressed, holding its state between presses |
value | Continuously, passing its input through. It contributes to its action's value rather than delivering on its own. |
delta | Once 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:
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
| Option | Default | Meaning |
|---|---|---|
edge | 'rising' | 'rising', 'falling', or 'both' |
threshold | none | An 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
| Option | Default | Meaning |
|---|---|---|
onValue | true | The value sent when toggled on |
offValue | false | The value sent when toggled off |
initialState | none | The starting on/off state |
threshold | none | As for a trigger |
invert | false | Alternate on release instead of press |
Value options
Applied in order: scale, offset, clamp, invert, then deadzone.
| Option | Default | Meaning |
|---|---|---|
scale | 1 | Multiplier |
offset | 0 | Added after scaling |
min, max | none | Clamp bounds |
invert | false | Negate |
deadzone | 0 | A 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.
| Option | Default |
|---|---|
scale | 1 |
invert | false |
deadzone | 0 |
Binding
A resolved binding:
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
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:
interface DeviceReader<State>
{
readonly sourceType : string;
readonly sourceKey : string;
getValue(state : State) : boolean | number | undefined;
}| Reader | Source type | Source key | State |
|---|---|---|---|
KeyboardReader(sourceKey) | key | A DOM KeyboardEvent.code: KeyW, Space, ShiftLeft, ArrowUp | KeyboardState |
MouseReader('button', sourceKey) | button | '0' left, '1' middle, '2' right | MouseState |
MouseReader('position', sourceKey) | position | 'x' or 'y', the cursor's client position | MouseState |
MouseReader('wheel', sourceKey) | wheel | 'deltaX', 'deltaY', or 'deltaZ' | MouseState |
GamepadReader('button', sourceKey) | button | The button index as a string, following the standard gamepad mapping | GamepadState |
GamepadReader('axis', sourceKey) | axis | The axis index as a string | GamepadState |
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:
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
| Method | Meaning |
|---|---|
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:
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:
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
function deliverAction<State>(
binding : Binding<State>,
state : State,
context : GameSimulationContext,
senderID : string
) : voidComputes 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
function deliverValueAction(
action : Action,
value : number,
context : GameSimulationContext,
senderID : string
) : voidBroadcasts 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.
// 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.
interface CaptureOptions
{
deviceTypes ?: readonly DeviceType[];
sourceTypes ?: readonly string[];
signal ?: AbortSignal;
}| Method | Meaning |
|---|---|
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.
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:
interface Configuration
{
actions : readonly Action[];
bindings : readonly BindingDefinition[];
contexts : readonly ContextDefinition[];
}
interface ContextDefinition
{
name : string;
exclusive : boolean;
}| Function | Meaning |
|---|---|
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 |
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.
