Skip to content

Input

Read input from a system through the Input resource. It tracks held state plus this-frame edges (pressed / released), so query it every frame — the edges are only true for the one frame the change happened.

import { defineSystem, Res, Input } from 'esengine';
const keys = defineSystem([Res(Input)], (input) => {
if (input.isKeyDown('KeyW')) { /* held down */ }
if (input.isKeyPressed('Space')) { /* pressed this frame */ }
if (input.isKeyReleased('Escape')) { /* released this frame */ }
});

Keys use DOM code strings: KeyW, KeyA, ArrowUp, Space, Escape, Digit1, …

const mouse = defineSystem([Res(Input)], (input) => {
if (input.isMouseButtonPressed(0)) { /* left click this frame */ }
const screenX = input.mouseX, screenY = input.mouseY; // screen pixels
const wheel = input.scrollDeltaY;
});

Buttons: 0 = left, 1 = middle, 2 = right.

input.mouseX / mouseY are screen pixels. For world coordinates — to place or hit entities — read UICameraInfo, which exposes the cursor already projected into the world:

import { defineSystem, Commands, Res, Input, UICameraInfo, Transform } from 'esengine';
const spawnAtCursor = defineSystem(
[Res(Input), Res(UICameraInfo), Commands()],
(input, camera, cmds) => {
if (!camera.valid) return;
if (input.isMouseButtonPressed(0)) {
cmds.spawn().insert(Transform, {
position: { x: camera.worldMouseX, y: camera.worldMouseY, z: 0 },
});
}
}
);

Systems in the FixedUpdate family run at a fixed cadence — zero times on a frame shorter than the timestep, several times when catching up. The *Pressed / *Released edges account for this: an edge is delivered to exactly one fixed step (the first at or after it arrived), so a jump read from a physics character controller is never dropped on a fast frame nor fired twice on a slow one — no buffering on your side.

// Schedule.FixedPreUpdate — the edge is delivered reliably.
const jump = defineSystem([Query(Mut(CharacterController)), Res(Input)], (players, input) => {
for (const [, cc] of players) {
if (cc.isOnFloor && input.isKeyPressed('Space')) cc.velocity.y = JUMP_FORCE;
}
});

The Input Map editor — named actions with keyboard and gamepad bindings

The Input Map editor: named actions (Move / Jump / Fire), each with as many keyboard, mouse, and gamepad bindings as you like.

Hard-coded key checks scatter device knowledge through gameplay code. An input map names each action once — with as many keyboard / mouse / gamepad bindings as you like — and gameplay queries the action by name:

import {
defineInputMap, Button, Axis2D,
Key, Keys2D, Stick, GpButton, GamepadButton,
} from 'esengine';
export const Controls = defineInputMap({
Move: Axis2D(Keys2D('KeyW', 'KeyS', 'KeyA', 'KeyD'), Stick('left')),
Jump: Button(Key('Space'), GpButton(GamepadButton.South)),
});

defineInputMap registers its own per-frame evaluation (after input is polled, before Update), so the map is live immediately — import it into any system and query:

const dir = Controls.axis2d('Move'); // { x, y }, length ≤ 1
if (Controls.pressed('Jump')) { /* this-frame edge */ }
Query Returns
down(name) Held — the action’s magnitude is ≥ 0.5.
pressed(name) / released(name) This-frame edges.
value(name) Button 0..1 · axis -1..1 · axis2d magnitude 0..1.
axis2d(name) { x, y } direction (normalized when longer than 1).

An action is Button(…), Axis1D(…), or Axis2D(…); each takes any number of bindings, and multiple bindings sum (then clamp), so keyboard and gamepad coexist:

Binding Feeds Meaning
Key(code) button / axis One keyboard key.
MouseButton(b) button / axis A mouse button.
GpButton(btn, pad?) button / axis A gamepad button (analog value for triggers).
GpAxis(axis, pad?, scale?) axis A raw gamepad axis.
Keys1D(neg, pos) axis Two keys → a signed 1D axis.
Keys2D(up, down, left, right) axis2d Four keys → a 2D direction.
Stick('left' | 'right', pad?) axis2d A gamepad stick (up = +y, matching Keys2D).

Those constructors are just sugar over plain, serializable data — Binding is a tagged union, and an action is a type plus its binding list:

import { type Binding, type ActionDef } from 'esengine';
const jump: ActionDef = {
type: 'button', // 'button' | 'axis' | 'axis2d'
bindings: [
{ kind: 'key', code: 'Space' }, // Key('Space')
{ kind: 'gpButton', button: 0 }, // GpButton(GamepadButton.South)
],
};
Binding kind Fields Constructor
key code Key
mouse button MouseButton
gpButton button, pad? GpButton
gpAxis axis, pad?, scale? GpAxis
keys1d neg, pos Keys1D
keys2d up, down, left, right Keys2D
stick stick: 'left' | 'right', pad? Stick

Because bindings are data, this one model is also the rebind and persistence format: toJSON() / loadJSON() round-trip the bindings alone (per-action Binding[]), while toAsset() serializes the full map — action types included — as an InputMapAsset ({ version, actions }), the content of an .inputmap file. loadJSON ignores actions the map doesn’t define, so stale saved data for a removed action never resurrects it.

Bindings are plain serializable data, so a controls screen stays small:

// "Press any input…" — captures the next key / gamepad button (mouse is opt-in)
await Controls.rebind('Jump'); // replace; { append: true } adds instead
Controls.save('controls'); // persist to platform Storage
// on boot:
Controls.load('controls'); // re-apply saved bindings (if any)

rebind / listenForBinding take a ListenOptions choosing which device families the capture listens to: keyboard and gamepad default on, mouse defaults off — so the click that pressed the “rebind” button isn’t itself captured. Axis sources are never captured; rebinding targets discrete inputs.

getBindings / setBindings inspect and edit bindings programmatically, and cancelListen() aborts a pending capture (it resolves with null).

A whole map also serializes as an asset: create a New Input Map in the editor’s Content Browser, then build the live map with loadInputMapAsset(json) instead of defineInputMap. toAsset() produces the same format from code.

GestureDetector turns raw touches into tap / long-press / swipe / pinch callbacks. Construct it over the Input resource and drive it from a system:

import { defineSystem, Res, Input, Time, GestureDetector } from 'esengine';
let gestures: GestureDetector | null = null;
const gestureSystem = defineSystem([Res(Input), Res(Time)], (input, time) => {
if (!gestures) {
gestures = new GestureDetector(input);
gestures.onTap = (x, y) => { /* screen px */ };
gestures.onLongPress = (x, y) => { /* held ~0.5 s without moving */ };
gestures.onSwipe = (dir, speed) => { /* 'left' | 'right' | 'up' | 'down' */ };
gestures.onPinch = (scale, cx, cy) => { /* two-finger zoom factor per frame */ };
}
gestures.update(time.delta);
});

A tap is a touch under 0.3 s with less than 10 px of travel; a swipe needs ≥ 50 px at ≥ 200 px/s; pinch reports the frame-to-frame scale of a two-finger spread with its center point.

Before a raw platform event lands in the Input resource, it passes through the InputRouter — a chain of responsibility with three tiers:

  1. Editor — tools (gizmos, marquee drags) get first claim.
  2. UI — widgets and dialogs claim what falls through.
  3. Game — implicit: the Input resource itself. Gameplay only ever sees events no upstream handler consumed.

A handler callback returns true to consume the event — it then neither updates Input nor reaches later tiers. Returning false / nothing lets it fall through. Because consumption is per-event (not a global “editor is active” flag), a tool can claim exactly what it cares about: a marquee drag consumes pointer moves while pressed but lets the wheel scroll through to the camera; a modal consumes Escape but ignores the arrow keys.

The engine’s UI plugin already occupies the UI tier — that is what makes isPointerOverUI() and click-through prevention work. You write a handler when building tools or embedding the engine in a host app:

import { inputRouter, type InputHandler } from 'esengine';
const modalHandler: InputHandler = {
onKeyDown(code, mods) {
if (code === 'Escape') { closeModal(); return true; } // consumed
},
onPointerDown(button, x, y, mods) {
return isInsideModal(x, y); // claim clicks on the modal, pass the rest
},
};
const release = inputRouter.setUIHandler(modalHandler);
// when the modal closes:
release();

inputRouter is a module-level singleton; setEditorHandler / setUIHandler install one handler per tier and return an unregister thunk (unregistering only removes your handler, never a newer one). All InputHandler methods are optional:

InputHandler method Signature
onKeyDown / onKeyUp (code, mods)
onPointerMove (x, y, mods)
onPointerDown (button, x, y, mods)
onPointerUp (button, mods)
onWheel (deltaX, deltaY, mods)
onTouchStart / onTouchMove (id, x, y)
onTouchEnd / onTouchCancel (id)

mods is a Modifiers snapshot — { shift, ctrl, alt, meta } — tracked by the router from key events (also readable any time as inputRouter.currentMods). On the key-up edge that releases a modifier, handlers still see it as held, matching DOM event.shiftKey semantics. A handler that throws is treated as not consuming, so a broken tool can’t swallow all input.

Group Methods
Keyboard isKeyDown(code), isKeyPressed(code), isKeyReleased(code).
Mouse isMouseButtonDown(b), isMouseButtonPressed(b), isMouseButtonReleased(b); mouseX / mouseY, scrollDeltaY.
Touch isTouchActive(id), the touches map of active points.
Gamepad isGamepadConnected(pad?), isGamepadButtonDown(btn, pad?), isGamepadButtonPressed/Released(btn, pad?), getGamepadAxis(axis, pad?).
Pointer isPointerOverUI() — the cursor is over a UI element.

Each *Down reads held state; *Pressed / *Released are the this-frame edges.

  • Poll every frame — edges (*Pressed / *Released) last one render frame, or one fixed step inside FixedUpdate (see above).
  • Guard world clicks with isPointerOverUI() so clicking a button doesn’t also hit the game world.
  • Use UICameraInfo for world coordinates, not raw mouseX/Y, so picking respects the camera.
  • Gamepad pad defaults to 0 — pass an index for local multiplayer.
  • CamerascreenToWorld for arbitrary screen points.
  • UI — UI consumes pointer input above the game world.