Gamepad Api

The Gamepad API is green tier and works with any USB or Bluetooth controller. But it does not push events -- you poll button state 60 times per second. Here is the loop pattern and why you need previous-state tracking.

June 10, 20264 min read10 / 17

Most browser APIs are event-driven. Something happens, a callback fires. You respond.

The Gamepad API is not that.

The Gamepad API has no button-press event. You ask the browser 60 times per second "is this button pressed right now?" and check the boolean yourself. Understanding this upfront saves a lot of confusion the first time you use it. It sits in the green tier -- every major browser supports it.

Connection and Discovery

When a gamepad is plugged in or connected via Bluetooth, a gamepadconnected event fires:

JavaScript
window.addEventListener('gamepadconnected', (event) => { console.log(event.gamepad.id) // "Xbox Controller" or similar console.log(event.gamepad.buttons.length) // number of buttons console.log(event.gamepad.axes.length) // number of axes (typically 4) startPollingLoop() }) window.addEventListener('gamepaddisconnected', (event) => { stopPollingLoop() })

The API is high-level. You do not need to know the internal specifics of the controller. The browser normalizes it into buttons and axes.

No permission dialog appears. The gamepad API is in the harmless tier -- accessing a gamepad is treated like accessing a keyboard. If the OS can see it, the browser can use it.

Two Gotchas Before You Start

navigator.getGamepads() never returns an empty array. It returns a fixed-size array of four slots, with nulls for unoccupied slots. Even with zero gamepads connected, you get [null, null, null, null]. When a gamepad connects, it occupies the first available slot.

JavaScript
// Wrong assumption: checking .length const gamepads = navigator.getGamepads() // gamepads.length is always 4 // Correct: check if the slot is non-null const gp = gamepads[0] if (gp) { /* gamepad is present */ }

The browser disconnects the gamepad on every page load. Even if the physical device is connected to the OS, the browser treats it as disconnected until the user presses a button. This is intentional -- passive fingerprinting. The page sees the gamepad only after user interaction triggers gamepadconnected.

A third practical issue: gamepads go into sleep mode after a few minutes of inactivity. The light turns off, the browser still thinks it is connected, but it stops sending state. Pressing any button wakes it up and resumes reporting.

The Poll Loop

Inside the loop, you call navigator.getGamepads() to get a snapshot of the current gamepad state. This snapshot reflects the state at the moment you call it.

JavaScript
let prevPressed = [] let animFrameId = null function loop() { const gamepads = navigator.getGamepads() const gp = gamepads[0] if (!gp) { animFrameId = requestAnimationFrame(loop) return } gp.buttons.forEach((button, index) => { const isPressed = button.pressed if (isPressed && !prevPressed[index]) { onButtonDown(index) // fires exactly once per press } if (!isPressed && prevPressed[index]) { onButtonUp(index) // fires exactly once per release } prevPressed[index] = isPressed }) animFrameId = requestAnimationFrame(loop) } function startPollingLoop() { animFrameId = requestAnimationFrame(loop) } function stopPollingLoop() { if (animFrameId) cancelAnimationFrame(animFrameId) }

The prevPressed array is the key. Without it, onButtonDown fires every frame while the button is held -- that is 30 to 60 times per second. Games do not want that for most button actions. By comparing current state to previous state, you detect the transition: not-pressed → pressed (button down) and pressed → not-pressed (button up).

Gamepad API: the poll loop pattern, button boolean state, axes -1 to 1, and previous-state tracking ExpandGamepad API: the poll loop pattern, button boolean state, axes -1 to 1, and previous-state tracking

Buttons and Axes

Buttons -- each gamepad.buttons[n] has two properties:

  • pressed: boolean, true while held
  • value: 0 to 1 float for analog triggers (useful for gas pedal-style controls)

Axes -- each gamepad.axes[n] is a float from -1.0 to 1.0. Most controllers have four axes, two per stick. For a standard layout:

  • axes[0]: left stick X (left=-1, right=1)
  • axes[1]: left stick Y (up=-1, down=1)
  • axes[2]: right stick X
  • axes[3]: right stick Y

Sticks rarely return exactly 0 at rest -- there is always slight drift. For movement, apply a dead zone:

JavaScript
function applyDeadzone(value, threshold = 0.1) { return Math.abs(value) > threshold ? value : 0 }

Browser Support

Green tier -- the Gamepad API works in Chrome, Firefox, Safari, and Edge. Both USB and Bluetooth controllers work if the OS recognizes them using the standard gamepad interface.

Any modern console controller works: Xbox, PlayStation DualShock/DualSense, Switch Pro Controller when connected via USB or Bluetooth. The button layout varies, but the buttons and axes arrays normalize them to a standard mapping.

The Gamepad API covers the standard feature set every controller shares. But some devices have capabilities that go beyond buttons and axes -- LED color, rumble motors, built-in accelerometers. For those, there is a lower-level API called WebHID that gives you full protocol access to any HID device.

The Essentials

  1. No button-press event exists. Use requestAnimationFrame to poll state 60× per second.
  2. Compare to previous frame state to detect transitions. Without it, held buttons fire your handler dozens of times.
  3. buttons[n].pressed is the boolean. buttons[n].value is the 0–1 float for analog triggers.
  4. axes[0..3] are -1 to 1 floats. Apply a dead zone (threshold ~0.1) to ignore stick drift.
  5. Green tier -- works in all browsers. USB and Bluetooth both work.

Further Reading and Watching