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.