jsguides

Using the Gamepad API for Browser-Based Game Controllers

The Gamepad API lets browsers read input from game controllers — anything from a basic gamepad to a flight stick, racing wheel, or dance pad. It works in Chrome, Firefox, Edge, and Safari. No libraries needed.

Connecting a Gamepad

The browser doesn’t register a gamepad the moment you plug it in. It waits until a page interaction happens — usually a button press or axis movement. This is a security feature to prevent pages from detecting hardware silently.

Listen for the connection:

window.addEventListener("gamepadconnected", (event) => {
  const gamepad = event.gamepad;
  console.log("gamepad connected:", gamepad.id);
  console.log("index:", gamepad.index);
  console.log("mapping:", gamepad.mapping);  // "standard" or ""
});

When the gamepad disconnects, the browser fires a corresponding event with the same gamepad object. At this point, gamepad.connected is false and you can no longer read its inputs. Use the disconnect event to clean up any player state or UI that depends on that controller.

window.addEventListener("gamepaddisconnected", (event) => {
  console.log("gamepad disconnected:", event.gamepad.index);
});

Querying gamepad state

Unlike keyboard or mouse events, gamepad input is polled, not event-driven. You call navigator.getGamepads() on each animation frame to read the current state. This means your game loop owns the timing. The browser does not push input events to you. Polling inside requestAnimationFrame keeps input reads synchronized with rendering, which avoids jitter and gives smooth motion.

function gameLoop() {
  const gamepads = navigator.getGamepads();

  for (const gamepad of gamepads) {
    if (!gamepad) continue;

    // read buttons and axes
    console.log(gamepad.buttons[0].pressed);  // true/false
    console.log(gamepad.axes[0]);               // -1 to 1
  }

  requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

navigator.getGamepads() returns an array. The first slot is always null. It is a quirk of the spec that slot 0 is reserved. Slots 1 onwards are connected gamepads, or null if that slot is empty.

Standard gamepad layout

The “standard” mapping (set by the browser for well-known controllers) assigns:

Buttons (0–17):

  • 0: A / Cross
  • 1: B / Circle
  • 2: X / Square
  • 3: Y / Triangle
  • 4: LB / L1
  • 5: RB / R1
  • 6: LT / L2 (sometimes analog)
  • 7: RT / R2 (sometimes analog)
  • 8: Back / Select
  • 9: Start
  • 10: L3 (left stick click)
  • 11: R3 (right stick click)
  • 12–15: D-pad up/left/down/right
  • 16–17: vendor-specific

Axes (0–3):

  • 0: Left stick horizontal (left = -1, right = 1)
  • 1: Left stick vertical (up = -1, down = 1)
  • 2: Right stick horizontal
  • 3: Right stick vertical

Reading Buttons

Each button in gamepad.buttons is a GamepadButton object:

const button = gamepad.buttons[0];

if (button.pressed) {
  // button is currently pressed
  console.log(button.value);  // 0 to 1 for analog buttons (triggers)
}

Triggers on modern controllers are analog. They report a value between 0 and 1, not just pressed/released. Instead of checking button.pressed, read button.value and compare it against a threshold. A value of 0.5 means the trigger is pulled halfway. This analog data lets you control the speed of an action. A light press produces a small jump, while a full press produces a strong one.

// Use trigger threshold to jump
if (gamepad.buttons[7].value > 0.5) {
  player.jump();
}

Dead zones prevent unwanted movement from imprecise hardware. Cheap controllers and worn-out sticks often send small non-zero values even when the stick is centered. Without a dead zone, your character drifts or the camera rotates on its own. The applyDeadzone function below clamps values near zero and returns clean input for the rest of the game loop.

function applyDeadzone(value, threshold = 0.1) {
  if (Math.abs(value) < threshold) return 0;
  return value;
}

const x = applyDeadzone(gamepad.axes[0]);
const y = applyDeadzone(gamepad.axes[1]);

Rumble (haptic feedback)

Once you have clean axis and button readings, you can layer on additional features. Modern controllers often support haptic feedback through a vibration actuator. Not every gamepad has one, so always check for availability first. The rumble API uses the playEffect method with "dual-rumble" as the effect type, which drives two motors: one for subtle feedback and one for stronger pulses.

async function vibrate(gamepad, duration = 200, intensity = 0.5) {
  if (gamepad.vibrationActuator) {
    await gamepad.vibrationActuator.playEffect("dual-rumble", {
      startDelay: 0,
      duration: duration,
      weakMagnitude: intensity,
      strongMagnitude: intensity,
    });
  }
}

// Short rumble on hit
vibrate(gamepad, 100, 0.3);

// Longer rumble for low health
vibrate(gamepad, 500, 1.0);

Not all controllers support haptic feedback. Check for gamepad.vibrationActuator before calling it. For controllers that do support it, the effect runs asynchronously. playEffect returns a promise that resolves when the rumble finishes. You can start a new effect before the previous one ends; the controller plays the most recent one.

Multiple controllers

Each gamepad gets a unique index. The first controller is 0, the second is 1, and so on. Keep track of who’s who:

const players = {};

window.addEventListener("gamepadconnected", (event) => {
  const gp = event.gamepad;
  players[gp.index] = {
    gamepad: gp,
    x: 0,
    y: 0,
  };
  console.log(`player ${gp.index} connected`);
});

window.addEventListener("gamepaddisconnected", (event) => {
  delete players[event.gamepad.index];
});

Using with canvas or WebGL

The pattern for games is straightforward: poll the gamepad in your update loop and map each input to a game action. The applyDeadzone function from earlier cleans up stick drift, and you read buttons inside the same loop. Because the gamepad state is polled rather than event-driven, your movement speed is frame-rate independent as long as you multiply axis values by a delta-time factor.

function update() {
  const gamepads = navigator.getGamepads();
  const gp = gamepads[0];
  if (!gp) return;

  // movement from left stick
  const moveX = applyDeadzone(gp.axes[0]);
  const moveY = applyDeadzone(gp.axes[1]);
  player.x += moveX * speed;
  player.y += moveY * speed;

  // jump from A button
  if (gp.buttons[0].pressed) {
    player.jump();
  }

  // aim from right stick
  aimX = applyDeadzone(gp.axes[2]);
  aimY = applyDeadzone(gp.axes[3]);

  requestAnimationFrame(update);
}

This replaces keyboard state tracking with gamepad state polling. The gamepad state is continuous. You get a new position every frame, not events for key down/up.

Permissions Policy

Chrome and Edge require an explicit feature policy to expose gamepad data in iframes. The parent page needs to grant it:

<iframe src="game.html" allow="gamepad"></iframe>

Without this, the iframe sees no gamepads.

Gotchas

No button press events. Unlike keyboard events, there’s no “gamepadbuttonpressed” event. You must poll navigator.getGamepads() inside requestAnimationFrame.

Index reuse. When a gamepad disconnects, its index becomes available for the next device that connects. Don’t store the index as a permanent player ID. Track by the Gamepad object itself.

Pressure-sensitive buttons. Only a few buttons (triggers, sometimes face buttons) report analog values. Most buttons are 0 or 1. Check button.value for analog, button.pressed for digital.

Hidden state until interaction. gamepadconnected only fires after the user presses a button. A gamepad can be technically “connected” at the OS level but invisible to the page until first interaction.

A stable input loop

The most reliable gamepad code does very little work in each frame: read the current state, apply thresholds, and pass clean values into your game logic. Treat the gamepad as a snapshot rather than an event stream. The browser hands you the current state of each controller on every frame, and your job is to translate it into movement and actions. For most game genres, this polling approach produces responsive controls without the complexity of an event-driven input system.

Keep the mapping layer completely separate from game rules. A button press becomes an action name, not a direct call to player.jump(). That separation lets you support multiple controller layouts and remap inputs without rewriting gameplay code.

When haptics are available, use them to reinforce important moments: a short rumble on hit, a stronger pulse for low health. Constant vibration stops feeling useful quickly. The same restraint applies across the API: only react to the inputs that matter for the experience you are building.

See also