Skip to content

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.

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).

Resolve the InputManager from the engine context and query actions by name:

import { InputManagerKey } from "@yagejs/input";
const input = engine.resolve(InputManagerKey);

input.getClockTime() returns the input system’s elapsed time in seconds. The clock advances from raw engine frame time, so scene pause and time scaling do not affect it. Use this clock when input edges and later deadlines must share one deterministic time source.

input.isPressed("jump"); // true while the key is held
input.isJustPressed("jump"); // true only in the window the key went down
input.isJustReleased("jump"); // true only in the window it was released

The isJust* queries answer “did this happen in my window”, and the window matches where you call from. In update code and listeners it is the current rendered frame. In fixedUpdate code (or a Phase.FixedUpdate system) it is the current fixed step: the edges that arrived since the previous step began. Either way each press is seen exactly once — when a frame runs several fixed steps only the first step sees it, and a press landing in a frame that runs no fixed step is held for the next step. Poll from whichever context your gameplay runs in; frame and step rates don’t change the answer.

For directional movement, turn two opposing actions into a single value:

const h = input.getAxis("left", "right"); // -1, 0, or 1
const v = input.getAxis("up", "down");
// Or get both axes as a Vec2 in one call:
const dir = input.getVector("left", "right", "up", "down");
// `speed` is in pixels per second; `dt` is in seconds.
entity.transform.translate(dir.x * speed * dt, dir.y * speed * dt);

Check how long an action has been continuously held. Durations are in seconds (matching engine time everywhere else) and count on the raw input clock (getClockTime()) unless you say otherwise: scene pause and time scaling do not affect that clock, so by default a hold keeps accruing while the scene is paused or frozen. To measure a hold in simulation seconds instead, see Counting on Scene Time.

const seconds = input.getHoldDuration("attack");
// Convenience check — true if held for at least the given duration
if (input.isHeldFor("attack", 0.5)) {
chargeAttack();
}

Classify a press as a tap or a hold with call-site thresholds — no per-action configuration. isJustHeldFor fires once, in the query window where the hold crosses the threshold; the release helpers fire once, in the window the action fully releases (for an action bound to several keys, letting go of one key while another still holds it doesn’t count). Windows follow your calling context, frame or fixed step, as described under Press State:

// Fires the frame the hold passes 0.5s — start a charged state here.
if (input.isJustHeldFor("attack", 0.5)) {
beginCharge();
}
// A tap: released before 0.2s.
if (input.isJustTapped("attack", 0.2)) {
quickAttack();
}
// A long press: released after being held at least 0.5s.
if (input.isJustReleasedAfter("attack", 0.5)) {
releaseChargedAttack();
}
// The exact hold length at release (0 outside the release window).
const held = input.getReleaseDuration("attack"); // seconds

getReleaseDuration is captured at the release edge, so it stays valid in the window the action is released — no need to sample getHoldDuration before the release lands.

Every duration query takes an optional clock: the six above plus consumeBufferedPress. Omit it and the duration counts on the raw input clock, which runs on unscaled frame time. Pass a scene’s SceneTime and it counts on that scene’s simulation seconds instead — the clock stops while the scene is stack-paused or frozen, and follows scene.timeScale together with any active SceneTime scale requests. Physics steps under that same effective scale, so a charge meter and the world it charges in run at one rate.

The clock is scene-wide. An entity left out of a scale request through excludeUpdates, or carrying its own entity.timeScale, updates at a rate the scene clock does not follow — a charge on that entity still counts at the scene’s rate. Measure those holds from the dt the entity’s own update receives. The raw clock does not match that entity either: it runs on real time, so it ignores scene.timeScale and the stack pause that stops the entity along with its scene.

import { SceneTimeKey } from "@yagejs/core";
// Inside a Component or Scene subclass.
const clock = this.use(SceneTimeKey);
// The charge stops while the pause menu is up and halves under slow motion.
if (input.isJustHeldFor("attack", 0.5, { clock })) {
beginCharge();
}
const charged = input.getReleaseDuration("attack", { clock });

The clock does not change which window an edge falls in. That still follows your calling context, frame or fixed step, as described under Press State — the clock only decides how the duration is measured. One consequence: a scene clock advances once per frame, so a hold measured on it does not change between the fixed steps of a single frame, and a fixedUpdate reader sees the crossing in the first step of the frame it lands in.

Each clock keeps its own readings, so one press can be a tap on the scene clock and a long press on the raw one. isJustHeldFor carries a separate threshold baseline per clock and fires exactly once on each. A hold that was already running when the scene started counts from zero on that scene’s clock, and its release reports only the seconds measured there — a long press begun before the scene was entered can read as a tap on that clock. A release that landed before the scene started reports no length at all: both isJustTapped and isJustReleasedAfter stay false for it.

Passing a clock the input plugin has not registered throws. That covers a SceneTime held from a scene that has since left the stack.

Act on a press up to a window late — the classic “buffer the jump just before landing” feel. consumeBufferedPress returns true if the action was pressed within the last window seconds and that press has not been claimed yet; it claims the press so it fires at most once, and only a new press re-arms it:

// Each frame: jump the moment we're grounded if a jump was pressed in the
// last 0.12s. The claim prevents a single press from firing twice.
if (grounded && input.consumeBufferedPress("jump", 0.12)) {
jump();
}

Consumption is scoped to this query — isJustPressed and action listeners still see every press.

The two-argument form measures the window on the raw input clock. To measure a jump buffer in simulation seconds instead, pass the scene’s SceneTime as the clock, as described under Counting on Scene Time:

import { SceneTimeKey } from "@yagejs/core";
const clock = this.use(SceneTimeKey);
if (grounded && input.consumeBufferedPress("jump", 0.12, { clock })) {
jump();
}
// World-space position (automatically accounts for camera offset and zoom)
const pos = input.getPointerPosition();
// Raw button state
if (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.

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:

<Panel>
<Button onClick={openMenu}>Settings</Button>
</Panel>

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.

For overlays that should let clicks through (decorative HUD borders, full-screen filters, see-through frames), pass consumeInput={false}:

<Panel consumeInput={false}>
<Image texture="hud-frame" />
</Panel>

The element still renders normally — it just becomes transparent to the action map.

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.

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.

consumePointer is also the answer to forwarding or replaying a synthetic pointer to the canvas. A DOM overlay above the canvas — a virtual joystick, an accessibility overlay, input-replay/macro tooling — often dispatches a synthetic PointerEvent to the canvas so game UI underneath (say a dialogue box) still receives the tap. Pair every such dispatch with consumePointer, or each forwarded touch leaks into the MouseLeft/Middle/Right action edge and fires whatever gameplay action is bound there:

overlayEl.addEventListener("pointerdown", (e) => {
// Forward to the canvas so UI listeners underneath still see the tap. Build
// the init explicitly — spreading `{ ...e }` drops pointerId/clientX/… since
// PointerEvent fields are not own-enumerable properties.
canvas.dispatchEvent(
new PointerEvent("pointerdown", {
pointerId: e.pointerId,
pointerType: e.pointerType, // else a forwarded touch/pen reads as mouse
isPrimary: e.isPrimary, // drives primary-pointer reads
clientX: e.clientX,
clientY: e.clientY,
button: 0,
bubbles: true,
cancelable: true,
}),
);
// ...but keep it out of gameplay actions.
input.consumePointer(e.pointerId);
});

onPointerDown listeners (the dialogue box’s binding, the renderer’s UI hit-test) still fire — only the action-map edge is suppressed.

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 isJustPressed
F0 rAF tick — InputPollSystem drains the queue
-> action edges applied, isJustPressed("fire") returns true
F0 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.

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 edge
input.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.

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.

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.

CodeDescription
GamepadA, GamepadB, GamepadX, GamepadYFace buttons (Xbox naming)
GamepadLB, GamepadRBShoulder bumpers
GamepadLT, GamepadRTTrigger buttons (fire when analog value crosses triggerThreshold)
GamepadSelect, GamepadStartSelect / Start
GamepadLeftStick, GamepadRightStickStick clicks (L3 / R3)
GamepadDPadUp/Down/Left/RightD-pad
GamepadHomeHome / Guide button
GamepadButtonNNumeric 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.

const move = input.getStick("left"); // Vec2, deadzoned, magnitude ≤ 1
const aim = input.getStick("right");
const charge = input.getTrigger("right"); // 0..1

Both 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,
});

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 | null
input.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");
});

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");
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 }[]
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.

input.fireGamepadButton("GamepadA", true); // routes through the real path
input.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 });

Synthetic action injection (by action name)

Section titled “Synthetic action injection (by action name)”

Sometimes you want to drive an action directly — from an on-screen touch button or virtual controls — without knowing which physical key it’s bound to. These methods are keyed by action name and throw on an unknown action:

input.fireAction("attack"); // one-frame pulse (isJustPressed for 1 frame)
input.fireActionDown("attack"); // sustained press: isPressed stays true,
// getHoldDuration accrues, onAction fires once
input.fireActionUp("attack"); // real release: isJustReleased + onActionReleased
input.setActionHeld("attack", held); // mirror a boolean onto down/up

fireActionDown/fireActionUp are the action-name twins of fireKeyDown/ fireKeyUp, so a touch overlay can drive hold and charge actions purely through the action map. fireActionDown is idempotent — re-calling while held neither resets the hold start nor re-fires the press edge.

A hold-to-charge attack driven by a touch button, with zero keymap knowledge. getReleaseDuration holds the charge length on the release frame, so the button can be mirrored in any order:

input.setActionHeld("attack", chargeButton.isHeld);
if (input.isJustReleased("attack")) {
const power = Math.min(input.getReleaseDuration("attack"), 1); // 0..1 over 1s
fireChargedAttack(power);
}

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 and ok is false

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.

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 ends
input.enableGroup("movement");
input.enableGroup("combat");

Disabled actions always return false / 0 when queried.

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"