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.

LookupMeaning
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 -1 and the second +1, matching a standard gamepad where +1 is 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:

  1. Choosing Add or Replace takes a gameplay suspension and asks for a control.
  2. The event that opened capture is ignored, and anything already held has to be released first.
  3. Capture runs on its own polling loop, so a paused game loop can still capture and apply.
  4. 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.
  5. Axis capture needs a deliberate push past halfway after neutral, so resting stick drift is not captured.
  6. 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.