Input devices
Meep's unified input layer — keyboard and pointer devices, the InputController component, binding-path syntax, and locational interaction.
Meep exposes two input devices on engine.devices: keyboard and pointer. Both
are started by the engine at boot. User code almost never interacts with them
directly — binding paths bridge the gap instead.
Devices
KeyboardDevice
engine.devices.keyboard listens for native DOM key events on the engine’s canvas
element. 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.
PointerDevice
engine.devices.pointer unifies mouse and touch into one interface. It fires
against the engine canvas element; pointer-up is also captured on window so drag
releases register even if the cursor leaves the element.
PointerDevice exposes:
| Signal | Arguments | When |
|---|---|---|
on.down | (position: Vector2, event: PointerEvent) | pointer pressed |
on.up | (position: Vector2, event: PointerEvent) | pointer released |
on.move | (position: Vector2, event: PointerEvent, delta: Vector2) | pointer moved |
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) |
pointer.position is the current pointer position as a live Vector2. The
buttons array (32 slots) holds one InputDeviceSwitch per mouse button: index 0
is left, 1 is middle, 2 is right. Convenience accessors mouseButtonLeft,
mouseButtonMiddle, and mouseButtonRight alias the same switches.
InputDeviceSwitch
Each pressable control — a keyboard key or a mouse 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
InputController — event-driven bindings
InputController is an ECS component that connects binding-path strings to
callback functions. When the component is linked to an entity the system wires each
binding to the corresponding signal; when the component is unlinked the wiring is
removed automatically.
Creating and attaching
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));
// Create the component.
const ic = new InputController();
// Add it to an entity.
new Entity().add(ic).build(ecd);
Bindings can be supplied at construction time:
const ic = new InputController([
{ path: "keyboard/keys/w/down", listener: () => { forward = true; } },
{ path: "keyboard/keys/w/up", listener: () => { forward = false; } },
]);
Or added imperatively with ic.bind(path, listener):
ic.bind("keyboard/keys/w/down", () => { forward = true; });
ic.bind("keyboard/keys/w/up", () => { forward = false; });
ic.bind returns the InputBinding so it can be referenced later if needed.
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.
Keyboard — key transitions:
keyboard/keys/<name>/down — fires when the key is pressed
keyboard/keys/<name>/up — fires when the key is released
<name> is any entry from KeyCodes: w, a, s, d, space, shift,
ctrl, enter, escape, up_arrow, down_arrow, left_arrow, right_arrow,
f1…f12, numpad_0…numpad_9, etc.
Pointer:
pointer/on/down
pointer/on/up
pointer/on/move
pointer/on/tap
pointer/on/drag
pointer/on/dragStart
pointer/on/dragEnd
pointer/on/wheel
Real examples from example sources
From examples-src/first-person/src/main.js — WASD movement and mouse-look:
// Key transitions — down sets the flag, up clears it.
ic.bind("keyboard/keys/w/down", () => { held.w = true; recomputeMove(); });
ic.bind("keyboard/keys/w/up", () => { held.w = false; recomputeMove(); });
ic.bind("keyboard/keys/space/down", () => { intent.jump = true; });
ic.bind("keyboard/keys/space/up", () => { intent.jump = false; });
ic.bind("keyboard/keys/shift/down", () => { intent.sprint = true; });
ic.bind("keyboard/keys/shift/up", () => { intent.sprint = false; });
// Pointer move — delta.x / delta.y are raw pixel deltas (movementX / movementY).
ic.bind("pointer/on/move", (position, event, delta) => {
if (document.pointerLockElement === null) return;
intent.look._add(delta.x * SENSITIVITY, delta.y * SENSITIVITY);
});
From examples-src/raycast-vehicle/src/main.js — constructed from an array:
const KEY_MAP = [
["w", "forward"], ["up_arrow", "forward"],
["s", "back"], ["down_arrow", "back"],
["a", "left"], ["left_arrow", "left"],
["d", "right"], ["right_arrow", "right"],
];
const bindings = [];
for (const [key, action] of KEY_MAP) {
bindings.push({ path: `keyboard/keys/${key}/down`, listener: () => { input[action] = true; } });
bindings.push({ path: `keyboard/keys/${key}/up`, listener: () => { input[action] = false; } });
}
new Entity().add(new InputController(bindings)).build(ecd);
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);
intent.jump = false;
intent.sprint = false;
intent.crouch = false;
});
Locational interaction
Every pointer signal carries a Vector2 position as its first argument. 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 normalized device coordinates and project a ray:
// From examples-src/wrecking-ball/src/main.js
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
});
For drag, pointer.position provides the live position between events:
const pointer = engine.devices.pointer.position;
// read every frame during a drag
engine.graphics.normalizeViewportPoint(pointer, ndc);
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 inside move handlers before consuming
deltas, as shown in the first-person example above.
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.