Input and rebinding
@irtio/input turns keyboard, mouse, controller and on-screen controls into named actions and
axes, and gives you a rebinding panel to mount wherever your settings live. It is optional: rooms,
clients and schemas do not know about it, and it does not need a room, a renderer or a UI
framework. Install it when you want rebindable controls without writing the device bookkeeping.
npm install @irtio/input Read actions, not keys
import { createInputs, gamepadAxis, gamepadButton, key, keyAxis, mouseButton } from '@irtio/input';
const inputs = createInputs({
target: canvas, // give it tabindex="0"; input is read only while it has focus
actions: {
fire: [mouseButton(0), key('Space'), gamepadButton(7)],
jump: [key('KeyW'), gamepadButton(0)],
},
axes: {
moveX: [keyAxis('KeyA', 'KeyD'), gamepadAxis(0)],
moveY: [keyAxis('KeyW', 'KeyS'), gamepadAxis(1, { invert: true })],
},
});
function frame(dt: number) {
inputs.update();
if (inputs.wasPressed.fire) fire();
move(inputs.axis.moveX, inputs.axis.moveY, dt);
} The action and axis names come from the config object, so inputs.wasPressed.sprint is a type
error until sprint exists. Bindings are plain data, not callbacks, which is what makes them
serializable and editable.
Keys are bound by physical KeyboardEvent.code, so KeyA is the key to the right of the shift
key on every layout. Labels say “(physical key)” rather than claiming to know what is printed on
the keycap.
One update, one snapshot
update() polls devices, processes the transitions that arrived since the last call in order, and
publishes one snapshot. Reading a flag does not consume it, so every system in the cycle sees the
same values.
| Lookup | Meaning |
|---|---|
wasPressed[action] | The action became active at least once since the last update |
wasReleased[action] | The action became inactive at least once since the last update |
wasCanceled[action] | The release was involuntary, not the player letting go |
isDown[action] | The action is active now |
axis[name] | Current value, clamped to [-1, 1] |
pointerDelta | { x, y } movement accumulated since the last update |
Because transitions are processed in order rather than compared end to end, a press and release
that both happen between two updates sets both edges while isDown stays false. Several complete
taps in one update coalesce into one press and one release. Keyboard auto-repeat is the same press
continuing, so it produces no new press.
An action is down while any bound control is down. Pressing a second control while the first is held produces no new press, and releasing one while the other is held produces no release.
Cancellation and suspension
Losing focus, hiding the tab, suspending input, unplugging a controller, and rebinding a control
that is currently held all release whatever that control was holding. Those releases set wasReleased like any other, and additionally set wasCanceled. A release the player performed
never sets wasCanceled.
Guard anything that happens on release:
inputs.update();
if (inputs.wasPressed.fire) charge = 0;
if (inputs.wasReleased.fire) {
// A blur, a pause menu or a rebind is not the player letting go of the trigger.
if (!inputs.wasCanceled.fire) fireCharged(charge);
charge = 0;
} suspend() returns a release function. Nested suspensions compose, so closing one menu while
another is open does not resume gameplay, and calling a release twice does nothing:
const resume = inputs.suspend();
// ... menu is open; axes read zero and no action can activate
resume(); A control that was held when input was suspended has to return to neutral before it counts again.
A movement key held through a pause menu has to be released and pressed again after the menu
closes. This is deliberate, and the panel’s help text says so, because the alternative is a player
who takes a step the moment a menu closes. If your game pauses on blur, run one update() before
resuming so the cancellation is observed.
Analog values
- In
keyAxis(a, b)the first key drives-1and the second+1, matching a standard gamepad where+1is right on horizontal axes and down on vertical axes before inversion. - Opposing keys in one pair cancel to zero.
- When several bindings drive one axis, the greatest absolute magnitude wins, and equal magnitudes are broken by declaration order.
gamepadAxis(index, { invert, deadZone })rescales the remaining range after the dead zone, so the first movement past it starts from zero rather than jumping.gamepadButton(index, { press, release })gives analog triggers hysteresis: a trigger resting between the two thresholds keeps the state it already had.- Gamepads are polled inside
update(), so a press and release that both happen between two polls cannot be recovered. Nonstandard controllers need explicit numeric bindings and get generic labels.
Gameplay polling happens only inside update(). The package runs no background loop of its own.
Fixed-step simulation
Call update() once per input-processing cycle, never once per system. For a fixed-step
simulation, sample continuous intent per tick and turn one-shot presses into commands:
function frame(now: number) {
inputs.update();
// Continuous intent: whichever ticks run next read the latest value.
intent = { x: inputs.axis.moveX, y: inputs.axis.moveY };
// One-shot intent: queued once, consumed on exactly one tick.
if (inputs.wasPressed.fire) commands.push({ type: 'fire' });
accumulator += now - previous;
previous = now;
while (accumulator >= TICK_MS) {
accumulator -= TICK_MS;
tick(intent, commands.splice(0, commands.length));
}
} Sampling edges once per simulation tick instead drops presses on frames that run no tick and replays them on catch-up frames. The package never writes to a room; sending the result is your code, the same as any other input.
examples/input-demo is this pattern end to end, with touch sources and the panel mounted in a
settings drawer.
Virtual sources
createSource() returns a disposable device you drive yourself. Use it for on-screen touch
controls, accessibility controls, and deterministic tests. Virtual controls go through the same
aggregation, cancellation and neutral-before-reactivate rules as real hardware:
const touch = inputs.createSource('touch');
fireButton.addEventListener('pointerdown', () => touch.button('fire', true));
fireButton.addEventListener('pointerup', () => touch.button('fire', false));
stick.addEventListener('input', () => touch.axis('x', Number(stick.value))); Bind them with virtualButton('touch', 'fire') and virtualAxis('touch', 'x'). In Node there are
no device adapters at all, so an instance driven only by virtual sources is fully deterministic:
const inputs = createInputs({ devices: [], actions: { fire: [virtualButton('test', 'fire')] } });
const source = inputs.createSource('test');
source.button('fire', true);
inputs.update();
// inputs.wasPressed.fire === true Inject controller state the same way with gamepadDevice({ getGamepads }).
The rebinding panel
mountBindings(element, options) appends one owned element inside the element you give it and
leaves your existing children alone. Several panels can observe one input instance and stay in
step. Disposing a panel removes only its own DOM, listeners, subscription and suspension token; it
does not dispose the inputs.
import { mountBindings } from '@irtio/input/ui';
import '@irtio/input/ui.css'; // optional
const panel = mountBindings(settingsElement, {
inputs,
labels: {
actions: { fire: 'Fire', jump: 'Jump' },
axes: { moveX: 'Move horizontally', moveY: 'Move vertically' },
},
});
// On teardown:
panel.dispose();
inputs.dispose(); The panel lists actions and axes with their current bindings, Add, Replace, Remove, a per-row
Reset and a Reset all. Key-axis bindings expose their negative and positive keys separately.
Controller axes expose axis selection and inversion. Labels you do not supply fall back to the
identifier, and every string in the panel is overridable through strings for localization.
Capture works like this:
- Choosing Add or Replace takes a gameplay suspension and asks for a control.
- The event that opened capture is ignored, and anything already held has to be released first.
- Capture runs on its own polling loop, so a paused game loop can still capture and apply.
- Escape and the Cancel button both cancel, and clicking Cancel does not bind that click. Escape itself can still be bound from the manual list.
- Axis capture needs a deliberate push past halfway after neutral, so resting stick drift is not captured.
- The proposed binding is shown with any conflicts. Conflicts need an explicit Keep both or Move binding; nothing is taken from another action silently. A key that matches one side of a key-axis pair counts as a conflict, because it is the same physical control.
The panel warns, without blocking, when a captured control is one the page cannot keep to itself, such as Escape while pointer lock is active or a key the browser handles. Defaults are only prevented for controls that are bound and not reserved.
Everything is native buttons, labels and selects with an aria-live status region, and a manual
selector so rebinding never requires being able to press the control. Game-supplied labels are
inserted as text, never as markup.
The optional stylesheet at @irtio/input/ui.css scopes every selector to the panel’s own class
and exposes CSS variables for colors, spacing and fonts. Without it the panel is unstyled native
controls that your own CSS can take over.
Saving bindings
The package never writes to storage. Subscribe to changes and save under your own versioned key:
inputs.onChange(() => {
localStorage.setItem('mygame/bindings/v1', JSON.stringify(inputs.exportBindings()));
});
const saved = localStorage.getItem('mygame/bindings/v1');
if (saved) {
const result = inputs.importBindings(JSON.parse(saved));
if (!result.ok) console.warn('saved controls were malformed; keeping defaults', result.errors);
else if (result.obsoleteActions.length > 0) console.info('unused saved names', result);
} importBindings validates the whole payload before it changes anything, so a malformed save
leaves the current bindings exactly as they were. Actions the save does not mention keep their
defaults, which is what makes a control added in a later build show up for existing players, and
names the game no longer has come back in obsoleteActions and obsoleteAxes instead of
disappearing quietly. Only binding overrides are stored, never device objects or pressed state.