Input
The @yagejs/input package provides a unified input layer that abstracts
keyboard, mouse, and gamepad devices behind named actions. You define
actions once and query them at runtime without worrying about which physical
key was pressed.
InputPlugin Setup
Section titled “InputPlugin Setup”import { InputPlugin } from "@yagejs/input";
engine.use( new InputPlugin({ actions: { jump: ["Space", "GamepadA"], left: ["KeyA", "ArrowLeft", "GamepadDPadLeft"], right: ["KeyD", "ArrowRight", "GamepadDPadRight"], up: ["KeyW", "ArrowUp"], down: ["KeyS", "ArrowDown"], attack: ["KeyJ", "MouseLeft", "GamepadX"], interact: ["KeyE"], }, groups: { movement: ["jump", "left", "right", "up", "down"], combat: ["attack"], gameplay: ["interact"], }, }),);The action map is a record of action name → array of physical input codes.
Codes can be KeyboardEvent.code values (e.g. "KeyA", "Space"),
synthetic mouse codes ("MouseLeft", "MouseMiddle", "MouseRight"), or
gamepad codes (see Gamepad below).
Querying Input
Section titled “Querying Input”Resolve the InputManager from the engine context and query actions by name:
import { InputManagerKey } from "@yagejs/input";
const input = engine.resolve(InputManagerKey);Press State
Section titled “Press State”input.isPressed("jump"); // true while the key is heldinput.isJustPressed("jump"); // true only on the frame the key went downinput.isJustReleased("jump"); // true only on the frame the key was releasedAxes and Vectors
Section titled “Axes and Vectors”For directional movement, turn two opposing actions into a single value:
const h = input.getAxis("left", "right"); // -1, 0, or 1const v = input.getAxis("up", "down");
// Or get both axes as a Vec2 in one call:const dir = input.getVector("left", "right", "up", "down");entity.transform.translate(dir.x * speed * dt, dir.y * speed * dt);Hold Duration
Section titled “Hold Duration”Check how long an action has been continuously held:
const ms = input.getHoldDuration("attack");
// Convenience check — true if held for at least the given durationif (input.isHeldFor("attack", 500)) { chargeAttack();}Pointer (Mouse / Touch)
Section titled “Pointer (Mouse / Touch)”// World-space position (automatically accounts for camera offset and zoom)const pos = input.getPointerPosition();
// Raw button stateif (input.isPointerDown()) { shootAt(pos);}getPointerPosition() returns world coordinates after wiring the camera with
input.setCamera(camera). If no camera is set, screen coordinates are
returned.
The singular getters above always report the primary pointer — whichever
contact the browser flagged isPrimary (the mouse cursor or the first finger
to touch). For multi-touch UIs, iterate every active pointer:
import type { PointerInfo } from "@yagejs/input";
for (const p of input.getPointers()) { // p.id is PointerEvent.pointerId — stable across moves for the same finger // p.type is "mouse" | "pen" | "touch" // p.screenPos / p.buttons / p.isPrimary / p.isDown / p.button}Per-pointer events let you track a single finger across its lifetime (down → move → up). Each subscriber returns a disposer:
const offDown = input.onPointerDown((p: PointerInfo) => { if (p.type === "touch") startTrackingFinger(p.id);});const offMove = input.onPointerMove((p) => updateTrackedFinger(p));const offUp = input.onPointerUp((p) => stopTrackingFinger(p.id));// Up listeners also fire on `pointercancel` — gestures clean up uniformly.To act only on a specific mouse button inside a down/up listener, check
p.button — the button whose edge triggered this event (0 left, 1
middle, 2 right; -1 for moves and query snapshots):
input.onPointerDown((p) => { if (p.button !== 0) return; // left-click only attackAt(camera.screenToWorld(p.screenPos.x, p.screenPos.y));});Touch primary contacts fire MouseLeft (since PointerEvent.button === 0 for
the first finger), so an action bound to MouseLeft works as a tap handler
with no extra wiring.
For device-orientation events (rotate-to-portrait detection, etc.), see Responsive Canvas → Orientation.
UI vs gameplay clicks
Section titled “UI vs gameplay clicks”A common source of bugs in past versions: a Pixi UI button is mapped to one
onClick callback, but the same pointerdown also fires whatever gameplay
action is bound to MouseLeft. The 0.5 release fixes this end-to-end with no
per-component boilerplate.
Every primitive in @yagejs/ui (and UIRoot in @yagejs/ui-react) marks
itself as a “consume surface”. When a pointerdown lands on a marked
container, @yagejs/input automatically claims that pointer for its entire
event cycle — gameplay action edges (MouseLeft, MouseMiddle, MouseRight,
or any custom binding to those codes) are suppressed, while the UI element’s
own handler still fires:
<UIPanel> <Button onClick={openMenu}>Settings</Button></UIPanel>Clicking “Settings” calls openMenu and does not fire any
MouseLeft-bound action. Even clicking the empty background of the panel
is suppressed — the renderer’s hit-test walks up from the topmost
interactive container and reports the panel as a UI hit.
Drag-through-up works correctly: pressing inside a panel and releasing outside still keeps the action gated, because the consume mark sticks for the entire down → up cycle.
Per-component escape hatch
Section titled “Per-component escape hatch”For overlays that should let clicks through (decorative HUD borders,
full-screen filters, see-through frames), pass consumeInput={false}:
<UIPanel consumeInput={false}> <Image texture="hud-frame" /></UIPanel>The element still renders normally — it just becomes transparent to the action map.
Custom Pixi containers
Section titled “Custom Pixi containers”If you build a custom interactive Pixi Container outside the YAGE UI
primitives and want it to auto-consume too, mark it directly:
import { markPointerConsumeContainer, unmarkPointerConsumeContainer,} from "@yagejs/core";import { Container } from "pixi.js";
const inventory = new Container();markPointerConsumeContainer(inventory);// ... attach to scene tree, attach handlers as usual
// On teardown:unmarkPointerConsumeContainer(inventory);markPointerConsumeContainer also sets eventMode = "static" — required
for Pixi’s hit-test to find the container.
Imperative consume from a handler
Section titled “Imperative consume from a handler”For one-off cases where you want to claim an event imperatively from inside a handler:
input.onPointerDown((p) => { if (clickedOnMyDraggable(p.screenPos)) { input.consumePointer(p.id); startDrag(p); }});consumePointer lifetime is per-event-cycle: cleared on the matching
pointerup/pointercancel, so the drag’s pointerup is also gated and a
later fresh down starts unmarked.
There’s an analogous consumeWheel() for one-frame suppression of
WheelUp/Down/Left/Right action edges.
Frame deferral
Section titled “Frame deferral”DOM-originated keyboard, pointer, and wheel events buffer onto an internal
queue and apply at Phase.EarlyUpdate (drained by InputPollSystem). Your
gameplay code reads isJustPressed at frame start and sees a single,
coherent snapshot — but that snapshot lands one frame after the browser’s
event dispatch:
F0 browser dispatches pointerdown -> pointer listeners fire synchronously (low-latency) -> action edge buffered, NOT yet visible to isJustPressedF0 rAF tick — InputPollSystem drains the queue -> action edges applied, isJustPressed("fire") returns trueF0 user systems run (Update, FixedUpdate, …)The single-frame latency is invisible to gameplay reading state at frame
start. The win: any listener that wants to claim the event (UI auto-consume,
consumePointer from a Pixi handler, the renderer’s hit-test fallback) gets
to run before action edges fire — eliminating the historical “Pixi must
register listeners before YAGE” load-bearing assumption.
Synthetic injection (fireKeyDown, firePointerDown, …) bypasses the queue
and applies state synchronously, so existing tests using these helpers don’t
need to change. Tests that drive dispatchEvent directly need an explicit
manager._drainInputQueue() (or a frame step) before assertions.
Listener APIs
Section titled “Listener APIs”Disposer-returning hooks for keys, actions, and wheel input. Use these
instead of raw DOM listeners — they participate in the action map, group
enable/disable, and consumePointer gating.
const offSpace = input.onKeyDown("Space", () => save());input.onKeyUp("Space", () => unfreeze());input.onKeyDown("*", (code) => console.log("any key:", code)); // wildcard
input.onAction("attack", () => playSwooshSfx()); // rising edgeinput.onActionReleased("attack", () => stopChargeSfx()); // falling edge
const offWheel = input.onWheel((dx, dy) => { camera.zoomBy(dy * -0.001);});
offSpace();offWheel();Action listeners honor group enable/disable — disabled-group actions don’t
fire, matching isPressed / isJustPressed behavior.
Scroll wheel
Section titled “Scroll wheel”wheel events surface as one-frame action edges (WheelUp, WheelDown,
WheelLeft, WheelRight) — rebindable like keys, never linger in
pressedKeys. Direct callback access via onWheel(fn) for raw deltas.
new InputPlugin({ actions: { zoomIn: ["WheelUp", "Equal"], zoomOut: ["WheelDown", "Minus"], }, wheelInvertY: false, // default; flip if your game wants positive dy = up preventDefaultWheel: false, // default; opt-in to swallow page scroll on the canvas});preventDefaultWheel: true attaches the listener with { passive: false },
so calling preventDefault() actually takes effect. Use it when your game
canvas should not scroll the page.
Gamepad
Section titled “Gamepad”Gamepads are polled each frame from navigator.getGamepads() and routed
through the same key pipeline as keyboard and mouse — so isPressed,
isJustPressed, hold-duration, and listenForNextKey all work uniformly
across devices.
Standard-mapping codes
Section titled “Standard-mapping codes”| Code | Description |
|---|---|
GamepadA, GamepadB, GamepadX, GamepadY | Face buttons (Xbox naming) |
GamepadLB, GamepadRB | Shoulder bumpers |
GamepadLT, GamepadRT | Trigger buttons (fire when analog value crosses triggerThreshold) |
GamepadSelect, GamepadStart | Select / Start |
GamepadLeftStick, GamepadRightStick | Stick clicks (L3 / R3) |
GamepadDPadUp/Down/Left/Right | D-pad |
GamepadHome | Home / Guide button |
GamepadButtonN | Numeric fallback for non-standard mappings (N is the browser button index — any non-negative integer the runtime emits, e.g. GamepadButton0, GamepadButton16) |
PlayStation users see ✕/○/△/□ on hardware that maps to GamepadA/B/X/Y
in code — code names are layout-canonical (Xbox), not glyph-specific.
Analog sticks and triggers
Section titled “Analog sticks and triggers”const move = input.getStick("left"); // Vec2, deadzoned, magnitude ≤ 1const aim = input.getStick("right");const charge = input.getTrigger("right"); // 0..1Both methods read from the active pad — the most-recently-used controller.
For couch-co-op or any case where you need to address a specific controller,
pass { pad: index }:
const player1 = input.getStick("left", { pad: 0 });const player2 = input.getStick("left", { pad: 1 });Default deadzones: 0.15 for sticks (radial), 0.05 for triggers. Override per plugin:
new InputPlugin({ deadzones: { stick: 0.2, trigger: 0.1 }, triggerThreshold: 0.6,});Active pad
Section titled “Active pad”A single pad is “active” at any time. It auto-promotes when the user presses a button or moves a stick / trigger above its deadzone, so single-player hot-swap (keyboard → controller mid-session) just works. The active pad’s own activity protects it from being stolen, so two players pressing buttons in parallel doesn’t ping-pong the active state.
input.getActivePad(); // GamepadInfo | nullinput.setActivePad(0); // manual switch (must be a connected pad)input.setActivePad(null); // no active pad; analog falls back to synthetic state
const unsubscribe = input.onActivePadChanged((info) => { // Replays the current active pad synchronously on subscribe, then fires // on every transition. hud.show(info ? `Controller: ${info.id}` : "No controller");});Multi-input movement
Section titled “Multi-input movement”The platformer example accepts both keyboard and stick input on the same action — let analog stick win when it has signal, fall back to digital otherwise:
const stickX = input.getStick("left").x;const dx = stickX !== 0 ? stickX : input.getAxis("left", "right");Connect / disconnect events
Section titled “Connect / disconnect events”const dispose = input.onGamepadConnected((info) => { // Replays currently-known pads on subscribe — you don't need a separate // gamepads() call to discover existing controllers. showPlayerPrompt(`Pad ${info.index}: ${info.id}`);});
input.onGamepadDisconnected((info) => pauseGame());
// Synchronous query — useful when you need ground truth right now.const pads = input.gamepads(); // { index, id }[]Press-any-button recipe
Section titled “Press-any-button recipe”let dispose: (() => void) | undefined;dispose = input.onGamepadConnected((info) => { showHud(`Player ready! (${info.id})`); dispose?.();});Replay-on-subscribe means this also fires for pads that were already known —
you handle “current and future” pads with one callback. The let dispose
indirection is needed because the callback can run synchronously inside
onGamepadConnected (during replay) before the const would be bound.
Synthetic injection (testing)
Section titled “Synthetic injection (testing)”input.fireGamepadButton("GamepadA", true); // routes through the real pathinput.fireGamepadAxis("leftX", 0.7);For deterministic inspector probes with a real controller plugged in, set
pollGamepads: false so polling can’t clobber the injected state — pair
this with DebugPlugin’s deterministicSeed for fully deterministic test
runs:
new InputPlugin({ pollGamepads: false, /* ... */ });new DebugPlugin({ deterministicSeed: 42 });Rebinding Actions
Section titled “Rebinding Actions”Allow players to remap controls at runtime — works uniformly for keyboard, mouse, and gamepad codes:
const result = input.rebind("jump", "GamepadY", { conflict: "replace" });if (!result.ok) { console.warn("Rebind conflict with", result.conflict?.action);}The conflict option determines what happens when the new code is already
bound to a different action in the same group:
"replace"— the other action loses the binding"keep-both"— the new binding is added without unbinding the conflict"reject"(default) — the rebind is cancelled andokisfalse
Listen for Next Input
Section titled “Listen for Next Input”Capture the next physical input — for “Press a key to bind” UI:
const code = await input.listenForNextKey();// code is "KeyZ" | "MouseLeft" | "GamepadA" | ... | null (cancelled)This intercepts the key (it does not register as a press) so the user mashing a button to set a binding doesn’t accidentally trigger the action they’re trying to rebind.
Action Groups
Section titled “Action Groups”Disable entire groups of actions when they shouldn’t be active — for example, disabling movement during a cutscene:
input.disableGroup("movement");input.disableGroup("combat");
// Re-enable when the cutscene endsinput.enableGroup("movement");input.enableGroup("combat");Disabled actions always return false / 0 when queried.
Display Names
Section titled “Display Names”When building a key-bindings UI, use getKeyDisplayName() to convert internal
key codes into human-readable labels:
import { getKeyDisplayName } from "@yagejs/input";
getKeyDisplayName("Space"); // "Space"getKeyDisplayName("KeyA"); // "A"getKeyDisplayName("ArrowLeft"); // "Left"getKeyDisplayName("MouseLeft"); // "Left Click"getKeyDisplayName("GamepadA"); // "A"getKeyDisplayName("GamepadDPadUp"); // "D-Pad Up"