Gameplay

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 signalskeyboard.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 signalskeyboard.on.down and keyboard.on.up fire for every key transition and receive the raw KeyboardEvent.

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:

SignalArgumentsWhen
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, f1f12, numpad_0numpad_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);

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.