raw Software

Gamecontroller.js reads USB game controllers directly from a Node.js process. It uses node-hid to open the controller as a Human Interface Device, translates the incoming HID reports with a device-specific profile, and emits named events for buttons and analog controls. This is backend hardware access, not a browser visualization or a wrapper around the browser Gamepad API.

I originally wrote the library to control a larger JavaScript robotics project with a physical gamepad. Existing packages exposed HID data, but I still needed a small layer that turned controller-specific bytes into stable, readable events for the rest of the backend.

View Gamecontroller.js on GitHub

From a USB Report to a JavaScript Event

A game controller normally sends short binary HID reports. The operating system exposes those reports through its HID subsystem, but the bytes themselves do not say that byte 5, bit 6 is the X button or that bytes 0 and 1 form a joystick position. That mapping depends on the exact controller model.

USB controller binary HID report node-hid device access device profile byte mapping Node.js events press, release, move

Gamecontroller.js keeps that model-specific knowledge in small profiles. The public API then presents the same event-oriented interface to a robotics process, local service, installation, simulator, or any other Node.js application that needs physical controls.

Installation

npm install gamecontroller

The npm package is named gamecontroller. Its current published version is 0.0.2 and it uses CommonJS. Because node-hid is a native dependency, installation may require a working native build toolchain and platform HID libraries. The process must also have permission to open the selected USB device. On a dedicated Linux system, grant access with a narrow udev rule for the controller's vendor and product IDs rather than running the complete Node.js application as root.

The package was originally published in 2017 and declares an unpinned node-hid dependency. For a reproducible deployment, test the combination with the intended Node.js release and commit the resulting lockfile.

Connect a Controller

The constructor receives a profile key, not a USB path. Register the error listener before calling connect(), because failure to find or open the HID device is reported as an event:

const GameController = require('gamecontroller');

const controller = new GameController('ps2');

controller.on('error', (error) => {
  console.error('Could not open the controller:', error.message);
});

controller.on('X:press', () => {
  console.log('X pressed');
});

controller.on('X:release', () => {
  console.log('X released');
});

controller.on('JOYL:move', ({ x, y }) => {
  console.log('Left stick:', x, y);
});

controller.connect(() => {
  console.log('Controller connected');
});

connect() opens the first HID device whose vendor and product IDs match the selected profile. Every incoming report is decoded, compared with the previous state, and converted into transition events. This avoids making application code inspect byte offsets or repeatedly compare complete state objects.

Event Model

Event Payload Meaning
NAME:press None A mapped button changed from released to pressed.
NAME:release None A mapped button changed from pressed to released.
NAME:move { x, y } One axis of a mapped two-dimensional control changed.
data Parsed state object A complete profile-decoded state arrived.
error Error The HID device could not be opened or another device error occurred.
close None The controller handle was closed.

Try the Event Model

This dummy controller runs entirely in the page: it does not open a USB device. Press a face button or drag the left stick to see the same event names that a backend receives after Gamecontroller.js has decoded an HID report.

controller:ready Backend event preview

The data event contains the state returned by the selected profile. It is already decoded; it is not the original Buffer emitted by node-hid. Use node-hid directly when inspecting unknown reports while creating a new profile.

Discover Known Devices

getDevices() enumerates attached HID devices and returns the profile keys whose vendor and product IDs match:

const GameController = require('gamecontroller');

const profiles = GameController.getDevices();
console.log(profiles);

This is profile discovery rather than a complete device inventory. The result does not include paths or serial numbers, and the constructor cannot select the second of two identical controllers. The source currently contains profiles for a PlayStation 2-compatible controller, two SNES adapters, an Xbox 360 entry, an Xbox 360 Guitar Xplorer, and a HOTAS Warthog joystick. Some profiles are more complete than others; notably, the plain xbox360 entry is only a stub and does not decode controls.

Inspect an Unknown Controller

A profile starts with the USB vendor ID, product ID, and a deterministic mapping from report bytes to named state. Enumerate devices first and select the exact device path when possible:

const HID = require('node-hid');

const devices = HID.devices();

for (const device of devices) {
  console.log({
    path: device.path,
    vendorId: device.vendorId,
    productId: device.productId,
    product: device.product,
    serialNumber: device.serialNumber,
  });
}

Then open one known path and print the changing bytes while moving exactly one control at a time:

const device = new HID.HID('/dev/hidraw3');

device.on('data', (report) => {
  console.log([...report]);
});

device.on('error', (error) => {
  console.error(error);
});

The path is platform-specific; use the value returned by HID.devices() rather than hard-coding the example. Record neutral, minimum, maximum, pressed, and released reports for every control. A robust profile should decode bit fields with masks, combine multi-byte axes with the correct byte order, and preserve a stable state shape across reports.

Normalize at the Application Boundary

Existing profiles expose device-native axis ranges such as 0 to 255 or 0 to 65535. Normalize those values in the application according to its needs. A robot may require a dead zone around the center, a signed range from -1 to 1, rate limiting, and a fail-safe command when reports stop arriving. Those are application policies and should not be confused with decoding the HID packet correctly.

Closing the Device

Release the HID handle during an orderly shutdown. The library also registers a process-exit handler, but explicit cleanup makes service behavior easier to test and reason about:

function shutdown() {
  controller.close();
  process.exit(0);
}

process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);

Gamecontroller.js is intentionally small: it turns known USB controller reports into useful Node.js events. The profile table is therefore the essential extension point, while device permissions, reconnect policy, calibration, dead zones, watchdogs, and safety behavior remain responsibilities of the application using those events.