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).
The map is read, never kept: actions, setActionMap and loadBindings copy
every key list. They accept a readonly map (ActionMapInput), so a catalog
declared once and shared across scenes can be as const:
import { InputPlugin } from "@yagejs/input";
export const ACTIONS = { jump: ["Space", "GamepadA"], attack: ["KeyJ", "MouseLeft"],} as const;
engine.use(new InputPlugin({ actions: ACTIONS }));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.context.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.
Press State
Section titled “Press State”input.isPressed("jump"); // true while the key is heldinput.isJustPressed("jump"); // true only in the window the key went downinput.isJustReleased("jump"); // true only in the window it was releasedinput.isJustReleasedByPlayer("jump"); // same window, only if the player let goThe 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.
A hold also ends when the engine drops it: the window loses focus, the page
hides, a gamepad disconnects, a pointer gesture is cancelled, or the game calls
clearAll(). isJustReleased reports all of those, which is right for a jump
that should stop when the player alt-tabs away. Where the release is what
commits something the player cannot take back — a menu confirming the row under
the cursor, a charged shot leaving the barrel — read
isJustReleasedByPlayer instead, and alt-tabbing commits nothing.
Hold-to-repeat
Section titled “Hold-to-repeat”A menu that moves one row per key press is tiring to use. Pass
{ repeat: true } and isJustPressed is true on the press and again while the
key stays down — after 0.35 seconds, then every 0.1 second:
let row = 0; // the highlighted menu row
if (input.isJustPressed("move-down", { repeat: true })) row += 1;if (input.isJustPressed("move-up", { repeat: true })) row -= 1;Set the two timings per call. They are arguments rather than per-action configuration, so two callers can repeat one action at different rates:
input.isJustPressed("move-up", { repeat: true, repeatDelay: 0.5, // seconds held before the first repeat repeatInterval: 0.06, // seconds between repeats after that});A repeatDelay that is not finite or is negative throws, and so does a
repeatInterval that is not finite or is not above zero. Both throw at the
call, before any input state is read.
The query returns a boolean, so a frame longer than the interval produces one edge and not several: a hitch moves the cursor one row rather than five.
Repeats count on the raw input clock unless you pass a clock, which keeps a
pause menu repeating while the scene under it is paused. Pass a scene’s
SceneTime to stop them with the scene, as described under
Counting on Scene Time. The clock applies to the
repeats only — the initial press edge always falls in the caller’s frame or
fixed step.
An action bound to a gamepad stick direction repeats the same way, with no
stick-specific code: a stick push past the threshold arrives as a
GamepadLeftStick* key edge, so it carries an ordinary hold. Menus built on
@yagejs/ui’s focus scopes get all of this without calling the query
themselves.
Axes and Vectors
Section titled “Axes and Vectors”For directional movement, turn two opposing actions into a single value:
import { Transform } from "@yagejs/core";import { InputManagerKey } from "@yagejs/input";
// Inside a Component's update(dt).const input = this.use(InputManagerKey);const speed = 200; // pixels per second
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");// `dt` is in seconds.this.entity.get(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. 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.
declare function chargeAttack(): void;
const seconds = input.getHoldDuration("attack");
// Convenience check — true if held for at least the given durationif (input.isHeldFor("attack", 0.5)) { chargeAttack();}Tap vs Hold
Section titled “Tap vs Hold”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:
declare function beginCharge(): void;declare function quickAttack(): void;declare function releaseChargedAttack(): void;
// 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"); // secondsgetReleaseDuration 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.
Counting on Scene Time
Section titled “Counting on Scene Time”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 { Component, SceneTimeKey } from "@yagejs/core";import { InputManagerKey } from "@yagejs/input";
declare function beginCharge(): void;
class ChargeAttack extends Component { update() { const input = this.use(InputManagerKey); 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.
Buffered Press
Section titled “Buffered Press”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:
declare const grounded: boolean;declare function jump(): void;
// 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 { Component, SceneTimeKey } from "@yagejs/core";import { InputManagerKey } from "@yagejs/input";
declare const grounded: boolean;declare function jump(): void;
class JumpBuffer extends Component { update() { const input = this.use(InputManagerKey); const clock = this.use(SceneTimeKey);
if (grounded && input.consumeBufferedPress("jump", 0.12, { clock })) { jump(); } }}An overlay scene that wants to drop the press that opened it measures the
window on a different clock. The press was stamped against the clock of the
scene below, before the overlay existed, so this.use(SceneTimeKey) on the
overlay hands back a clock the press was never measured on. Reach the other
scene through engine.scenes.all and read its clock with tryResolveScoped,
which returns undefined where Scene.use would throw:
import { Scene, SceneManagerKey, SceneTimeKey } from "@yagejs/core";import { InputManagerKey } from "@yagejs/input";
class PauseMenu extends Scene { readonly name = "pause";
onEnter() { const input = this.use(InputManagerKey); const below = this.use(SceneManagerKey).all.find((s) => s.name === "game"); const clock = below?.tryResolveScoped(SceneTimeKey); if (clock) input.consumeBufferedPress("pause", 0.2, { clock }); }}Pointer (Mouse / Touch)
Section titled “Pointer (Mouse / Touch)”import type { Vec2 } from "@yagejs/core";
declare function shootAt(target: Vec2): void;
// 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; generation changes when an id is reused // p.type is "mouse" | "pen" | "touch" // p.screenPos / p.buttons / p.isPrimary / p.isDown / p.button}For tap-driven components, read down edges retained for the current rendered frame. A quick down/up pair remains visible even when both browser events arrive before the frame runs:
declare function placeMarker(position: Vec2): void;
for (const press of input.getPointerPresses({ button: 0 })) { placeMarker(press.worldPos);}Claimed presses are excluded by default. Pass consumed: "include" to inspect
all presses or consumed: "only" to inspect only claimed presses. Each result
also includes screenPos, worldPos, generation, and consumed. The
positions and held-button state describe the applied down edge. A pointer or
camera move later in the frame does not alter the press record.
Per-pointer events let you track a single finger across its lifetime (down → move → up). Each subscriber returns a disposer:
declare function startTrackingFinger(id: number): void;declare function updateTrackedFinger(pointer: PointerInfo): void;declare function stopTrackingFinger(id: number): void;
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):
import type { CameraEntity } from "@yagejs/renderer";
declare const camera: CameraEntity;declare function attackAt(target: Vec2): void;
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 Pixi UI button can handle its own click without also firing the gameplay
action bound to MouseLeft.
UI elements inherit consumeInput from the nearest ancestor that specifies
it. UISurface and UIRoot default to true, as does a standalone UI element
with no explicit ancestor setting. A pointer-down or wheel event on consuming
UI suppresses gameplay action edges while the UI element still handles the
event. A pointer claim lasts for that press cycle.
import { Button, Panel } from "@yagejs/ui-react";
function SettingsButton({ openMenu }: { openMenu: () => void }) { return ( <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.
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}:
import { Image, Panel } from "@yagejs/ui-react";
<Panel consumeInput={false}> <Image texture="hud-frame" /></Panel>Descendants inherit false, including layout containers, labels and buttons.
Set consumeInput={true} on any control that must consume input. The nearest
explicit value wins, so false also overrides a consuming parent. Removing
the prop restores inheritance. The same rule applies to imperative UI:
import { UISurface } from "@yagejs/ui";
declare function pause(): void;const hud = new UISurface({ consumeInput: false });hud.panel().button("Pause", { consumeInput: true, onClick: pause });The element still renders and runs its pointer callbacks. Consumption controls gameplay input; it does not forward clicks to UI widgets behind the element. Floating content inherits from its display parent, so give a floating overlay its own setting. Modal focus blockers still consume the blocked area.
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();inventory.eventMode = "static";markPointerConsumeContainer(inventory);// ... attach to scene tree, attach handlers as usual
// On teardown:unmarkPointerConsumeContainer(inventory);markPointerConsumeContainer(container, policy) accepts true (the default),
false, or "inherit". The nearest explicit boolean on the hit path wins.
"inherit" supplies the standalone UI default of true when no ancestor has
an explicit setting. Unregistered paths do not consume.
getPointerConsumePolicy(container) reads the container’s own setting, or
undefined when unregistered. isPointerConsumeContainer(container) answers
whether it explicitly specifies true. unmarkPointerConsumeContainer removes
the setting. These functions and the PointerConsumePolicy type are exported
from @yagejs/core.
Registration does not change hit testing. Configure eventMode, bounds or a
hit area, and pointer handlers on a custom container as needed.
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:
declare function clickedOnMyDraggable(position: Vec2): boolean;declare function startDrag(pointer: PointerInfo): void;
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.
Call consumeWheel() inside an onWheel callback to suppress that wheel
event’s WheelUp/Down/Left/Right action edges. The call throws outside an
active wheel callback. Each event has its own claim, even when several wheel
events drain in one frame.
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:
declare const overlayEl: HTMLElement; // the DOM overlay above the canvasdeclare const canvas: HTMLCanvasElement; // the game canvas
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);});Explicit onPointerDown listeners still fire. Consumption suppresses only the
action-map edge.
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 -> InputPlugin queues the eventF0 rAF tick — InputPollSystem drains the queue -> UI hit-test -> pointer listeners run -> 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.
A key that goes down and up between two drains keeps both edges. The drain applies the queued events in the order they arrived, and a release never clears the press edge before it:
F0 browser dispatches keydown, then keyup -> InputPlugin queues bothF0 rAF tick — InputPollSystem drains the queue -> key edges applied in order -> isJustPressed("jump") and isJustReleased("jump") both true, isPressed("jump") already falseThat window is the current frame for update code and listeners, and the next
fixed step to run for fixedUpdate code. A script that sends a press as one
down+up pair — Playwright’s page.keyboard.press, or mouse.down() followed
by mouse.up() — needs no delay between the two calls.
The window closes at the end of that frame, when InputClearSystem clears the
edge sets. Code running between frames, such as a page.evaluate after the
frame completed, is in no window: for input a frame already drained, it reads
false for both edges. To observe an edge from a script, record it inside the
frame — a component that stores isJustPressed("jump") during its update —
and read that record afterwards.
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.
When the window blurs or the page becomes hidden, held keyboard, gamepad, and pointer input releases through the normal paths. Browser events queued before that focus boundary are discarded. Synthetic action-source holds remain owned by their source until it releases them.
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.
declare function save(): void;declare function unfreeze(): void;declare function playSwooshSfx(): void;declare function stopChargeSfx(): void;
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) => { input.consumeWheel(); // claims this event only camera.zoom *= 1 - 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.
import { InputPlugin } from "@yagejs/input";
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.
Wheel-bound actions participate in consumeBufferedPress(). Marked UI
surfaces are checked before wheel action edges, so scrolling a UIScrollView
does not also fire wheel-bound gameplay actions.
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) |
GamepadLeftStickUp/Down/Left/Right | Left-stick directions |
GamepadRightStickUp/Down/Left/Right | Right-stick directions |
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.
A stick push past the threshold arrives as one of the
GamepadLeftStick* / GamepadRightStick* key edges and carries an ordinary
hold, so a stick direction bound to a movement action works with every press
query. That includes hold-to-repeat: bind the direction and
a held stick steps a menu at the same rate a held key does, with no threshold
code of your own. Read getStick when you want the analog value itself.
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 });An explicit { pad } read uses only that physical controller. It returns zero
when the pad is absent or idle and never falls back to synthetic axes. Default
reads may use synthetic axes when no active pad has a value outside its
deadzone.
Default deadzones: 0.15 for sticks (radial), 0.05 for triggers. Override per plugin:
import { InputPlugin } from "@yagejs/input";
new InputPlugin({ deadzones: { stick: 0.2, trigger: 0.1 }, triggerThreshold: 0.6,});Deadzones must be finite and in [0, 1). The trigger threshold must be finite
and in (0, 1]. Physical stick direction codes press at 0.5 and release below
0.375, which prevents repeated edges near the boundary.
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.
declare const hud: { show(message: string): void };
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”declare function showPlayerPrompt(message: string): void;declare function pauseGame(): void;
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”import { InputManagerKey } from "@yagejs/input";
declare function showHud(message: string): void;
const input = engine.context.resolve(InputManagerKey);let dispose: (() => void) | undefined;let replayed = false;dispose = input.onGamepadConnected((info) => { if (replayed) return; replayed = true; showHud(`Player ready! (${info.id})`); if (dispose) dispose();});if (replayed) dispose();Replay-on-subscribe means this also fires for pads that were already known.
The replayed branch disposes after registration when the callback ran before
onGamepadConnected returned its disposer.
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:
import { DebugPlugin } from "@yagejs/debug";import { InputPlugin } from "@yagejs/input";
new InputPlugin({ pollGamepads: false, /* ... */ });new DebugPlugin({ deterministicSeed: 42 });Synthetic action injection (by action name)
Section titled “Synthetic action injection (by action name)”Use fireAction for a one-frame pulse. For an on-screen control or another
sustained synthetic device, create one source per independent owner. Two
sources can hold the same action without either one releasing the other’s
press.
declare const chargeButton: { readonly isHeld: boolean }; // your on-screen button
input.fireAction("attack"); // one-frame pulse (isJustPressed for 1 frame)
const touchControls = input.createActionSource();touchControls.setHeld("attack", chargeButton.isHeld);
// Disable or destroy the synthetic device.touchControls.releaseAll();setHeld(action, true) throws if the action is not in the current map.
Reasserting the same hold is idempotent. A press and release before frame clear
preserve both edges.
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:
declare function fireChargedAttack(power: number): void;
touchControls.setHeld("attack", chargeButton.isHeld);
if (input.isJustReleased("attack")) { const power = Math.min(input.getReleaseDuration("attack"), 1); // 0..1 over 1s fireChargedAttack(power);}Action bindings are captured when a press starts. Rebinding affects the next
press and does not retarget a held key or source. If several inputs hold one
action, isJustReleased(action) reports each contributing release. Duration
helpers report only the release that ends the action’s final hold.
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
Conflict detection covers one group, so a key bound to two actions in
different groups is a legal map. Pressing that key presses both actions:
isJustPressed reads true for each, each gets its own listener call, and
each gets its own buffered press. Buffered presses are recorded per action
name, so consumeBufferedPress("dash", 0.15) claims the dash press and leaves
the other action’s press for whatever consumes that one. Read
input.getActionsForKey(code) for every action a key drives, and check it
before a rebind when a key should drive one action only:
const shared = input.getActionsForKey("ShiftLeft"); // e.g. ["dash", "sprint"]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"Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/packages/input.md.