Files
skills/docs/gaming/games/controller/src/gamepad/gamepad.md
2026-05-29 08:20:56 +01:00

5.3 KiB

gamepad — GamepadController

(no submodules)

Purpose

Browser-tier device adapter that polls the Web Gamepad API and normalises button + axis state into the canonical 37-field ControllerInput shape exposed by BaseController. The adapter covers the W3C "standard mapping" layout (face buttons, bumpers, triggers, sticks, D-pad, system buttons) out of the box and exposes the button + axis maps so non-standard pads or custom layouts can be described per-instance.

The class never reads from navigator at module-load time. The GamepadSource is either supplied via options.source or resolved lazily from globalThis.navigator.getGamepads() when update() runs. Tests inject a deterministic source returning fixture pads and drive the adapter directly — no jsdom or navigator polyfill required.

Features

  • Standard W3C button mapping for all 17 indices, including analog triggers (lt/rt) and D-pad-to-digital mapping.
  • Standard axis mapping covering both digital halves (right/left/up/down) and centred analog representations (lx/ly/rx/ry).
  • Per-axis deadzone (defaults to 0.1) applied before the magnitude is rescaled to the [0, 255] field range.
  • Per-instance padIndex so two adapters on the same source can drive split-screen co-op without further routing.
  • Connection diff — when the source returns null, undefined, or a pad with connected: false, the adapter zeroes the mapped fields exactly once and stops emitting until the pad reappears.
  • Validates every map entry against CONTROLLER_INPUT_KEYS; invalid entries throw ControllerError(INVALID_GAMEPAD_MAP) with the offending tuple in context.entry.
  • Inherits the full BaseController surface — onInput, onAnyInput, setThreshold, getDirectionalVector, telemetry instrumentation.

Usage: Basic

import { GamepadController } from "@suaveplan/controller/gamepad";

const gp = new GamepadController();
gp.onInput("a", (_input, value) => {
    if (value === 255) console.log("A pressed");
});

// pump every frame
function tick() {
    gp.update();
    requestAnimationFrame(tick);
}
tick();

Usage: Advanced

Drive the adapter from a synthetic source with deterministic timing — no browser globals required:

import { GamepadController } from "@suaveplan/controller/gamepad";
import type { GamepadLike } from "@suaveplan/controller/gamepad";

let pad: GamepadLike | null = null;
const gp = new GamepadController({
    source: () => [pad],
    deadzone: 0.2,
    padIndex: 0,
    axisMap: {
        0: { positive: "right", negative: "left", analog: "lx" },
        1: { positive: "down", negative: "up", analog: "ly" },
    },
    buttonMap: { 0: "a", 1: "b" },
    clock: () => performance.now(),
    pollRate: 120,
});

pad = {
    buttons: [
        { pressed: true, value: 1 },
        { pressed: false, value: 0 },
    ],
    axes: [0.75, -0.5],
};
gp.update();
console.log(gp.getValue("a")); // 255
console.log(gp.getValue("right")); // 191

API Reference

  • class GamepadController extends BaseController
    • constructor(options?: GamepadControllerOptions)
    • update(): void — polls the source, diffs against last frame, pushes updates through setState(). Throws ControllerError(GAMEPAD_API_UNAVAILABLE) when no source is available. No-op after destroy().
    • getDeadzone(): number — resolved deadzone.
    • getPadIndex(): number — bound pad index.
    • getButtonMap(): GamepadButtonMap — immutable button map.
    • getAxisMap(): GamepadAxisMap — immutable axis map.
  • DEFAULT_GAMEPAD_BUTTON_MAP: GamepadButtonMap — W3C standard mapping.
  • DEFAULT_GAMEPAD_AXIS_MAP: GamepadAxisMap — W3C standard axis layout (left + right sticks).
  • interface GamepadControllerOptions extends BaseControllerOptions — adds source, buttonMap, axisMap, deadzone, padIndex.
  • interface GamepadLike — structural subset of Gamepad (buttons[i].pressed, buttons[i].value, axes[i], connected).
  • type GamepadSource = () => readonly (GamepadLike | null | undefined)[].
  • interface GamepadAxisBinding — positive, negative, analog fields, all optional.

Implementation Notes

  • Button values are taken from buttons[i].value when present so analog triggers report the full [0, 255] range. Pads that omit value (older drivers, software emulators) fall back to a binary pressed ? 255 : 0.
  • Axis values are clamped to [-1, 1] before the deadzone test so drivers that report slightly over-range readings (common on dead or worn sticks) cannot push the field above 255.
  • The disconnect zero-out is one-shot — guarded by a wasConnected flag — so a permanently absent pad does not flood downstream consumers with redundant value=0 events every poll.
  • The Zod options schema is .strict() so unknown options are rejected at the boundary as ControllerError(INVALID_OPTIONS); field-level rejections (e.g. deadzone: -1) flow through the same code with the Zod issues in context.issues.
  • super.update() re-applies the pollRate gate, so callers may invoke update() more often than the configured rate without paying repeated traversal cost — the source function is only invoked once per gated tick.