Handmade Web Games

Chapter 8: 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.

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!

Controller
Connect a controller

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 [MDN] supports up to four simultaneously connected gamepads.

const [, , , ] = .();

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.

This array generally fills in from left to right, but it can also be sparse. 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:

.(); // => [undefined, undefined, Gamepad, undefined]

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

.(); // => [undefined, Gamepad, Gamepad, undefined]

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

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

const [] = .();

if () {
  const  = .[9];
  if (.) {
    // do start action
  }
}
0A
1B
2X
3Y
4LB
5RB
6LT
7RT
8View
9Menu
10LS
11RS
12D↑
13D↓
14D←
15D→
16Xbox
17–

Test your gamepad buttons here!

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!

/**
 * 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  = {
  /** Xbox A / PlayStation āœ• / Nintendo B */
  : 0,
  /** Xbox B / PlayStation ā—‹ / Nintendo A */
  : 1,
  /** Xbox X / PlayStation ā–” / Nintendo Y */
  : 2,
  /** Xbox Y / PlayStation ā–³ / Nintendo X */
  : 3,

  /**
   * Xbox LB / PlayStation L1 / Nintendo L
   * Check the boolean `pressed` property.
   */
  : 4,
  /**
   * Xbox RB / PlayStation R1 / Nintendo R
   * Check the boolean `pressed` property.
   */
  : 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.
   */
  : 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.
   */
  : 7,

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

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

  /** Directional pad up button */
  : 12,
  /** Directional pad down button */
  : 13,
  /** Directional pad left button */
  : 14,
  /** Directional pad right button */
  : 15,

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

export type  = (typeof )[keyof typeof ];

Triggers

Triggers are a special type of button that have a floating point value property between 0 and 1.

Left trigger0.00
01
Right trigger0.00
01

Squeeze a trigger on your controller to see its value.

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:

const [] = .();

if () {
  const  = .[6];
  const  = 1 + .;
  // ...
}

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.

Left stick
Right stick
Left X
-101
0.00
Left Y
-101
0.00
Right X
-101
0.00
Right Y
-101
0.00

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

const  = { : 100, : 100 };

const [] = .();

if () {
  const [
    , // -1 is left, 1 is right
    , // -1 is up, 1 is down
    ,
    ,
  ] = .;
  . +=  * ;
  . +=  * ;
}

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

/**
 * 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

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 direction of the vector (in radians), use atan2(y, x). To get its magnitude use sqrt(x² + y²), which JavaScript conveniently has a built-in function for: Math.hypot(x, y).

const [p1] = navigator.getGamepads();

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

Math.atan2(0.00, 0.00) = 0.00 = 0.00°

Math.hypot(0.00, 0.00) = 0.00

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.

Am I drifting?

Wiggle the thumb sticks a bit, then release them back to the neutral position.

Left stick
NOT DRIFTING
Right stick
NOT DRIFTING

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?

Axial normalization: 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.

Radial normalization: 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.

Scaled radial normalization: To prevent the jolting behavior, treat 20% as the new zero, and scale the rest of the values from there.

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.

Deadzone0.50
Axial
Radial
Scaled Radial
Axial
0.00
Radial
0.00
Scaled Radial
0.00

Move the left stick to compare deadzone normalization methods.

For an excellent overview on deadzone normalization, I recommend Josh Sutphin's Doing Thumbstick Dead Zones Right. Carlos PƩrez Ramil's interactive demo and deep dive article 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:

export function (: number, : number,  = 0.2) {
  const  = .(, );

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

  // scale the remaining magnitude
  const  = .(, 1);
  const  = ( - ) / (1 - );
  const  =  / ;
  return {
    :  * ,
    :  * ,
  };
}
Bonus: 4-way and 8-way snapping

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.

RawneutralSnappedin deadzone

Move the left stick to see how the raw input compares to the snapped + deadzone-normalized input.

function (: number, : number, : number,  = 0.2) {
  const  = .(.(, ), 1);
  if ( <= ) return { : 0, : 0 };
  const  = .(, );
  const  = (. * 2) / ;
  const  =  * .( / );
  const  = ( - ) / (1 - );
  return {
    : .() * ,
    : .() * ,
  };
}

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

/**
 * snaps analog stick to 4 directions
 */
export function (: number, : number,  = 0.2) {
  return (, , 4, );
}

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".

The problem with this approach is that hovering near the activation threshold 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.

One threshold

Push the left stick to the right, then try to hover around the 0.80 activation/deactivation point.
activate / deactivate
01
rightward input: 0.00
move right events: 0
state: inactive

The technique for addressing this is called hysteresis [Wikipedia], which for our purposes here simply means having different activation and deactivation thresholds. (More broadly, hysteresis refers to the concept that a system's past state informs its current state.)

Two thresholds

Push past 0.80 to fire once, then relax below 0.40 to become inactive again.
deactivate
activate
01
rightward input: 0.00
move right events: 0
state: inactive

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.

Revisiting the jump example

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 jump button was pressed in the last cycle. We'll define jump as the "south" face button at index 0 (A on an Xbox controller or āœ• 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.

const size = 30;
const groundY = bounds.height - 20;
const state = {
wasJumpPressed: false,
box: {
x: 50,
y: groundY - size,
vy: 0, // y velocity in px/s
},
};

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.

const  = {
  : {
    : [
      new <number>(), // player 1
      new <number>(), // player 2
      new <number>(), // player 3
      new <number>(), // player 4
    ],
  },
};

function () {
  for (const  of .()) {
    if (!) continue;
    if ((, .)) {
      // do one-shot action for this gamepad
    }
  }

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

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

// new helper functions:

function (: Gamepad, : number) {
  const  = ..[.];
  return .[]. && !.();
}

function () {
  for (const  of ..) {
    .();
  }

  for (const  of .()) {
    if (!) continue;

    const  = ..[.];

    for (const [, ] of ..()) {
      if (.) {
        .();
      }
    }
  }
}

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.

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.

Haptic playground
Effect:
200
0.60
0.30
0.10
0.10

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:

const [] = .();

if () {
  .?.("dual-rumble", {
    : 200, // milliseconds
    : 0.3, // 0-1
    : 0.6, // 0-1
  });
}

Trigger-rumble

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

const [] = .();

if () {
  .?.("trigger-rumble", {
    : 200, // milliseconds
    : 0.5, // 0-1
    : 0.2, // 0-1
    // "trigger-rumble" also supports the strong and weak palm haptics
    : 0, // 0-1
    : 0, // 0-1
  });
}

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 [caniuse] is better, so if you're specifically targeting palm haptics, "dual-rumble" is the better choice.

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 to your game.

Haptic layering demo

Try out this engine rev that begins by ramping the high-frequency weakMagnitude motor upward first. Then after redlining for 300ms we bring in the heavier strongMagnitude motor for an engine stall.

Read the code
async function (: number) {
  return new (() => (, ));
}

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

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

  // begin stall
  await .?.("dual-rumble", {
    : 100,
    : 1,
    : 0.8,
  });

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

  for (const  of ) {
    await .?.("dual-rumble", {
      : .,
      : .,
    });
    await (.);
  }
}

for (const  of .()) {
  if (!) continue;
  ();
}

See also

On this page