Input System
The @web-engine-dev/input package provides unified input handling across keyboard, mouse, gamepad, and touch devices. Instead of polling raw hardware state, you define abstract actions (like "jump" or "move") and bind them to physical inputs. This decouples gameplay code from specific devices and makes rebinding straightforward.
Architecture Overview
InputManager
├── Devices (Keyboard, Mouse, Gamepad, Touch)
├── ActionMaps (groups of related actions)
├── InputContextStack (priority-based input routing)
├── InputChannels (named scopes with per-device blocking)
├── Bindings → Actions (input → abstract action mapping)
├── Combo Detection (input sequences)
├── Input Buffering (coyote-time patterns)
└── Recording / Playback (input replay)The InputManager class is the central controller. It polls hardware devices each frame, evaluates bindings, and produces typed action values that your game logic reads.
Action Mapping
Actions abstract physical inputs into semantic game operations. You group related actions into action maps, then register them with the InputManager.
Defining Actions
import { InputManager } from '@web-engine-dev/input';
const inputManager = new InputManager();
inputManager.initialize({
enableKeyboard: true,
enableMouse: true,
enableGamepad: true,
enableTouch: true,
});
inputManager.registerActionMap({
name: 'gameplay',
actions: [
{
name: 'jump',
valueType: 'button',
bindings: [
{ type: 'keyboard', key: 'Space' },
{ type: 'gamepadButton', button: 'a' },
],
},
{
name: 'move',
valueType: 'axis2d',
bindings: [
{ type: 'compositeAxis', up: 'KeyW', down: 'KeyS', left: 'KeyA', right: 'KeyD' },
{ type: 'gamepadAxis', axis: 'leftStickX' },
{ type: 'gamepadAxis', axis: 'leftStickY' },
],
},
{
name: 'look',
valueType: 'axis2d',
bindings: [
{ type: 'mouseAxis', axis: 'x', sensitivity: 0.5 },
{ type: 'mouseAxis', axis: 'y', sensitivity: 0.5, invert: true },
],
},
],
});Action Value Types
| Type | Properties | 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, camera look |
axis3d | x, y, z, deltas, magnitude | 6DOF movement |
Reading Actions
There are two approaches to consuming actions: polling and event-based.
Polling (recommended for gameplay):
function gameLoop(deltaTime: number) {
inputManager.update(deltaTime);
const jump = inputManager.getAction('jump');
if (jump?.type === 'button' && jump.justPressed) {
player.jump();
}
const move = inputManager.getAction('move');
if (move?.type === 'axis2d' && move.magnitude > 0) {
player.move(move.x, move.y);
}
// Shorthand for "was the button just pressed this frame?"
if (inputManager.isActionTriggered('pause')) {
togglePause();
}
}Event-based (for UI or one-shot actions):
const unsubscribe = inputManager.onAction('jump', (event) => {
if (event.phase === 'performed') {
player.jump();
}
});
// Clean up when done
unsubscribe();Input Phases
Every action event carries a phase describing the input lifecycle:
| Phase | Description |
|---|---|
started | Input just began (button down, stick moved from center) |
performed | Input is active (button held, stick displaced) |
canceled | Input ended (button up, stick returned to center) |
Binding Types
The input system supports seven binding types across four device categories.
Keyboard
// Simple key press
{ type: 'keyboard', key: 'Space' }
// With modifier keys
{ type: 'keyboard', key: 'KeyS', modifiers: { ctrl: true } }
// Key with axis value (for mapping keys to axis actions)
{ type: 'keyboard', key: 'KeyW', value: 1.0 }
// Composite WASD binding for 2D movement
{ type: 'compositeAxis', up: 'KeyW', down: 'KeyS', left: 'KeyA', right: 'KeyD' }Mouse
// Button
{ type: 'mouseButton', button: 'left' } // 'left' | 'right' | 'middle' | 'back' | 'forward'
// Axis (movement, scroll)
{ type: 'mouseAxis', axis: 'x', sensitivity: 0.5, invert: false }
{ type: 'mouseAxis', axis: 'wheel' } // 'x' | 'y' | 'wheel' | 'wheelX'Gamepad
// Button (W3C Standard Gamepad mapping)
{ type: 'gamepadButton', button: 'a', gamepadIndex: 0 }
// Axis with deadzone
{ type: 'gamepadAxis', axis: 'leftStickX', deadzone: 0.15, sensitivity: 1.0 }Standard gamepad buttons: a, b, x, y, leftBumper, rightBumper, leftTrigger, rightTrigger, select, start, leftStick, rightStick, dpadUp, dpadDown, dpadLeft, dpadRight, home.
Axes: leftStickX, leftStickY, rightStickX, rightStickY.
Touch
// Tap gesture
{ type: 'touch', gesture: 'tap', fingerCount: 1 }
// Swipe in a screen region
{ type: 'touch', gesture: 'swipe', zone: { x: 0, y: 0, width: 0.5, height: 1, normalized: true } }
// Pan with axis reading
{ type: 'touch', gesture: 'pan', axis: 'panX', sensitivity: 1.0 }Supported gestures: tap, doubleTap, longPress, swipe, pinch, rotate, pan.
All bindings support an optional priority field (higher values take precedence when multiple bindings map to the same action).
Input Contexts
The context stack lets you route input differently based on game state. When a menu opens, you push a new context that can block gameplay input.
// Gameplay is the base context
inputManager.pushContext({
name: 'gameplay',
priority: 0,
actionMaps: ['gameplay', 'camera'],
consumeInput: false,
});
// Opening a menu pushes a higher-priority context
inputManager.pushContext({
name: 'menu',
priority: 10,
actionMaps: ['ui'],
consumeInput: true, // Block lower contexts from receiving input
});
// Closing the menu pops the context
inputManager.popContext();
// Inspect the stack
const stack = inputManager.getContextStack();
console.log(stack.activeContext?.name); // 'gameplay' (after pop)Input Channels
Channels are a more powerful version of contexts with per-device blocking and named lifecycle:
const channel: InputChannel = {
name: 'viewport',
priority: 5,
actionMaps: ['camera'],
exclusive: false,
blockDevices: ['mouse'], // Only block mouse for lower-priority channels
enabled: true,
};When exclusive is true, lower-priority channels are completely blocked. When blockDevices is set, only those device types are blocked for lower-priority channels.
Input Buffering
Input buffering stores recent inputs for a short window, enabling "coyote time" and other forgiving input patterns:
inputManager.initialize({
inputBuffering: {
enabled: true,
maxBufferTime: 150, // Buffer inputs for 150ms
maxBufferSize: 8,
},
});Combo Detection
Define input sequences for fighting-game-style combos:
const combo: ComboDefinition = {
name: 'hadouken',
sequence: ['down', 'down-forward', 'forward', 'punch'],
maxInterval: 500, // Max ms between inputs
strict: true, // No other inputs allowed between sequence steps
};Rebinding
The rebinding system lets players customize their controls:
const override: BindingOverride = {
action: 'jump',
originalBinding: { type: 'keyboard', key: 'Space' },
newBinding: { type: 'keyboard', key: 'KeyE' },
};Pointer Lock
For FPS-style camera control:
await inputManager.requestPointerLock();
// Mouse deltas now provide raw, unconstrained movement
inputManager.exitPointerLock();Haptic Feedback
Trigger gamepad vibration:
inputManager.triggerHaptic(0, { // gamepadIndex: 0
duration: 100,
strongMagnitude: 0.5,
weakMagnitude: 0.25,
});Input Recording and Playback
Record and replay input for testing, demos, or replays:
inputManager.startRecording();
// ... gameplay ...
const recording = inputManager.stopRecording();
// recording.frames contains per-frame input events
// Later: replay the recording
inputManager.playRecording(recording);ECS Integration
The input package provides resource descriptors for use with @web-engine-dev/resources:
import { InputManagerResource, InputStateResource } from '@web-engine-dev/input';
// Insert into resource store
resources.insert(InputManagerResource, inputManager);
// Read in ECS systems
const input = resources.get(InputManagerResource);
const state = resources.get(InputStateResource);
// state.actions, state.pressedKeys, state.mousePosition, etc.The InputState resource provides a read-only snapshot updated each frame with:
actions-- currently active action valuespressedKeys,justPressedKeys,justReleasedKeys-- keyboard statemousePosition,mouseDelta-- mouse statetouchCount,gamepadCount-- device countsisPointerLocked-- pointer lock status
Configuration Reference
inputManager.initialize({
enableKeyboard: true,
enableMouse: true,
enableGamepad: true,
enableTouch: true,
gamepadDeadzone: 0.15, // Values below this treated as 0
mouseSensitivity: 1.0, // Global mouse sensitivity multiplier
mouseAxisScale: 0.01, // Pixel-to-normalized conversion factor
gamepadAxisSensitivity: 1.0, // Global gamepad axis multiplier
analogToDigitalThreshold: 0.5, // Axis-to-button conversion threshold
preventDefaultKeys: ['Tab'], // Block browser defaults for these keys
capturePointerLock: false, // Auto-request pointer lock
targetElement: canvas, // Scope mouse events to this element
// Touch gesture thresholds
doubleTapDelay: 300, // ms between taps for double-tap
longPressDelay: 500, // ms for long press detection
touchSwipeThreshold: 50, // px minimum for swipe
touchPanThreshold: 10, // px minimum for pan
touchPinchThreshold: 20, // px minimum for pinch
touchRotateThreshold: 0.1, // radians minimum for rotate
});Related Packages
@web-engine-dev/ecs-- Entity Component System for structuring game logic@web-engine-dev/resources-- Resource management for ECS integration