Skip to content

Input System ​

This guide walks through SAGE's input system by building a controllable cube with movement, look, jumping, shooting, sprinting, and crouching, driven by actions that work across keyboard, mouse, and gamepad.

The full source is in examples/src/examples/input-demo/.

Try it live

Run this example at Examples > Input Demo.

What the input system does ​

device state   -->  binding      -->  action:<name> event   -->  behavior
[KeyW held]         [value, +1]       [action:moveForward]      [MovementBehavior.onEvent]
[left stick Y]      [value, -0.7]     [action:moveForward]      [MovementBehavior.onEvent]
[LMB pressed]       [trigger]         [action:shoot]            [CombatBehavior.onEvent]

A game declares what it can do, its actions, its action contexts, and its bindings, onto engine.managers.inputManager. Once a rendered frame, before that frame's fixed steps, InputManager captures device state, decides which bindings fire, and broadcasts each fired action as an action:<name> event on the engine's bus. A behavior subscribes to the actions it answers and never sees a key code.

Project structure ​

input-demo/
├── index.vue                  # Vue component: UI and boot
├── events.ts                  # EventMap augmentation for the action events
├── constants.ts
├── behaviors/
│   ├── MovementBehavior.ts    # Position, velocity, sprint, crouch, jump
│   ├── LookBehavior.ts        # Look axes
│   └── CombatBehavior.ts      # Shot requests
├── entities/
│   └── PlayerEntity.ts        # The one entity, with its meshConfig
├── input/
│   ├── actions.ts             # Actions, contexts, binding definitions, activation
│   ├── helpers.ts             # BindingInput and DeviceReader factories
│   ├── rebind.ts              # Live rebinding through InputCapture
│   ├── labels.ts, display.ts  # Bindings card display
└── game/
    ├── DemoLevel.ts           # Custom Level: builds the scene, binds the simulation
    ├── poll.ts                # Per-frame poll of the player's state
    ├── camera.ts, bullets.ts, meshes.ts

Step 1: Declare the action events ​

Every action arrives as an action:<name> event, and the event map is closed, so the game declares them:

typescript
// events.ts
declare module '@skewedaspect/sage-core'
{
    interface EventMap
    {
        'action:moveForward' : number;
        'action:moveRight' : number;
        'action:lookX' : number;
        'action:lookY' : number;
        'action:jump' : boolean;
        'action:shoot' : boolean;
        'action:sprint' : boolean;
        'action:crouch' : boolean;
    }
}

export {};

An analog action's payload is a number; a digital action's is a boolean.

Step 2: Register the actions ​

typescript
import type { Action } from '@skewedaspect/sage';

const ACTIONS : readonly Action[] = [
    { type: 'analog', name: 'moveForward', minValue: -1, maxValue: 1 },
    { type: 'analog', name: 'moveRight', minValue: -1, maxValue: 1 },
    { type: 'analog', name: 'lookX', minValue: -1, maxValue: 1 },
    { type: 'analog', name: 'lookY', minValue: -1, maxValue: 1 },
    { type: 'analog', name: 'zoom', minValue: -1, maxValue: 1 },
    { type: 'digital', name: 'jump' },
    { type: 'digital', name: 'shoot' },
    { type: 'digital', name: 'sprint' },
    { type: 'digital', name: 'crouch' },
];

for(const action of ACTIONS)
{
    inputManager.actions.register(action);
}

A name registered twice throws. An optional label is what a settings screen shows. Step 4 folds this same list into one configuration object, and importConfiguration runs exactly this loop over its actions array.

Step 3: Describe the inputs ​

A binding names its input as a device, a source type, and a source key. The example wraps the shape in factories:

typescript
import type { BindingInput } from '@skewedaspect/sage';

export function keyboardInput(sourceKey : string) : BindingInput
{
    return { device: { type: 'keyboard', index: 0 }, sourceType: 'key', sourceKey };
}

export function mouseButtonInput(button : number) : BindingInput
{
    return { device: { type: 'mouse', index: 0 }, sourceType: 'button', sourceKey: String(button) };
}

export function mouseWheelInput(axis : 'deltaX' | 'deltaY' | 'deltaZ') : BindingInput
{
    return { device: { type: 'mouse', index: 0 }, sourceType: 'wheel', sourceKey: axis };
}

export function gamepadAxisInput(sourceKey : string) : BindingInput
{
    return { device: { type: 'gamepad', index: 0 }, sourceType: 'axis', sourceKey };
}

export function gamepadButtonInput(sourceKey : string) : BindingInput
{
    return { device: { type: 'gamepad', index: 0 }, sourceType: 'button', sourceKey };
}

Keyboard keys use DOM KeyboardEvent.code values: KeyW, Space, ShiftLeft, ArrowUp. Gamepad buttons and axes use the standard mapping's indices as strings: '0' for A/Cross, '7' for the right trigger, axis '0' for the left stick's X.

Step 4: Write the binding definitions ​

A binding definition is plain JSON: a kind, an action, an input, a context, and the kind's options. The whole configuration -- actions, contexts, and bindings -- is one Configuration object, the shape a configuration file holds and exportConfiguration writes:

typescript
import type { Configuration } from '@skewedaspect/sage';

export const GAMEPLAY_CONTEXT = 'gameplay';
export const MENU_CONTEXT = 'menu';

export const configuration : Configuration = {
    actions: [
        { type: 'analog', name: 'moveForward', minValue: -1, maxValue: 1 },
        { type: 'analog', name: 'moveRight', minValue: -1, maxValue: 1 },
        { type: 'analog', name: 'lookX', minValue: -1, maxValue: 1 },
        { type: 'analog', name: 'lookY', minValue: -1, maxValue: 1 },
        { type: 'digital', name: 'jump' },
        { type: 'digital', name: 'shoot' },
        { type: 'digital', name: 'sprint' },
        { type: 'digital', name: 'crouch' },
    ],

    contexts: [
        { name: GAMEPLAY_CONTEXT, exclusive: true },
        { name: MENU_CONTEXT, exclusive: true },
    ],

    bindings: [
        // filled in below
    ],
};

The bindings array is where the four kinds go.

Value bindings ​

A value binding passes its input through continuously. A key reads as 0 or 1, so scale gives it a direction; a stick reads as -1 to 1, and deadzone filters drift.

typescript
bindings.push(
    {
        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 },
    },
    {
        kind: 'value',
        action: 'moveForward',
        context: 'gameplay',
        input: gamepadAxisInput('1'),
        options: { scale: -1.0, deadzone: 0.15 },
    }
);

Every value binding on one action contributes to the same value: the three above sum, so W and S cancel while both are held and the action reads the one still held when the other is released. The action is delivered every frame while that value is away from zero, and once more on the frame it returns to zero. See Value actions.

Trigger bindings ​

A trigger fires once when its input changes state. edge is 'rising', 'falling', or 'both'.

typescript
bindings.push(
    {
        kind: 'trigger',
        action: 'jump',
        context: 'gameplay',
        input: keyboardInput('Space'),
        options: { edge: 'rising' },
    },
    {
        kind: 'trigger',
        action: 'jump',
        context: 'gameplay',
        input: gamepadButtonInput('0'),
        options: { edge: 'rising' },
    },
    {
        kind: 'trigger',
        action: 'shoot',
        context: 'gameplay',
        input: keyboardInput('KeyF'),
        options: { edge: 'rising' },
    },
    {
        kind: 'trigger',
        action: 'shoot',
        context: 'gameplay',
        input: mouseButtonInput(0),
        options: { edge: 'rising' },
    },
    {
        kind: 'trigger',
        action: 'shoot',
        context: 'gameplay',
        input: gamepadButtonInput('7'),
        options: { edge: 'rising' },
    }
);

Three devices drive shoot. One action may be bound any number of times.

Toggle bindings ​

A toggle alternates each time its input is pressed and holds its state between presses:

typescript
bindings.push(
    { kind: 'toggle', action: 'sprint', context: 'gameplay', input: keyboardInput('ShiftLeft') },
    { kind: 'toggle', action: 'crouch', context: 'gameplay', input: keyboardInput('KeyC') }
);

Delta bindings ​

A delta fires once for a one-shot sample its input reports, such as a wheel notch, whenever the sample is nonzero. There is no held value to compare against.

typescript
bindings.push(
    { kind: 'delta', action: 'zoom', context: 'gameplay', input: mouseWheelInput('deltaY'), options: { scale: 0.01 } }
);

Step 5: Wire the configuration onto InputManager ​

importConfiguration registers every action and every context a configuration names, and hands the binding definitions back for the caller to resolve. InputManager.bind(definition) builds the reader a definition's input names -- through readerFor -- and registers the live binding under its context in one call, so a game binds every definition an imported configuration hands back with no reader code of its own:

typescript
import { importConfiguration } from '@skewedaspect/sage';
import type { BindingDefinition, InputManager } from '@skewedaspect/sage';

export interface InputSystem
{
    readonly inputManager : InputManager;
    definitions : BindingDefinition[];
}

export function createInputSystem(inputManager : InputManager) : InputSystem
{
    const definitions = [ ...importConfiguration(configuration, inputManager.actions, inputManager.contexts) ];

    for(const definition of definitions)
    {
        inputManager.bind(definition);
    }

    return { inputManager, definitions };
}

resolveBinding(definition, action, reader?) is what bind calls internally; a caller reading a device state shape of its own hands it a reader directly instead of letting bind pick one.

Step 6: Activate a context ​

Bindings in a context only deliver while the context is active. Both contexts here are exclusive, so activating one deactivates the other. Activation primes each binding against the current device state, so a key already held when the context arrives does not read as a fresh press. InputManager.deviceState(type, index), given a device type held as a value rather than written as a literal, answers the union of the three device states, so priming never has to branch on which kind of device a binding names:

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

activateContext(inputManager, GAMEPLAY_CONTEXT);

Switching to the menu context is activateContext(inputManager, MENU_CONTEXT). Gameplay bindings stop delivering until the gameplay context is activated again.

Step 7: Answer the actions in a behavior ​

The player is a NodeEntity composed from three behaviors, each subscribing to the actions it answers:

typescript
export class MovementBehavior extends Behavior<MovementState>
{
    static readonly events = [
        'action:moveForward', 'action:moveRight', 'action:sprint', 'action:crouch', 'action:jump',
    ] as const;

    readonly defaults : MovementState = {
        position: { x: 0, y: CUBE_INITIAL_Y, z: 0 },
        velocityY: 0,
        forward: 0,
        right: 0,
        sprint: false,
        crouch: false,
    };

    onEvent(event : GameEvent) : boolean
    {
        if(event.type === 'action:moveForward')
        {
            this.state.forward = event.payload;
            return true;
        }

        if(event.type === 'action:moveRight')
        {
            this.state.right = event.payload;
            return true;
        }

        if(event.type === 'action:sprint')
        {
            this.state.sprint = event.payload;
            return true;
        }

        if(event.type === 'action:crouch')
        {
            this.state.crouch = event.payload;
            return true;
        }

        if(event.type === 'action:jump')
        {
            if(this.state.position.y <= GROUND_Y)
            {
                this.state.velocityY = JUMP_VELOCITY;
            }

            return true;
        }

        return false;
    }

    onUpdate(dt : number) : void
    {
        const speed = this.state.sprint ? MOVE_SPEED_SPRINT : MOVE_SPEED_NORMAL;
        const scale = this.state.crouch ? MOVE_SCALE_CROUCH : 1.0;

        this.state.position.z -= this.state.forward * speed * scale * dt;
        this.state.position.x += this.state.right * speed * scale * dt;

        this.state.velocityY -= GRAVITY * dt;
        this.state.position.y += this.state.velocityY * dt;

        if(this.state.position.y < GROUND_Y)
        {
            this.state.position.y = GROUND_Y;
            this.state.velocityY = 0;
        }
    }
}

onEvent records what the input said; onUpdate moves the entity once per fixed step. An action captured this frame reaches onEvent at this frame's drain, and onUpdate acts on it at the next fixed step.

CombatBehavior counts action:shoot in shotsFired and announces each one as combat:shotFired, sender included. A behavior cannot spawn a Babylon mesh, so the Vue component subscribes to that event on the engine bus and spawns the bullet there:

typescript
bus.on('combat:shotFired', (event) =>
{
    const shooter = simulation.getEntity(event.senderID);

    if(shooter instanceof PlayerEntity)
    {
        spawnBullet(scene, shooter.state.position, bullets);
    }
});

News, not a poll: a shot is a thing that happened once, so it is an event rather than a state field the game loop would have to notice changed.

Step 8: Poll the player's state ​

Writing a state field notifies nobody. Once a frame, after the fixed steps have run, the demo reads the player's state and drives everything the entity system does not own from it: the UI's action display, the camera's rotation, the mesh's crouch squash, and the bullets already in flight.

typescript
export function pollPlayerState(ctx : PollContext) : void
{
    const { player, actionValues } = ctx;

    actionValues.moveForward = player.state.forward;
    actionValues.moveRight = player.state.right;
    actionValues.lookX = player.state.lookX;
    actionValues.lookY = player.state.lookY;
    actionValues.sprint = player.state.sprint;
    actionValues.crouch = player.state.crouch;

    if(ctx.camera)
    {
        updateCamera(ctx.camera, player.state.lookX, player.state.lookY, ctx.dt);
    }

    updatePlayerCrouchVisual(player.mesh, player.state.crouch);
    updateBullets(ctx.bullets, ctx.dt);
}

A shot fired is news, handled once, where Step 7 shows it; moving the bullets already spawned is a fact true every frame, so it belongs here instead.

The Bindings card also highlights which physical inputs are currently held. That reads inputManager.deviceState(type, index) directly, the same state every device reader reads.

Rebinding ​

InputCapture waits for the next deliberate input and answers with the BindingInput that would bind it. The demo rebinds jump's keyboard binding:

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

export function startJumpKeyCapture(signal : AbortSignal) : InputCapture
{
    return new InputCapture({ deviceTypes: [ 'keyboard' ], sourceTypes: [ 'key' ], signal });
}

export async function rebindJumpKey(inputSystem : InputSystem, capture : InputCapture) : Promise<string>
{
    const { inputManager } = inputSystem;
    const input = await capture.wait();

    const definition : BindingDefinition = {
        kind: 'trigger',
        action: 'jump',
        context: GAMEPLAY_CONTEXT,
        input,
        options: { edge: 'rising' },
    };

    for(const old of inputManager.getBindingsForAction('jump', 'keyboard'))
    {
        inputManager.contexts.removeBinding(old);
    }

    const binding = inputManager.bind(definition);

    inputSystem.definitions = inputSystem.definitions.filter((existing) =>
        existing.action !== 'jump' || existing.input.device.type !== 'keyboard');
    inputSystem.definitions.push(binding.toDefinition());

    return input.sourceKey;
}

InputManager.bind does the same work createInputSystem used it for in Step 5: look the action up, pick the reader, and register the binding. binding.toDefinition() answers with the definition the live binding was built from, which is what keeps the Bindings card's own list of definitions in sync with what is actually live.

The Vue component registers the capture with the input manager, which feeds it every deliberate input it observes, and cancels it through the AbortController if the player clicks Cancel:

typescript
const controller = new AbortController();
const capture = startJumpKeyCapture(controller.signal);
const unregister = engine.managers.inputManager.registerCapture(capture);

try
{
    const key = await rebindJumpKey(inputSystem, capture);
    logEvent(`jump rebound to ${ key }`, 'context');
}
finally
{
    unregister();
}

getBindingsForAction('jump', 'keyboard') finds the old keyboard binding without touching the gamepad one, and removeBinding retires it before the replacement is added. Gameplay bindings keep delivering while a capture waits.

Claiming input, raw movement, and device changes ​

Three more binding-level facts this demo does not need but a full game does:

A binding claims its input from the browser by declaring preventDefault : true. F3 opening the browser's own find bar, or the wheel scrolling the page underneath the canvas, is the failure a claim is for: while a live binding in an active context claims an input, InputManager calls event.preventDefault() on it, whether or not that input makes the binding fire. A claim follows context activation and survives export and import with the rest of a binding's definition.

A mouse reader also reads raw movement: MouseState.movement is how far the pointer travelled since the last routing pass, and 'movement' is a MouseSourceType a binding's input names alongside 'button', 'position', and 'wheel'. A delta binding on a movement axis is how a game drives a look control from mouse motion rather than from held keys, the way this demo's arrow-key and gamepad-stick bindings drive lookX/lookY instead.

InputManager also broadcasts input:deviceChanged whenever a routing pass leaves a different device type active than the previous one, carrying deviceType and previousDeviceType, and input:schemeChanged whenever a pass leaves a different input scheme active, carrying scheme and previousScheme. A UI subscribed to them on the engine bus swaps its control prompts without polling lastActiveDeviceType or activeScheme itself.

A prompt reads the scheme rather than the device type. A scheme is one way of playing, keyboard-mouse or gamepad, and the keyboard and the mouse are one way of playing rather than two, so getBindingsForAction('jump', inputManager.activeScheme, GAMEPLAY_CONTEXT) answers with the keyboard binding and the mouse binding together and never the gamepad one. Asking with lastActiveDeviceType in that position answers with one device's bindings, which leaves a mouse-bound action unprompted the moment the player touches the keyboard. Raw mouse movement never changes the active scheme, so a nudged mouse does not swap a gamepad player's glyphs.

What you learned ​

  • Actions are declared once, in a Configuration; importConfiguration registers them and hands back the binding definitions
  • A binding definition is JSON; InputManager.bind makes it live, picking the reader itself
  • Four binding kinds: value, trigger, toggle, and delta
  • Contexts group bindings, and activation primes them against the current device state
  • InputManager routes every live binding once a frame; a behavior answers with onEvent
  • The game reads entity state once a frame for everything outside the entity system, and hears a one-shot action such as a shot fired as an event instead
  • InputCapture plus getBindingsForAction, removeBinding, and InputManager.bind make a rebind
  • A control prompt asks activeScheme, since the keyboard and the mouse are one input scheme
  • A binding claims its input from the browser with preventDefault, and raw mouse movement is a reader like any other

Next steps ​

  • Input: the full reference, including configuration export and import
  • Entity Behaviors: a game built from behaviors and entity classes

Released under the MIT License.