Guide

Guide Input Keyboard & actions

Keyboard & actions

Capture keys, support configurable bindings, and map multiple devices to the same intent-driven actions.

Intro ~5 min read

What you'll learn

  • capture keys with scene-scoped bindings
  • handle taps, holds, and rebinding
  • map several devices to one intent
  • keep gameplay code device-agnostic

Before you start

Keyboard & actions

ExoJS maps keys to numeric channels via the Keyboard enum. Keyboard.Space is channel 32, Keyboard.A is 65, Keyboard.Left is 37. Values follow the browser’s legacy KeyboardEvent.keyCode mapping. Keys only fire while the canvas has focus; when focus leaves, all held keys are released automatically.

Scene-scoped bindings

The scene’s this.inputs registry creates bindings that are automatically disposed when the scene unloads. Four listener types cover the common patterns:

MethodFires
onTrigger(channel, callback)Once when the key is released within 300 ms of press (a “tap”)
onStart(channel, callback)Once when the key transitions from inactive to active
onActive(channel, callback)Every frame while the key is held down
onStop(channel, callback)Once when the key transitions from active to inactive

Each returns an InputBinding you can call .unbind() on to detach early. The threshold option (in ms) overrides the 300 ms default for onTrigger:

import { Keyboard, Scene } from '@codexo/exojs';

class GameScene extends Scene {
    init(loader) {
        this.inputs.onTrigger(Keyboard.Space, () => {
            this.player.jump();
        });

        this.inputs.onTrigger(Keyboard.Escape, () => {
            this.app.scene.pushScene(new PauseScene());
        }, { threshold: 200 });
    }
}

For held-key patterns — movement, continuous actions — use onActive/onStop to track a boolean flag, then consume it in update:

init(loader) {
    this.move = { left: 0, right: 0, up: 0, down: 0 };

    this.inputs.onActive(Keyboard.A, () => { this.move.left  = 1; });
    this.inputs.onStop(Keyboard.A,    () => { this.move.left  = 0; });
    this.inputs.onActive(Keyboard.D, () => { this.move.right = 1; });
    this.inputs.onStop(Keyboard.D,    () => { this.move.right = 0; });
    this.inputs.onActive(Keyboard.W, () => { this.move.up    = 1; });
    this.inputs.onStop(Keyboard.W,    () => { this.move.up    = 0; });
    this.inputs.onActive(Keyboard.S, () => { this.move.down  = 1; });
    this.inputs.onStop(Keyboard.S,    () => { this.move.down  = 0; });
}

update(delta) {
    const speed = 280 * delta.seconds;
    const dx = (this.move.right - this.move.left) * speed;
    const dy = (this.move.down - this.move.up) * speed;
    this.sprite.move(dx, dy);
}

Application-level signals

app.input exposes two lower-level signals that fire on raw keydown/keyup events, regardless of the current scene:

app.input.onKeyDown.add(channel => {
    console.log('Key pressed:', channel);
});

app.input.onKeyUp.add(channel => {
    console.log('Key released:', channel);
});

These are raw — no threshold, no auto-disposal. Use them for global shortcuts that should work across every scene (dev tools, screenshot capture) or as the listener layer for a custom key-rebinding UI.

Key rebinding

The onKeyDown signal accepts a callback that receives the raw channel number. This is the mechanism for letting the player remap controls at runtime:

init(loader) {
    this.jumpChannel = Keyboard.Space;
    this.rebindRequested = false;
    this.jumpDirty = true;

    // The rebind trigger — press J to enter rebind mode
    this.inputs.onTrigger(Keyboard.J, () => {
        this.rebindRequested = true;
    });

    // Capture the next keydown as the new jump binding
    this.app.input.onKeyDown.add(channel => {
        if (!this.rebindRequested) return;
        this.jumpChannel = channel;
        this.rebindRequested = false;
        this.jumpDirty = true;
    });

    this._rebindJump();
}

_rebindJump() {
    if (!this.jumpDirty) return;
    this._jumpBinding?.unbind();
    this._jumpBinding = this.inputs.onTrigger(this.jumpChannel, () => {
        this.jumpVelocity = -260;
    });
    this.jumpDirty = false;
}

update(delta) {
    this._rebindJump();
    // ... apply jumpVelocity ...
}

Available key constants

The Keyboard enum covers the standard set. The most commonly used:

Keyboard.A .. Keyboard.Z
Keyboard.Zero .. Keyboard.Nine
Keyboard.Space      Keyboard.Enter      Keyboard.Escape
Keyboard.Left       Keyboard.Right      Keyboard.Up        Keyboard.Down
Keyboard.Shift      Keyboard.Control    Keyboard.Alt
Keyboard.Tab        Keyboard.Backspace  Keyboard.Delete
Keyboard.PageUp     Keyboard.PageDown   Keyboard.Home      Keyboard.End
Keyboard.F1 .. Keyboard.F12
Keyboard.NumPad0 .. Keyboard.NumPad9

The full list includes punctuation, brackets, and navigation keys. All values match the browser’s legacy KeyboardEvent.keyCode map used by ExoJS.

Canvas focus

Keyboard input is gated on canvas focus. When the user tabs away or clicks outside the canvas, all held keys are released automatically and no further key events fire until focus returns. The app.input.onCanvasFocusChange signal notifies you of transitions:

app.input.onCanvasFocusChange.add(focused => {
    if (!focused) this.pause();
});

A common pattern is to pause gameplay on focus loss and resume on return. The engine handles the key-release side; your scene only needs to react to the focus state.

When to use which listener

  • onTrigger for one-shot actions: jump, shoot, menu open/close, toggle.
  • onActive/onStop pair for held-state actions: movement, charge attacks, UI scrolling.
  • onStart for actions that fire once on press and don’t care about release timing: inventory open, screenshot.
  • app.input.onKeyDown for global shortcuts and rebinding UIs that need to see every raw key event.

All scene-scoped bindings are cleaned up when the scene’s destroy hook runs — no manual .unbind() calls needed unless you want to detach a binding mid-scene.

Action mapping: one intent, many devices

Hard-coding Keyboard.Space for jump works until someone picks up a controller. Hard-coding GamepadButton.South works until someone prefers the keyboard. Action mapping means naming what the player intends to do — “jump”, “move right”, “pause” — and binding each intent to one or more input channels regardless of device.

ExoJS does not ship a dedicated action-map abstraction layer. Instead, the existing binding API supports multi-channel arrays and the scene-scoped this.inputs registry gives you automatic cleanup. You structure the mapping yourself, but the pieces are already in place.

Multi-channel bindings

Every binding factory — onTrigger, onStart, onActive, onStop — accepts an array of channels. The callback fires when any channel in the array activates:

this.inputs.onTrigger([Keyboard.Space, Keyboard.J], () => {
    this.player.jump();
});

The callback receives the sampled value (0..1 for buttons, -1..1 for bipolar axes) from the most-active channel. For simple triggers this value is rarely needed — the important thing is that the action fires regardless of which key was pressed.

Combined keyboard and gamepad

Per-pad bindings on Gamepad (pad.onTrigger, pad.onActive, etc.) coexist with scene-scoped keyboard bindings. The typical pattern is to set up both in init, track the input state in simple variables, and let update consume whichever device is active:

import { GamepadAxis, GamepadButton, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs';

class PlayerScene extends Scene {
    async load(loader) {
        await loader.load(Texture, { hero: 'image/hero.png' });
    }

    init(loader) {
        this.sprite = new Sprite(loader.get(Texture, 'hero'))
            .setAnchor(0.5)
            .setPosition(400, 300);

        // Keyboard state
        this.keys = { left: 0, right: 0, up: 0, down: 0 };

        // Gamepad state
        this.stick = { x: 0, y: 0 };

        // Jump impulse — either device
        this.jumpImpulse = 0;

        // --- Keyboard bindings (auto-disposed on scene unload) ---
        this.inputs.onActive(Keyboard.A, () => { this.keys.left  = 1; });
        this.inputs.onStop(Keyboard.A,    () => { this.keys.left  = 0; });
        this.inputs.onActive(Keyboard.D, () => { this.keys.right = 1; });
        this.inputs.onStop(Keyboard.D,    () => { this.keys.right = 0; });
        this.inputs.onActive(Keyboard.W, () => { this.keys.up    = 1; });
        this.inputs.onStop(Keyboard.W,    () => { this.keys.up    = 0; });
        this.inputs.onActive(Keyboard.S, () => { this.keys.down  = 1; });
        this.inputs.onStop(Keyboard.S,    () => { this.keys.down  = 0; });

        this.inputs.onTrigger(Keyboard.Space, () => {
            this.jumpImpulse = -220;
        });

        // --- Gamepad bindings (per-slot, must be unbound manually if slot changes) ---
        const pad0 = this.app.input.getGamepad(0);

        pad0.onTrigger(GamepadButton.South, () => {
            this.jumpImpulse = -220;
        });

        pad0.onActive(GamepadAxis.LeftStickX, value => {
            this.stick.x = value;
        });
        pad0.onStop(GamepadAxis.LeftStickX, () => {
            this.stick.x = 0;
        });

        pad0.onActive(GamepadAxis.LeftStickY, value => {
            this.stick.y = value;
        });
        pad0.onStop(GamepadAxis.LeftStickY, () => {
            this.stick.y = 0;
        });
    }

    update(delta) {
        // Best input wins: prefer the device with the larger magnitude
        const keyX = this.keys.right - this.keys.left;
        const keyY = this.keys.down - this.keys.up;

        const moveX = Math.abs(this.stick.x) > Math.abs(keyX) ? this.stick.x : keyX;
        const moveY = Math.abs(this.stick.y) > Math.abs(keyY) ? this.stick.y : keyY;

        this.sprite.move(moveX * 260 * delta.seconds, moveY * 260 * delta.seconds);

        // Jump physics (same impulse source for both devices)
        this.sprite.move(0, this.jumpImpulse * delta.seconds);
        this.jumpImpulse = Math.min(0, this.jumpImpulse + 800 * delta.seconds);
    }

    draw(context) {
        context.backend.clear();
        context.render(this.sprite);
    }
}

The “best input wins” logic — Math.abs(this.stick.x) > Math.abs(keyX) — picks whichever device is being used more actively at any given moment. A player can transition from keyboard to gamepad mid-frame without any state discontinuity.

Structuring input in production scenes

A clean pattern for larger projects is to centralise input state in a small plain object and let each device category write into it:

init(loader) {
    // One source of truth for movement intent
    this.move = { x: 0, y: 0 };
    this.actions = { jump: 0, interact: 0, pause: 0 };

    // Keyboard writes into move / actions
    this.inputs.onActive(Keyboard.A, () => { this.move.x = -1; });
    this.inputs.onStop(Keyboard.A,    () => { if (this.move.x === -1) this.move.x = 0; });
    this.inputs.onActive(Keyboard.D, () => { this.move.x = 1; });
    this.inputs.onStop(Keyboard.D,    () => { if (this.move.x === 1) this.move.x = 0; });

    this.inputs.onTrigger(Keyboard.Space, () => { this.actions.jump = 1; });
    this.inputs.onTrigger(Keyboard.Escape, () => { this.actions.pause = 1; });

    // Gamepad writes into the same move / actions
    const pad = this.app.input.getGamepad(0);
    pad.onActive(GamepadAxis.LeftStickX, v => { this.move.x = v; });
    pad.onStop(GamepadAxis.LeftStickX,   () => { this.move.x = 0; });
    pad.onActive(GamepadAxis.LeftStickY, v => { this.move.y = v; });
    pad.onStop(GamepadAxis.LeftStickY,   () => { this.move.y = 0; });

    pad.onTrigger(GamepadButton.South, () => { this.actions.jump = 1; });
    pad.onTrigger(GamepadButton.Start, () => { this.actions.pause = 1; });

    // Consume actions once in update, then reset
}

update(delta) {
    if (this.actions.pause) {
        this.app.scene.pushScene(new PauseScene());
        this.actions.pause = 0;
    }

    if (this.actions.jump && this.player.onGround) {
        this.player.velocity.y = -400;
    }
    this.actions.jump = 0;

    this.player.move(this.move.x * 260 * delta.seconds, this.move.y * 260 * delta.seconds);
}

This keeps input handling in one location, makes it easy to add a third device (touch controls, arcade stick), and avoids scattered onTrigger callbacks that directly call game logic.

Per-pad cleanup

Scene-scoped bindings (this.inputs.onTrigger(...)) are automatically disposed when the scene’s destroy runs. Per-pad bindings (pad.onTrigger(...)) are not — they live on the Gamepad instance, which outlives any single scene. Store per-pad bindings in an array and unbind them in destroy:

init(loader) {
    this._padBindings = [];
    const pad = this.app.input.getGamepad(0);

    this._padBindings.push(
        pad.onTrigger(GamepadButton.South, () => this.player.jump()),
        pad.onActive(GamepadAxis.LeftStickX, v => { this.move.x = v; }),
        pad.onStop(GamepadAxis.LeftStickX, () => { this.move.x = 0; }),
    );
}

destroy() {
    for (const binding of this._padBindings) {
        binding.unbind();
    }
    this._padBindings.length = 0;
}

When to add indirection

The flat approach — one object, direct writes from listeners — scales well for most projects. You might want an intermediate action-map layer when:

  • You need runtime-rebindable controls that persist across scenes.
  • You need to serialize/deserialize control configurations.
  • You have a large number of actions and keeping them as flat object properties becomes unwieldy.

In those cases, build a thin ActionMap class that maps action names to arrays of channels and exposes actionMap.bind('jump', [Keyboard.Space, GamepadButton.South]). The engine does not ship this abstraction — it is a few dozen lines of your own code on top of the existing binding API.

Examples

Keyboard Keyboard Open in Playground View source
import { Application, Color, Graphics, Keyboard, Scene } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: new Color(10, 12, 20),
    loader: {
        basePath: 'assets/',
    },
});

// Two ways to read the keyboard, shown side by side:
//
//   - on-event: `inputs.onStart` / `onStop` fire once on the press / release
//     transition. Great for discrete actions (here: a recentre tap on Escape).
//   - per-frame polling: the binding returned by `inputs.onActive` samples the
//     channel buffer every frame, so reading `binding.active` inside update()
//     gives the live held-state — no callback bookkeeping required.
//
// Both WASD and the arrow keys drive the same square via a single binding per
// direction (each binding watches two channels at once).
class KeyboardScene extends Scene {
    private square!: Graphics;
    private position = { x: 400, y: 300 };
    // The structural shape of an InputBinding's pollable state (the class itself
    // is internal to the engine, but `active` / `value` are its public surface).
    private up!: { readonly active: boolean; readonly value: number };
    private down!: { readonly active: boolean; readonly value: number };
    private left!: { readonly active: boolean; readonly value: number };
    private right!: { readonly active: boolean; readonly value: number };
    private hud!: ReturnType<typeof mountControls>;

    override init(): void {
        const { width, height } = this.app.canvas;

        this.square = new Graphics();
        this.position = { x: width / 2, y: height / 2 };

        // Per-frame polling source: one binding per direction, each listening to
        // both the WASD key and the matching arrow key. We keep the references
        // and read their live state in update() rather than mutating flags.
        this.up = this.inputs.onActive([Keyboard.W, Keyboard.Up], () => {});
        this.down = this.inputs.onActive([Keyboard.S, Keyboard.Down], () => {});
        this.left = this.inputs.onActive([Keyboard.A, Keyboard.Left], () => {});
        this.right = this.inputs.onActive([Keyboard.D, Keyboard.Right], () => {});

        // On-event source: a discrete tap that snaps the square back to centre.
        this.inputs.onStart(Keyboard.Escape, () => {
            this.position.x = width / 2;
            this.position.y = height / 2;
        });

        this.hud = mountControls({
            title: 'Keyboard',
            controls: [
                { keys: ['W', 'A', 'S', 'D'], action: 'move (per-frame polling)' },
                { keys: ['↑', '↓', '←', '→'], action: 'move (same bindings)' },
                { keys: 'Esc', action: 'recentre (on-event)' },
            ],
            status: 'Held: none',
            hint: 'Click the canvas first so it has keyboard focus.',
        });
    }

    override update(delta): void {
        const { width, height } = this.app.canvas;
        const speed = 280 * delta.seconds;
        const moveX = (this.right.active ? 1 : 0) - (this.left.active ? 1 : 0);
        const moveY = (this.down.active ? 1 : 0) - (this.up.active ? 1 : 0);

        this.position.x = Math.max(20, Math.min(width - 20, this.position.x + moveX * speed));
        this.position.y = Math.max(20, Math.min(height - 20, this.position.y + moveY * speed));

        const held = [this.up.active && 'Up', this.down.active && 'Down', this.left.active && 'Left', this.right.active && 'Right'].filter(Boolean);

        this.hud.setStatus(`Held: ${held.length ? held.join(' + ') : 'none'}`);
    }

    override draw(context): void {
        context.backend.clear();
        this.square.clear();
        this.square.fillColor = new Color(120, 200, 255);
        this.square.drawRectangle(this.position.x - 20, this.position.y - 20, 40, 40);
        context.render(this.square);
    }
}

app.start(new KeyboardScene());

WASD movement with onActive/onStop — the standard held-key pattern.

Key Rebinding Keyboard Open in Playground View source
import { Application, Color, Graphics, Keyboard, Scene } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: new Color(10, 12, 20),
    loader: {
        basePath: 'assets/',
    },
});

// Reverse lookup from a Keyboard channel back to a human-readable key name, so
// the binding is shown as "Space" rather than the raw channel number. The
// Keyboard enum's reverse map (numeric value -> member name) gives this for free.
const KEY_NAMES = Keyboard as unknown as Record<number, string>;

function keyName(channel: number): string {
    return KEY_NAMES[channel] ?? `Key ${channel}`;
}

// A serialisable mapping is exactly what makes a key-binding persist: write the
// plain object to localStorage on every rebind, read it back on load.
const STORAGE_KEY = 'exo-example-key-rebinding';

interface InputProfile {
    jump: number;
}

function loadProfile(): InputProfile {
    try {
        const raw = localStorage.getItem(STORAGE_KEY);

        if (raw) {
            const parsed = JSON.parse(raw) as Partial<InputProfile>;

            if (typeof parsed.jump === 'number') {
                return { jump: parsed.jump };
            }
        }
    } catch {
        // localStorage may be unavailable (sandboxed) — fall through to default.
    }

    return { jump: Keyboard.Space };
}

function saveProfile(profile: InputProfile): void {
    try {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(profile));
    } catch {
        // Non-fatal — persistence is best-effort.
    }
}

class KeyRebindingScene extends Scene {
    private graphics!: Graphics;
    private profile: InputProfile = loadProfile();
    private rebindRequested = false;
    private jumpVelocity = 0;
    private heroY = 0;
    private groundY = 0;
    private jumpBinding: { unbind(): void } | undefined;
    private hud!: ReturnType<typeof mountControls>;

    override init(): void {
        const { height } = this.app.canvas;

        this.groundY = height - 240;
        this.heroY = this.groundY;
        this.graphics = new Graphics();

        // Tap J to arm a rebind; the next key pressed becomes the new jump key.
        // We arm on the *release* (onTrigger) so the J keydown itself isn't
        // captured as the new binding in the same frame.
        this.inputs.onTrigger(Keyboard.J, () => {
            this.rebindRequested = true;
            this.refreshHud();
        });

        this.app.input.onKeyDown.add(channel => {
            if (!this.rebindRequested) {
                return;
            }

            this.rebindRequested = false;
            this.profile.jump = channel;
            saveProfile(this.profile);
            this.bindJump();
            this.refreshHud();
        });

        this.bindJump();

        this.hud = mountControls({
            title: 'Key Rebinding',
            controls: [
                { keys: keyName(this.profile.jump), action: 'jump' },
                { keys: 'J', action: 'rebind jump' },
            ],
            status: '',
            hint: '',
        });

        this.refreshHud();
    }

    private bindJump(): void {
        this.jumpBinding?.unbind();
        this.jumpBinding = this.inputs.onStart(this.profile.jump, () => {
            if (this.heroY >= this.groundY - 0.5) {
                this.jumpVelocity = -560;
            }
        });
    }

    private refreshHud(): void {
        this.hud.setControls([
            { keys: keyName(this.profile.jump), action: 'jump' },
            { keys: 'J', action: 'rebind jump' },
        ]);
        this.hud.setStatus(`Jump key: ${keyName(this.profile.jump)} (saved)`);
        this.hud.setHint(this.rebindRequested ? 'Press any key to assign jump…' : 'Binding restored from localStorage on reload.');
    }

    override update(delta): void {
        // Simple gravity so the rebound jump is visible.
        this.jumpVelocity = Math.min(900, this.jumpVelocity + 1800 * delta.seconds);
        this.heroY += this.jumpVelocity * delta.seconds;

        if (this.heroY > this.groundY) {
            this.heroY = this.groundY;
            this.jumpVelocity = 0;
        }
    }

    override draw(context): void {
        const { width } = this.app.canvas;

        context.backend.clear();
        this.graphics.clear();

        // Static ground line, just below where the hero square rests.
        this.graphics.fillColor = new Color(40, 48, 64);
        this.graphics.drawRectangle(0, this.groundY + 40, width, 4);

        // Hero square — heroY is animated by the (rebindable) jump key.
        this.graphics.fillColor = new Color(255, 190, 90);
        this.graphics.drawRectangle(width / 2 - 20, this.heroY, 40, 40);

        context.render(this.graphics);
    }
}

app.start(new KeyRebindingScene());

Press J to enter rebind mode, then press any key to reassign the jump action.

Action Mapping Keyboard Gamepad Open in Playground View source
import { Application, Color, GamepadAxis, GamepadButton, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: new Color(10, 12, 20),
    loader: {
        basePath: 'assets/',
    },
});

// The lesson: bind several *physical* inputs to a few *named actions*, then read
// only the actions in the update loop. Keyboard and gamepad feed the same
// `moveX` / `moveY` / `jump` values, so the gameplay code never branches on the
// device. Whichever device pushes a control harder this frame wins — so you can
// pick up either input mid-motion without a mode switch.
class ActionMappingScene extends Scene {
    private sprite!: Sprite;
    private keys = { left: 0, right: 0, up: 0, down: 0 };
    private stick = { x: 0, y: 0 };
    private jumpImpulse = 0;
    private lastDevice = 'keyboard';
    private actions = { moveX: 0, moveY: 0, jump: false };
    private hud!: ReturnType<typeof mountControls>;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { ship: 'image/ship-a.png' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;

        this.sprite = new Sprite(loader.get(Texture, 'ship')).setAnchor(0.5).setPosition(width / 2, height / 2);

        const pad0 = this.app.input.getGamepad(0);

        // --- Move action: keyboard WASD/arrows feed key axes ---
        this.inputs.onActive([Keyboard.A, Keyboard.Left], () => (this.keys.left = 1));
        this.inputs.onStop([Keyboard.A, Keyboard.Left], () => (this.keys.left = 0));
        this.inputs.onActive([Keyboard.D, Keyboard.Right], () => (this.keys.right = 1));
        this.inputs.onStop([Keyboard.D, Keyboard.Right], () => (this.keys.right = 0));
        this.inputs.onActive([Keyboard.W, Keyboard.Up], () => (this.keys.up = 1));
        this.inputs.onStop([Keyboard.W, Keyboard.Up], () => (this.keys.up = 0));
        this.inputs.onActive([Keyboard.S, Keyboard.Down], () => (this.keys.down = 1));
        this.inputs.onStop([Keyboard.S, Keyboard.Down], () => (this.keys.down = 0));

        // --- Move action: gamepad left stick feeds the same axes ---
        pad0.onActive(GamepadAxis.LeftStickX, value => (this.stick.x = value));
        pad0.onStop(GamepadAxis.LeftStickX, () => (this.stick.x = 0));
        pad0.onActive(GamepadAxis.LeftStickY, value => (this.stick.y = value));
        pad0.onStop(GamepadAxis.LeftStickY, () => (this.stick.y = 0));

        // --- Jump action: Space OR the South button, one shared impulse ---
        this.inputs.onStart(Keyboard.Space, () => this.queueJump('keyboard'));
        pad0.onStart(GamepadButton.South, () => this.queueJump('gamepad'));

        this.hud = mountControls({
            title: 'Action Mapping',
            controls: [
                { keys: ['W', 'A', 'S', 'D'], action: 'Move (keyboard)' },
                { keys: 'L-Stick', action: 'Move (gamepad)' },
                { keys: ['Space', 'A'], action: 'Jump (either device)' },
            ],
            status: 'Move 0.00, 0.00 · Jump idle',
            hint: 'Driven by: keyboard',
        });
    }

    private queueJump(device: string): void {
        this.jumpImpulse = -220;
        this.lastDevice = device;
    }

    override update(delta): void {
        const keyX = this.keys.right - this.keys.left;
        const keyY = this.keys.down - this.keys.up;

        // Resolve each named action from whichever device is pushing hardest.
        this.actions.moveX = Math.abs(this.stick.x) > Math.abs(keyX) ? this.stick.x : keyX;
        this.actions.moveY = Math.abs(this.stick.y) > Math.abs(keyY) ? this.stick.y : keyY;
        this.actions.jump = this.jumpImpulse < 0;

        if (this.actions.moveX !== 0 || this.actions.moveY !== 0) {
            this.lastDevice = Math.abs(this.stick.x) > Math.abs(keyX) || Math.abs(this.stick.y) > Math.abs(keyY) ? 'gamepad' : 'keyboard';
        }

        this.sprite.move(this.actions.moveX * 260 * delta.seconds, this.actions.moveY * 260 * delta.seconds);
        this.sprite.move(0, this.jumpImpulse * delta.seconds);
        this.jumpImpulse = Math.min(0, this.jumpImpulse + 800 * delta.seconds);

        this.hud.setStatus(`Move ${this.actions.moveX.toFixed(2)}, ${this.actions.moveY.toFixed(2)} · Jump ${this.actions.jump ? 'active' : 'idle'}`);
        this.hud.setHint(`Driven by: ${this.lastDevice}`);
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.sprite);
    }
}

app.start(new ActionMappingScene());

A single sprite controlled by both keyboard (WASD + Space) and gamepad (left stick + South button), demonstrating the “best input wins” pattern.

Where to go next

The next chapter, Mouse and pointer, covers the unified pointer system — mouse, touch, and pen input with multi-touch slot management and gesture recognition.