Beej's Bit Bucket

 ⚡ Tech and Programming Fun

2017-03-01, 2022-06-11

Using Gamepads and Joysticks in JavaScript

Plug in (or press a button on) a gamepad or joystick, and see what happens, above! Maybe something bad... maybe something good. We won't know until you try!

Gamepads on PCs are super-popular these days, and so it's no wonder that there's some level of support in JavaScript. And it's also no wonder that the level of support is highly variable among different browsers.

Hint Goat Why do you have to plug in a controller or press a button before this starts working? Answer: to prevent information leaks. You don't want any old web pages collecting information on which joysticks you have plugged in, after all.

Maybe you have thousands of dollars in flight controls and you don't want people to know without you knowing they know, you know? Or maybe you don't want to let advertisers use the state of your system for fingerprinting.

In any case, you have to notify the web page that you're OK with it using your gamepads, either by pressing a button on the pad or by plugging on in after the page has loaded.

Let's dive on in!

Browser Support

You do have support on Edge, Chrome, Firefox, and Opera. Safari has support in version 10.1+. IE can just forget it–you shouldn't be using that legacy browser anyway.

Warning

And now for more bad news! Ready?

I found Edge to have some, um, weirdnesses. Sometimes it takes a while after hitting a button for the gamepad to appear. Also I can't seem to get it to show another one of my joysticks at all.

I know you're shocked, but please try to disguise it as enthusiasm. Thanks.

Of course, within each browser there are different levels of compliance. Oh, and the same gamepad might show up in different ways on different browsers.

Ah, the fun of web programming.

Let's test for browser support:

// Return true if gamepad support exists on this browser

function supportsGamepads() {
    return !!(navigator.getGamepads);
}

The Main Object

Browsers that support gamepads expose a function called navigator.getGamepads() that returns the state of all the connected gamepads in a big array. (Note that sometimes the values in the array are null, representing non-existent gamepads... coughChromecough.)

Once you have the gamepads array, you can iterate through it to get all kinds of information about the connected devices.

// Get the state of all gamepads
let gamepads = navigator.getGamepads();

for (let i = 0; i < gamepads.length; i++) {
    console.log("Gamepad " + i + ":");

    if (gamepads[i] === null) {
        console.log("[null]");
        continue;
    }

    if (!gamepads[i].connected) {
        console.log("[disconnected]");
        continue;
    }

    console.log("    Index: " + gamepads[i].index);
    console.log("    ID: " + gamepads[i].id);
    console.log("    Axes: " + gamepads[i].axes.length);
    console.log("    Buttons: " + gamepads[i].buttons.length);
    console.log("    Mapping: " + gamepads[i].mapping);
}

When you run that code in the debugger, you should see similar output to this on the debug console:

Gamepad 0:
    Index: 0
    ID: ©Microsoft Corporation Controller (STANDARD GAMEPAD Vendor: 045e Prod
    Axes: 4
    Buttons: 17
    Mapping: standard
Gamepad 1:
    Index: 1
    ID: Microsoft SideWinder Precision 2 Joystick (Vendor: 045e Product: 0038
    Axes: 6
    Buttons: 8
    Mapping: 
Gamepad 2:
[null]
Gamepad 3:
[null]

Of course, unless you happen to have the same controllers as I do and happen to be running the code in the same browser as I am, you'll see different results. Even with the same controllers plugged in, I get different results between Firefox and Chrome.

Note we also test the connected property of the gamepad. I have yet to encounter a case where the gamepad both exists in the array and connected is set to false, but the spec says it can happen.

Identifying Gamepads (when you have more than one)

Did you notice the seemingly-redundant property called index in the gamepad object? Why is there an index that already corresponds to the index in the array returned by getGamepads()?

Turns out that as you connect and disconnect gamepads, the objects in the gamepads array might be shuffled around so that the index property no longer corresponds to the index in the gamepads array!

What this means is that when you're looking for input from a specific gamepad, you have to search for it by its index property. Don't trust that it will necessarily be in the same place in the array returned by getGamepads().

If you unplug a controller and plug it right back in, the browser will make an effort to set its index property to whatever it was before.

Note that the id property, which gives a human-readable representation of what controller is plugged in, varies from browser to browser; the exact format is browser-specific.

The Game Loop and Events

You're probably used to JavaScript being all event-driven. It would make sense, in that paradigm, that you'd listen for events on a controller and react to them.

But... that's not what we're going to do this time.

I'm not sure the rationale, but I suspect that the designers of the Gamepad API felt that it would be almost always exclusively be used for games and other interactive sites that required frequent rendering, probably in a loop. And if you're going to be rendering a scene 30 times per second, anyway, you're probably going to want to just poll the state of the controllers each frame. Bringing the state in through event handlers would just be messier.

For your game (or whatever—I don't mean to imply you're not serious), you'll probably have an animation loop that uses requestAnimationFrame(), like this:

// Handle controls and drawing a new frame

function handleFrame(timestamp) {

    let gamepads = navigator.getGamepads();

    handleInput(gamepads); // Exercise for the reader

    drawScene(); // Exercise for the reader
    
    // Render it again, Sam
    requestAnimationFrame(handleFrame);
}

// Kick off the rendering
requestAnimationFrame(handleFrame);

The Standard Gamepad

There are so many different types of gamepads in the world, what is one to do? How do you know which button to hook up to "Fire"?

Well, the designers of the gamepad spec have attempted, with marginal success, to make our lives easier in that regard by defining the Standard Gamepad.

The Standard Gamepad A picture of the sleek and sexy Standard Gamepad, stolen directly out of the spec.

If a browser recognizes a gamepad as basically looking like the one, above, it remaps the buttons and axes into a known pattern with published, common indexes for the various buttons and axes. (The values are in the spec.)

Additionally (and this is important), it sets the .mapping property of the controller to the string "standard". This is how you can tell you have a standard controller at your disposal.

let gamepads = navigator.getGamepads();

if (gamepad[0].mapping == "standard") {
    console.log("Controller has standard mapping");
} else {
    console.log("Controller does not have standard mapping");
}

If the mapping property isn't set to "standard", you have a controller that the browser hasn't recognized as standard. What you do next is really up to you, but probably involves either (A) pretending the controller is standard and hoping for the best, (B) giving up and not supporting the controller, or (C) implementing a UI to allow the user to remap the controller.

Option A is the easiest, but will probably produce horrific results. Not recommended. Option B is almost as easy, is at least predictable, and keeps your code simple. Option C is hard to code but is certainly the most flexible.

You might be thinking, "But I have a wired Xbox 360 controller, the most popular gamepad for PCs there is. Surely browsers recognize it as a standard controller."

Definitely maybe! Chrome, for instance, calls it standard. The triggers are exposed as analog buttons, and the D-pad is exposed as digital buttons, just like the spec says. Great news!

But on Firefox for Linux, oh dear. Firefox does not report the controller as standard. The triggers are exposed as axes, as is the D-pad. And the axes for the analog sticks aren't at the same array indexes in the axes array, either.

So if you decide you're going to support the Xbox 360 controller on Chrome and Linux FF, you're going to have your work cut out for you. Might as well do Option C, above, in that case.

Detecting Disconnections and Connections

This is a bit of a sore point with browsers that implement the gamepad API. It kinda works as intended, but not really.

In theory, the browser should fire off two events on the window object at the appropriate time: gamepadconnected and gamepaddisconnected.

These events have a gamepad property attached to their argument that you can use to determine which gamepad has been hooked up.

In practice, browser support is spotty. On my Chrome for Linux, I get a disconnect event when I connect a gamepad, and no event when I disconnect one. Not so useful.

So instead, since I'm polling for gamepad status every frame anyway, I just poll for connections and disconnections. A not-so-robust way to do this is to simply count the number of connected gamepads in the array and return any changes as a difference.

/**
 * This function returns a positive number of connections,
 * a negative number of disconnections, or zero for no change.
 */
let testForConnections = (function() {

    // Keep track of the connection count
    let connectionCount = 0;

    // Return a function that does the actual tracking
    return function () {
        let gamepads = navigator.getGamepads();
        let count = 0;
        let diff;

        for (let i = gamepads.length - 1; i >= 0; i--) {
            let g = gamepads[i];

            // Make sure they're not null and connected
            if (g && g.connected) {
                count++;
            }
        }

        // Return any changes
        diff = count - connectionCount;

        connectionCount = count;

        return diff;
    }
}());

/**
 * Main animation frame handler
 */
function handleAnimFrame() {

    let tc = testForConnections();

    if (tc > 0) {
        console.log(tc + " gamepads connected");
    } else if (tc < 0) {
        console.log((-tc) + " gamepads disconnected");
    } else {
        // Gamepads neither connected nor disconnected.
    }

    requestAnimationFrame(handleAnimFrame);
}

The Buttons

We can detect connections, see what gamepads are connected, and that's great and all. But eventually you're going to actually want to see which buttons are pressed, and which joysticks are deflected, right?

Let's start with the buttons, since that's a tiny bit more straightforward.

When you get a gamepad from the array, there's a property on it called buttons. This refers to an array of objects that contains information about all the buttons on the gamepad.

Each button object has two notable properties: pressed and value.

pressed is pretty self-explanitory. If the button's pressed, this value is true. If it's not pressed, it's false.

But what does it mean for a button to be "pressed"? Well, if it's a simple switch, then it's either on or off, period. But what about analog buttons like the triggers on the Xbox 360 controller? These can be partially pressed.

In that case, the browser chooses a threshold where if the button is pressed past it, it is reported as pressed, and not otherwise.

And what is that value that is measured against the threshold? It's stored in the value property and goes between 0.0 (not pressed at all) and 1.0 (fully pressed).

(Digital all-or-nothing buttons will set the value to 0.0 or 1.0, and never in between.)

Here's some sample code that reads all the button states on a controller:

// Show button state

let gamepads = navigator.getGamepads();

// Blindly assuming this is connected
let gp = gamepads[0];

// Blindly assuming there's a button
let button = gp.buttons[0];

if (button.pressed) {
    console.log("Button pressed!");
} else {
    console.log("Button not pressed");
}

console.log("Button value: " + button.value);

On a standard mapped gamepad, the D-pad is exposed as buttons. Otherwise it could be buttons or axes.

On a standard mapped gamepad, the triggers, even if analog, are exposed as buttons. Otherwise they could be buttons or axes.

The Axes and the Joysticks

Similar to the buttons, the axes are available in a property on the gamepad called axes.

Your average analog stick is made up of two axes to make up the x and y coordinates of the joystick position. So a standard-mapped controller will show four axes, two per analog stick.

Axis
Left Stick \(x\) 0
Left Stick \(y\) 1
Right Stick \(x\) 2
Right Stick \(y\) 3

Axis indexes for left and right analog sticks on a standard-mapped gamepad.

Values on the axes run from -1 (up or left) to 1 (down or right), with 0 being neutral.

If the D-pad on a non-standard mapped controller is shown as axes, it will typically show full deflection (-1 or 1) or zero deflection.

let gamepads = navigator.getGamepads();

// Blindly assuming this is connected
let gp = gamepads[0];

// Blindly assuming standard mapping
let x = gp.axes[2]);
let y = gp.axes[3]);

console.log("Right stick X: " + x);
console.log("Right stick Y: " + y);

if (y < 0) {
    console.log("Right stick is being pushed up!");
}

The Dead Zone

Some analog sticks are nice enough to report their position as 0,0 when they're left alone. Others tend to report their positions are, say, 0.0001,-0.0026 when you let them sit at neutral.

Usually the user expects that when he or she lets go of the joystick, all associated motion should cease. So, according to the principle of least astonishment, we should probably assume that very small values coming from the axes should count the same as 0,0.

(Like all rules, there are exceptions to this. But typically a small dead zone is desired.)

Fortunately, this isn't too bad to set up, though there is a bit of remapping going on to smooth out the resultant stick values. For lots more fun and background about that sort of thing, see my blog entry on Transforming Numbers.

The basic approach is going to be this:

  1. Get the length of the joystick deflection.

  2. If it's less than our deadzone cutoff, return values indicating no deflection.

  3. If it's greater than our cutoff, compute the amount over the cutoff we are.

  4. Remap this amount into the range \([0,1]\).

  5. Scale the input values by the normalized overage amount.

The upshot of this is once the deadzone is exceeded, we'll still have a nice smooth progression from the origin outward.

/**
 * Set a value based on the deadzone
 */
function setDeadzone(x, y, deadzone=0.2) {
    let m = Math.sqrt(x*x + y*y);

    if (m < deadzone)
        return [0, 0];

    let over = m - deadzone;  // 0 -> 1 - deadzone
    let nover = over / (1 - deadzone);  // 0 -> 1

    let nx = x / m;
    let ny = y / m;

    return [nx * nover, ny * nover];
}

// Blindly assuming this is connected
let gp = gamepads[0];

// Blindly assuming standard mapping
let x = gp.axes[2]);
let y = gp.axes[3]);

[x, y] = setDeadzone(x, y);

// Show the new values
console.log("Right stick X (with deadzone): " + x);
console.log("Right stick Y (with deadzone): " + y);

And that's what the "Deadzone" checkbox does in the demo at the top of the page.

Clamping Joystick Values

One more thing you might like to do is clamp the values on the joystick so that they are normalized between different sticks.

What does this mean?

Well, for example, my ancient Microsoft Sidewinder Precision 2 Joystick runs both the \(x\) and \(y\) axes all the way up to -1,-1 if you push the stick up and left. That's kind of expected, right?

But my Xbox 360 controller only goes to -0.76,-0.76 because the plasting housing around the stick forces the motion into a round shape and won't let it move all the way to -1,-1.

So, for example, if you set the speed of the player character based solely on the joystick axes values, the player would be able to move faster with the Sidewinder than with the 360 controller! That seems decidedly unfair.

One thing we can do is clamp the values at a mathematical circle or radius 1.0 that we place at the center of the stick. Then, assuming all joysticks have at least enough play to reach that circle, they'll all be limited to the same levels.

Also, as a bonus, the length of the maximum deflection in any direction will be equal to 1.0 since that's the radius of the circle we're clamping to.

What we'll do is treat the \(x\) and \(y\) values from the stick as a 2D vector \((x,y)\) and first take its magnitude, which is a mathy way of saying "its length". If the length of the vector is greater than 1.0, it's outside our circle and needs to be clamped to length 1.0.

How do we clamp it to length 1.0? Fortunately there's a process for doing this: we want to normalize the vector. That is, we'll turn it into a vector pointing the same direction as the original stick vector, except of length 1.0. And we do this by dividing the components of the vector by its magnitude (length).

/**
 * Clamp the joystick coords at length 1.0
 */
function clampStick(x, y) {
    // Compute magnitude (length) of vector
    let m = Math.sqrt(x*x + y*y);

    // If the length greater than 1, normalize it (set it to 1)
    if (m > 1) {
        x /= m;
        y /= m;
    }

    // Return the (possibly normalized) vector
    return [x, y];
}

// Blindly assuming this is connected
let gp = gamepads[0];

// Blindly assuming standard mapping
let x = gp.axes[2]);
let y = gp.axes[3]);

// Get clamped values
[x, y] = clampStick(x, y);

// Show the new values
console.log("Right stick X (with clamping): " + x);
console.log("Right stick Y (with clamping): " + y);

And this is what the "Clamped" mode is on the demo at the top of the page.

Now get out there and write some games! 😀

References and Links

License

The demo code at the top of this article is licensed under the MIT open source license.

The remaining code examples in this article are hereby granted to the Public Domain.

Comments

Blog  ⚡  Email beej@beej.us  ⚡  Home page