Input
Take mouse, touch, keyboard and gamepad input through @voxolith/engine/input - one input per surface, gestures, named actions and touch controls.
All camera and movement input goes through @voxolith/engine/input, never through listeners you add
yourself. One createInput per surface owns every listener; gestures, actions, on-screen controls
and camera controllers all read from it. Two controls can then share a canvas without fighting, the
whole lot disposes in one call, and the same game code runs on a desktop, a phone and a gamepad.
Setting up a surface
import { createInput, makeOrbitController, prepareSurface, recogniseGestures } from "@voxolith/engine/input";
prepareSurface(canvas, { contextMenu: false }); // no browser pan, zoom, selection or tap flash
const input = createInput(canvas, { loop }); // every event invalidates the frame loop
const orbit = makeOrbitController(input, { distance: 200, distanceLimits: [40, 600], pan: "secondary" });
recogniseGestures(input, { tap: (t) => select(t.x, t.y) });prepareSurfacereplaces per-apptouch-actionCSS.contextMenu: falsesuppresses the context menu so right-drag can pan.- Passing
{ loop }makes every input event callloop.invalidate(), which is all a render-on-demand app needs. While something is held,input.active()is true; render continuously then if your controls move things over time. - Keys are read by
KeyboardEvent.code(physical position), so WASD stays put on AZERTY. Held keys are released when the window loses focus. Only codes passed toinput.captureKeyshave their default action prevented.
Gestures
recogniseGestures(input, handlers, options) turns raw pointers into tap, doubleTap,
longPress, dragStart/drag/dragEnd, pinch and wheel.
- Tap versus drag is decided on total travel since the pointer went down, not on any single move, so a slow pan that crawls a pixel at a time is still a drag. The slop is 6 px for a mouse, 8 for a pen, 10 for touch. Past it, a pointer is a drag for the rest of its life.
- A second finger turns a drag into a pinch (the drag ends with
cancelled). After a pinch nothing is a tap until every finger is up. Ctrl+wheel, which is how trackpads report a pinch, arrives as a pinch too. - Several recognisers can share an input. Give the one that should win a higher
priority, and have it claim the pointer. The camera controllers then leave it alone:
const owner = {};
input.on((e) => {
if (e.kind !== "pointer") return;
if (e.phase === "down" && nearHandle(e.pointer.x, e.pointer.y)) input.claim(e.pointer.id, owner);
else if (e.phase === "move" && input.claimedBy(e.pointer.id) === owner) dragHandle(e.pointer.x, e.pointer.y);
}, 10);The world example's draggable lamp works exactly like this. In a recogniser, return true from
dragStart (or from press, to claim from the first touch) instead.
Actions: controls by meaning
Game code should ask for jump, not Space. makeActions binds named buttons and axes to key:,
pad: and touch: sources together, and makeTouchControls puts a floating joystick and buttons on
screen that feed the touch: ones.
import { axis, button, createInput, makeActions, makeTouchControls, prepareSurface } from "@voxolith/engine/input";
prepareSurface(canvas);
const input = createInput(canvas);
const actions = makeActions(input, {
strafe: axis({ negative: ["key:KeyA"], positive: ["key:KeyD"], analog: ["pad:LeftX", "touch:move.x"] }),
walk: axis({ negative: ["key:KeyS"], positive: ["key:KeyW"], analog: ["pad:LeftY-", "touch:move.y-"] }),
jump: button("key:Space", "pad:A", "touch:jump"),
});
makeTouchControls(input, { joysticks: [{ id: "move", side: "left" }], buttons: [{ id: "jump", label: "Jump" }] });
// In the frame callback:
input.update(); // once per frame, before reading edges
const [sx, wz] = actions.vector("strafe", "walk"); // clamped to the unit circle
if (actions.pressed("jump")) jump();- Screen and stick Y are down-positive;
pad:LeftY-andtouch:move.y-read the stick pushed up. pressedandreleasedreport what changed between the last twoinput.update()calls. Callupdate()exactly once per frame, before reading them.- Bindings are plain JSON:
actions.bindings()to save a player's rebinding,actions.load(saved)to restore it. - Touch controls show once a touch is seen and hide on mouse, keyboard or gamepad use. Theme them
with
--vx-control-bg,--vx-control-fgand--vx-control-active.
Pointer lock and gamepads
makeLookController asks for pointer lock when the surface is clicked with a mouse (Esc
releases); while locked, movement arrives as look events rather than pointer events. The first
connected gamepad (standard mapping, radial deadzone 0.15) is polled in input.update(), so a game
that reads the pad must render continuously while it is in use; connecting one invalidates the
loop. input.lastDevice() and input.onDevice(fn) tell you what the player used last, for showing
the right prompts.
Pitfalls
- Do not add your own
pointerdownorkeydownlisteners for gameplay: they bypass claiming and the frame loop. The input tracks pressed pointers, not a hovering mouse, so the examples add a plainpointermovelistener for hover feedback (a cursor change) only. - One
createInputper surface. Priorities and claims live in an input, so a second one on the same canvas cannot coordinate with the first. - Call
input.dispose()when a surface goes away; it removes every listener and releases pointer lock.
See also
- Cameras for the orbit and look controllers and their yaw conventions
- Mobile and PWA for touch-first layouts
- Engine: input
- API:
createInput,recogniseGestures,makeActions,makeTouchControls,prepareSurface
Streaming
Build a large world in chunks around the camera with makeChunkedWorld, wait for the ground near the camera, place things deterministically with scatterRegion, and stream fine terrain, filled on workers.
Cameras
Orbit, first-person and chase cameras in Voxolith - the renderer's camera frames, the engine's controllers, and which way yaw turns.