Handmade Web Games

Gamepads

gamepads API Reference

Player controllers

Each player (gamepads.p1gamepads.p4) provides:

  • index - 0 | 1 | 2 | 3
  • gamepad - Raw Gamepad object
  • To detect layout/model/vendor/product from the browser Gamepad.id string, call identifyGamepad (exported from @spud.gg/api) so apps that don't need identification can tree-shake the large internal ID list. For a connected player: identifyGamepad(player.gamepad?.id ?? ''). It returns:
    • layoutGamepadBaseLayout (Nintendo | PlayStation | Xbox | Generic)
    • generalModelGamepadModel (generic model enum such as Xbox360, including for many off-brand pads)
    • vendorId / productId – Hex strings parsed from the gamepad id when known
    • productName – Human-readable product string when known, otherwise 'Unknown'
  • justConnected - true if the gamepad just connected this frame
  • justDisconnected - true if the gamepad just disconnected this frame
  • isDown(button) - Checks if a button is currently pressed
  • wasDown(button) - Checks if a button was pressed in the previous frame
  • justPressed(button) - Checks if a button was just pressed (current frame && not previous)
  • justReleased(button) - Checks if a button was just released (previous frame && not current)
  • buttonHeld(button) - Checks if a button is being held (pressed in both current and previous frame)
  • buttonsDown - A Set of all currently pressed buttons
  • previousButtonsDown - A Set of all buttons pressed in the previous frame
  • leftStick/rightStick - Analog stick accessors with properties:
    • x/y - Normalized values that account for deadzones
    • rawX/rawY - Raw axis values directly from the gamepad
    • magnitude - Distance from center of the analog stick (0 to 1)
    • angle - Angle in radians (-π to π)
    • angleDegrees - Angle in degrees (0 to 360), clockwise from right
    • cardinal - Nearest cardinal direction (N/NE/E/SE/S/SW/W/NW)
    • snap4 - Normalized values that snap to 4-way directions (up/down/left/right)
    • snap8 - Normalized values that snap to 8-way directions (including diagonals)
    • deadzone - The amount of deadzone for the stick
    • motion(options?) - Detect directional motion with hysteresis to prevent jittery state changes
      • options.activate - Threshold for activating motion (0-1, default: 0.75)
      • options.deactivate - Threshold for deactivating motion (0-1, default: 0.25)
      • options.diagonals - If true, returns 8-way directions; if false, returns 4-way (default: false)
      • Returns (4-way): { justMoved: { Up, Down, Left, Right }, direction: { x, y } }
      • Returns (8-way): { justMoved: { N, NE, E, SE, S, SW, W, NW }, direction: CardinalDirection | 'Neutral' }
  • leftTrigger/rightTrigger - Trigger accessors with properties:
    • value - Normalized value that accounts for deadzone
    • rawValue - Raw analog button value directly from the gamepad
    • deadzone - The amount of deadzone for the trigger
    • rumble(duration?, intensity?) - Method to provide haptic feedback to the trigger
      • duration: number - Duration in milliseconds (optional, default: 30)
      • intensity: HapticIntensity | number - Intensity of the rumble (optional, default: Balanced)
  • rumble(duration?, intensity?) - Easy vibration effect
    • duration: number - Duration in milliseconds (optional, default: 30)
    • intensity: HapticIntensity - Intensity of the rumble (optional, default: Balanced)
  • haptic({ duration?, strong?, weak? }) - Fine-grained haptic control
    • duration: number - Duration of haptic effect in milliseconds (optional, default: 0)
    • strong: number - Intensity of strong actuator (0-1) (optional, default: 0)
    • weak: number - Intensity of weak actuator (0-1) (optional, default: 0)
  • hapticReset() - Cancels any ongoing haptic feedback

Examples

// check if a button is currently pressed
if (gamepads.p1.isDown(Button.South)) {
  console.log('A/✕ button is being pressed');
}

// check for buttons that were just pressed this frame
if (gamepads.p2.justPressed(Button.North)) {
  console.log('Y/△ button was just pressed');
}

// using analog stick values with deadzone handling
const { x, y } = gamepads.p1.leftStick;
yourGame.moveCharacter(x, y); // values are normalized with deadzone applied

// using analog stick with 8-way directional snapping
const { x: snapX, y: snapY } = gamepads.p1.rightStick.snap8;
yourGame.aimWeapon(snapX, snapY);

// using analog stick geometric properties
const { magnitude, angle, cardinal } = gamepads.p1.leftStick;
if (magnitude > 0.8) yourGame.sprint();
yourGame.rotateCamera(angle);
if (cardinal === 'NE') yourGame.openNorthEastDoor();

// using motion detection for menu navigation
const { justMoved } = gamepads.p1.leftStick.motion();
if (justMoved[Motion.Up]) yourGame.selectPreviousMenuItem();
if (justMoved[Motion.Down]) yourGame.selectNextMenuItem();

// using 8-way motion for aiming with custom activation/deactivation thresholds
const { direction, didJustMove } = gamepads.p1.rightStick.motion({
  diagonals: true, // 8-way
  activate: 0.5,
  deactivate: 0.2,
});
if (didJustMove) {
  yourGame.aimWeapon(direction);
}

// using trigger values
if (gamepads.p1.rightTrigger.value > 0.8) {
  yourGame.fireWeapon();

  // apply haptic feedback to the trigger
  gamepads.p1.rightTrigger.rumble(100, HapticIntensity.Heavy);
}

// apply general haptic feedback
gamepads.p1.rumble(200, HapticIntensity.Balanced);

// custom haptic control
gamepads.p1.haptic({
  duration: 300,
  strong: 0.7,
  weak: 0.3,
});

gamepads.p1.leftTrigger.rumble();
gamepads.p1.rightTrigger.rumble(50, HapticIntensity.Heavy);

// cancel all haptic effects
gamepads.p1.hapticReset();

Constants and types

  • Button - Button constants that map to their index in the standard mapping. eg. DpadUp, Start, Select, LeftShoulder, North/South/East/West (maps to Xbox Y/A/X/B or PlayStation △/✕/□/○ or Nintendo X/B/A/Y), etc.
  • Axis - Gamepad axes -- Axis.LeftStickX, Axis.LeftStickY, Axis.RightStickX, Axis.RightStickY
  • Player - Player.P1, Player.P2, Player.P3, Player.P4
  • GamepadBaseLayout - Controller base layouts -- GamepadBaseLayout.Xbox, GamepadBaseLayout.PlayStation, GamepadBaseLayout.Nintendo, GamepadBaseLayout.Generic
  • GamepadModel - Common controller models, which several specific products can map to -- GamepadModel.Xbox360, GamepadModel.PlayStationDualSense, GamepadModel.SwitchPro, GamepadModel.SteamDeck, etc.
  • identifyGamepad(rawId: string) - Maps a browser Gamepad.id string to layout, generalModel, vendorId, productId, and productName. Import only when you need identification so bundlers can omit the ID database otherwise.
  • HapticIntensity - HapticIntensity.Light, HapticIntensity.Balanced, HapticIntensity.Heavy
  • Motion - Directional movement -- Motion.Up, Motion.Down, Motion.Left, Motion.Right
  • CardinalDirection - 8-way directions for joystick movements -- CardinalDirection.N, CardinalDirection.NE, CardinalDirection.S, etc.

Examples

import {
  CardinalDirection,
  GamepadBaseLayout,
  GamepadModel,
  Button,
  HapticIntensity,
  Motion,
  gamepads,
  identifyGamepad,
} from '@spud.gg/api';

// button constants
if (gamepads.p1.justPressed(Button.South)) yourGame.jump();
if (gamepads.p1.isDown(Button.DpadLeft)) yourGame.moveLeft();

// haptic intensity levels
gamepads.p1.rumble(100, HapticIntensity.Light);
gamepads.p1.rumble(100, HapticIntensity.Balanced);
gamepads.p1.rumble(100, HapticIntensity.Heavy);

// gamepad identification (optional import — omit if you don't need layout/model detection)
const info = identifyGamepad(gamepads.p1.gamepad?.id ?? '');
if (info.layout === GamepadBaseLayout.Nintendo) {
  // probably flip A/B and X/Y
}
if (info.generalModel === GamepadModel.PlayStationDualSense) {
  // show touchpad in controller layout
}

// motion and direction constants (4-way)
const { direction, didJustMove } = gamepads.p1.leftStick.motion();
if (direction.x === Motion.Left) yourGame.moveLeft();
if (direction.y === Motion.Up) yourGame.moveUp();

// cardinal directions for 8-way movement
const { direction: cardinalDir } = gamepads.p1.leftStick.motion({ diagonals: true });
if (cardinalDir === CardinalDirection.NE) yourGame.moveNorthEast();

Player groups

utilities for handling multiple players at once.

anyPlayer

  • isDown(button) - Checks if any connected player is pressing the button
  • wasDown(button) - Checks if any connected player was pressing the button in the previous frame
  • justPressed(button) - Checks if any connected player just pressed the button
  • justReleased(button) - Checks if any connected player just released the button
  • buttonHeld(button) - Checks if any connected player is holding the button

connectedPlayers

  • list - Array of only the connected players (gamepads that actually exist)
  • isDown(button) - Checks if all connected players are pressing the button
  • buttonHeld(button) - Checks if all connected players are holding the button
  • haptic({ duration?, strong?, weak? }) - Apply haptic feedback to all connected players
    • duration: number - Duration of haptic effect in milliseconds (optional, default: 0)
    • strong: number - Intensity of strong actuator (0-1) (optional, default: 0)
    • weak: number - Intensity of weak actuator (0-1) (optional, default: 0)
  • rumble(duration?, intensity?) - Applies rumble effect to all connected players
    • duration: number - Duration in milliseconds (optional, default: 30)
    • intensity: HapticIntensity - Intensity of the rumble (optional, default: Balanced)
  • leftTrigger.rumble(duration?, intensity?) - Trigger rumble for all connected players
    • duration: number - Duration in milliseconds (optional, default: 30)
    • intensity: HapticIntensity - Intensity of the rumble (optional, default: Balanced)
  • rightTrigger.rumble(duration?, intensity?) - Trigger rumble for all connected players
    • duration: number - Duration in milliseconds (optional, default: 30)
    • intensity: HapticIntensity - Intensity of the rumble (optional, default: Balanced)

singlePlayer

gamepads.singleplayer is a reference to the most recently active gamepad's player. So if gamepads.p2 was the most recently active player, gamepads.singlePlayer === gamepads.p2. This allows you to easily create single-player games without needing to worry about configuring each controller. The player can seamlessly switch between any connected controllers, or connect a new one and continue playing without any additional setup. Anyone can pick up any connected controller and immediately start playing.

Examples with player groups

import { Button, gamepads, HapticIntensity, identifyGamepad, spud } from '@spud.gg/api';

// start the game when all players press start
if (gamepads.connectedPlayers.isDown(Button.Start)) {
  yourGame.startGame();
}

// charge a special move when all players press a button simultaneously
if (gamepads.connectedPlayers.buttonHeld(Button.South)) {
  yourGame.chargeTeamSpecialMove();
}

// apply haptic feedback to all connected players
gamepads.connectedPlayers.rumble(500, HapticIntensity.Heavy);

// or a custom haptic pattern for all players
gamepads.connectedPlayers.haptic({
  duration: 200,
  strong: 0.8,
  weak: 0.4,
});

// 30ms trigger rumble haptic feedback for all connected players
gamepads.connectedPlayers.leftTrigger.rumble();
gamepads.connectedPlayers.rightTrigger.rumble();

// pause the game when any player presses the Select/Back button
if (gamepads.anyPlayer.justPressed(Button.Select)) {
  spud.pauseGame();
}

// don't worry about p1, p2, etc. for a single-player game.
// instead just use whichever connected controller is actually triggering inputs.
const isJumping = gamepads.singlePlayer.isDown(Button.South);

// loop through just the connected controllers
gamepads.connectedPlayers.list.forEach((player) => {
  const info = identifyGamepad(player.gamepad?.id ?? '');
  const gp = player.gamepad;
  console.log(
    `${spud.players[player.index].name}: ${info.productName} ${info.generalModel} controller "${gp?.id ?? ''}"`,
  );
});

There's also the playerCount property showing the number of currently connected controllers. It's effectively the same as gamepads.connectedPlayers.list.length.

console.log(`${gamepads.playerCount} controller(s) connected`);

players

Currently only includes the .list property, which returns a tuple containing four Player objects that have nullable gamepad properties.

players.list is the same exact thing as connectedPlayers.list but also includes disconnected players. Unlike connectedPlayers.list this will:

  • always return an array of length 4
  • possibly include null values for the gamepad property on each player in the array

clearInputs

clearInputs() saves the current state of all gamepads so wasDown, justPressed, justReleased, buttonHeld, justConnected, and justDisconnected work correctly on the next frame. Call this once per update, after your update logic and before the next update frame begins.

Angle degrees

the angle of the left analog stick in degrees in the range [0, 360)

angles are clockwise: 0° is right/east, 90° is down/south, 180° is left/west, 270° is up/north. note that positive y-axis values indicate "down" on analog sticks.

Motion

detect directional motion of the left analog stick with activation/deactivation thresholds.

this method uses "hysteresis" to prevent jittery state changes. this means movement is activated when the stick's normalized magnitude exceeds the activation threshold (0.75 by default) and is deactivated when it returns below the deactivation threshold (0.25 by default).

the api is different for 4-way vs 8-way directions.

4-way directions

  1. we give you both the x direction (Left | Right) and the y direction (Up | Down).
  2. activation thresholds are based on the normalized axis values for x and y independently.

In other words, you can have justMoved.Up and justMoved.Left at the same time (unlike in the 8-way directional version).

8-way directions

  1. we give you only a single cardinal direction (NE, S, SW, etc.).
  2. the activation threshold is the magnitude of the normalized vector that combines both x and y.

Other info

  • motion.justMoved values are only set for a single frame, so you don't need to deduplicate them.
  • motion.direction values are persistent and only unset when the stick goes below the deactivation threshold.

Resume cooldown

prevent input bleed-through on resume by artificially preventing A from being pressed within the first second of being resumed unless it's been released within that timeframe.

Controller layouts

The browsers aren't much help when it comes to determining the controller model or layout, so we've been compiling our own list of gamepad layouts.

Here's the rundown: each Gamepad has an id property that will look something like "810-3-USB Gamepad" or "Xbox Elite Series 2 Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b22)", depending on your browser and gamepad model. These IDs contain both a vendor ID and a USB ID as well as (sometimes) the model name. With only the vendor IDs you can get close to knowing the base layout of the controller:

const microsoftVendorId = "045e"; // xbox layout
const sonyVendorId = "054c"; // playstation layout
const nintendoVendorId = "057e"; // nintendo layout

But there are still many other widely used gamepads with vendor IDs not included here, as well as generic vendor IDs like 810 that early PlayStation controllers used.

We've aggregated vendor and USB product IDs from...

...and compiled and deduplicated these into a list you can access from the identifyGamepad function in the Spud API.

On this page