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 simulation 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 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.

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
{ 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
{ 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
{ 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
{ kind: 'delta', action: 'zoom', context: 'gameplay', input: mouseWheelInput('deltaY'), options: { scale: 0.01 } },

Step 5: Resolve the bindings

A definition becomes a live binding through resolveBinding, paired with its registered action and a device reader for its device. A reader is typed to its device's state, so resolution branches on the device type:

typescript
import { GamepadReader, KeyboardReader, MouseReader, resolveBinding } from '@skewedaspect/sage';
import type { BindingInput, DeviceReader, GamepadState, KeyboardState, MouseState } from '@skewedaspect/sage';

export function keyboardReader(input : BindingInput) : DeviceReader<KeyboardState>
{
    return new KeyboardReader(input.sourceKey);
}

export function mouseReader(input : BindingInput) : DeviceReader<MouseState>
{
    if(input.sourceType === 'button')
    {
        return new MouseReader('button', input.sourceKey);
    }

    return new MouseReader('wheel', input.sourceKey);
}

export function gamepadReader(input : BindingInput) : DeviceReader<GamepadState>
{
    if(input.sourceType === 'axis')
    {
        return new GamepadReader('axis', input.sourceKey);
    }

    return new GamepadReader('button', input.sourceKey);
}
typescript
export function createInputSystem(inputManager : InputManager) : InputSystem
{
    for(const action of ACTIONS)
    {
        inputManager.actions.register(action);
    }

    inputManager.contexts.registerContext('gameplay', true);
    inputManager.contexts.registerContext('menu', true);

    const definitions = getBindingsConfig();

    for(const definition of definitions)
    {
        const action = requireAction(inputManager, definition.action);

        if(definition.input.device.type === 'keyboard')
        {
            inputManager.contexts.addBinding(resolveBinding(definition, action, keyboardReader(definition.input)));
        }
        else if(definition.input.device.type === 'mouse')
        {
            inputManager.contexts.addBinding(resolveBinding(definition, action, mouseReader(definition.input)));
        }
        else
        {
            inputManager.contexts.addBinding(resolveBinding(definition, action, gamepadReader(definition.input)));
        }
    }

    return { inputManager, definitions };
}

requireAction looks the action up in inputManager.actions and throws when it is missing.

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:

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);
    });
}

activateContext(inputManager, 'gameplay');

Switching to the menu context is activateContext(inputManager, 'menu'). 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,
        jumpCount: 0,
    };

    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')
        {
            this.state.jumpCount += 1;

            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 shotsRequested. A behavior cannot spawn a Babylon mesh, so the game loop compares the counter against what it saw last frame and spawns one bullet per new request. A counter, not a flag, means two shots landing in one drain are both honored.

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 bullet spawning.

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

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

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

    updatePlayerCrouchVisual(playerEntity.mesh, playerEntity.state.crouch);
}

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, resolveBinding } from '@skewedaspect/sage';
import type { Binding, BindingDefinition, KeyboardState } 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 action = requireAction(inputManager, 'jump');

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

    const binding : Binding<KeyboardState> = resolveBinding(definition, action, keyboardReader(input));

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

    inputManager.contexts.addBinding(binding);

    return input.sourceKey;
}

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.

What you learned

  • Actions are declared twice: registered on inputManager.actions, and typed in the EventMap
  • A binding definition is JSON; resolveBinding makes it live with the action and a device reader
  • 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
  • InputCapture plus getBindingsForAction and removeBinding make a rebind

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.