JavaScript Gamepad API Table
| Piece | What it does | Field note |
|---|---|---|
navigator.getGamepads() | The poll call | Returns an array with nulls; NO input events exist - you pull state every frame |
gamepadconnected / disconnected | The two events | Fire only after the user PRESSES a button - anti-fingerprinting, not a bug |
gamepad.mapping === 'standard' | The layout promise | Standard = Xbox-like: button 0 = A, 9 = Start, axes 0-3 = sticks; empty string = raw vendor order |
buttons[i].pressed / .value | Digital + analog | Face buttons boolean; TRIGGERS carry analog value 0.0-1.0 (buttons 6-7 standard) |
axes[0..1] / [2..3] | The stick pairs | Left stick then right stick, -1 to 1 from center; resting jitter 0.02-0.08 is normal |
radial deadzone ~0.12 | The jitter filter | Rescale after the cutoff ((v - sign(v)*dz) / (1 - dz)) or the first 12% feels dead |
gamepad.vibrationActuator | Rumble | playEffect('dual-rumble', {...}) - Chromium-family, no permission prompt; guard for absence |
requestAnimationFrame poll | The read loop | Pads report 125-250Hz internally; rAF sampling reads fresh state - diff snapshots for press edges |
The Gamepad API has no input events - that is the design fact everything else follows from. You call navigator.getGamepads() whenever you want fresh state (usually once per requestAnimationFrame frame), and you read button and axis arrays like a snapshot of a joystick board. Nothing pushes to you; you pull every frame.
Bottom line: gamepadconnected fires only after the user PRESSES A BUTTON - browsers refuse to expose devices before real input as an anti-fingerprinting measure, so your UI must say press any button instead of expecting an instant connection. And the standard mapping is the API's one sanity promise: when gamepad.mapping === 'standard', button 0 is A, 9 is Start, axes 0-3 are the sticks, on every platform that honors it.
The honest part: resting analog sticks are never perfectly zero - they jitter around 0.02-0.08 depending on hardware wear. Every deadzone you skip becomes a character that drifts when the player is not touching anything; every deadzone you overshoot feels laggy. A radial deadzone of 0.1-0.15 with smooth rescaling is the working default.
How to use
- Poll inside your frame loop: function tick(){ const pads = navigator.getGamepads(); for (const p of pads) if (p) readPad(p); requestAnimationFrame(tick); } - snapshots go stale between frames, so poll every frame while gameplay is active.
- Gate on the connect event: window.addEventListener('gamepadconnected', e => startPlayer(e.gamepad.index)) - before the first button press, getGamepads() reports the device but with empty data in some browsers; treat the event as your real start signal.
- Read the standard mapping: if (p.mapping === 'standard') p.buttons[0] is A/Cross, p.buttons[1] is B/Circle, p.buttons[9] is Start, p.axes[0..1] and [2..3] are the two sticks - one layout table for every mainstream pad.
- Apply a deadzone: const dz = (v) => Math.abs(v) < 0.12 ? 0 : (v - Math.sign(v) * 0.12) / (1 - 0.12) - the rescale keeps full throw after the deadzone instead of a dead first 12%.
- Rumble on hit: pad.vibrationActuator.playEffect('dual-rumble', { duration: 120, strongMagnitude: 0.8, weakMagnitude: 0.4 }) - Chromium-family support, no permission prompt; guard for absence and degrade silently.
Frequently asked questions
Why does my game never see the controller until I press a button?
Privacy by design: browsers do not expose gamepad DATA until the device produces real input, which is when the gamepadconnected event fires. Before that, getGamepads() may list the pad with no readable state - enough to know something is plugged in, not enough to read it. Ship the press-any-button prompt: it is not a workaround, it IS the standard UX; Steam's big-picture mode and every console do the same dance for the same reason.
Do I really have to poll every frame - is there no gamepadinput event?
Correct, there is none in the stable API: the committee chose polling because gamepads report at their own rate (often 125-250Hz) and event-per-report would flood the main thread. Poll once per requestAnimationFrame and diff against the previous snapshot to detect presses and releases yourself; that diff is also where you implement your own edge-detection (pressed THIS frame, not held). Chrome's data updates between rAF ticks, so frame-rate polling reads fresh state - no throttle needed at 60fps, and a 120Hz display simply samples twice as often.
What is the standard mapping and what do I do when it is missing?
mapping === 'standard' is the vendor's promise that buttons and axes follow the Xbox-style layout: face buttons 0-3, D-pad 12-15, shoulder 4-5, triggers 6-7 (analog), clicks 10-11, sticks clicks 10-11 region per spec sheet. Some pads (adapters, exotic vendors) report mapping: '' - raw hardware order. For those, either show a remap UI or refuse gracefully; guessing hardware layouts from id strings is whack-a-mole. The pragmatic call: standard-only support covers the overwhelming majority of modern pads.
How do analog triggers and sticks differ in the API?
Sticks are axes (analog pair per stick, floating -1 to 1 from center); face buttons are digital (buttons[i].pressed boolean) while TRIGGERS are buttons with analog VALUE - buttons[6].value and buttons[7].value report 0.0-1.0 trigger travel on standard-mapped pads. Use the value for driving and shooting games where half-pull matters, and remember the pressed boolean on triggers fires around the 0.5 midpoint - reading value directly gives you the finer curve.