Keyboard & actions
Capture keys, support configurable bindings, and map multiple devices to the same intent-driven actions.
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:
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:
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.
Even raw key signals need canvas focus
onKeyDown/onKeyUp fire across every scene, but they’re still gated on canvas focus like all keyboard input — nothing fires while the canvas is blurred, and any held key is released automatically when focus leaves. Subscribe to onCanvasFocusChange if you need to pause on blur.
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:
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
onTriggerfor one-shot actions: jump, shoot, menu open/close, toggle.onActive/onStoppair for held-state actions: movement, charge attacks, UI scrolling.onStartfor actions that fire once on press and don’t care about release timing: inventory open, screenshot.app.input.onKeyDownfor 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:
Per-pad bindings don't auto-dispose
Scene-scoped bindings from this.inputs are cleaned up when the scene unloads, but pad.onTrigger/pad.onActive bindings live on the long-lived Gamepad and are not. Keep their InputBinding handles and call unbind() in destroy, or they stack up across scene changes.
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:
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:
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:
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.pressedandreleasedboth fire on the same frame for a tap that started and ended between two frames, so a fast press is never missed.AxisAction— a signedvaluein -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 2Dvalue, 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.ChordActionandSequenceAction— 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
WASD movement with onActive/onStop — the standard held-key pattern.
Press J to enter rebind mode, then press any key to reassign the jump action.
A single sprite controlled by both keyboard (WASD + Space) and gamepad (left stick + South button), demonstrating the “best input wins” pattern.
Try it
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.
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.
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.

