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. 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:
| 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:
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
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:
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
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
Playground
API
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.