Input & Bindings
SAGE's binding system creates an abstraction layer between physical input devices (keyboard, mouse, gamepad) and game actions. You define what your game can do (actions), then map hardware inputs to those actions (bindings), organized into switchable groups (contexts).
Core Concepts
| Concept | Role |
|---|---|
| Action | A named game event (jump, throttle) -- the "what" |
| Binding | Connects a physical input to an action -- the "how" |
| Context | Groups bindings that activate/deactivate together -- the "when" |
Actions
Actions represent game events triggered by user input. Two types:
DigitalAction
Boolean on/off values (jumping, interacting, firing).
interface DigitalAction
{
type : 'digital';
name : string;
label ?: string;
}AnalogAction
Numeric values for continuous input (throttle, camera rotation).
interface AnalogAction
{
type : 'analog';
name : string;
label ?: string;
minValue ?: number;
maxValue ?: number;
}Both types support an optional label for display in UIs (e.g., settings screens). If omitted, use name as a fallback.
Action (Union Type)
type Action = DigitalAction | AnalogAction;Registering Actions
const bindingManager = gameEngine.managers.bindingManager;
bindingManager.registerAction({ name: 'jump', type: 'digital', label: 'Jump' });
bindingManager.registerAction({
name: 'move_x',
type: 'analog',
label: 'Strafe',
minValue: -1,
maxValue: 1,
});Bindings
Bindings connect physical inputs to actions. Three types are supported:
Binding Types
type BindingType = 'trigger' | 'toggle' | 'value';| Type | Behavior | Use Case |
|---|---|---|
trigger | Fires once on button state change | Jump, shoot, interact |
toggle | Alternates between on/off each press | Crouch toggle, flashlight |
value | Continuously passes input values | Joystick axes, triggers |
Binding Interface
All bindings implement:
interface Binding
{
readonly type : BindingType;
readonly action : Action;
readonly context ?: string;
readonly deviceID : string;
readonly reader : DeviceValueReader;
process(state : InputState, eventBus : GameEventBus) : void;
resetEdgeState() : void;
toJSON() : BindingDefinition;
}Binding Definitions
Bindings are typically created from definition objects rather than class instances. Definitions are JSON-serializable and can be loaded from config files.
TriggerBindingDefinition
interface TriggerBindingDefinition
{
type : 'trigger';
action : string;
input : InputDefinition;
context ?: string;
options ?: {
edgeMode ?: 'rising' | 'falling' | 'both';
threshold ?: number;
passthrough ?: boolean;
};
}| Option | Type | Default | Description |
|---|---|---|---|
edgeMode | 'rising' | 'falling' | 'both' | 'rising' | When to fire: press, release, or both |
threshold | number | 0.5 | Activation threshold for analog inputs |
passthrough | boolean | false | Pass raw value instead of boolean |
ToggleBindingDefinition
interface ToggleBindingDefinition
{
type : 'toggle';
action : string;
input : InputDefinition;
context ?: string;
state ?: boolean;
options ?: {
invert ?: boolean;
initialState ?: boolean;
threshold ?: number;
onValue ?: boolean | number;
offValue ?: boolean | number;
};
}| Option | Type | Default | Description |
|---|---|---|---|
initialState | boolean | false | Starting toggle state |
onValue | boolean | number | true | Value emitted when toggled on |
offValue | boolean | number | false | Value emitted when toggled off |
invert | boolean | false | Invert the input signal |
threshold | number | 0.5 | Activation threshold for analog inputs |
ValueBindingDefinition
interface ValueBindingDefinition
{
type : 'value';
action : string;
input : InputDefinition;
context ?: string;
options ?: {
scale ?: number;
offset ?: number;
min ?: number;
max ?: number;
invert ?: boolean;
emitOnChange ?: boolean;
deadzone ?: number;
};
}| Option | Type | Default | Description |
|---|---|---|---|
scale | number | 1.0 | Multiplier applied to the raw value |
offset | number | 0 | Added after scaling |
min | number | -- | Clamp minimum |
max | number | -- | Clamp maximum |
invert | boolean | false | Negate the input value |
emitOnChange | boolean | false | Only emit when value actually changes |
deadzone | number | 0 | Ignore values below this magnitude |
InputDefinition
Combines device ID and reader configuration:
type InputDefinition = DeviceValueReaderDefinition & {
deviceID : string;
};The type field determines which reader is created:
type | Additional Fields | Description |
|---|---|---|
'keyboard' | sourceKey | Key code (e.g., 'KeyW', 'Space', 'Escape') |
'mouse' | sourceType, sourceKey | sourceType: 'button', 'position', 'wheel' |
'gamepad' | sourceType, sourceKey | sourceType: 'button', 'axis' |
Device IDs
| Device | ID |
|---|---|
| Keyboard | keyboard-0 |
| Mouse | mouse-0 |
| Gamepad 1 | gamepad-0 |
| Gamepad 2 | gamepad-1 |
| Gamepad N | gamepad-N |
Keyboard Source Keys
Keyboard inputs use DOM KeyboardEvent.code values as the sourceKey. No sourceType field is needed.
Common keys:
| Category | Keys |
|---|---|
| Letters | KeyA through KeyZ |
| Digits | Digit0 through Digit9 |
| Arrows | ArrowUp, ArrowDown, ArrowLeft, ArrowRight |
| Modifiers | ShiftLeft, ShiftRight, ControlLeft, ControlRight, AltLeft, AltRight |
| Special | Space, Enter, Escape, Tab, Backspace, Delete |
| Function | F1 through F12 |
| Numpad | Numpad0 through Numpad9, NumpadAdd, NumpadSubtract, etc. |
{ deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'KeyW' }
{ deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'Space' }
{ deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'ShiftLeft' }Mouse Source Keys
Mouse inputs require both sourceType and sourceKey.
Buttons (sourceType: 'button')
sourceKey | Button |
|---|---|
button-0 | Left mouse button |
button-1 | Middle mouse button (wheel click) |
button-2 | Right mouse button |
button-3 | Back (side button) |
button-4 | Forward (side button) |
{ deviceID: 'mouse-0', type: 'mouse', sourceType: 'button', sourceKey: 'button-0' }Position (sourceType: 'position')
sourceKey | Value |
|---|---|
absolute:x | Cursor X position (clientX) |
absolute:y | Cursor Y position (clientY) |
relative:x | Cursor X movement delta |
relative:y | Cursor Y movement delta |
{ deviceID: 'mouse-0', type: 'mouse', sourceType: 'position', sourceKey: 'relative:x' }Wheel (sourceType: 'wheel')
sourceKey | Value |
|---|---|
deltaX | Horizontal scroll amount |
deltaY | Vertical scroll amount |
deltaZ | Z-axis scroll amount (rare) |
{ deviceID: 'mouse-0', type: 'mouse', sourceType: 'wheel', sourceKey: 'deltaY' }Gamepad Source Keys
Gamepad inputs require both sourceType and sourceKey. Values follow the standard gamepad mapping.
Buttons (sourceType: 'button')
sourceKey | Xbox | PlayStation |
|---|---|---|
button-0 | A | Cross |
button-1 | B | Circle |
button-2 | X | Square |
button-3 | Y | Triangle |
button-4 | LB | L1 |
button-5 | RB | R1 |
button-6 | LT | L2 |
button-7 | RT | R2 |
button-8 | Back / Select | Share |
button-9 | Start | Options |
button-10 | L3 (left stick click) | L3 |
button-11 | R3 (right stick click) | R3 |
button-12 | D-pad Up | D-pad Up |
button-13 | D-pad Down | D-pad Down |
button-14 | D-pad Left | D-pad Left |
button-15 | D-pad Right | D-pad Right |
{ deviceID: 'gamepad-0', type: 'gamepad', sourceType: 'button', sourceKey: 'button-0' }Axes (sourceType: 'axis')
Axes return continuous values from -1 to +1.
sourceKey | Axis |
|---|---|
axis-0 | Left stick horizontal (-1 left, +1 right) |
axis-1 | Left stick vertical (-1 up, +1 down) |
axis-2 | Right stick horizontal (-1 left, +1 right) |
axis-3 | Right stick vertical (-1 up, +1 down) |
{ deviceID: 'gamepad-0', type: 'gamepad', sourceType: 'axis', sourceKey: 'axis-0' }BindingManager
The BindingManager is the central hub for input processing. It is available through the engine:
const bindingManager = gameEngine.managers.bindingManager;Action API
| Method | Signature | Description |
|---|---|---|
registerAction | (action : Action) => void | Register a new action. Throws if already registered. |
getAction | (name : string) => Action | null | Look up a registered action by name |
Binding API
| Method | Signature | Description |
|---|---|---|
registerBinding | (definition : BindingDefinition) => void | Create and register a binding from a definition |
unregisterBindings | (actionName : string, context? : string | null) => void | Remove all bindings for an action in a context |
getBindingsForAction | (actionName : string, context? : string | null) => Binding[] | Get all bindings for an action |
Context API
| Method | Signature | Description |
|---|---|---|
registerContext | (name : string, exclusive? : boolean) => Context | Register a context (default: exclusive) |
activateContext | (name : string) => void | Activate a context; deactivates other exclusive contexts |
deactivateContext | (name : string) => void | Deactivate a context |
getActiveContexts | () => string[] | List all currently active contexts |
isContextActive | (name : string) => boolean | Check if a context is active |
getContext | (name : string) => Context | null | Look up a context by name |
Configuration API
| Method | Signature | Description |
|---|---|---|
exportConfiguration | () => BindingConfiguration | Export all actions, bindings, and contexts |
importConfiguration | (config : BindingConfiguration) => void | Import a configuration, replacing the current one |
BindingConfiguration
interface BindingConfiguration
{
actions : Action[];
bindings : BindingDefinition[];
contexts : {
name : string;
exclusive : boolean;
active : boolean;
}[];
}Input Capture API
captureInput() listens for the next intentional input event and returns an InputDefinition you can plug directly into registerBinding(). It is designed for key rebinding UIs — the player clicks "rebind jump", presses a key, and you get back exactly the definition you need.
| Method | Signature | Description |
|---|---|---|
captureInput | (options : CaptureInputOptions) => Promise<InputDefinition> | Capture the next intentional input event |
CaptureInputOptions
interface CaptureInputOptions
{
deviceTypes : DeviceType[];
sourceTypes ?: Array<'key' | 'button' | 'axis' | 'position' | 'wheel'>;
signal ?: AbortSignal;
}| Option | Type | Required | Description |
|---|---|---|---|
deviceTypes | DeviceType[] | Yes | Which device types to listen for ('keyboard', 'mouse', 'gamepad') |
sourceTypes | Array<'key' | 'button' | 'axis' | 'position' | 'wheel'> | No | Filter which source types to capture |
signal | AbortSignal | No | Cancel or timeout the capture via standard AbortSignal |
Behavior
While a capture is active, all normal bindings are suppressed — SAGE activates a temporary exclusive context so gameplay input does not fire. When the capture completes or is cancelled, the previous context state is automatically restored. Only one capture can be active at a time; calling captureInput() while one is already in progress throws an error.
Noise Filtering
captureInput() automatically filters out unintentional input:
- Keyboard — captures key-down only (ignores key-up and OS key repeat)
- Mouse — captures button presses only (ignores movement and wheel)
- Gamepad — captures button presses and axis movements beyond a 0.5 deadzone (ignores stick drift)
Examples
Basic capture — listen for any keyboard or gamepad input:
const bindingManager = gameEngine.managers.bindingManager;
const input = await bindingManager.captureInput({
deviceTypes: [ 'keyboard', 'gamepad' ],
});
// input is a ready-to-use InputDefinition
bindingManager.unregisterBindings('jump', 'gameplay');
bindingManager.registerBinding({
type: 'trigger',
action: 'jump',
input,
context: 'gameplay',
options: { edgeMode: 'rising' },
});Capture with timeout and cancellation:
const controller = new AbortController();
// Cancel on Escape key
window.addEventListener('keydown', (e) =>
{
if(e.code === 'Escape')
{
controller.abort();
}
}, { once: true });
try
{
const input = await bindingManager.captureInput({
deviceTypes: [ 'keyboard', 'mouse', 'gamepad' ],
sourceTypes: [ 'key', 'button' ],
signal: AbortSignal.any([
controller.signal,
AbortSignal.timeout(10_000),
]),
});
// Use the captured input to update the binding
bindingManager.unregisterBindings('shoot', 'gameplay');
bindingManager.registerBinding({
type: 'trigger',
action: 'shoot',
input,
context: 'gameplay',
options: { edgeMode: 'rising' },
});
}
catch(err)
{
if(err instanceof DOMException && err.name === 'AbortError')
{
console.log('Capture cancelled');
}
}Contexts
Contexts group bindings so they can be activated and deactivated together. An exclusive context deactivates all other exclusive contexts when activated. A non-exclusive context can be active alongside any others.
bindingManager.registerContext('gameplay', true); // exclusive
bindingManager.registerContext('menu', true); // exclusive
bindingManager.registerContext('always', false); // non-exclusive
// Switching contexts
bindingManager.activateContext('gameplay');
bindingManager.activateContext('menu'); // auto-deactivates 'gameplay'Bindings without a context are always active, regardless of which contexts are enabled.
INFO
When a context is reactivated, all bindings in that context have their edge state reset. This prevents stale input state from a previous activation from swallowing the first input event.
Subscribing to Actions
Once actions and bindings are registered, subscribe to action events using engine.subscribeAction():
engine.subscribeAction('jump', (event) =>
{
console.log('Jump! Value:', event.payload.value);
console.log('Device:', event.payload.deviceId);
});The subscribeAction() method provides typed ActionPayload with value and deviceId properties.
Within behaviors, subscribe directly on the event bus:
this.entity.eventBus.subscribe('action:move_forward', (event) =>
{
const isActive = event.payload.value === true;
// handle movement...
});Complete Example: WASD Movement
Registering Actions and Bindings
const bindingManager = gameEngine.managers.bindingManager;
// Register movement actions
const actions = [
{ name: 'move_forward', type: 'digital' as const },
{ name: 'move_backward', type: 'digital' as const },
{ name: 'move_left', type: 'digital' as const },
{ name: 'move_right', type: 'digital' as const },
{ name: 'jump', type: 'digital' as const },
];
actions.forEach((action) => bindingManager.registerAction(action));
// Register context
bindingManager.registerContext('gameplay', true);
// Define WASD bindings
const bindings : BindingDefinition[] = [
{
type: 'trigger',
action: 'move_forward',
input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'KeyW' },
context: 'gameplay',
options: { edgeMode: 'both' },
},
{
type: 'trigger',
action: 'move_backward',
input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'KeyS' },
context: 'gameplay',
options: { edgeMode: 'both' },
},
{
type: 'trigger',
action: 'move_left',
input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'KeyA' },
context: 'gameplay',
options: { edgeMode: 'both' },
},
{
type: 'trigger',
action: 'move_right',
input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'KeyD' },
context: 'gameplay',
options: { edgeMode: 'both' },
},
{
type: 'trigger',
action: 'jump',
input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'Space' },
context: 'gameplay',
options: { edgeMode: 'rising' },
},
];
bindings.forEach((b) => bindingManager.registerBinding(b));
bindingManager.activateContext('gameplay');Handling Actions
engine.subscribeAction('move_forward', (event) =>
{
if(event.payload.value === true)
{
player.velocity.z = -player.speed;
}
else if(!player.isMovingBackward)
{
player.velocity.z = 0;
}
});Multiple Input Devices
Bind the same action to multiple devices:
// Keyboard
bindingManager.registerBinding({
type: 'trigger',
action: 'jump',
input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'Space' },
context: 'gameplay',
});
// Gamepad A button
bindingManager.registerBinding({
type: 'trigger',
action: 'jump',
input: { deviceID: 'gamepad-0', type: 'gamepad', sourceType: 'button', sourceKey: 'button-0' },
context: 'gameplay',
});Gamepad Analog Sticks
bindingManager.registerAction({ name: 'look_x', type: 'analog', minValue: -1, maxValue: 1 });
bindingManager.registerBinding({
type: 'value',
action: 'look_x',
input: { deviceID: 'gamepad-0', type: 'gamepad', sourceType: 'axis', sourceKey: 'axis-2' },
options: { deadzone: 0.15, emitOnChange: true },
});Entity Integration
Entity definitions can declare required actions:
const playerDef : GameEntityDefinition = {
type: 'player',
defaultState: { /* ... */ },
behaviors: [ PlayerMovementBehavior ],
actions: [
{ name: 'move_forward', type: 'digital' },
{ name: 'move_backward', type: 'digital' },
{ name: 'jump', type: 'digital' },
],
};Saving and Loading Bindings
Export and import binding configurations for user-customizable controls:
// Save
const config = bindingManager.exportConfiguration();
localStorage.setItem('controls', JSON.stringify(config));
// Load
const saved = localStorage.getItem('controls');
if(saved)
{
bindingManager.importConfiguration(JSON.parse(saved));
}The binding system is storage-agnostic -- it produces and consumes plain JSON-serializable objects. Use localStorage, IndexedDB, files, or a server API as needed.
