Gamepad API for Web Games: A Developer's Practical Guide
Adding controller support to a browser game takes an afternoon — doing it well takes a little longer. The Gamepad API is small, stable, and free of plugins, but it has a polling model, a privacy quirk, and a few hardware realities that catch first-timers off guard. This is the guide for developers shipping it.
The core model: you poll, the browser doesn't push
Unlike keyboard or mouse input, the Gamepad API has no per-button events. You ask for the current state, typically once per frame inside your requestAnimationFrame loop, via navigator.getGamepads(). It returns an array-like snapshot: each connected controller is a Gamepad object, or null for empty slots.
There are exactly two events in the whole API — gamepadconnected and gamepaddisconnected — and they only tell you a device arrived or left. Everything else is polling. This design is deliberate: games already run a per-frame loop, and polling keeps input latency predictable instead of scattering it across event callbacks.
Practical consequence: never cache a Gamepad object across frames and read it later. The spec lets browsers return live objects, but the safe pattern is to call getGamepads() fresh every frame and read the snapshot immediately.
Buttons, axes, and the standard mapping
Each Gamepad exposes buttons (an array of objects with pressed and value) and axes (an array of floats from -1.0 to 1.0). Triggers are buttons with analog value; sticks are pairs of axes.
The mapping property is the part that saves you. When it equals "standard" — which it does for the overwhelming majority of modern pads in Chrome, Edge, Firefox, and Safari — button 0 is the bottom face button, button 9 is start, axes 0/1 are the left stick, and so on, following the de-facto standard layout. Write your code against the standard mapping and you cover Xbox, PlayStation, Switch Pro, and most third-party pads in one code path.
When mapping is empty or something else, you're dealing with an exotic device. The pragmatic move for an indie web game: show a "controller detected but layout unknown" note and fall back to keyboard, rather than building a full remapping UI most players will never open.
Dead zones are your job, not the browser's
Analog sticks report tiny nonzero values at rest — every stick drifts a little. The API gives you raw values, so implement a dead zone: ignore magnitudes below roughly 0.15–0.25 (tune per game; twin-stick shooters want tighter, menus can be looser). Apply it radially (on the stick vector's length), not per-axis, or diagonal movement will feel sticky near the center.
While you're at it, normalize the remaining range so full deflection still reaches 1.0 — otherwise your dead zone silently caps the player's max speed. This two-line fix is the difference between "the controls feel fine" and a launch-week thread about mushy movement.
Rumble: vibrationActuator
Haptics live behind gamepad.vibrationActuator. If present, call playEffect("dual-rumble", { strongMagnitude, weakMagnitude, duration }) — it returns a promise that resolves with "complete" or "preempted". Support is uneven: great on Xbox pads in Chromium, spotty on PlayStation pads, absent on many Bluetooth connections and on iOS. Always feature-detect and treat rumble as garnish, never as gameplay-critical feedback.
One gotcha: rumble typically requires a user gesture context, like audio. Trigger your first effect from a button press (e.g., the "start game" click) rather than during an attract-mode animation, or it will silently do nothing.
The "press a button first" rule
Here's the quirk that generates the most confused bug reports: navigator.getGamepads() returns an empty list until the user has pressed a button on the pad in that browsing session. This is a fingerprinting defense — a page shouldn't be able to enumerate your hardware without interaction. So your "press any key" screen genuinely needs a "or press a button on your controller" prompt, and your connection flow should handle the pad appearing mid-session via the gamepadconnected event.
Related: in some browsers the pad goes quiet when the tab loses focus and reappears on return. Handle gamepaddisconnected gracefully — pause the game or show a reconnect hint rather than leaving the player character running into a wall.
Secure contexts and where the API works
The Gamepad API is available in secure contexts: https:// origins and localhost. If you're testing from a file:// URL or plain http:// on a LAN address, getGamepads() may simply return nothing with no error. Serve over HTTPS (or localhost) from day one and this class of mystery disappears.
Desktop support is broad and stable across Chrome, Edge, Firefox, and Safari. Mobile is the gap: most mobile browsers expose little or no gamepad support, so keep touch and keyboard paths as first-class citizens rather than afterthoughts.
Multiple pads and hot-swapping
getGamepads() can return up to four controllers. Assign pads to players by index, but don't hard-code index 0 as "player one" forever — if pad 0 disconnects mid-session, indices shift. A robust pattern: track pads by gamepad.id + gamepad.index at connection time, and on disconnect, pause and offer a "press a button to rejoin" flow that rebinds whichever pad responds.
For local multiplayer, show a tiny "P1 · Xbox pad" style indicator fed from the id string. Players with two identical pads will thank you when they can tell which is which.
A launch-week testing checklist
Run through this on real hardware before you ship:
Three pad families. At minimum: an Xbox-style pad, a PlayStation pad, and one cheap third-party pad. The third one is where mapping surprises live.
Wired and Bluetooth. Latency and rumble behavior differ; Bluetooth on some platforms drops the vibration actuator entirely.
Two browsers. Chromium and Firefox at least — axis ordering on nonstandard pads has historically differed.
Disconnect mid-game. Yank the cable (or toggle Bluetooth) during play and confirm the game pauses instead of soft-locking.
Fresh profile, no gestures. Load the game, don't touch anything, and confirm the "press a button" prompt appears and works — this is the exact flow the privacy rule governs.
Stick calibration edge cases. Test with a worn pad that drifts; your dead zone should absorb it without eating intentional micro-movements.
Honest limits
The Gamepad API won't give you motion controls, touchpad clicks, or headset audio — it's buttons, axes, and rumble, and that's the whole surface. Latency over Bluetooth plus the browser's input pipeline means frame-perfect fighting-game timing is aspirational rather than guaranteed. And on the web you don't control the player's hardware, so design your control scheme for the standard mapping and treat everything else as a bonus. Within those bounds, though, it's one of the most stable, least surprising web APIs you'll ever ship against — an afternoon to add, a checklist to get right.