Guide

GuideInputKeyboard & actions

Keyboard & actions

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

Intro~12 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. A member names a physical key position, resolved from the browser’s layout-independent KeyboardEvent.code — Keyboard.A is the key at the QWERTY “A” position on every layout, the one an AZERTY keyboard prints “Q” on, so a WASD binding stays the same physical square under the player’s hand. The names describe the US-QWERTY legend, not the character the key produces; use DOM text input for typed characters, dead keys, and IME composition. The channel numbers themselves are opaque values — do not assume they equal any keyCode. 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:

Method Fires
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:

examples/guides/keyboard-and-actions/trigger-threshold.ts
override init(): void {
  this.inputs.onTrigger(Keyboard.Space, () => {
    player.jump();
  });

  this.inputs.onTrigger(
    Keyboard.Escape,
    () => {
      togglePause();
    },
    { threshold: 200 },
  );
}

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

examples/guides/keyboard-and-actions/movement.ts
override init(): void {
  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;
  });
}

override update(delta: Seconds): void {
  const speed = 280 * delta;
  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:

import { Application } from '@codexo/exojs';

const app = new Application();
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:

examples/guides/keyboard-and-actions/rebinding.ts
override init(): void {
  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(): void {
  if (!this.jumpDirty) return;
  this._jumpBinding?.unbind();
  this._jumpBinding = this.inputs.onTrigger(this.jumpChannel, () => {
    this.jumpVelocity = -260;
  });
  this.jumpDirty = false;
}

override update(delta: Seconds): void {
  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. Punctuation members carry the code name of the key (Keyboard.Semicolon, Keyboard.Backquote, Keyboard.BracketLeft, …), the same vocabulary the pattern tokens use (keyboard.semicolon) — the name is the US legend, the binding is the position, so on a QWERTZ keyboard Keyboard.Semicolon is the key printed “ö”. Keys ExoJS does not track (media keys, IME/language keys) drive no channel at all. To resolve a raw DOM event yourself — in a rebinding UI, say — use keyboardChannelFromCode(event.code); for a modifier this returns the side-specific channel (see below), never the aggregate.

Modifier sides

Every modifier exposes both a side-specific channel and an aggregate one:

Keyboard.ShiftLeft    Keyboard.ShiftRight    Keyboard.Shift    (aggregate)
Keyboard.ControlLeft  Keyboard.ControlRight  Keyboard.Control  (aggregate)
Keyboard.AltLeft      Keyboard.AltRight      Keyboard.Alt      (aggregate)
Keyboard.MetaLeft     Keyboard.MetaRight     Keyboard.Meta     (aggregate)

Bind to the aggregate (Keyboard.Control) for a normal game binding — it is active whenever either physical key is held, and releasing one side while the other stays down keeps it active. Bind to a side-specific channel (Keyboard.ControlLeft) only when you genuinely need to tell the two keys apart, such as a key-press visualizer or a rebinding UI. onKeyDown/onKeyUp always report the side-specific channel that was actually pressed — the aggregate is buffer state an action reads, not something that fires its own signal, so one physical key press is still exactly one onKeyDown dispatch.

ChordAction/SequenceAction string patterns also accept a few shorthand aliases for the modifier and Escape tokens: Ctrl for Control, Cmd / Command / Super for Meta, Opt for Alt, and Esc for Escape — so 'Ctrl+K' and 'Control+K' are equivalent. Aliases are a string-pattern convenience only; array bindings always use the Keyboard enum member directly.

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:

import { Application } from '@codexo/exojs';

const app = new Application();
app.input.onCanvasFocusChange.add(focused => {
    if (!focused) app.scenes.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:

examples/guides/keyboard-and-actions/movement.ts
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:

examples/guides/keyboard-and-actions/player-scene.ts
class PlayerScene extends Scene {
  private sprite!: Sprite;
  private keys!: { left: number; right: number; up: number; down: number };
  private stick!: { x: number; y: number };
  private jumpImpulse!: number;

  override async load(): Promise<void> {
    await this.loader.load('image/hero.png');
  }

  override init(): void {
    this.sprite = new Sprite(this.loader.get('image/hero.png')).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;
    });
  }

  override update(delta: Seconds): void {
    // 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, moveY * 260 * delta);

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

  override draw(context: RenderingContext): void {
    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:

examples/guides/keyboard-and-actions/movement.ts
override init(): void {
  // 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
}

override update(delta: Seconds): void {
  if (this.actions.pause) {
    this.togglePause(); // pauses/resumes via app.scenes + shows/hides the pause overlay on scene.ui
    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, this.move.y * 260 * delta);
}

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:

examples/guides/keyboard-and-actions/gamepad-bindings.ts
override init(): void {
  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;
    }),
  );
}

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

Named actions

Wiring listeners by hand is fine for a couple of keys. Once a control scheme has a name for each thing the player can do — and each of those can come from the keyboard or the pad — describe it instead with an ActionMap:

examples/guides/keyboard-and-actions/action-map.ts
class GameScene extends Scene {
  // Your own game object - anything exposing these three methods.
  declare player: { jump(): void; steer(amount: number): void; move(direction: Vector): void };

  controls = new ActionMap({
    jump: new ButtonAction([Keyboard.Space, GamepadButton.South]),
    attack: new ButtonAction([PointerButton.Primary, GamepadButton.West]),
    steer: new AxisAction([GamepadAxis.LeftStickX, { negative: Keyboard.A, positive: Keyboard.D }]),
    move: new VectorAction([
      { x: GamepadAxis.LeftStickX, y: GamepadAxis.LeftStickY },
      { up: Keyboard.W, down: Keyboard.S, left: Keyboard.A, right: Keyboard.D },
    ]),
  });

  override init(): void {
    this.inputs.attach(this.controls);
  }

  override update(): void {
    if (this.controls.jump.pressed) {
      this.player.jump();
    }

    this.player.steer(this.controls.steer.value);
    this.player.move(this.controls.move.value);
  }
}

Attaching through this.inputs ties the map to the scene: it is detached when the scene ends, and its actions are reset across a suspend so a key held while the scene was away does not read as a fresh press on resume.

Which input object to use

Inside a scene, reach for this.inputs for everything — action maps, scopes, per-channel bindings, and the gamepad slots (this.inputs.gamepads, this.inputs.connectedGamepads, this.inputs.getGamepad(0)). Everything it creates dies with the scene, which is what you want for gameplay.

app.input is the application-lifetime surface. Use it for input that must outlive any single scene — a debug overlay’s hotkeys, a global pause key, a device-configuration screen that watches every pad — via app.input.attach(map). Nothing there is scoped to a scene, and nothing there takes part in a scene’s scope stack.

The rule of thumb: gameplay is this.inputs; system input is app.input.

The action kinds

  • ButtonAction — value (0..1), active, pressed, released. Sources may be digital or analog; the strongest one wins. pressed and released both fire on the same frame for a tap that started and ended between two frames, so a fast press is never missed.
  • AxisAction — a signed value in -1..1, from a stick axis or from two opposing button groups ({ negative, positive }). Either side may be omitted for a one-sided axis.
  • VectorAction — a 2D value, from { x, y } stick axes or from { up, down, left, right } buttons. The result is clamped to unit length, so a digital diagonal is no faster than a cardinal direction.
  • ChordAction and SequenceAction — several channels required at once, or in order. Covered in their own chapter, Chords and sequences.

Pass one binding directly or several as an array; options are always a separate second argument (new ButtonAction(GamepadButton.RightTrigger, { threshold: 0.5 })).

How alternatives resolve

Alternative bindings are never summed. Each is evaluated on its own and the one with the largest deflection wins — so holding D while the stick rests reads as full right, not as one-and-a-bit. VectorAction picks whole vectors, so x never comes from the keyboard while y comes from the pad.

An action’s threshold is its own, separate from a gamepad’s device-level deadzone: the deadzone decides what reaches the channel at all, the threshold decides what counts as active.

When you still want raw bindings

The listener-based API below is still the right tool when you need a callback at the exact moment an edge happens rather than a value polled in update(), or when you are reacting to a channel that is not part of a named control scheme.

Examples

KeyboardKeyboardOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, Graphics, type InputBinding, Keyboard, type RenderingContext, Scene, type Seconds } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

// 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: `inputs.onActive` called without a callback just
//     returns the binding, which samples the channel buffer every frame - so
//     reading `binding.active` inside update() gives the live held-state.
//
// 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 };
  private up!: InputBinding;
  private down!: InputBinding;
  private left!: InputBinding;
  private right!: InputBinding;
  private hud!: ReturnType<typeof mountControls>;

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

    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: Seconds): void {
    const app = this.app;
    const { width, height } = app;
    const speed = 280 * delta;
    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: RenderingContext): void {
    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);
  }
}

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

await app.start(KeyboardScene);

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

Key RebindingKeyboardOpen in PlaygroundView source

Preview is paused until you click Play.

import { ActionMap, Application, BindingProfile, ButtonAction, Color, FixedResolutionCanvasSizing, Graphics, type InputToken, inputToken, Keyboard, type RenderingContext, Scene, type Seconds } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

// A binding is persisted as a stable lowercase token ("keyboard.space"), never
// as an enum number: tokens survive an engine upgrade, a different browser, and
// a controller plugged into another port. This turns one back into something a
// player can read on screen.
function keyName(token: InputToken | undefined): string {
  return token?.replace(/^keyboard\./, '').replaceAll('-', ' ') ?? 'unbound';
}

// A BindingProfile stores only what the player CHANGED, so writing the whole
// thing to localStorage still leaves every action the game gains later at its
// own default.
const STORAGE_KEY = 'exo-example-key-rebinding';

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

    if (raw) {
      return BindingProfile.fromJSON(JSON.parse(raw));
    }
  } catch {
    // Unavailable storage, or a profile written by an older build - either
    // way, fall through to the developer defaults.
  }

  return new BindingProfile();
}

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

class KeyRebindingScene extends Scene {
  private graphics!: Graphics;
  private controls = new ActionMap({
    jump: new ButtonAction(Keyboard.Space),
    // Not `rebind`: an action name has to clear ActionMap's own surface, and
    // `rebind()` is the method this example calls three lines down.
    startRebind: new ButtonAction(Keyboard.J),
  });

  private profile = loadProfile();
  private rebindRequested = false;
  private jumpVelocity = 0;
  private heroY = 0;
  private groundY = 0;
  private hud!: ReturnType<typeof mountControls>;

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

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

    this.controls.applyProfile(this.profile);
    this.inputs.attach(this.controls);

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

      this.rebindRequested = false;
      // Rebinding is atomic and baseline-safe: the key being pressed right
      // now does not read as a fresh jump on the very next frame.
      this.controls.rebind('jump', channel);
      this.profile.set('jump', this.controls.jump.serialize());
      saveProfile(this.profile);
      this.refreshHud();
    });

    this.hud = mountControls({
      title: 'Key Rebinding',
      controls: this.hudControls(),
      status: '',
      hint: '',
    });

    this.refreshHud();
  }

  private jumpToken(): InputToken | undefined {
    return this.controls.jump.channels.map(inputToken)[0];
  }

  private hudControls(): { keys: string; action: string }[] {
    return [
      { keys: keyName(this.jumpToken()), action: 'jump' },
      { keys: 'J', action: 'rebind jump' },
    ];
  }

  private refreshHud(): void {
    this.hud.setControls(this.hudControls());
    this.hud.setStatus(`Jump key: ${keyName(this.jumpToken())} (saved)`);
    this.hud.setHint(this.rebindRequested ? 'Press any key to assign jump…' : 'Binding restored from localStorage on reload.');
  }

  override update(delta: Seconds): void {
    // Arm on the RELEASE of J, so the J keydown itself is not captured as
    // the new binding in the same frame.
    if (this.controls.startRebind.released && !this.rebindRequested) {
      this.rebindRequested = true;
      this.refreshHud();
    }

    if (this.controls.jump.pressed && this.heroY >= this.groundY - 0.5) {
      this.jumpVelocity = -560;
    }

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

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

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

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

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

await app.start(KeyRebindingScene);

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

Action MappingKeyboardGamepadOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, GamepadAxis, GamepadButton, Keyboard, type RenderingContext, Scene, type Seconds, Sprite } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

// 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 init(): void {
    const app = this.app;
    const { width, height } = app;

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

    const pad0 = 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: Seconds): 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, this.actions.moveY * 260 * delta);
    this.sprite.move(0, this.jumpImpulse * delta);
    this.jumpImpulse = Math.min(0, this.jumpImpulse + 800 * delta);

    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: RenderingContext): void {
    context.render(this.sprite);
  }
}

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

await app.start(ActionMappingScene);

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

Rebinding and saved profiles

An action’s binding is data. A map can hand it out, take a replacement, and report what a player’s saved settings say — without the gameplay code that reads controls.jump.pressed changing at all.

Every control has a stable, lowercase token: keyboard.space, keyboard.key-w, pointer.primary, gamepad.button.south, gamepad.axis.left-stick-x. Tokens are the only form that may be written to disk. They carry no gamepad slot, no browser device index, and no enum number, so a save file survives a new build, a different browser, and a controller plugged into a different port.

examples/guides/keyboard-and-actions/binding-profile.ts
class RebindScene extends Scene {
  controls = new ActionMap({
    jump: new ButtonAction(Keyboard.Space),
    crouch: new ButtonAction(Keyboard.ControlLeft),
  });

  override init(): void {
    this.inputs.attach(this.controls);

    const saved = localStorage.getItem('bindings');

    if (saved !== null) {
      this.controls.applyProfile(BindingProfile.fromJSON(JSON.parse(saved)));
    }
  }

  rebindJump(key: Keyboard): void {
    this.controls.rebind('jump', key);

    const profile = new BindingProfile().set('jump', this.controls.jump.serialize());

    localStorage.setItem('bindings', JSON.stringify(profile));
  }
}

A few properties are worth knowing:

  • A profile stores overrides, not a snapshot. An action the profile never mentions keeps whatever default the current build gives it, so shipping a new action does not leave returning players unable to use it.
  • Application is all-or-nothing. An unknown token or a binding whose kind no longer matches its action throws, and the map is left exactly as it was. Unknown tokens are never quietly mapped onto some other control.
  • A rebind is baseline-safe. A key held across the change does not surface as a fresh press, and a release that follows is still reported.
  • Conflicts are reported, not resolved. map.conflicts() lists every channel two of its actions bind, for a settings screen to warn with; two actions on one key stays a legitimate design.

For iterating a map in a rebinding UI, map.names, map.get(name) and map.entries() expose the actions with the names they were declared under, and every action reports its kind, its effective binding, its defaultBinding and the channels it currently reads.

Input scopes

A pause menu should take the keys it needs and leave the rest alone. That is what an InputScope is: one or more action maps that, while pushed, own the controls they bind.

examples/guides/keyboard-and-actions/input-scope.ts
class GameplayScene extends Scene {
  controls = new ActionMap({
    jump: new ButtonAction(Keyboard.Space),
    move: new VectorAction({ up: Keyboard.W, down: Keyboard.S, left: Keyboard.A, right: Keyboard.D }),
  });

  menu = new InputScope(
    new ActionMap({
      close: new ButtonAction(Keyboard.Escape),
      confirm: new ButtonAction(Keyboard.Space),
    }),
  );

  override init(): void {
    this.inputs.attach(this.controls);
  }

  openMenu(): void {
    this.inputs.pushScope(this.menu);
  }

  closeMenu(): void {
    this.inputs.popScope(this.menu);
  }
}

While the menu scope is pushed, Space reaches only confirm — jump sees neither the value nor the press edge, so it cannot reconstruct one. W/A/S/D are not bound by the menu and keep reaching move.

The rules are short:

  • Maps attached with this.inputs.attach() sit at the bottom, below every scope.
  • A scope claims exactly the controls its maps currently bind — rebind one and the claim follows.
  • Only LOWER levels are masked. Maps inside one scope are peers and never hide anything from each other.
  • Pushing and popping re-baselines the levels whose controls changed, so a button held while a menu opens or closes never fires an edge it should not.
  • A scope’s maps are attached on push and detached on pop.

A scope is not a preventDefault(): it decides which of your actions sees a control, not what the browser does with the event.

when still answers a different question. It says whether a map is active at all given the scene’s state ('active', 'paused', 'always'); a scope says which of the currently active contexts gets an overlapping control.

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.