How to Poll Gamepad API Inputs in JavaScript

The Gamepad API allows web applications to interface directly with connected controllers, but unlike typical browser inputs that rely purely on events, controller state updates require a polling model. This article explains the mechanics of polling gamepad states in browser JavaScript, focusing on the distinction between connection lifecycle events and real-time input capture using navigator.getGamepads() within an animation loop.

Polling vs. Event-Driven Inputs

Standard web interactions (such as mouse clicks or keyboard presses) trigger discrete event listeners like keydown or mousemove. While the Gamepad API provides the gamepadconnected and gamepaddisconnected window events to notify the browser when a device is plugged in or removed, it does not fire continuous events for button presses or thumbstick movements.

Because controllers produce high-frequency, continuous analog and digital data, firing an event for every micro-movement would overwhelm the browser’s event loop. Instead, the Gamepad API relies on polling: the application actively queries the hardware state at a fixed interval.

The Polling Mechanism: navigator.getGamepads()

The primary method for reading controller data is navigator.getGamepads(). When invoked, this method returns an array of Gamepad objects (or null for empty slots) representing the current snapshot of connected controllers.

Because the returned Gamepad object represents a static snapshot in time, you must call navigator.getGamepads() repeatedly to receive updated input states. Storing a reference to a Gamepad object will not automatically update its properties as buttons are pressed.

Implementing the Polling Loop with requestAnimationFrame

To ensure inputs are polled in sync with the display’s refresh rate and without unnecessary performance overhead, developers use requestAnimationFrame().

The standard implementation involves defining a loop function that queries navigator.getGamepads(), processes the input values, and schedules the next iteration:

function pollGamepad() {
  const gamepads = navigator.getGamepads();
  const gp = gamepads[0]; // Access the first connected controller

  if (gp) {
    // Process button states
    gp.buttons.forEach((button, index) => {
      if (button.pressed) {
        console.log(`Button ${index} pressed with value: ${button.value}`);
      }
    });

    // Process analog axes (thumbsticks)
    gp.axes.forEach((axis, index) => {
      // Apply a deadzone threshold to avoid drift
      if (Math.abs(axis) > 0.1) {
        console.log(`Axis ${index} position: ${axis}`);
      }
    });
  }

  // Continue the polling loop
  requestAnimationFrame(pollGamepad);
}

// Start polling when a controller connects
window.addEventListener("gamepadconnected", (e) => {
  console.log("Gamepad connected:", e.gamepad.id);
  requestAnimationFrame(pollGamepad);
});

Reading State: Buttons and Axes

Within each polled Gamepad snapshot, inputs are divided into two main categories:

  1. buttons Array: An array of GamepadButton objects. Each object provides a boolean pressed state and a numerical value between 0.0 (unpressed) and 1.0 (fully pressed), accommodating pressure-sensitive triggers.
  2. axes Array: An array of floating-point numbers typically ranging from -1.0 to 1.0, representing the horizontal and vertical positions of analog thumbsticks. When polling axes, applying a software deadzone (ignoring values close to 0.0) is recommended to prevent input drift caused by hardware tolerances.