Skip to content

Input & Bindings

SAGE's binding system creates an abstraction layer between physical input devices (keyboard, mouse, gamepad) and game actions. You define what your game can do (actions), then map hardware inputs to those actions (bindings), organized into switchable groups (contexts).

Core Concepts

ConceptRole
ActionA named game event (jump, throttle) -- the "what"
BindingConnects a physical input to an action -- the "how"
ContextGroups bindings that activate/deactivate together -- the "when"

Actions

Actions represent game events triggered by user input. Two types:

DigitalAction

Boolean on/off values (jumping, interacting, firing).

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

AnalogAction

Numeric values for continuous input (throttle, camera rotation).

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

Both types support an optional label for display in UIs (e.g., settings screens). If omitted, use name as a fallback.

Action (Union Type)

typescript
type Action = DigitalAction | AnalogAction;

Registering Actions

typescript
const bindingManager = gameEngine.managers.bindingManager;

bindingManager.registerAction({ name: 'jump', type: 'digital', label: 'Jump' });

bindingManager.registerAction({
    name: 'move_x',
    type: 'analog',
    label: 'Strafe',
    minValue: -1,
    maxValue: 1,
});

Bindings

Bindings connect physical inputs to actions. Three types are supported:

Binding Types

typescript
type BindingType = 'trigger' | 'toggle' | 'value';
TypeBehaviorUse Case
triggerFires once on button state changeJump, shoot, interact
toggleAlternates between on/off each pressCrouch toggle, flashlight
valueContinuously passes input valuesJoystick axes, triggers

Binding Interface

All bindings implement:

typescript
interface Binding
{
    readonly type : BindingType;
    readonly action : Action;
    readonly context ?: string;
    readonly deviceID : string;
    readonly reader : DeviceValueReader;

    process(state : InputState, eventBus : GameEventBus) : void;
    resetEdgeState() : void;
    toJSON() : BindingDefinition;
}

Binding Definitions

Bindings are typically created from definition objects rather than class instances. Definitions are JSON-serializable and can be loaded from config files.

TriggerBindingDefinition

typescript
interface TriggerBindingDefinition
{
    type : 'trigger';
    action : string;
    input : InputDefinition;
    context ?: string;
    options ?: {
        edgeMode ?: 'rising' | 'falling' | 'both';
        threshold ?: number;
        passthrough ?: boolean;
    };
}
OptionTypeDefaultDescription
edgeMode'rising' | 'falling' | 'both''rising'When to fire: press, release, or both
thresholdnumber0.5Activation threshold for analog inputs
passthroughbooleanfalsePass raw value instead of boolean

ToggleBindingDefinition

typescript
interface ToggleBindingDefinition
{
    type : 'toggle';
    action : string;
    input : InputDefinition;
    context ?: string;
    state ?: boolean;
    options ?: {
        invert ?: boolean;
        initialState ?: boolean;
        threshold ?: number;
        onValue ?: boolean | number;
        offValue ?: boolean | number;
    };
}
OptionTypeDefaultDescription
initialStatebooleanfalseStarting toggle state
onValueboolean | numbertrueValue emitted when toggled on
offValueboolean | numberfalseValue emitted when toggled off
invertbooleanfalseInvert the input signal
thresholdnumber0.5Activation threshold for analog inputs

ValueBindingDefinition

typescript
interface ValueBindingDefinition
{
    type : 'value';
    action : string;
    input : InputDefinition;
    context ?: string;
    options ?: {
        scale ?: number;
        offset ?: number;
        min ?: number;
        max ?: number;
        invert ?: boolean;
        emitOnChange ?: boolean;
        deadzone ?: number;
    };
}
OptionTypeDefaultDescription
scalenumber1.0Multiplier applied to the raw value
offsetnumber0Added after scaling
minnumber--Clamp minimum
maxnumber--Clamp maximum
invertbooleanfalseNegate the input value
emitOnChangebooleanfalseOnly emit when value actually changes
deadzonenumber0Ignore values below this magnitude

InputDefinition

Combines device ID and reader configuration:

typescript
type InputDefinition = DeviceValueReaderDefinition & {
    deviceID : string;
};

The type field determines which reader is created:

typeAdditional FieldsDescription
'keyboard'sourceKeyKey code (e.g., 'KeyW', 'Space', 'Escape')
'mouse'sourceType, sourceKeysourceType: 'button', 'position', 'wheel'
'gamepad'sourceType, sourceKeysourceType: 'button', 'axis'

Device IDs

DeviceID
Keyboardkeyboard-0
Mousemouse-0
Gamepad 1gamepad-0
Gamepad 2gamepad-1
Gamepad Ngamepad-N

Keyboard Source Keys

Keyboard inputs use DOM KeyboardEvent.code values as the sourceKey. No sourceType field is needed.

Common keys:

CategoryKeys
LettersKeyA through KeyZ
DigitsDigit0 through Digit9
ArrowsArrowUp, ArrowDown, ArrowLeft, ArrowRight
ModifiersShiftLeft, ShiftRight, ControlLeft, ControlRight, AltLeft, AltRight
SpecialSpace, Enter, Escape, Tab, Backspace, Delete
FunctionF1 through F12
NumpadNumpad0 through Numpad9, NumpadAdd, NumpadSubtract, etc.
typescript
{ deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'KeyW' }
{ deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'Space' }
{ deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'ShiftLeft' }

Mouse Source Keys

Mouse inputs require both sourceType and sourceKey.

Buttons (sourceType: 'button')

sourceKeyButton
button-0Left mouse button
button-1Middle mouse button (wheel click)
button-2Right mouse button
button-3Back (side button)
button-4Forward (side button)
typescript
{ deviceID: 'mouse-0', type: 'mouse', sourceType: 'button', sourceKey: 'button-0' }

Position (sourceType: 'position')

sourceKeyValue
absolute:xCursor X position (clientX)
absolute:yCursor Y position (clientY)
relative:xCursor X movement delta
relative:yCursor Y movement delta
typescript
{ deviceID: 'mouse-0', type: 'mouse', sourceType: 'position', sourceKey: 'relative:x' }

Wheel (sourceType: 'wheel')

sourceKeyValue
deltaXHorizontal scroll amount
deltaYVertical scroll amount
deltaZZ-axis scroll amount (rare)
typescript
{ deviceID: 'mouse-0', type: 'mouse', sourceType: 'wheel', sourceKey: 'deltaY' }

Gamepad Source Keys

Gamepad inputs require both sourceType and sourceKey. Values follow the standard gamepad mapping.

Buttons (sourceType: 'button')

sourceKeyXboxPlayStation
button-0ACross
button-1BCircle
button-2XSquare
button-3YTriangle
button-4LBL1
button-5RBR1
button-6LTL2
button-7RTR2
button-8Back / SelectShare
button-9StartOptions
button-10L3 (left stick click)L3
button-11R3 (right stick click)R3
button-12D-pad UpD-pad Up
button-13D-pad DownD-pad Down
button-14D-pad LeftD-pad Left
button-15D-pad RightD-pad Right
typescript
{ deviceID: 'gamepad-0', type: 'gamepad', sourceType: 'button', sourceKey: 'button-0' }

Axes (sourceType: 'axis')

Axes return continuous values from -1 to +1.

sourceKeyAxis
axis-0Left stick horizontal (-1 left, +1 right)
axis-1Left stick vertical (-1 up, +1 down)
axis-2Right stick horizontal (-1 left, +1 right)
axis-3Right stick vertical (-1 up, +1 down)
typescript
{ deviceID: 'gamepad-0', type: 'gamepad', sourceType: 'axis', sourceKey: 'axis-0' }

BindingManager

The BindingManager is the central hub for input processing. It is available through the engine:

typescript
const bindingManager = gameEngine.managers.bindingManager;

Action API

MethodSignatureDescription
registerAction(action : Action) => voidRegister a new action. Throws if already registered.
getAction(name : string) => Action | nullLook up a registered action by name

Binding API

MethodSignatureDescription
registerBinding(definition : BindingDefinition) => voidCreate and register a binding from a definition
unregisterBindings(actionName : string, context? : string | null) => voidRemove all bindings for an action in a context
getBindingsForAction(actionName : string, context? : string | null) => Binding[]Get all bindings for an action

Context API

MethodSignatureDescription
registerContext(name : string, exclusive? : boolean) => ContextRegister a context (default: exclusive)
activateContext(name : string) => voidActivate a context; deactivates other exclusive contexts
deactivateContext(name : string) => voidDeactivate a context
getActiveContexts() => string[]List all currently active contexts
isContextActive(name : string) => booleanCheck if a context is active
getContext(name : string) => Context | nullLook up a context by name

Configuration API

MethodSignatureDescription
exportConfiguration() => BindingConfigurationExport all actions, bindings, and contexts
importConfiguration(config : BindingConfiguration) => voidImport a configuration, replacing the current one

BindingConfiguration

typescript
interface BindingConfiguration
{
    actions : Action[];
    bindings : BindingDefinition[];
    contexts : {
        name : string;
        exclusive : boolean;
        active : boolean;
    }[];
}

Input Capture API

captureInput() listens for the next intentional input event and returns an InputDefinition you can plug directly into registerBinding(). It is designed for key rebinding UIs — the player clicks "rebind jump", presses a key, and you get back exactly the definition you need.

MethodSignatureDescription
captureInput(options : CaptureInputOptions) => Promise<InputDefinition>Capture the next intentional input event

CaptureInputOptions

typescript
interface CaptureInputOptions
{
    deviceTypes : DeviceType[];
    sourceTypes ?: Array<'key' | 'button' | 'axis' | 'position' | 'wheel'>;
    signal ?: AbortSignal;
}
OptionTypeRequiredDescription
deviceTypesDeviceType[]YesWhich device types to listen for ('keyboard', 'mouse', 'gamepad')
sourceTypesArray<'key' | 'button' | 'axis' | 'position' | 'wheel'>NoFilter which source types to capture
signalAbortSignalNoCancel or timeout the capture via standard AbortSignal

Behavior

While a capture is active, all normal bindings are suppressed — SAGE activates a temporary exclusive context so gameplay input does not fire. When the capture completes or is cancelled, the previous context state is automatically restored. Only one capture can be active at a time; calling captureInput() while one is already in progress throws an error.

Noise Filtering

captureInput() automatically filters out unintentional input:

  • Keyboard — captures key-down only (ignores key-up and OS key repeat)
  • Mouse — captures button presses only (ignores movement and wheel)
  • Gamepad — captures button presses and axis movements beyond a 0.5 deadzone (ignores stick drift)

Examples

Basic capture — listen for any keyboard or gamepad input:

typescript
const bindingManager = gameEngine.managers.bindingManager;

const input = await bindingManager.captureInput({
    deviceTypes: [ 'keyboard', 'gamepad' ],
});

// input is a ready-to-use InputDefinition
bindingManager.unregisterBindings('jump', 'gameplay');
bindingManager.registerBinding({
    type: 'trigger',
    action: 'jump',
    input,
    context: 'gameplay',
    options: { edgeMode: 'rising' },
});

Capture with timeout and cancellation:

typescript
const controller = new AbortController();

// Cancel on Escape key
window.addEventListener('keydown', (e) =>
{
    if(e.code === 'Escape')
    {
        controller.abort();
    }
}, { once: true });

try
{
    const input = await bindingManager.captureInput({
        deviceTypes: [ 'keyboard', 'mouse', 'gamepad' ],
        sourceTypes: [ 'key', 'button' ],
        signal: AbortSignal.any([
            controller.signal,
            AbortSignal.timeout(10_000),
        ]),
    });

    // Use the captured input to update the binding
    bindingManager.unregisterBindings('shoot', 'gameplay');
    bindingManager.registerBinding({
        type: 'trigger',
        action: 'shoot',
        input,
        context: 'gameplay',
        options: { edgeMode: 'rising' },
    });
}
catch(err)
{
    if(err instanceof DOMException && err.name === 'AbortError')
    {
        console.log('Capture cancelled');
    }
}

Contexts

Contexts group bindings so they can be activated and deactivated together. An exclusive context deactivates all other exclusive contexts when activated. A non-exclusive context can be active alongside any others.

typescript
bindingManager.registerContext('gameplay', true);   // exclusive
bindingManager.registerContext('menu', true);        // exclusive
bindingManager.registerContext('always', false);     // non-exclusive

// Switching contexts
bindingManager.activateContext('gameplay');
bindingManager.activateContext('menu');  // auto-deactivates 'gameplay'

Bindings without a context are always active, regardless of which contexts are enabled.

INFO

When a context is reactivated, all bindings in that context have their edge state reset. This prevents stale input state from a previous activation from swallowing the first input event.

Subscribing to Actions

Once actions and bindings are registered, subscribe to action events using engine.subscribeAction():

typescript
engine.subscribeAction('jump', (event) =>
{
    console.log('Jump! Value:', event.payload.value);
    console.log('Device:', event.payload.deviceId);
});

The subscribeAction() method provides typed ActionPayload with value and deviceId properties.

Within behaviors, subscribe directly on the event bus:

typescript
this.entity.eventBus.subscribe('action:move_forward', (event) =>
{
    const isActive = event.payload.value === true;
    // handle movement...
});

Complete Example: WASD Movement

Registering Actions and Bindings

typescript
const bindingManager = gameEngine.managers.bindingManager;

// Register movement actions
const actions = [
    { name: 'move_forward', type: 'digital' as const },
    { name: 'move_backward', type: 'digital' as const },
    { name: 'move_left', type: 'digital' as const },
    { name: 'move_right', type: 'digital' as const },
    { name: 'jump', type: 'digital' as const },
];

actions.forEach((action) => bindingManager.registerAction(action));

// Register context
bindingManager.registerContext('gameplay', true);

// Define WASD bindings
const bindings : BindingDefinition[] = [
    {
        type: 'trigger',
        action: 'move_forward',
        input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'KeyW' },
        context: 'gameplay',
        options: { edgeMode: 'both' },
    },
    {
        type: 'trigger',
        action: 'move_backward',
        input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'KeyS' },
        context: 'gameplay',
        options: { edgeMode: 'both' },
    },
    {
        type: 'trigger',
        action: 'move_left',
        input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'KeyA' },
        context: 'gameplay',
        options: { edgeMode: 'both' },
    },
    {
        type: 'trigger',
        action: 'move_right',
        input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'KeyD' },
        context: 'gameplay',
        options: { edgeMode: 'both' },
    },
    {
        type: 'trigger',
        action: 'jump',
        input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'Space' },
        context: 'gameplay',
        options: { edgeMode: 'rising' },
    },
];

bindings.forEach((b) => bindingManager.registerBinding(b));
bindingManager.activateContext('gameplay');

Handling Actions

typescript
engine.subscribeAction('move_forward', (event) =>
{
    if(event.payload.value === true)
    {
        player.velocity.z = -player.speed;
    }
    else if(!player.isMovingBackward)
    {
        player.velocity.z = 0;
    }
});

Multiple Input Devices

Bind the same action to multiple devices:

typescript
// Keyboard
bindingManager.registerBinding({
    type: 'trigger',
    action: 'jump',
    input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'Space' },
    context: 'gameplay',
});

// Gamepad A button
bindingManager.registerBinding({
    type: 'trigger',
    action: 'jump',
    input: { deviceID: 'gamepad-0', type: 'gamepad', sourceType: 'button', sourceKey: 'button-0' },
    context: 'gameplay',
});

Gamepad Analog Sticks

typescript
bindingManager.registerAction({ name: 'look_x', type: 'analog', minValue: -1, maxValue: 1 });

bindingManager.registerBinding({
    type: 'value',
    action: 'look_x',
    input: { deviceID: 'gamepad-0', type: 'gamepad', sourceType: 'axis', sourceKey: 'axis-2' },
    options: { deadzone: 0.15, emitOnChange: true },
});

Entity Integration

Entity definitions can declare required actions:

typescript
const playerDef : GameEntityDefinition = {
    type: 'player',
    defaultState: { /* ... */ },
    behaviors: [ PlayerMovementBehavior ],
    actions: [
        { name: 'move_forward', type: 'digital' },
        { name: 'move_backward', type: 'digital' },
        { name: 'jump', type: 'digital' },
    ],
};

Saving and Loading Bindings

Export and import binding configurations for user-customizable controls:

typescript
// Save
const config = bindingManager.exportConfiguration();
localStorage.setItem('controls', JSON.stringify(config));

// Load
const saved = localStorage.getItem('controls');
if(saved)
{
    bindingManager.importConfiguration(JSON.parse(saved));
}

The binding system is storage-agnostic -- it produces and consumes plain JSON-serializable objects. Use localStorage, IndexedDB, files, or a server API as needed.

Released under the MIT License.