Skip to content

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

typescript
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

TypePropertiesUse Case
buttonpressed, justPressed, justReleased, heldDurationJump, fire, interact
axis1dvalue (-1 to 1), deltaThrottle, zoom
axis2dx, y, deltaX, deltaY, magnitudeMovement, camera look
axis3dx, y, z, deltas, magnitude6DOF movement

Reading Actions

There are two approaches to consuming actions: polling and event-based.

Polling (recommended for gameplay):

typescript
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):

typescript
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:

PhaseDescription
startedInput just began (button down, stick moved from center)
performedInput is active (button held, stick displaced)
canceledInput ended (button up, stick returned to center)

Binding Types

The input system supports seven binding types across four device categories.

Keyboard

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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.

typescript
// 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:

typescript
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:

typescript
inputManager.initialize({
  inputBuffering: {
    enabled: true,
    maxBufferTime: 150,  // Buffer inputs for 150ms
    maxBufferSize: 8,
  },
});

Combo Detection

Define input sequences for fighting-game-style combos:

typescript
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:

typescript
const override: BindingOverride = {
  action: 'jump',
  originalBinding: { type: 'keyboard', key: 'Space' },
  newBinding: { type: 'keyboard', key: 'KeyE' },
};

Pointer Lock

For FPS-style camera control:

typescript
await inputManager.requestPointerLock();
// Mouse deltas now provide raw, unconstrained movement
inputManager.exitPointerLock();

Haptic Feedback

Trigger gamepad vibration:

typescript
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:

typescript
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:

typescript
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 values
  • pressedKeys, justPressedKeys, justReleasedKeys -- keyboard state
  • mousePosition, mouseDelta -- mouse state
  • touchCount, gamepadCount -- device counts
  • isPointerLocked -- pointer lock status

Configuration Reference

typescript
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
});

Proprietary software. All rights reserved.