# Chapter 8: Controllers and haptics (/guides/handmade-web-games/controllers-and-haptics)





Web games are no longer limited to only mouse and keyboard inputs! Using controllers in web games has been possible since 2012, but even over a decade later I seldom see games take advantage of these capabilities, which is a real shame since controller support--especially in local multiplayer games--can completely transform gameplay.

This chapter will teach you how to add controller support to your web games from scratch. But if you're more interested in a higher-level wrapper for handling controllers so that you don't need to do the math yourself for things like analog stick normalization, we've also [got you covered](/spud-api/api-reference/gamepads).

<Callout type="info" title="Grab a controller!">
  Since this chapter covers controller input and haptics, you'll need a Bluetooth or USB connected
  controller to interact with the examples. In any modern browser and OS, most Xbox and PlayStation
  controllers should work!
</Callout>

<ControllerDiagram />

## The Gamepad API [#the-gamepad-api]

Browsers refer to controllers as "gamepads" so that's what we'll call them from here on out, too. The [Gamepad API](https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API/Using_the_Gamepad_API) <small className="opacity-75">\[MDN]</small> supports up to four simultaneously connected gamepads.

```ts twoslash
const [p1, p2, p3, p4] = navigator.getGamepads();
```

<Callout type="info" title="Not seeing your gamepad?">
  Even after it is properly connected, a gamepad won't show up in the `navigator.getGamepads()`
  array until you trigger an interaction from it, such as pressing a button or wiggling an analog
  stick.
</Callout>

This array generally fills in from left to right, but empty slots are returned as `null`. Browsers try to keep the same controller in the same index, so if three controllers are initially connected and then players 1 and 2 disconnect, the array will look like this:

```ts twoslash
navigator.getGamepads(); // => [null, null, Gamepad, null]
```

Let's say P2 rejoins. They'll enter back into slot 2:

```ts twoslash
navigator.getGamepads(); // => [null, Gamepad, Gamepad, null]
```

Just note that this is a best-effort algorithm, so it isn't guaranteed that a specific controller will rejoin into any particular slot. The practical takeaway here is that your gameplay should not depend on specific slots being filled. If you have a two-player game, it may be the case that the two connected controllers are in slots 3 and 4, not 1 and 2.

So, once you have a reference to a `Gamepad` object, what can you do with it?

## Buttons [#buttons]

Each `Gamepad` contains an array of `buttons`. The index of each button in the array tells you which button it is. The <kbd>Start</kbd> button, for example, is at index `9`, so to check if it's pressed for Player 1, you would run:

```ts twoslash
const [p1] = navigator.getGamepads();

if (p1) {
  const startButton = p1.buttons[9];
  if (startButton.pressed) {
    // do start action
  }
}
```

<ButtonsDemo />

Press some buttons on your controller to see how they're mapped above. But having to remember which button is at each index is a pain. So feel free to copy this constant to refer to gamepad buttons in your codebase by name rather than by index!

```ts twoslash
/**
 * Standard gamepad button mappings using the HTML5 Gamepad API button indices.
 * These values correspond to the physical buttons on most modern controllers.
 * @see https://www.w3.org/TR/gamepad/#dfn-standard-gamepad
 */
export const Button = {
  /** Xbox A / PlayStation ✕ / Nintendo B */
  South: 0,
  /** Xbox B / PlayStation ○ / Nintendo A */
  East: 1,
  /** Xbox X / PlayStation □ / Nintendo Y */
  West: 2,
  /** Xbox Y / PlayStation △ / Nintendo X */
  North: 3,

  /**
   * Xbox LB / PlayStation L1 / Nintendo L
   * Check the boolean `pressed` property.
   */
  LeftShoulder: 4,
  /**
   * Xbox RB / PlayStation R1 / Nintendo R
   * Check the boolean `pressed` property.
   */
  RightShoulder: 5,
  /**
   * Xbox LT / PlayStation L2 / Nintendo ZL
   * When used as a button, check the boolean `pressed` property.
   * When used as an analog value, check the floating point `value` (0-1 range) property.
   */
  LeftTrigger: 6,
  /**
   * Xbox RT / PlayStation R2 / Nintendo ZR
   * When used as a button, check the boolean `pressed` property.
   * When used as an analog value, check the floating point `value` (0-1 range) property.
   */
  RightTrigger: 7,

  /** Select/View/Back button */
  Select: 8,
  /** Start/Menu button */
  Start: 9,

  /** Left analog stick clickable button */
  LeftStick: 10,
  /** Right analog stick clickable button */
  RightStick: 11,

  /** Directional pad up button */
  DpadUp: 12,
  /** Directional pad down button */
  DpadDown: 13,
  /** Directional pad left button */
  DpadLeft: 14,
  /** Directional pad right button */
  DpadRight: 15,

  /**
   * Microsoft "Xbox" button / Sony "PlayStation" button / Nintendo Switch "Home" button
   */
  Home: 16,
  /**
   * Nintendo Switch controller "Capture" button
   * Alias: `Button.TouchPad` (for Sony DualSense/DualShock)
   */
  Capture: 17,
  /**
   * Sony TouchPad button
   * Alias: `Button.Capture` (for Nintendo Switch controllers)
   */
  TouchPad: 17,
} as const;

export type Button = (typeof Button)[keyof typeof Button];
```

## Triggers [#triggers]

Triggers are a special type of button that have a floating point `value` property between `0` and `1`. Squeeze a trigger on your controller to see its value.

<TriggerDemo />

The left trigger happens to be the button at index `6`, so if you wanted to use it to increase your player's speed anywhere between 1-2x, you could do this:

```ts twoslash
const [p1] = navigator.getGamepads();

if (p1) {
  const leftTrigger = p1.buttons[6];
  const speedBoostMultiplier = 1 + leftTrigger.value;
  // ...
}
```

## Axes [#axes]

In addition to buttons and triggers, gamepads have two analog sticks (aka joysticks aka left/right thumbsticks). Each stick has two axes--`x` and `y`. An axis is a floating point number between `-1` and `1`, where `0` is the neutral resting position. The Gamepad API provides an array of four axes we can read.

Move the left and right sticks on your controller to see each axis.

<AxesDemo />

```ts twoslash
/** dt is the fixed `deltaTime` value from the game loop in chapter 3 */
const dt = 0;
//---cut---
const state = { x: 100, y: 100 };

const [p1] = navigator.getGamepads();

if (p1) {
  const [
    leftStickX, // -1 is left, 1 is right
    leftStickY, // -1 is up, 1 is down
    rightStickX,
    rightStickY,
  ] = p1.axes;
  state.x += leftStickX * dt;
  state.y += leftStickY * dt;
}
```

Here's another constant you can copy so you can refer to each axis by name rather than by index:

```ts
/**
 * Standard gamepad axis mappings using the HTML5 Gamepad API axis indices.
 * Axis values range from -1 to 1.
 */
export const Axis = {
  /** Left analog stick horizontal movement */
  LeftStickX: 0,
  /** Left analog stick vertical movement */
  LeftStickY: 1,
  /** Right analog stick horizontal movement */
  RightStickX: 2,
  /** Right analog stick vertical movement */
  RightStickY: 3,
} as const;

export type Axis = (typeof Axis)[keyof typeof Axis];
```

### Thinking in terms of vectors [#thinking-in-terms-of-vectors]

Despite reading the `x` and `y` axes separately, you'll typically want to think of them as forming a single vector together. To get the <strong className="font-bold text-rose-700 dark:text-amber-400">direction</strong> of the vector (in radians), use `atan2(y, x)`. To get its <strong className="font-bold text-sky-600 dark:text-sky-400">magnitude</strong> use `sqrt(x² + y²)`, which JavaScript conveniently has a built-in function for: `Math.hypot(x, y)`.

```ts
const [p1] = navigator.getGamepads();

if (p1) {
  const [x, y] = p1.axes;
  const direction = Math.atan2(y, x);
  const magnitude = Math.hypot(x, y);
}
```

<Atan2Demo />

### Deadzones [#deadzones]

If you've ever experienced ghost movement from loose analog sticks, you know how frustrating it can be when deadzones are not properly accounted for. Most controllers have a tiny bit of drift in each axis when you release your thumbs from them. It's rare that they fully zero out.

<Callout type="warn" title="Am I drifting?">
  Wiggle the thumb sticks a bit, then release them back to the neutral position.

  <DriftCheck />
</Callout>

To account for that, it's a good practice to ignore the first \~20% of movement. But what does it really mean to *ignore* those initial input values?

**<strong className="font-bold text-rose-700 dark:text-rose-400">Axial normalization</strong>:**
The naïve approach ("axial normalization") simply lops off the first 20% of values on each axis. This can work if your game's movement is grid-based, but if you want to be able to freely rotate to any angle, you won't be able to. Axial normalization prevents you from hitting angles that are close to the horizontal and vertical axes, snapping you to straight lines when you approach them.

**<strong className="font-bold text-amber-600 dark:text-amber-400">Radial normalization</strong>:**
Another option is to read both axes and normalize the combined vector ("radial normalization"). This preserves small angular changes and lets you rotate smoothly, but there is still a problem. It's still impossible to move very slowly or precisely since your speed jolts from 0 all the way up to 20% before being able to change smoothly.

**<strong className="font-bold text-sky-600 dark:text-sky-400">Scaled radial normalization</strong>:**
To prevent the jolting behavior, treat 20% as the new zero, and scale the rest of the values from there.

<Callout type="info">
  As you play with this demo, pay close attention to how axial normalization prevents you from being
  able to smoothly rotate. Then when looking at radial normalization, try to reach a very small
  magnitude--it's impossible to get to a magnitude lower than the deadzone. Both of these issues are
  resolved using scaled radial normalization.
</Callout>

<DeadzoneDemo />

For an excellent overview on deadzone normalization, I recommend Josh Sutphin's [Doing Thumbstick Dead Zones Right](https://joshsutphin.com/blog/doing-thumbstick-dead-zones-right.html). Carlos Pérez Ramil's [interactive demo](https://minimuino.github.io/thumbstick-deadzones/demo/) and [deep dive article](https://minimuino.github.io/thumbstick-deadzones/) on deadzones are also well worth a visit!

Here's some drop-in code you can use in order to eliminate deadzones on two axes using scaled radial normalization:

```ts twoslash
export function normalizeRadialAxisValue(x: number, y: number, deadzone = 0.2) {
  const magnitude = Math.hypot(x, y);

  // ignore inputs below deadzone magnitude
  if (magnitude <= deadzone) {
    return { x: 0, y: 0 };
  }

  // scale the remaining magnitude
  const clampedMagnitude = Math.min(magnitude, 1);
  const normalized = (clampedMagnitude - deadzone) / (1 - deadzone);
  const scale = normalized / magnitude;
  return {
    x: x * scale,
    y: y * scale,
  };
}
```

<details>
  <summary>
    <strong>Bonus: 4-way and 8-way snapping</strong>
  </summary>

  If your game only needs movement in a predefined number of directions--rather than the full range of axis values--you can use these helper functions to snap the angle to any number of steps.

  <SnappingDemo />

  ```ts twoslash
  function snapToRadian(x: number, y: number, numberOfSnaps: number, deadzone = 0.2) {
    const magnitude = Math.min(Math.hypot(x, y), 1);
    if (magnitude <= deadzone) return { x: 0, y: 0 };
    const angle = Math.atan2(y, x);
    const snapRadians = (Math.PI * 2) / numberOfSnaps;
    const snappedAngle = snapRadians * Math.round(angle / snapRadians);
    const normalizedMagnitude = (magnitude - deadzone) / (1 - deadzone);
    return {
      x: Math.cos(snappedAngle) * normalizedMagnitude,
      y: Math.sin(snappedAngle) * normalizedMagnitude,
    };
  }

  /**
   * snaps analog stick to 8 directions
   */
  export function snap8(x: number, y: number, deadzone = 0.2) {
    return snapToRadian(x, y, 8, deadzone);
  }

  /**
   * snaps analog stick to 4 directions
   */
  export function snap4(x: number, y: number, deadzone = 0.2) {
    return snapToRadian(x, y, 4, deadzone);
  }
  ```
</details>

### Using analog sticks to fire events [#using-analog-sticks-to-fire-events]

Sometimes you want an analog stick to behave more like the D-pad, like when you're using it to navigate menus. In other words, sometimes you want stick movement to produce one-shot events.

A simple implementation to address this might be to start with 4-way snapping and an activation threshold. You could have logic like: "if the stick is pointing to the right with a magnitude above 80%, move to the next menu item".

<Callout type="warn">
  The problem with this approach is that hovering near the <strong className="font-bold text-emerald-600 dark:text-amber-400">activation threshold</strong> can rapidly re-fire the movement event. Although it may only happen rarely, even occasional flickering or double-movement can make your game feel unpolished.

  <HysteresisProblemDemo />
</Callout>

<Callout type="success">
  The technique for addressing this is called &#x2A;[hysteresis](https://en.wikipedia.org/wiki/Hysteresis)* <small className="opacity-75">\[Wikipedia]</small>, which for our purposes here simply means having
  different <strong className="font-bold text-emerald-600 dark:text-amber-400">activation threshold</strong> and <strong className="font-bold text-red-700 dark:text-sky-400">deactivation threshold</strong>. (More broadly, hysteresis refers to the concept that a system's past state informs its current state.)

  <HysteresisDemo />
</Callout>

## Polled state instead of events [#polled-state-instead-of-events]

Gamepads don't emit events for button presses or joystick movements. Instead, you have to poll a snapshot of their current state. Each time you call `navigator.getGamepads()` you read the latest state of every gamepad.

Because of that, if you want to know when a button was *just* pressed, you need to compare the latest snapshot against some game state from the previous frame.

<Scrollycoding preview="canvas2d">
  <slot>
    <>    </>
  </slot>

  <slot path="steps">
    <>
      In the previous chapter we looked at how to react to keyboard events in order to trigger one-shot actions. The critical insight was to record the key(s) down *immediately* into our `state` from the event handler, and to reset that state at the end of the `fixedUpdate` cycle in the game loop.

      Although gamepads don't emit events for button presses, we can still use a very similar approach.

      In our game state, we'll store a boolean flag for whether the <kbd>jump</kbd> button was pressed in the last cycle. We'll define <kbd>jump</kbd> as the "south" face button at index `0` (<kbd>A</kbd> on an Xbox controller or <kbd>✕</kbd> on a PlayStation controller.) At the top of the `fixedUpdate` cycle, we can *read* this flag. If the controller's jump button is currently pressed but the flag is `false`, that means this is the first moment in our `fixedUpdate` loop that the button became pressed. That makes it possible to react to whether the player `justJumped()`. After you're done with any logic that reads these inputs, just before the end of the `fixedUpdate` function we'll save the prior cycle's value with `wasJumpPressed = button.pressed`.
    </>
  </slot>
</Scrollycoding>

That example only handled *one gamepad* (player 1) and *one button press* (`Button.South` at index `0`). Can the approach be generalized to support all four gamepads and all 18 buttons? Yes!

In the next code example, `previousButtons` keeps one set of buttons for each of the four gamepad slots, and `saveGamepadSnapshot()` saves the current buttons at the end of each `fixedUpdate()`. This powers `justPressed(gamepad: Gamepad, button: number)`, which allows you to react to any gamepad's button presses.

```ts twoslash
/**
 * Standard gamepad button mappings using the HTML5 Gamepad API button indices.
 * These values correspond to the physical buttons on most modern controllers.
 * @see https://www.w3.org/TR/gamepad/#dfn-standard-gamepad
 */
const Button = {
  /** Xbox A / PlayStation ✕ / Nintendo B */
  South: 0,
  /** Xbox B / PlayStation ○ / Nintendo A */
  East: 1,
  /** Xbox X / PlayStation □ / Nintendo Y */
  West: 2,
  /** Xbox Y / PlayStation △ / Nintendo X */
  North: 3,

  /**
   * Xbox LB / PlayStation L1 / Nintendo L
   * Check the boolean `pressed` property.
   */
  LeftShoulder: 4,
  /**
   * Xbox RB / PlayStation R1 / Nintendo R
   * Check the boolean `pressed` property.
   */
  RightShoulder: 5,
  /**
   * Xbox LT / PlayStation L2 / Nintendo ZL
   * When used as a button, check the boolean `pressed` property.
   * When used as an analog value, check the floating point `value` (0-1 range) property.
   */
  LeftTrigger: 6,
  /**
   * Xbox RT / PlayStation R2 / Nintendo ZR
   * When used as a button, check the boolean `pressed` property.
   * When used as an analog value, check the floating point `value` (0-1 range) property.
   */
  RightTrigger: 7,

  /** Select/View/Back button */
  Select: 8,
  /** Start/Menu button */
  Start: 9,

  /** Left analog stick clickable button */
  LeftStick: 10,
  /** Right analog stick clickable button */
  RightStick: 11,

  /** Directional pad up button */
  DpadUp: 12,
  /** Directional pad down button */
  DpadDown: 13,
  /** Directional pad left button */
  DpadLeft: 14,
  /** Directional pad right button */
  DpadRight: 15,

  /**
   * Nintendo Switch "Home" button. Sony "PlayStation" button. Microsoft "Xbox" button.
   */
  Home: 16,
  /**
   * Nintendo Switch controller "Capture" button
   * Alias: `Button.TouchPad` (for Sony DualSense/DualShock)
   */
  Capture: 17,
  /**
   * Sony TouchPad button
   * Alias: `Button.Capture` (for Nintendo Switch controllers)
   */
  TouchPad: 17,
} as const;
// ---cut---
const state = {
  gamepad: {
    previousButtons: [
      new Set<number>(), // player 1
      new Set<number>(), // player 2
      new Set<number>(), // player 3
      new Set<number>(), // player 4
    ],
  },
};

function fixedUpdate() {
  for (const gamepad of navigator.getGamepads()) {
    if (!gamepad) continue;
    if (justPressed(gamepad, Button.South)) {
      // do one-shot action for this gamepad
    }
  }

  // save prev state after handling current inputs
  saveGamepadSnapshot();
}

function draw() {
  // draw the latest game state
}

// new helper functions:

function justPressed(gamepad: Gamepad, button: number) {
  const previousButtons = state.gamepad.previousButtons[gamepad.index];
  return gamepad.buttons[button].pressed && !previousButtons.has(button);
}

function saveGamepadSnapshot() {
  for (const previousButtons of state.gamepad.previousButtons) {
    previousButtons.clear();
  }

  for (const gamepad of navigator.getGamepads()) {
    if (!gamepad) continue;

    const previousButtons = state.gamepad.previousButtons[gamepad.index];

    for (const [button, buttonState] of gamepad.buttons.entries()) {
      if (buttonState.pressed) {
        previousButtons.add(button);
      }
    }
  }
}
```

## Haptics [#haptics]

Most haptics-capable controllers expose low- and high-frequency rumble motors, and some also expose trigger vibration motors.
The Gamepad API lets you control these with values like `strongMagnitude`, `weakMagnitude`, `leftTrigger`, and `rightTrigger`.

<Callout type="error" title="Sorry, Firefox users...">
  Firefox and iOS Safari *still* don't support haptic feedback for gamepads! As such, consider
  haptics to be a progressive enhancement--not something critical for your game to work. You can
  keep tabs on browser support at [caniuse](https://caniuse.com/wf-gamepad-haptics).
</Callout>

<HapticPlayground />

### Dual-rumble [#dual-rumble]

You can target both the low- and high-frequency palm haptic motors at once (or just one at a time) using the `"dual-rumble"` vibration effect:

```ts twoslash
const [p1] = navigator.getGamepads();

if (p1) {
  p1.vibrationActuator?.playEffect("dual-rumble", {
    duration: 200, // milliseconds
    weakMagnitude: 0.3, // 0-1
    strongMagnitude: 0.6, // 0-1
  });
}
```

### Trigger-rumble [#trigger-rumble]

Haptics within each trigger can be activated using the `"trigger-rumble"` effect:

```ts twoslash
const [p1] = navigator.getGamepads();

if (p1) {
  p1.vibrationActuator?.playEffect("trigger-rumble", {
    duration: 200, // milliseconds
    leftTrigger: 0.5, // 0-1
    rightTrigger: 0.2, // 0-1
    // "trigger-rumble" also supports the strong and weak palm haptics
    weakMagnitude: 0, // 0-1
    strongMagnitude: 0, // 0-1
  });
}
```

<Callout type="warn" title="Why not always use trigger-rumble?">
  Even though everything you can do with `"dual-rumble"` can also be done with `"trigger-rumble"`,
  `"dual-rumble"` [browser support](https://caniuse.com/?search=play+effect){" "}
  <small className="opacity-75">\[caniuse]</small> is better, so if you're specifically targeting
  palm haptics, `"dual-rumble"` is the better choice.
</Callout>

### Stack multiple haptic effects [#stack-multiple-haptic-effects]

For more convincing haptics, layer several effects that play back-to-back with different parameters. Combined with good sound design and visual effects like screen shake, well-implemented haptics can contribute a lot of [juice](/guides/handmade-web-games/techniques-for-game-feel-and-juice) to your game.

<Callout type="idea" title="Haptic layering demo">
  Try out this engine rev that begins by ramping the high-frequency `weakMagnitude` motor upward first. Then after redlining for 200ms we bring in the heavier `strongMagnitude` motor for an engine stall.

  <EngineRevButton />

  <details>
    <summary>
      <strong>Read the code</strong>
    </summary>

    ```ts twoslash
    async function sleep(ms: number) {
      return new Promise((resolve) => setTimeout(resolve, ms));
    }

    async function playEngineRev(gamepad: Gamepad) {
      // engine rev ramp up
      const weakRamp = [0.02, 0.05, 0.09, 0.15, 0.23, 0.34, 0.48, 0.65, 0.83, 1];
      for (const weak of weakRamp) {
        await gamepad.vibrationActuator?.playEffect("dual-rumble", {
          duration: 140,
          weakMagnitude: weak,
        });
      }

      // stay at redline peak for 200ms
      await gamepad.vibrationActuator?.playEffect("dual-rumble", {
        duration: 200,
        weakMagnitude: 1,
      });

      // begin stall
      await gamepad.vibrationActuator?.playEffect("dual-rumble", {
        duration: 100,
        strongMagnitude: 1,
        weakMagnitude: 0.8,
      });

      const stall = [
        { strong: 1, duration: 100, gap: 50 },
        { strong: 0.75, duration: 50, gap: 100 },
        { strong: 0.5, duration: 25, gap: 0 },
      ] as const;

      for (const step of stall) {
        await gamepad.vibrationActuator?.playEffect("dual-rumble", {
          duration: step.duration,
          strongMagnitude: step.strong,
        });
        await sleep(step.gap);
      }
    }

    for (const gamepad of navigator.getGamepads()) {
      if (!gamepad) continue;
      playEngineRev(gamepad);
    }
    ```
  </details>
</Callout>

## Up next [#up-next]

Next we'll add sound. [Chapter 9 →](/guides/handmade-web-games/audio) provides a few approaches to in-game audio for music, sound effects, and spatial audio.

## See also [#see-also]
