Gameplay

Input devices

Meep's input layer - the InputMap binding system with typed triggers, layers and cross-map arbitration, the InputController gesture path, and the keyboard, pointer and gamepad devices underneath.

Meep exposes three input devices on engine.devices: keyboard, pointer, and gamepad. All three are started by the engine at boot. Two binding layers sit on top of them, and which one you want depends on what you are binding:

  • InputMap - the Input Map System. Typed triggers (keys, mouse buttons, gamepad buttons, and chords, sequences, holds and releases built out of them) and analog channels bind to action names, and an action fires as an ECS entity event. Keyboard and pointer-button bindings belong here.
  • InputController - string paths from engine.devices to callbacks. Not deprecated: it is the only way to bind a pointer gesture - tap, drag, dragStart, dragEnd, move, pinch - which PointerDevice synthesizes on top of its buttons and which the binding layer does not model.

Neither system is registered by default - add the one you use to the entity manager yourself. (EngineHarness.buildBasics is one exception: its orbital camera controller registers InputControllerSystem if nothing else has.) The Input / InputSystem / InputBinding trio is deprecated.

The Input Map System

An InputMap is a component holding bindings. A binding names an action; what the action does is an entity event listener on the owning entity. Nothing behavioural is stored in the map, which is the seam that lets bindings be data.

import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { InputMap } from "@woosh/meep-engine/src/engine/input/ecs/ism/map/InputMap.js";
import { InputMapSystem } from "@woosh/meep-engine/src/engine/input/ecs/ism/InputMapSystem.js";
import { KeyboardInputDeviceAdapter } from "@woosh/meep-engine/src/engine/input/ecs/ism/device/KeyboardInputDeviceAdapter.js";
import { PointerInputDeviceAdapter } from "@woosh/meep-engine/src/engine/input/ecs/ism/device/PointerInputDeviceAdapter.js";
import { GamepadInputDeviceAdapter } from "@woosh/meep-engine/src/engine/input/ecs/ism/device/GamepadInputDeviceAdapter.js";
import { InputTriggerKey } from "@woosh/meep-engine/src/engine/input/ecs/ism/trigger/InputTriggerKey.js";
import { InputTriggerChord } from "@woosh/meep-engine/src/engine/input/ecs/ism/trigger/InputTriggerChord.js";

await em.addSystem(new InputMapSystem([
    new KeyboardInputDeviceAdapter(engine.devices.keyboard),
    new PointerInputDeviceAdapter(engine.devices.pointer),
    new GamepadInputDeviceAdapter(engine.devices.gamepad)
]));

const map = new InputMap();

map.bind("jump", InputTriggerKey.from("space"));
map.bind("undo", InputTriggerChord.from(InputTriggerKey.from("ctrl"), InputTriggerKey.from("z")));

const entity = new Entity().add(map).build(ecd);

ecd.addEntityEventListener(entity, "jump", () => player.jump());
ecd.addEntityEventListener(entity, "undo", () => editor.actions.undo());

bind(action, ...triggers) takes several triggers for one action - any of them firing fires the action. unbind(action) removes it. Lifetime is entity lifetime: bindings go live when the component links and stop when it unlinks, so a mode that owns an entity needs no enable/disable bookkeeping. Structural edits are picked up automatically - the system recompiles a map whose bindings or layers changed, including edits made through a layer handle you kept.

One InputMap instance backs one entity. Sharing an instance across two entities is asserted against: the component holds a single compiled program, so the second link would hijack it.

InputMap.serializable is false in 3.21.0. The binding format is designed to serialize and the component is shaped for it, but the adapter is not built yet - build maps in code.

Two orderings, and they are different

Conflating these is what makes input systems muddy, so the map keeps them apart:

OrderingScopeArbitrates overUse it for
Layerswithin one mapaction namesa user keymap rebinding an action the defaults also bind
order + occlusionbetween mapsphysical switchesa modal dialog taking a key away from the scene underneath

Layers cascade by action name

map.layers is an array; index 0 is the base, higher indices are higher in the cascade. bind, bindCoordinate and unbind on the map itself operate on the base layer; map.layer(i), map.pushLayer() and map.popLayer() reach the rest.

The cascade works exactly like CSS layers: the topmost layer that binds an action supplies all of that action’s triggers, and lower layers’ entries for it are inert. Layers carry no name - a stack is addressed by index, so there is nothing to look up and nothing to mistype.

map.bind("fire", InputTriggerMouseButton.from(MouseButtons.left));   // defaults, layer 0

const userKeymap = map.pushLayer();                                   // layer 1

userKeymap.bind("fire", InputTriggerKey.from("f"));                   // replaces, does not add
userKeymap.unbind("crouch");                                          // masks the default entirely

Re-binding an action already bound in the same layer replaces its triggers - one entry per action per layer is what makes the cascade well defined. unbind in a higher layer leaves an empty entry behind on purpose: the cascade stops at the topmost layer that mentions the action, and that layer supplies no triggers. An action cannot be a trigger action and a coordinate action in the same layer.

order and occlusion arbitrate between maps

Every linked map is evaluated in descending order (higher first), ties going to the oldest-linked. A trigger that fires consumes the switches it matched, so a map’s own bindings arbitrate by specificity - more sequence steps first, then wider chords, then declaration order - and Ctrl+Z beats a bare Z. occlusion decides whether that consumption is still standing when the next map down is walked.

order 10, first refusalswitches it did not consumedispatchswitch_edgedialog_mapscene_mapentity_event

InputOcclusion (.../ism/InputOcclusion.js):

ValueEffect on maps below
Pass (0)Sees everything, blocks nothing. HUD overlays, debug readouts, recorders.
BlockMatched (1)Default. Switches consumed by this map’s fired triggers are invisible below, for that edge only. A lower map’s isActive and coordinate reads are untouched - a dialog binding Escape stops the scene’s Escape while the camera keeps panning behind it.
BlockAll (2)Every map below is suppressed outright: no dispatch, isActive reads false, coordinate reads return zero, sequences do not advance and holds do not run. The modal hammer - this is the one that stops a pause menu leaving the world drifting.

BlockAll suppression is static, recomputed when the linked map set, any order or any occlusion changes. Coming back out from under it resets every trigger against current reality, so a half-matched sequence cannot complete on one further step.

Triggers

All named exports under @woosh/meep-engine/src/engine/input/ecs/ism/trigger/. They compose: a chord’s children are triggers, not sources, so a chord of a hold, or a chord spanning a keyboard and a mouse, needs no special case.

ClassFactoryWhat it is
InputTriggerKey.from(key)True while a keyboard key is held. key is a KeyCodes name or value; a typo asserts where it is written instead of never firing.
InputTriggerMouseButton.from(button)True while a pointer button is held. button is a MouseButtons name or value. Not a gesture.
InputTriggerGamepadButton.from(button)True while a gamepad button is held. Binds against gamepad.main, so it resolves before any pad is plugged in and survives a hot-swap.
InputTriggerChord.from(...children)AND. True while every child is true.
InputTriggerSequence.from(...steps)True on the single evaluation point at which its steps complete in order.
InputTriggerHold.from(child, duration)True once child has been continuously true for duration seconds.
InputTriggerRelease.from(child)NOT. True while child is not - this is how “on release” is expressed.
InputTriggerSwitch, InputTrigger-Base classes.
map.bind("interact", InputTriggerHold.from(InputTriggerKey.from("e"), 0.5));
map.bind("charge_release", InputTriggerRelease.from(InputTriggerKey.from("space")));
map.bind("konami_start", InputTriggerSequence
    .from(InputTriggerKey.from("up_arrow"), InputTriggerKey.from("up_arrow"))
    .withStepTimeout(0.25));

A sequence advances on a step’s rising edge, not on its satisfaction, so one long press of A cannot satisfy both steps of [A, A]. Its window is measured from when the previous step stopped being true - which is what makes a charge input work - and defaults to INPUT_SEQUENCE_DEFAULT_STEP_TIMEOUT, 1 second; withStepTimeout(seconds) tightens it (0.15-0.25 for fighting-game motions). A mismatch never resets a sequence; only the window does.

Two things worth knowing before you reach for them:

  • A hold’s maturity is announced by no device edge, so it is detected on the tick that crosses the duration. Its latency is bounded by the frame.
  • Do not wrap a sequence in InputTriggerRelease. A sequence’s test is a single-edge pulse, so negating it is true almost always and rises again right after each completion - an echo of the fire, not “the sequence stopped matching”.

Analog channels

map.bindCoordinate(action, ...channels) binds an N-dimensional analog value rather than an edge. Read it with map.axis1(vector1, action), map.axis2(vector2, action), map.axis3(vector3, action), or the raw map.coordinate(array, offset, action).

import { InputCoordinateDevice } from "@woosh/meep-engine/src/engine/input/ecs/ism/coordinate/InputCoordinateDevice.js";
import { InputCoordinateSwitches } from "@woosh/meep-engine/src/engine/input/ecs/ism/coordinate/InputCoordinateSwitches.js";
import { GamepadCoordinates } from "@woosh/meep-engine/src/engine/input/devices/gamepad/GamepadCoordinates.js";
import { PointerCoordinates } from "@woosh/meep-engine/src/engine/input/devices/PointerCoordinates.js";

// Stick first, WASD as the fallback - one action, two devices.
map.bindCoordinate("move",
    InputCoordinateDevice.fromGamepad(GamepadCoordinates.left_stick),
    InputCoordinateSwitches.from([
        InputTriggerKey.from("a"), InputTriggerKey.from("d"),   // x: negative, positive
        InputTriggerKey.from("w"), InputTriggerKey.from("s")    // y: negative, positive
    ])
);

map.bindCoordinate("look", InputCoordinateDevice.fromPointer(PointerCoordinates.move));

// ... in a system, once per frame
map.axis2(move, "move");
map.axis2(look, "look");

Several channels on one action resolve by priority: the first with any non-zero component wins, and the last is the fallback. That is what a consumer hand-writes when fusing a stick with WASD, and it avoids the arithmetic nonsense of summing a keyboard’s 1 with a stick’s 0.3. Every channel bound to one action must agree on dimensionality (asserted).

ChannelComponentsKind
InputCoordinateDevice.fromGamepad(GamepadCoordinates.left_stick | right_stick | dpad)2Normalized, hardware frame (+x right, +y down)
InputCoordinateDevice.fromPointer(PointerCoordinates.position)2Absolute, viewport pixels
InputCoordinateDevice.fromPointer(PointerCoordinates.move)2Delta, latched per frame
InputCoordinateDevice.fromPointer(PointerCoordinates.wheel)3Delta, latched per frame
InputCoordinateSwitches.from(triggers)triggers.length / 22 triggers per axis, negative then positive

Relative channels latch: the adapter sums arriving events into an accumulator and publishes it once per frame, so every reader in a frame sees the same number regardless of system order, and events arriving mid-frame belong to the next one. Wheel components are the sign of each delta, so accumulating that channel counts ticks rather than measuring scroll distance. Both directions of a switch axis held reads zero for that axis.

Channels report raw composed values. Dead zones, response curves and sensitivity are gameplay policy and stay at the call site - see apply_radial_dead_zone. Hardware error has already been corrected by the device.

Dispatch, timing and lifetime

Dispatch is eager: an edge is evaluated and dispatched inside the device signal that produced it. There is no input buffer and no added frame of latency - and the consequence is that your handler runs inside a DOM event handler, so keep it short and do not assume you are on the tick.

InputMapSystem.update(dt) dispatches no edge-driven action. It services only what changes without an edge: hold maturity, sequence windows, delta-coordinate latching and reset, and suppression recomputation. Time is advanced there and nowhere else, so every window in the system is simulation time - holds do not mature while the game is paused, and they stretch under slow motion.

A handler receives one InputActionEvent:

FieldMeaning
actionthe action name
sourcean InputSource - device ("keyboard", "pointer", "gamepad") and code. The switch whose edge completed the trigger; for a hold maturing, the hold’s first source.
timeseconds since the system started, in simulation time
valueVector3, reserved for the driving coordinate; zero for a trigger with no analog source

One instance is reused and mutated in place. Reading it inline is correct; retaining it is not - copy anything you need to keep.

map.isActive(action) polls the level instead. It reads false for an unlinked map and for a suppressed one. A sequence has no meaningful level - its trigger is true for a single evaluation point - so bind a listener rather than polling one.

Device adapters

An adapter presents a concrete device to the binding layer as switches and coordinates. The contract that matters is totality: an adapter answers for every code it declares whether or not hardware is present, and absent hardware reads as up and as zero - never undefined, never a throw. That is what lets a gamepad binding resolve on a machine with no gamepad.

AdapterDevice idNotes
KeyboardInputDeviceAdapter(keyboard)"keyboard"Codes are KeyCodes.
PointerInputDeviceAdapter(pointer)"pointer"Buttons are MouseButtons (MouseEvent.button ordinals). Gestures are deliberately absent.
GamepadInputDeviceAdapter(gamepad)"gamepad"Presents gamepad.main. Codes are GamepadButtons.
ManualInputDeviceAdapter(id, device)your choiceWraps a ManualInputDevice.
InputDeviceAdapter-Base class.

The id constants are exported as KEYBOARD_INPUT_DEVICE_ID, POINTER_INPUT_DEVICE_ID and GAMEPAD_INPUT_DEVICE_ID from the same directory. system.registerDevice(adapter) may be called after maps are linked; every linked map is recompiled, so bindings on a device that was missing become live.

ManualInputDeviceAdapter takes its id as a constructor argument, which is the point: two manual devices registered as "keyboard" and "gamepad" let a cross-device chord be driven, recorded or replayed with no hardware and no DOM.

import { ManualInputDevice } from "@woosh/meep-engine/src/engine/input/devices/ManualInputDevice.js";
import { ManualInputDeviceAdapter } from "@woosh/meep-engine/src/engine/input/ecs/ism/device/ManualInputDeviceAdapter.js";
import { KEYBOARD_INPUT_DEVICE_ID } from "@woosh/meep-engine/src/engine/input/ecs/ism/device/KEYBOARD_INPUT_DEVICE_ID.js";

const device = new ManualInputDevice();
const adapter = new ManualInputDeviceAdapter(KEYBOARD_INPUT_DEVICE_ID, device);

One keyboard-specific consequence worth knowing: KeyboardDevice suppresses a browser default only for a key whose down signal has handlers, so browser suppression follows the bound key set automatically. It is per key, not per chord - binding only Ctrl+S also suppresses a bare S.

InputController - pointer gestures

InputController connects binding-path strings to callback functions. When the component links, the system wires each binding to the corresponding signal; when it unlinks, the wiring is removed.

It is not deprecated, and it is the right tool for exactly one thing: pointer gestures. A tap is not a button press - it fires on release, and only if the pointer moved under 10 px and was held under a second - and drag, dragStart, dragEnd, move and pinch are the same kind of stateful construction. The binding layer models buttons, not gestures. Keyboard keys and pointer buttons belong in an InputMap.

import InputController from "@woosh/meep-engine/src/engine/input/ecs/components/InputController.js";
import InputControllerSystem from "@woosh/meep-engine/src/engine/input/ecs/systems/InputControllerSystem.js";

// Register the system once (it needs access to engine.devices).
await em.addSystem(new InputControllerSystem(engine.devices));

const ic = new InputController();

ic.bind("pointer/on/tap", (position) => selectAt(position));
ic.bind("pointer/on/drag", (position, origin, lastPosition) => panBy(position, lastPosition));

new Entity().add(ic).build(ecd);

Both are default exports. Bindings can also be supplied at construction time:

const ic = new InputController([
    { path: "pointer/on/dragStart", listener: (origin) => beginBoxSelect(origin) },
    { path: "pointer/on/dragEnd",   listener: (position) => endBoxSelect(position) },
]);

ic.bind(path, listener) returns the InputBinding so it can be referenced later.

Binding-path syntax

A path is a /-delimited property walk from engine.devices to the Signal to subscribe to. The system calls signal.add(listener) when the component links and signal.remove(listener) when it unlinks.

pointer/on/tap
pointer/on/drag
pointer/on/dragStart
pointer/on/dragEnd
pointer/on/move
pointer/on/wheel
pointer/on/pinch
pointer/on/pinchStart
pointer/on/pinchEnd
pointer/on/down
pointer/on/up
pointer/on/globalUp

Paths to keyboard and gamepad signals (keyboard/keys/<name>/down, gamepad/main/buttons/<index>/down, and so on) resolve, but prefer an InputMap for those: a string path is resolved with resolvePath, which throws when it cannot be resolved, and a path names exactly one signal, so no combination of inputs is expressible.

InputController exposes an on.unlinked signal that fires when the entity is destroyed (or the component is removed). Use it to zero out any state the bindings accumulated:

ic.on.unlinked.add(() => {
    intent.move.set(0, 0);
    dragging = false;
});

InputControllerSystem also carries an enabled ObservedValue; setting it false holds every binding out of the device signals without unlinking any component. It is one of only two systems in the engine that define enabled - it is not part of the System contract.

Devices

KeyboardDevice

engine.devices.keyboard listens for native DOM key events on the view stack’s element (engine.viewStack.el), which carries tabindex because only a focused element receives keyboard events. Engine focuses it when the view stack links, and again on any pointerdown inside it that did not land on a focusable control of your own - an input, a button, a link - so clicking a DOM overlay and clicking back restores the keyboard. When the element loses focus (blur, focusout, window blur), every held key is forcibly released so keys can’t get stuck across focus changes. Key-repeat events are suppressed: each press fires exactly one down and one up.

Two access patterns are available:

  • Per-key signals - keyboard.keys.<name>.down and keyboard.keys.<name>.up are Signal instances that fire when the key transitions. keyboard.keys.<name>.is_down is a live boolean. Key names match KeyCodes (lowercase, underscored): w, space, shift, ctrl, up_arrow, f1, numpad_0, etc.
  • Any-key signals - keyboard.on.down and keyboard.on.up fire for every key transition and receive the raw KeyboardEvent.

Every member of KeyCodes is materialized as a switch in the constructor, so every key resolves before anything is pressed.

PointerDevice

engine.devices.pointer unifies mouse and touch into one interface. It listens for Pointer events (pointerdown, pointermove) on the view stack’s element, not mousedown/mousemove - a synthesized MouseEvent reports nothing; pointer-up is also captured on window so drag releases register even if the cursor leaves the element.

The engine’s view-stack root uses pointer-events: none so DOM UI can overlay the game. The render viewport opts back in with auto; GraphicsEngine.adopt_canvas preserves that inline setting when the renderer mounts a canvas, including on restart. Events on the canvas bubble to the view-stack listener. Custom overlays still need appropriate pointer-event styling so they do not intercept game input.

PointerDevice exposes:

SignalArgumentsWhen
on.down(position: Vector2, event: PointerEvent)pointer pressed
on.up(position: Vector2, event: PointerEvent)pointer released over the element
on.globalUp(position: Vector2, event)a release anywhere on the page. on.up only fires for a release over the element, so a gesture that began on it and ended off it is never closed by that one
on.move(position: Vector2, event: PointerEvent, delta: Vector2)pointer moved. delta is the pointer-lock movement, from movementX/movementY - what a first-person camera reads
on.tap(position: Vector2, event: PointerEvent)short press, no significant movement
on.dragStart(origin: Vector2, event: PointerEvent)drag begins
on.drag(position, origin, lastPosition, event)drag in progress
on.dragEnd(position: Vector2)drag released
on.wheel(delta: Vector3, position: Vector2, event: WheelEvent)scroll; delta components are ±1 (sign only, normalized across browsers)
on.pinchStart(extents: Vector2)a second touch lands; extents is the mean half-size of the touch box
on.pinch(extents: Vector2, startExtents: Vector2)pinch in progress; compare the two for a scale factor
on.pinchEnd-fewer than two touches remain

pointer.position is the current pointer position as a live Vector2. The buttons array (32 slots) holds one InputDeviceSwitch per mouse button, indexed by MouseEvent.button ordinals - the same order MouseButtons names: left is 0, middle is 1, right is 2, then back and forward. Convenience accessors mouseButtonLeft, mouseButtonMiddle, and mouseButtonRight alias the same switches.

GamepadDevice

engine.devices.gamepad sits on top of the browser’s Gamepad API. That API is poll-only - it fires no events for button or axis changes - so the device polls once per animation frame while running and turns the snapshots into the same signal-and-switch surface the other devices expose. poll() can also be called by hand from a fixed-rate loop. On disconnect every held button is released with its normal signals, so nothing gets stuck.

The device reports what the hardware reports, corrected for what the hardware gets wrong. Dead zones, response curves, and which world direction a stick means are game policy and stay in your code.

  • pads - known pads indexed by Gamepad.index, possibly sparse. Handles are retained across disconnects and reused on reconnect, so they’re safe to hold.
  • main - the active pad, for the common single-player case. It always exists (check main.connected for hardware), binds to the first pad that connects, and rebinds to the oldest still-connected pad when that one is lost. It’s a separate handle fed from the same snapshot, which makes it a stable binding target: InputTriggerGamepadButton and InputCoordinateDevice.fromGamepad both go through it, so a gamepad binding resolves before any hardware exists and never goes stale.
  • on.connected / on.disconnected - (pad). on.down / on.up - (buttonIndex, pad). on.axis - (axisIndex, value, pad).

Each GamepadHandle carries buttons (an InputDeviceSwitch per index - every slot of the standard mapping is materialized up front, so reads need no guard), buttonValues (analog [0,1], for triggers), axes (raw, [-1,1], indexed by GamepadAxes), and stickLeft / stickRight.

A GamepadStick exposes raw and value. Read value: it is raw with the stick’s learned rest position removed, reading exactly neutral inside the learned noise radius - a real fix for drifting sticks that a fixed per-axis gate guesses at. Calibration describes one physical stick, so it is dropped when main moves to a different pad.

value is otherwise unshaped - no dead zone, no curve. Shape it at the call site with apply_radial_dead_zone, whose circular boundary is isotropic (a per-axis gate lets through up to inner * sqrt(2) on the diagonal) and which rescales the remainder over the full [0,1] range, so speed ramps from zero instead of popping as the stick leaves the dead zone:

import { apply_radial_dead_zone }
    from "@woosh/meep-engine/src/engine/input/analog/apply_radial_dead_zone.js";
import { gamepad_write_dpad_vector }
    from "@woosh/meep-engine/src/engine/input/devices/gamepad/gamepad_write_dpad_vector.js";
import { GamepadButtons }
    from "@woosh/meep-engine/src/engine/input/devices/gamepad/GamepadButtons.js";
import Vector2 from "@woosh/meep-engine/src/core/geom/Vector2.js";

const stick = new Vector2();
const dpad = new Vector2();

const pad = engine.devices.gamepad.main;

if (pad.connected) {
    const jump = pad.buttons[GamepadButtons.a].is_down;

    apply_radial_dead_zone(stick, pad.stickLeft.value.x, pad.stickLeft.value.y, 0.15, 0.95);
    gamepad_write_dpad_vector(dpad, pad);   // digital view of the D-pad
}

An InputMap does the stick-or-D-pad fusion for you - bind both as channels on one coordinate action and the priority rule picks - but the dead zone stays yours either way.

GamepadButtons names the standard-mapping indices after the Xbox layout (a = bottom, b = right, x = left, y = top, plus bumpers, triggers, back / start / home, stick clicks, and the four dpad_* entries).

InputDeviceSwitch

Each pressable control - a keyboard key, a mouse button, a gamepad button - is represented by an InputDeviceSwitch:

const sw = engine.devices.keyboard.keys.space;
sw.is_down    // boolean: currently held?
sw.is_up      // boolean: inverse of is_down
sw.down       // Signal - fires on press
sw.up         // Signal - fires on release

Locational interaction

Every pointer signal carries a Vector2 position as its first argument, and PointerCoordinates.position reports the same thing as a channel. The position is in canvas-local pixel coordinates - relative to the element’s bounding rect, matching the device’s position property.

For world interaction (hit-testing, picking), convert the position to clip space and project a ray:

engine.devices.pointer.on.down.add((position) => {
    engine.graphics.normalizeViewportPoint(position, ndc);
    engine.graphics.viewportProjectionRay(ndc.x, ndc.y, raySource, rayDir);
    // ... raycast into the physics world, or hand the ray to PickingSystem
});

normalizeViewportPoint writes −1…1 with +Y up; viewportProjectionRay writes a world-space origin and a unit direction. For drag, pointer.position provides the live position between events.

The tap signal fires only when pointer travel between down and up is below 10 pixels and elapsed time is below 1 second, making it a reliable click/tap detector across mouse and touch.

Programmatic cursor control

Meep doesn’t provide an abstraction for cursor type - set it directly on the canvas element from the game loop or from pointer event handlers:

const canvas = engine.graphics.domElement;
canvas.style.cursor = "grab";       // during hover
canvas.style.cursor = "grabbing";   // during drag
canvas.style.cursor = "";           // default

For pointer lock (mouse-look), call the browser API on the canvas element:

canvas.requestPointerLock();        // capture - hides cursor, provides raw deltas
document.exitPointerLock();         // release

Check document.pointerLockElement === canvas before consuming look deltas, whether you read them from pointer/on/move or from the move coordinate channel.

Deprecated: Input, InputSystem, InputBinding

Input (.../input/ecs/components/Input.js) binds a device signal path to an entity event name, and InputSystem (.../input/ecs/systems/InputSystem.js) drives it. Both are marked @deprecated in favour of InputMap and InputMapSystem; they export and work, because an unreferenced export is still public surface. InputBinding at .../input/ecs/ism/InputBinding.js is Input’s record type and is deprecated with it - the binding layer’s own record type is .../ism/map/InputBinding.js.

Two defects, beyond the string paths themselves, are why the binding layer is the one to use:

  • A path is resolved with resolvePath, which throws when it cannot be resolved - so a gamepad binding cannot link until the player has plugged in and pressed something. An adapter’s totality rule fixes exactly this.
  • A binding names exactly one signal, so no combination of inputs is expressible. Chords, sequences, holds and releases have nowhere to live.