Getting Xbox Controller inputs through Node.js

For an upcoming Blog Post I wanted to connect my Xbox controller and start reading the input values through a simple node.js script that I could send back to an Arduino Nano. This entire process however seemed to be quite "interesting" to find suitable libraries and long-term supported solutions.

When I searched for libraries on the NPM registry or GitHub, it seemed that they 1. Either didn't work due to Windows blocking access or 2. Were utilizing outdated libraries that hadn't been updated in a longtime.

Therefor I went on to find a custom solution that was more supported. Eventually I stumbled on the "GamePad API". This API seemed to be what I wanted, but is utilizing the window object, which we know is not supported natively in Node.js.

To solve this, I decided to use a headless browser through puppeteer! It might be a bit "hacky" but it actually does the trick 😊

Creating the Library

So let's give this a go! According to the API spec above, we can see that navigator.getGamepads() returns our GamePad devices but that a statechange emitter is only supported in Firefox. I decided to still stick with the default browser in puppeteer (even though it does support other browsers, but this is still experimental).

To be able to re-utilize this, I want to have a clean code where my KeyBinding state detector is forked and running in a child process. Only when a button is detected I want to do something through a .on function. For this I will be utilizing an EventEmitter.

GameController class

const puppeteer = require('puppeteer');
const EventEmitter = require('events').EventEmitter;

const buttons = require('./controllers/xbox.json');

class GameController {
  constructor() {
    this.eventEmitter = new EventEmitter();
    this.SIGNAL_POLL_INTERVAL_MS = 50;
    this.THUMBSTICK_NOISE_THRESHOLD = 0.15;
  }

  on(event, cb) {
    this.eventEmitter.on(event, cb);
  }

  async init() {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();

    // Expose a handler to the page
    await page.exposeFunction('sendEventToProcessHandle', (event, msg) => {
      this.eventEmitter.emit(event, JSON.stringify(msg));
    });

    await page.exposeFunction('consoleLog', (e) => {
      console.log(e);
    });

    // listen for events of type 'status' and
    // pass 'type' and 'detail' attributes to our exposed function
    await page.evaluate(([ buttons, SIGNAL_POLL_INTERVAL_MS, THUMBSTICK_NOISE_THRESHOLD ]) => {
      let interval = {};

      window.addEventListener("gamepadconnected", (e) => {
        let gp = navigator.getGamepads()[e.gamepad.index];
        window.sendEventToProcessHandle('GAMEPAD_CONNECTED');

        interval[e.gamepad.index] = setInterval(() => {
          gp = navigator.getGamepads()[e.gamepad.index];

          // [ 
          //    0 = THUMBSTICK_LEFT_LEFT_RIGHT,
          //    1 = THUMBSTICK_LEFT_UP_DOWN,
          //    2 = THUMBSTICK_RIGHT_LEFT_RIGHT,
          //    3 = THUMBSTICK_RIGHT_UP_DOWN
          // ]
          let sum = gp.axes.reduce((a, b) => a + b, 0);

          if (Math.abs(sum) > THUMBSTICK_NOISE_THRESHOLD) {
            window.sendEventToProcessHandle('thumbsticks', gp.axes);
          }

          for (let i = 0; i < gp.buttons.length; i++) {
            if (gp.buttons[i].pressed == true) {
              window.sendEventToProcessHandle(buttons[i]);
              window.sendEventToProcessHandle('button', buttons[i]);
            }
          }
        }, SIGNAL_POLL_INTERVAL_MS);
      });

      window.addEventListener("gamepaddisconnected", (e) => {
        window.sendEventToProcessHandle('GAMEPAD_DISCONNECTED');
        window.consoleLog("Gamepad disconnected at index " + gp.index);
        clearInterval(interval[e.gamepad.index]);
      });
    }, [ buttons, this.SIGNAL_POLL_INTERVAL_MS, this.THUMBSTICK_NOISE_THRESHOLD ]);
  }
}

module.exports = GameController;

Xbox Button Mapping

To map the controller, a file is created in controllers/xbox.json that contains the indexes mapped to the button names:

{
    "0": "A",
    "1": "B",
    "2": "X",
    "3": "Y",
    "4": "BUMPER_LEFT",
    "5": "BUMPER_RIGHT",
    "6": "TRIGGER_LEFT",
    "7": "TRIGGER_RIGHT",
    "8": "BUTTON_VIEW",
    "9": "BUTTON_MENU",
    "10": "THUMBSTICK_L_CLICK",
    "11": "THUMBSTICK_R_CLICK",
    "12": "D_PAD_UP",
    "13": "D_PAD_DOWN",
    "14": "D_PAD_LEFT",
    "15": "D_PAD_RIGHT",
    "16": ""
}

Demonstration Code

The above class can now be utilized in a simple way, where we are able to create the GameController, call the .init method and then start listening with our .on method as shown below:

const GameController = require('./GameController');

(async () => {
    const gameController = new GameController();
    await gameController.init();
    gameController.on('button', (btn) => console.log(`Button: ${btn} pressed`))
    gameController.on('thumbsticks', (val) => console.log(val))
})();

Which will result in the following when we press some buttons:

[0.030746936798095703,-1,0.036850571632385254,0.0174410343170166]
[0.0008697509765625,1,0.036850571632385254,0.0174410343170166]
[-0.007248044013977051,1,0.036850571632385254,0.0174410343170166]
[-0.00904858112335205,1,0.036850571632385254,0.0174410343170166]
[-0.056534647941589355,1,0.036850571632385254,0.0174410343170166]
Button: "B" pressed
Button: "X" pressed
Button: "Y" pressed
Button: "A" pressed
Button: "B" pressed
Button: "BUMPER_RIGHT" pressed
Button: "TRIGGER_RIGHT" pressed

Conclusion

We now have a class that allows us to easily access our controller connected, as long as it is supported by the browser, opening up the possibility to support other controllers as well, without being dependend on specific interfaces and working cross-browser.