Input Handling
The @web-engine-dev/input package provides a context-aware action mapping system that abstracts keyboard, mouse, gamepad, and touch into named actions. Players can rebind actions without changing game code.
Quick Setup
Create an InputManager instance, register action maps, then call update() each frame:
import { InputManager } from '@web-engine-dev/input';
const inputManager = new InputManager();
inputManager.initialize({
enableKeyboard: true,
enableMouse: true,
enableGamepad: true,
enableTouch: true,
gamepadDeadzone: 0.15,
});
// Register gameplay action map
inputManager.registerActionMap({
name: 'gameplay',
actions: [
{
name: 'move',
valueType: 'axis2d',
bindings: [
{ type: 'keyboard', key: 'KeyA', axis: '-x' },
{ type: 'keyboard', key: 'KeyD', axis: '+x' },
{ type: 'keyboard', key: 'KeyW', axis: '-y' },
{ type: 'keyboard', key: 'KeyS', axis: '+y' },
{ type: 'gamepadAxis', axis: 'leftStickX' },
],
},
{
name: 'jump',
valueType: 'button',
bindings: [
{ type: 'keyboard', key: 'Space' },
{ type: 'gamepadButton', button: 'a' },
],
},
{
name: 'pause',
valueType: 'button',
bindings: [
{ type: 'keyboard', key: 'Escape' },
{ type: 'gamepadButton', button: 'start' },
],
},
],
});
// Push starting context
inputManager.pushContext({
name: 'gameplay',
priority: 0,
actionMaps: ['gameplay'],
});
// Call every frame before reading actions
function gameLoop(deltaTime: number): void {
inputManager.update(deltaTime);
// ... rest of game loop
}Reading Input in Systems
Poll actions each frame using getAction() which returns a typed value object:
function PlayerMovementSystem(world: World): void {
// Get action value - returns null if action not active
const move = inputManager.getAction('move');
if (move?.type === 'axis2d') {
// x, y are in the -1 to 1 range
const vx = move.x * 200;
const vy = move.y * 200;
const query = world.query().with(Velocity, Player).build();
for (const result of world.run(query)) {
const [vel] = result.components;
world.insert(result.entity, Velocity, { ...vel, x: vx, y: vy });
}
}
}
function PlayerActionSystem(world: World): void {
const jump = inputManager.getAction('jump');
if (jump?.type === 'button' && jump.justPressed) {
// true only on the first frame the button is pressed
triggerJump();
}
if (jump?.type === 'button' && jump.pressed) {
// true every frame the button is held
}
if (jump?.type === 'button' && jump.justReleased) {
// true only on the frame the button is released
}
// Shorthand for one-off checks
if (inputManager.isActionTriggered('pause')) {
togglePause();
}
}Action Value Types
valueType | Fields | Use Case |
|---|---|---|
button | pressed, justPressed, justReleased, heldDuration | Jump, fire, interact |
axis1d | value (-1 to 1), delta | Throttle, zoom |
axis2d | x, y, deltaX, deltaY, magnitude | Movement, look |
axis3d | x, y, z, deltas, magnitude | 6DOF movement |
Reading Input in Scripts
In a Script, there is no ctx.getResource() shorthand. Access the InputManager (or InputStateResource for raw keys) via ctx.world:
import { type ScriptContext } from '@web-engine-dev/scripting';
import { type InputState, InputStateResource } from '@web-engine-dev/input';
export class PlayerScript implements Script {
// ...
onUpdate(ctx: ScriptContext): void {
// Option A: use action mapping via the shared InputManager reference
const jump = inputManager.getAction('jump');
if (jump?.type === 'button' && jump.justPressed && this.isGrounded) {
this.jump(ctx);
}
// Option B: raw key state via ECS resource (no action mapping)
const rawInput = ctx.world?.getResource(InputStateResource) as InputState | undefined;
if (rawInput?.justPressedKeys.has('Space')) {
this.jump(ctx);
}
}
}Raw Key Codes
InputStateResource exposes pressedKeys, justPressedKeys, and justReleasedKeys as ReadonlySet<string> using browser KeyboardEvent.code values such as 'KeyW', 'Space', 'ArrowLeft'.
Input Contexts
The context stack allows different action maps at different game states. Higher priority wins:
// Push a menu context when pausing (blocks gameplay input below it)
if (inputManager.isActionTriggered('pause')) {
inputManager.pushContext({
name: 'menu',
priority: 10,
actionMaps: ['ui'],
consumeInput: true, // block lower-priority contexts
});
}
// Pop back to gameplay when closing the menu
if (inputManager.isActionTriggered('cancel')) {
inputManager.popContext();
}Gamepad Support
Gamepads are automatically detected once enableGamepad: true is passed to initialize(). Use haptic feedback via:
// Trigger haptic feedback on gamepad index 0
inputManager.triggerHaptic(0, {
duration: 100, // ms
strongMagnitude: 0.5,
weakMagnitude: 0.25,
});Standard Gamepad Button Names
Face: a, b, x, y Shoulders: leftBumper, rightBumper, leftTrigger, rightTrigger Sticks: leftStick, rightStick Menu: select, start, home D-Pad: dpadUp, dpadDown, dpadLeft, dpadRight
Touch Input
Touch gestures are configured as bindings in an action map:
inputManager.registerActionMap({
name: 'mobile',
actions: [
{
name: 'tap',
valueType: 'button',
bindings: [{ type: 'touch', gesture: 'tap', fingerCount: 1 }],
},
{
name: 'swipeLeft',
valueType: 'button',
bindings: [
{
type: 'touch',
gesture: 'swipe',
zone: { x: 0, y: 0, width: 0.5, height: 1, normalized: true },
},
],
},
],
});Available gestures: tap, doubleTap, longPress, swipe, pinch, rotate, pan.
Mouse Input
Raw mouse state is available through InputStateResource:
import { type InputState, InputStateResource } from '@web-engine-dev/input';
function AimSystem(world: World): void {
const input = world.getResource(InputStateResource) as InputState;
const { x, y } = input.mousePosition; // screen-space pixels
const { x: dx, y: dy } = input.mouseDelta; // pixels moved this frame
// For pointer-lock (FPS) mode, use deltas:
if (input.isPointerLocked) {
rotateCamera(dx * sensitivity, dy * sensitivity);
}
}For FPS pointer lock:
await inputManager.requestPointerLock();
// ...later
inputManager.exitPointerLock();Input Recording & Playback
The input system supports recording and replaying input for testing and replays:
// Start recording
inputManager.startRecording();
// ... gameplay ...
// Stop and retrieve recording
const recording = inputManager.stopRecording();
// Later: play it back deterministically
inputManager.playRecording(recording);Event-Based Input
For non-polling scenarios (e.g. UI), react to input events instead of polling:
inputManager.onAction('jump', (event) => {
if (event.phase === 'performed') {
player.jump();
}
});| Phase | When |
|---|---|
started | Input just began (button down, stick moved from center) |
performed | Input is active (button held) |
canceled | Input ended (button up, stick returned to neutral) |
Next Steps
- Camera Systems, position the camera relative to player input or world events
- Physics, respond to physical collision events
- UI & HUD, display input prompts and button icons