Skip to content

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:

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

typescript
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

valueTypeFieldsUse Case
buttonpressed, justPressed, justReleased, heldDurationJump, fire, interact
axis1dvalue (-1 to 1), deltaThrottle, zoom
axis2dx, y, deltaX, deltaY, magnitudeMovement, look
axis3dx, y, z, deltas, magnitude6DOF 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:

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

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

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

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

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

typescript
await inputManager.requestPointerLock();
// ...later
inputManager.exitPointerLock();

Input Recording & Playback

The input system supports recording and replaying input for testing and replays:

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

typescript
inputManager.onAction('jump', (event) => {
  if (event.phase === 'performed') {
    player.jump();
  }
});
PhaseWhen
startedInput just began (button down, stick moved from center)
performedInput is active (button held)
canceledInput 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

Proprietary software. All rights reserved.