Files

10 KiB
Raw Permalink Blame History

Unified Input for Browser Games (keyboard + mouse + touch + Gamepad, action mapping, deadzones, buffering)

Consolidated from MDN (Gamepad API, Pointer Events, KeyboardEvent) and fighting/platformer input-design canon (see Sources). Focus: what an AI builder needs so games are playable on every device with one code path, controls are rebindable, and input feels responsive (buffering) rather than dropped.


1. Architecture: one action layer over all devices

Never scatter if (keys.KeyW) gameplay checks through your code. Build two layers:

  1. Raw device state — updated from events (keyboard/pointer) and polling (gamepad).
  2. Abstract actions — a fixed set like moveX, moveY, aimX, jump, attack, pause. Gameplay reads only actions. Any device can drive any action → keyboard, touch, and gamepad all "just work," and rebinding is trivial.
const actions = { moveX:0, moveY:0, jump:false, attack:false /* ... */ };

Compute actions once per frame in an updateInput() called at the top of the loop, then gameplay consumes actions (and derived justPressed edges).


2. Keyboard: use event.code, track state, don't act on the event

  • Track pressed keys in a Set on keydown/keyup; read the set in the loop. Do NOT run movement/gameplay directly in the event handler (event repeat rate ≠ frame rate → frame-dependent, laggy movement).
  • Use event.code (physical key, e.g. 'KeyW', 'Space', 'ArrowLeft') not event.key (layout/locale-dependent) and not the deprecated keyCode. event.code keeps WASD in the same physical spot on AZERTY/QWERTZ.
  • preventDefault() for game keys (Space, arrows) so the page doesn't scroll; but don't blanket-block everything (leave F5, devtools, tab-out).
  • Clear the key set on blur/visibilitychange — otherwise a key held while tabbing out gets "stuck down" forever (very common bug).
  • For text-entry vs gameplay, gate input by focus/mode.
const keys = new Set();
addEventListener('keydown', e => { keys.add(e.code); if (GAME_KEYS.has(e.code)) e.preventDefault(); });
addEventListener('keyup',   e => keys.delete(e.code));
addEventListener('blur',    () => keys.clear());

3. Pointer Events: unify mouse + touch + pen

  • Use Pointer Events (pointerdown/move/up/cancel) instead of separate mouse + touch listeners. One API covers mouse, touch, and stylus, with pointerId for multitouch and pressure/pointerType.
  • Handle pointercancel (OS steals the touch) as a release — forgetting it strands buttons/joysticks "held."
  • Set CSS touch-action: none on the canvas and preventDefault() to stop scroll/zoom/pull-to-refresh eating input.
  • Convert client coords to canvas/world coords using getBoundingClientRect() and devicePixelRatio; don't assume clientX == canvas pixel.
  • Mouse-look for FPS uses the Pointer Lock API (requestPointerLock on a gesture; read movementX/Y) — see the Three.js controls skill.

4. Touch controls (mobile)

  • Virtual joystick for movement (e.g. nipplejs, static or dynamic) mapped to moveX/moveY normalized 1..1; on-screen buttons or a right-side aim/tap zone for actions. Split-screen: left = move, right = act/aim.
  • Minimum 44px hit targets (Apple HIG). Give visual feedback on press.
  • Support multitouch: track pointers by pointerId so moving the joystick doesn't cancel a jump button.
  • Normalize touch → the same actions as keyboard/gamepad; apply with delta time.

5. Gamepad API: poll every frame, use standard mapping + deadzones

  • Poll navigator.getGamepads() every frame in the loop. The gamepadconnected/gamepaddisconnected events only tell you a pad exists — do not cache the Gamepad object; it's a snapshot. Get fresh state each frame.
  • A pad often only appears after the user presses a button ("waking"). Handle connect/disconnect gracefully mid-game.
  • Prefer gamepad.mapping === 'standard' — browsers remap Xbox/PS/etc. to a consistent layout:
    • Buttons: 0=A/Cross, 1=B/Circle, 2=X/Square, 3=Y/Triangle, 4/5=bumpers, 6/7=triggers (use .value 01, analog), 8=Back/Select, 9=Start, 10/11=stick presses, 1215=D-pad U/D/L/R.
    • Axes (1..1): 0=LX, 1=LY (1 is up), 2=RX, 3=RY.
  • Read digital with buttons[i].pressed, analog triggers with buttons[i].value > threshold.
  • Non-standard pads (mapping !== 'standard') have unpredictable indices — offer a rebinding screen as fallback.
  • Haptics via gamepad.vibrationActuator?.playEffect('dual-rumble', {...}) where supported (progressive enhancement).

Deadzones (required for analog sticks)

Sticks never rest at exactly 0. Apply a deadzone or you get drift ("character walks by itself").

  • Radial deadzone (correct for sticks — treat X/Y together, not per-axis) with re-normalization so full tilt still reaches magnitude 1:
function radialDeadzone(x, y, dz = 0.15) {
  const m = Math.hypot(x, y);
  if (m < dz) return { x: 0, y: 0 };
  const scale = ((m - dz) / (1 - dz)) / m; // re-normalize
  return { x: x * scale, y: y * scale };
}
  • Typical deadzone 0.10.25. Also deadzone trigger .value. Per-axis deadzones make diagonals feel wrong — prefer radial.

6. Action mapping & rebinding

  • Store bindings as data (action → list of physical inputs): { jump: ['Space','KeyW', {pad:0}], attack: ['Mouse0', {pad:2}] }. updateInput() resolves bindings into actions.
  • Rebinding UI: capture the next input event, write it into the binding table, persist to localStorage (see save-persistence.md). Detect and warn on conflicts.
  • Support multiple simultaneous devices and per-action multiple bindings (WASD and arrows and stick all drive moveX).
  • Keep a justPressed / justReleased edge set: compare this frame's action booleans to last frame's for one-shot actions (jump, shoot, menu confirm). Reading level-triggered state for these causes repeat-fire bugs.

7. Input buffering (responsiveness / fairness)

  • Buffer discrete actions for a short window (~100150ms / ~68 frames) so an input pressed slightly before it's actionable still fires. Essential for platformers and fighting games — makes controls feel "tight" instead of "eating" inputs.
    • E.g. jump pressed 4 frames before landing → still jump on landing.
  • Coyote time: allow jump for a few frames after walking off a ledge — pairs with jump buffering; both dramatically improve platformer feel.
  • Store buffered inputs with a timestamp/frame stamp; consume when the action becomes valid, and expire after the window.
  • Poll gamepad and sample input at a fixed rate tied to your fixed-timestep update for deterministic gameplay/netcode; buffering + fixed step is what makes combos/prediction reproducible.

8. Bug-prevention checklist

  • Movement in the keydown handler → frame-rate-dependent, jerky; set flags, move in the loop with dt.
  • Using event.key/keyCode → breaks on non-US layouts / deprecated; use event.code.
  • Stuck keys after tab-out → clear key set on blur/visibilitychange.
  • Caching the Gamepad object → stale input; call getGamepads() every frame.
  • No stick deadzone → character drifts; apply radial deadzone with re-normalization.
  • Per-axis deadzone → mushy/wrong diagonals; use radial.
  • Ignoring pointercancel → stuck touch buttons/joysticks on mobile.
  • Page scrolls/zooms during play → missing touch-action:none / preventDefault.
  • Repeat-firing one-shot actions → reading level state instead of justPressed edges.
  • "Ate my jump" feel → no input buffering / coyote time.
  • Gamepad never detected → user hasn't pressed a button to wake it; handle connect event + prompt.

Defaults to apply

  • Always generate the two-layer input system: raw devices → normalized actions, with justPressed/justReleased edges. Gameplay reads only actions, so keyboard+mouse, touch (nipplejs + buttons), and gamepad all work from one code path.
  • Bake in the safety defaults: event.code, clear keys on blur, poll gamepad each frame, radial deadzone (~0.15) with re-normalization, touch-action:none, handle pointercancel.
  • Include jump/input buffering (~120ms) + coyote time in platformers/action templates by default — biggest feel win for cheap.
  • Make bindings data-driven and persisted so a rebinding screen is a small add, and auto-detect the active device to show correct button prompts.
  • Semantic signs for move/steer/flight are not defined here — open the controls skill (.grok/skills/controls/SKILL.md) so steer/roll mean player-left correctly. This file is plumbing; that skill is the sign convention + mandatory A/D self-test.

Sources