Gamepad API for web games
The Gamepad API brings console-quality controller support to web games. Support Xbox, PlayStation, Switch Pro, and generic controllers.
1) Detecting gamepads
js
window.addEventListener('gamepadconnected', (e) => {
console.log('Gamepad connected:', e.gamepad.id)
console.log('Index:', e.gamepad.index)
console.log('Buttons:', e.gamepad.buttons.length)
console.log('Axes:', e.gamepad.axes.length)
})
window.addEventListener('gamepaddisconnected', (e) => {
console.log('Gamepad disconnected:', e.gamepad.id)
})2) Polling gamepad state
Gamepad state must be polled (not event-driven):
js
function pollGamepads() {
const gamepads = navigator.getGamepads()
for (const gamepad of gamepads) {
if (gamepad) {
processGamepad(gamepad)
}
}
}
// Call in your game loop
function gameLoop() {
pollGamepads()
update()
render()
requestAnimationFrame(gameLoop)
}3) Standard button mapping
The "standard" mapping (most Xbox/PlayStation controllers):
js
const BUTTONS = {
A: 0, // Cross (PS) / A (Xbox)
B: 1, // Circle (PS) / B (Xbox)
X: 2, // Square (PS) / X (Xbox)
Y: 3, // Triangle (PS) / Y (Xbox)
LB: 4, // L1 (PS) / LB (Xbox)
RB: 5, // R1 (PS) / RB (Xbox)
LT: 6, // L2 (PS) / LT (Xbox)
RT: 7, // R2 (PS) / RT (Xbox)
SELECT: 8, // Share (PS) / Back (Xbox)
START: 9, // Options (PS) / Start (Xbox)
L3: 10, // Left stick press
R3: 11, // Right stick press
DPAD_UP: 12,
DPAD_DOWN: 13,
DPAD_LEFT: 14,
DPAD_RIGHT: 15,
HOME: 16, // PS button / Xbox button
}
const AXES = {
LEFT_X: 0,
LEFT_Y: 1,
RIGHT_X: 2,
RIGHT_Y: 3,
}4) Reading buttons and sticks
js
function processGamepad(gamepad) {
// Buttons (digital or analog)
const jump = gamepad.buttons[BUTTONS.A].pressed
const shoot = gamepad.buttons[BUTTONS.X].pressed
// Triggers (analog 0-1)
const leftTrigger = gamepad.buttons[BUTTONS.LT].value
const rightTrigger = gamepad.buttons[BUTTONS.RT].value
// Sticks (analog -1 to 1)
const leftX = gamepad.axes[AXES.LEFT_X]
const leftY = gamepad.axes[AXES.LEFT_Y]
const rightX = gamepad.axes[AXES.RIGHT_X]
const rightY = gamepad.axes[AXES.RIGHT_Y]
return { jump, shoot, leftTrigger, rightTrigger, leftX, leftY, rightX, rightY }
}5) Deadzone handling
Sticks rarely rest at exactly 0:
js
function applyDeadzone(value, deadzone = 0.15) {
if (Math.abs(value) < deadzone) return 0
// Rescale remaining range to 0-1
const sign = Math.sign(value)
const adjusted = (Math.abs(value) - deadzone) / (1 - deadzone)
return sign * adjusted
}
function getStickInput(gamepad) {
return {
leftX: applyDeadzone(gamepad.axes[AXES.LEFT_X]),
leftY: applyDeadzone(gamepad.axes[AXES.LEFT_Y]),
rightX: applyDeadzone(gamepad.axes[AXES.RIGHT_X]),
rightY: applyDeadzone(gamepad.axes[AXES.RIGHT_Y]),
}
}6) Radial deadzone (better for 2D movement)
js
function applyRadialDeadzone(x, y, deadzone = 0.15) {
const magnitude = Math.sqrt(x * x + y * y)
if (magnitude < deadzone) {
return { x: 0, y: 0 }
}
const normalized = {
x: x / magnitude,
y: y / magnitude,
}
const adjusted = (magnitude - deadzone) / (1 - deadzone)
return {
x: normalized.x * adjusted,
y: normalized.y * adjusted,
}
}
// Usage
const stick = applyRadialDeadzone(
gamepad.axes[AXES.LEFT_X],
gamepad.axes[AXES.LEFT_Y]
)
player.vx = stick.x * player.speed
player.vy = stick.y * player.speed7) Complete gamepad manager
js
class GamepadManager {
constructor() {
this.gamepads = new Map()
this.prevState = new Map()
window.addEventListener('gamepadconnected', (e) => {
this.gamepads.set(e.gamepad.index, e.gamepad)
console.log('Connected:', e.gamepad.id)
})
window.addEventListener('gamepaddisconnected', (e) => {
this.gamepads.delete(e.gamepad.index)
this.prevState.delete(e.gamepad.index)
})
}
poll() {
const gamepads = navigator.getGamepads()
for (const gp of gamepads) {
if (gp) this.gamepads.set(gp.index, gp)
}
}
getState(index = 0) {
const gp = this.gamepads.get(index)
if (!gp) return null
const stick = applyRadialDeadzone(gp.axes[0], gp.axes[1])
const rightStick = applyRadialDeadzone(gp.axes[2], gp.axes[3])
return {
connected: true,
leftStick: stick,
rightStick: rightStick,
// Face buttons
a: gp.buttons[0].pressed,
b: gp.buttons[1].pressed,
x: gp.buttons[2].pressed,
y: gp.buttons[3].pressed,
// Bumpers
lb: gp.buttons[4].pressed,
rb: gp.buttons[5].pressed,
// Triggers (analog)
lt: gp.buttons[6].value,
rt: gp.buttons[7].value,
// D-pad
dpadUp: gp.buttons[12].pressed,
dpadDown: gp.buttons[13].pressed,
dpadLeft: gp.buttons[14].pressed,
dpadRight: gp.buttons[15].pressed,
// Meta
select: gp.buttons[8].pressed,
start: gp.buttons[9].pressed,
}
}
isPressed(index, button) {
const gp = this.gamepads.get(index)
return gp?.buttons[button]?.pressed || false
}
wasJustPressed(index, button) {
const current = this.isPressed(index, button)
const prev = this.prevState.get(index)?.[button] || false
return current && !prev
}
endFrame() {
for (const [index, gp] of this.gamepads) {
const state = {}
for (let i = 0; i < gp.buttons.length; i++) {
state[i] = gp.buttons[i].pressed
}
this.prevState.set(index, state)
}
}
}
// Usage
const gamepadManager = new GamepadManager()
function update() {
gamepadManager.poll()
const state = gamepadManager.getState(0)
if (state) {
player.vx = state.leftStick.x * player.speed
player.vy = state.leftStick.y * player.speed
if (gamepadManager.wasJustPressed(0, BUTTONS.A)) {
player.jump()
}
}
gamepadManager.endFrame()
}8) Vibration / haptic feedback
js
function vibrate(gamepad, duration = 200, weakMagnitude = 0.5, strongMagnitude = 0.5) {
if (gamepad.vibrationActuator) {
gamepad.vibrationActuator.playEffect('dual-rumble', {
startDelay: 0,
duration,
weakMagnitude, // High frequency motor
strongMagnitude, // Low frequency motor
})
}
}
// Usage
function onPlayerHit() {
const gp = navigator.getGamepads()[0]
if (gp) vibrate(gp, 150, 0.3, 0.6)
}
function onExplosion() {
const gp = navigator.getGamepads()[0]
if (gp) vibrate(gp, 300, 0.8, 1.0)
}9) Multiple players
js
class LocalMultiplayer {
constructor(maxPlayers = 4) {
this.maxPlayers = maxPlayers
this.players = []
}
getActivePlayers() {
const gamepads = navigator.getGamepads()
const active = []
for (let i = 0; i < this.maxPlayers; i++) {
if (gamepads[i]) {
active.push({
index: i,
gamepad: gamepads[i],
state: this.getState(gamepads[i])
})
}
}
return active
}
getState(gp) {
// ... same as before
}
}
// In game loop
const multiplayer = new LocalMultiplayer(4)
function update() {
const players = multiplayer.getActivePlayers()
for (const { index, state } of players) {
updatePlayer(index, state)
}
}10) Fallback and combined input
Support both keyboard and gamepad:
js
class InputManager {
constructor() {
this.keyboard = new KeyboardInput()
this.gamepad = new GamepadManager()
}
poll() {
this.gamepad.poll()
}
getInput(playerIndex = 0) {
const gpState = this.gamepad.getState(playerIndex)
const kbState = this.keyboard.getState()
// Combine inputs (gamepad takes priority for analog)
return {
moveX: gpState?.leftStick.x || (kbState.left ? -1 : kbState.right ? 1 : 0),
moveY: gpState?.leftStick.y || (kbState.up ? -1 : kbState.down ? 1 : 0),
jump: gpState?.a || kbState.space,
shoot: gpState?.x || kbState.z,
pause: gpState?.start || kbState.escape,
}
}
endFrame() {
this.gamepad.endFrame()
this.keyboard.endFrame()
}
}