Skip to content

Input System

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

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

SAGE's input system adds a layer of indirection between raw hardware input and game logic:

Physical Input  -->  Binding  -->  Action  -->  Game Logic
[KeyW pressed]      [value +1.0]  [moveForward]  [move cube forward]
[Left stick Y]      [value -0.7]  [moveForward]  [move cube forward]
[LMB click]         [trigger]     [shoot]        [spawn bullet]

Your game logic only knows about actions like moveForward and shoot. It never touches raw key codes or gamepad axes. This means you can remap controls, support new devices, or add accessibility options without changing a single line of game code.

Project structure

input-demo/
├── index.vue              # Vue component — UI and orchestration
├── constants.ts           # Tuning values (speeds, sizes, deadzones)
├── types.ts               # TypeScript interfaces
├── input/
│   ├── bindings.ts        # Action registration and binding config
│   ├── helpers.ts         # Factory functions for input descriptors
│   ├── labels.ts          # Human-readable input labels
│   ├── display.ts         # UI display data
│   └── highlights.ts      # Active input tracking for visual feedback
├── game/
│   ├── actions.ts         # Action event subscriptions
│   ├── movement.ts        # Cube movement and camera rotation
│   └── bullets.ts         # Projectile spawning and management
└── level/
    └── setup.ts           # BabylonJS scene setup

The separation matters: the input/ module knows about bindings but nothing about cubes. The game/ module knows about actions but nothing about key codes. That is the whole point.

Step 1: Define actions

Actions are the abstract things your game responds to. You register them with the binding manager before setting up any bindings. There are two types:

  • analog — continuous values with a range (movement, camera rotation)
  • digital — boolean on/off (jump, shoot, sprint)

Both types support an optional label for display in settings UIs (e.g., showing "Strafe" instead of move_right). If omitted, fall back to name.

typescript
const bindingManager = engine.managers.bindingManager;

// Analog actions — have a value range
bindingManager.registerAction({ type: 'analog', name: 'moveForward', label: 'Move Forward', minValue: -1, maxValue: 1 });
bindingManager.registerAction({ type: 'analog', name: 'moveRight', label: 'Strafe', minValue: -1, maxValue: 1 });
bindingManager.registerAction({ type: 'analog', name: 'lookX', label: 'Look Horizontal', minValue: -1, maxValue: 1 });
bindingManager.registerAction({ type: 'analog', name: 'lookY', label: 'Look Vertical', minValue: -1, maxValue: 1 });

// Digital actions — on or off
bindingManager.registerAction({ type: 'digital', name: 'jump', label: 'Jump' });
bindingManager.registerAction({ type: 'digital', name: 'shoot', label: 'Shoot' });
bindingManager.registerAction({ type: 'digital', name: 'sprint', label: 'Sprint' });
bindingManager.registerAction({ type: 'digital', name: 'crouch', label: 'Crouch' });

Step 2: Create input descriptors

Every binding needs an InputDefinition that identifies the physical input. These are verbose objects, so the example wraps them in helper functions:

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

export function keyboardInput(sourceKey : string) : InputDefinition
{
    return {
        deviceID: 'keyboard-0',
        type: 'keyboard',
        sourceType: 'key',
        sourceKey,
    };
}

export function mouseInput(button : string) : InputDefinition
{
    return {
        deviceID: 'mouse-0',
        type: 'mouse',
        sourceType: 'button',
        sourceKey: `button-${ button }`,
    };
}

export function gamepadAxis(sourceKey : string) : InputDefinition
{
    return {
        deviceID: 'gamepad-0',
        type: 'gamepad',
        sourceType: 'axis',
        sourceKey,
    };
}

export function gamepadButton(sourceKey : string) : InputDefinition
{
    return {
        deviceID: 'gamepad-0',
        type: 'gamepad',
        sourceType: 'button',
        sourceKey,
    };
}

Keyboard keys use DOM KeyboardEvent.code values ('KeyW', 'Space', 'ShiftLeft'). Gamepad buttons and axes use the standard mapping ('button-0' for A/Cross, 'axis-0' for left stick X).

Step 3: Register bindings

Bindings connect physical inputs to actions. There are three binding types, each with different behavior:

Value bindings — continuous input

Value bindings fire whenever the input value changes. For keyboard keys, that means firing with the scale value when pressed and 0 when released. For gamepad axes, the raw analog value is passed through (optionally multiplied by scale).

typescript
// W key: moveForward = +1.0 when pressed, 0 when released
{ type: 'value',
    action: 'moveForward',
    context: 'gameplay',
    input: keyboardInput('KeyW'),
    options: { scale: 1.0 } },

// S key: moveForward = -1.0 when pressed (backward)
{ type: 'value',
    action: 'moveForward',
    context: 'gameplay',
    input: keyboardInput('KeyS'),
    options: { scale: -1.0 } },

// Gamepad left stick Y: continuous -1 to +1 with deadzone
// scale: -1.0 inverts the axis (gamepad "up" is negative, but we want it positive)
{ type: 'value',
    action: 'moveForward',
    context: 'gameplay',
    input: gamepadAxis('axis-1'),
    options: { scale: -1.0, deadzone: 0.15 } },

The scale option multiplies the raw input. For keyboard keys (which output 0 or 1), this lets you use negative values for opposite directions. The deadzone option filters out small analog values to prevent stick drift from triggering movement.

Multiple bindings can map to the same action. The W key, S key, and left stick Y all contribute to moveForward. SAGE handles combining them.

Trigger bindings — one-shot events

Trigger bindings fire once per press, not continuously while held. The edgeMode option controls when:

  • 'rising' — fire when the button goes from released to pressed (most common)
  • 'falling' — fire on release
  • 'both' — fire on both transitions
typescript
// Jump on spacebar press
{ type: 'trigger',
    action: 'jump',
    context: 'gameplay',
    input: keyboardInput('Space'),
    options: { edgeMode: 'rising' } },

// Jump on gamepad A button
{ type: 'trigger',
    action: 'jump',
    context: 'gameplay',
    input: gamepadButton('button-0'),
    options: { edgeMode: 'rising' } },

The shoot action demonstrates binding three different devices to one action — keyboard, mouse, and gamepad all trigger shoot:

typescript
{ type: 'trigger',
    action: 'shoot',
    context: 'gameplay',
    input: keyboardInput('KeyF'),
    options: { edgeMode: 'rising' } },

{ type: 'trigger',
    action: 'shoot',
    context: 'gameplay',
    input: mouseInput('0'),           // Left mouse button
    options: { edgeMode: 'rising' } },

{ type: 'trigger',
    action: 'shoot',
    context: 'gameplay',
    input: gamepadButton('button-7'), // Right trigger
    options: { edgeMode: 'rising' } },

Toggle bindings — hold to activate

Toggle bindings fire on both press and release, reporting the current held state. The payload value is true while held and false on release. Use these for "hold to activate" mechanics.

typescript
// Hold Shift to sprint
{ type: 'toggle',
    action: 'sprint',
    context: 'gameplay',
    input: keyboardInput('ShiftLeft') },

// Hold C to crouch
{ type: 'toggle',
    action: 'crouch',
    context: 'gameplay',
    input: keyboardInput('KeyC') },

Step 4: Register and activate contexts

All bindings reference a context. Contexts let you have different binding sets for different game states — gameplay controls during play, menu navigation during menus.

typescript
bindingManager.registerContext('gameplay', true);
bindingManager.registerContext('menu', true);

// Start with gameplay active
bindingManager.activateContext('gameplay');

The second parameter (true) allows multiple contexts to be active simultaneously. To switch contexts:

typescript
bindingManager.deactivateContext('gameplay');
bindingManager.activateContext('menu');

Only bindings in active contexts fire events. This means you can have the same key do different things in different contexts without conflicts.

Step 5: Subscribe to action events

Once bindings are set up, you subscribe to actions using engine.subscribeAction(). You pass just the action name — SAGE handles the action: event prefix internally.

Analog actions (movement and look)

typescript
engine.subscribeAction('moveForward', (event) =>
{
    const value = Number(event.payload.value);
    movement.forward = value;  // plain game state
});

engine.subscribeAction('moveRight', (event) =>
{
    const value = Number(event.payload.value);
    movement.right = value;
});

engine.subscribeAction('lookX', (event) =>
{
    look.x = Number(event.payload.value);
});

Trigger actions (jump and shoot)

typescript
engine.subscribeAction('jump', (event) =>
{
    // Only jump when on the ground
    if(cube.position.y <= 0.51)
    {
        movement.velocityY = JUMP_VELOCITY;
    }
});

engine.subscribeAction('shoot', (event) =>
{
    spawnBullet(scene, cube, bullets);
});

The event.payload.deviceId field tells you which device triggered the action, if you need to distinguish between input sources.

Toggle actions (sprint and crouch)

typescript
engine.subscribeAction('sprint', (event) =>
{
    isSprinting = Boolean(event.payload.value);
});

engine.subscribeAction('crouch', (event) =>
{
    isCrouching = Boolean(event.payload.value);
});

For toggles, event.payload.value is true when pressed and false when released.

Step 6: Movement with modifiers

The game loop reads the accumulated input state and applies movement each frame. Sprint and crouch modify the base movement speed:

typescript
export function updateCubeMovement(
    cube : Mesh,
    movement : MovementState,
    actionValues : ActionValues
) : void
{
    // Speed selection: sprint doubles speed, crouch halves it
    const speed = actionValues.sprint ? MOVE_SPEED_SPRINT : MOVE_SPEED_NORMAL;
    const scale = actionValues.crouch ? MOVE_SCALE_CROUCH : 1.0;

    // Apply movement
    cube.position.z -= movement.forward * speed * scale;
    cube.position.x += movement.right * speed * scale;

    // Gravity and jump physics
    movement.velocityY -= GRAVITY;
    cube.position.y += movement.velocityY;

    // Ground collision
    const groundY = actionValues.crouch ? GROUND_Y_CROUCH : GROUND_Y_NORMAL;
    if(cube.position.y < groundY)
    {
        cube.position.y = groundY;
        movement.velocityY = 0;
    }

    // Crouch visual — squash the cube
    cube.scaling.y = actionValues.crouch ? CROUCH_SCALE : 1.0;

    // Keep in bounds
    cube.position.x = Math.max(-BOUNDS, Math.min(BOUNDS, cube.position.x));
    cube.position.z = Math.max(-BOUNDS, Math.min(BOUNDS, cube.position.z));
}

Camera rotation works the same way — the look input values drive ArcRotateCamera angles:

typescript
export function updateCamera(camera : ArcRotateCamera, look : LookState) : void
{
    camera.alpha += look.x * CAMERA_ROTATE_SPEED;
    camera.beta -= look.y * CAMERA_ROTATE_SPEED;

    // Clamp beta to prevent camera flipping
    camera.beta = Math.max(CAMERA_BETA_MIN, Math.min(CAMERA_BETA_MAX, camera.beta));
}

Step 7: The bullet system

Shooting spawns a glowing sphere that travels forward until it expires or leaves the arena:

typescript
export function spawnBullet(scene : Scene, cube : Mesh, bullets : Bullet[]) : void
{
    const mesh = MeshBuilder.CreateSphere('bullet', { diameter: 0.15 }, scene);
    mesh.position = cube.position.clone();
    mesh.position.y = 0.5;

    const mat = new StandardMaterial('bulletMat', scene);
    mat.diffuseColor = new Color3(1, 0.4, 0.2);
    mat.emissiveColor = new Color3(0.8, 0.2, 0.1);
    mesh.material = mat;

    bullets.push({
        mesh,
        velocity: { x: 0, z: -BULLET_SPEED },
        life: BULLET_LIFETIME,
    });
}

Each frame, bullets are updated and expired ones are cleaned up. The reverse iteration pattern avoids index shifting issues when removing elements:

typescript
export function updateBullets(bullets : Bullet[]) : void
{
    for(let i = bullets.length - 1; i >= 0; i--)
    {
        const bullet = bullets[i];

        bullet.mesh.position.x += bullet.velocity.x;
        bullet.mesh.position.z += bullet.velocity.z;
        bullet.life--;

        if(bullet.life <= 0
            || Math.abs(bullet.mesh.position.x) > BULLET_BOUNDS
            || Math.abs(bullet.mesh.position.z) > BULLET_BOUNDS)
        {
            bullet.mesh.dispose();
            bullets.splice(i, 1);
        }
    }
}

Step 8: The render loop

Everything comes together in the render loop. Note that gamepad input requires polling each frame:

typescript
engine.renderEngine.runRenderLoop(() =>
{
    // Poll gamepad state — required each frame for gamepad input
    engine.managers.inputManager.pollGamepads();

    // Update game state
    updateCubeMovement(cube, movement, actionValues);
    updateCamera(camera, look);
    updateBullets(bullets);

    // Render
    scene.render();
});

Gamepad polling

SAGE does not automatically poll gamepad state. You must call engine.managers.inputManager.pollGamepads() each frame, or gamepad inputs will not be detected. Keyboard and mouse events are handled automatically via DOM event listeners.

Reactivity strategy

The example deliberately separates Vue reactive state from game state:

  • Game state (movement, look, bullets) — plain objects, updated 60+ times per second in the game loop. No Vue reactivity overhead.
  • UI state (actionValues, displayBindings, eventLog) — Vue reactive objects, updated by action subscriptions and read by the template.

This "dual-write" pattern keeps the game loop fast while still powering responsive UI updates.

Resource cleanup

On component unmount, detach any event listeners and dispose the scene:

typescript
onBeforeUnmount(() =>
{
    inputHighlightTracker?.detach();
    scene?.dispose();
});

Rebinding controls

Most games let players rebind their controls. SAGE's captureInput() API makes this straightforward -- it returns a Promise<InputDefinition> that resolves with the next intentional input, ready to plug directly into registerBinding().

The rebinding flow

A typical rebind goes like this:

  1. Player clicks a "rebind" button next to an action (e.g., "Jump: Space")
  2. UI shows a "Press any key..." prompt
  3. captureInput() waits for intentional input, suppressing all gameplay bindings
  4. Player presses the desired key or button
  5. The promise resolves with an InputDefinition
  6. You remove the old binding, register the new one, and update the UI
typescript
async function rebindAction(
    bindingManager : BindingManager,
    actionName : string,
    context : string
) : Promise<InputDefinition>
{
    // Show the "press any key" prompt
    showRebindPrompt(actionName);

    // Capture the next input from any device
    const input = await bindingManager.captureInput({
        deviceTypes: [ 'keyboard', 'mouse', 'gamepad' ],
        sourceTypes: [ 'key', 'button' ],
    });

    // Swap the binding
    bindingManager.unregisterBindings(actionName, context);
    bindingManager.registerBinding({
        type: 'trigger',
        action: actionName,
        input,
        context,
        options: { edgeMode: 'rising' },
    });

    // Persist the new configuration
    const config = bindingManager.exportConfiguration();
    localStorage.setItem('controls', JSON.stringify(config));

    hideRebindPrompt();

    return input;
}

While captureInput() is waiting, SAGE activates a temporary exclusive context that suppresses all normal bindings. Your game won't process any input until the capture resolves or is cancelled.

Cancellation and timeouts

You probably don't want the capture to wait forever. Use an AbortSignal to support both an escape key and a timeout:

typescript
async function rebindWithCancel(
    bindingManager : BindingManager,
    actionName : string,
    context : string
) : Promise<InputDefinition | null>
{
    const controller = new AbortController();

    // Let the player press Escape to cancel
    const onEscape = (e : KeyboardEvent) : void =>
    {
        if(e.code === 'Escape')
        {
            controller.abort();
        }
    };

    window.addEventListener('keydown', onEscape);

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

        // Success — update the binding
        bindingManager.unregisterBindings(actionName, context);
        bindingManager.registerBinding({
            type: 'trigger',
            action: actionName,
            input,
            context,
            options: { edgeMode: 'rising' },
        });

        return input;
    }
    catch(err)
    {
        if(err instanceof DOMException && err.name === 'AbortError')
        {
            // Player cancelled or timed out — no changes
            return null;
        }

        throw err;
    }
    finally
    {
        window.removeEventListener('keydown', onEscape);
        hideRebindPrompt();
    }
}

TIP

captureInput() automatically restores the previous context state when it completes or is cancelled. You don't need to manually reactivate your gameplay context.

WARNING

Only one capture can be active at a time. Calling captureInput() while another capture is in progress will throw an error. Make sure your UI disables other rebind buttons while a capture is active.

What you learned

  • Actions abstract game intent from physical input
  • Three binding types: value (continuous), trigger (one-shot), toggle (hold)
  • engine.subscribeAction() connects actions to game logic
  • Contexts let you swap binding sets for different game states
  • Gamepad requires per-frame polling with inputManager.pollGamepads()
  • Separate game state from Vue reactive state for performance
  • captureInput() enables key rebinding UIs with built-in noise filtering and cancellation

Next steps

Released under the MIT License.