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 fromengine.devicesto callbacks. Not deprecated: it is the only way to bind a pointer gesture -tap,drag,dragStart,dragEnd,move,pinch- whichPointerDevicesynthesizes 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:
| Ordering | Scope | Arbitrates over | Use it for |
|---|---|---|---|
| Layers | within one map | action names | a user keymap rebinding an action the defaults also bind |
order + occlusion | between maps | physical switches | a 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.
InputOcclusion (.../ism/InputOcclusion.js):
| Value | Effect 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.
| Class | Factory | What 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).
| Channel | Components | Kind |
|---|---|---|
InputCoordinateDevice.fromGamepad(GamepadCoordinates.left_stick | right_stick | dpad) | 2 | Normalized, hardware frame (+x right, +y down) |
InputCoordinateDevice.fromPointer(PointerCoordinates.position) | 2 | Absolute, viewport pixels |
InputCoordinateDevice.fromPointer(PointerCoordinates.move) | 2 | Delta, latched per frame |
InputCoordinateDevice.fromPointer(PointerCoordinates.wheel) | 3 | Delta, latched per frame |
InputCoordinateSwitches.from(triggers) | triggers.length / 2 | 2 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:
| Field | Meaning |
|---|---|
action | the action name |
source | an InputSource - device ("keyboard", "pointer", "gamepad") and code. The switch whose edge completed the trigger; for a hold maturing, the hold’s first source. |
time | seconds since the system started, in simulation time |
value | Vector3, 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.
| Adapter | Device id | Notes |
|---|---|---|
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 choice | Wraps 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.
Cleanup on unlink
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>.downandkeyboard.keys.<name>.upareSignalinstances that fire when the key transitions.keyboard.keys.<name>.is_downis a live boolean. Key names matchKeyCodes(lowercase, underscored):w,space,shift,ctrl,up_arrow,f1,numpad_0, etc. - Any-key signals -
keyboard.on.downandkeyboard.on.upfire for every key transition and receive the rawKeyboardEvent.
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:
| Signal | Arguments | When |
|---|---|---|
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 byGamepad.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 (checkmain.connectedfor 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:InputTriggerGamepadButtonandInputCoordinateDevice.fromGamepadboth 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.
Related
- First-person controller - the intent surface that input bindings write.
- Control contexts - state-machine wrappers that group input entities and activate/deactivate them together.
- Picking - async, batched world picks from a ray.