HTML5: Using gamepads as input

Something that is very easy to overlook among the many HTML5-based games out there now, many of which have sprung up in the last year or two, is that some browsers will detect and expose an API to use gamepads to play those games. It is not as well-known outside some games on the Google Play store and to those who watch the functionality added in different browser updates, but it is out there. You can use gamepads like the Xbox and PlayStation controllers to play some HTML5 games, if supported.

Unfortunately, the specification is still in flux and even support across browsers isn’t very strong. To my knowledge, only Chrome and Firefox use the Gamepad API and, even then, they both, as is often the case, disagree with how to implement it. In fact, not even Can I Use, the often go-to place for cross-browser support, has a listing for it. That is how rare it is.

However, just because support is scare doesn’t mean you can’t write code to use it now and, hopefully, in the future too. Gamepad support can be a bonus for those who have controllers and want to use them. It can be an extra selling point for your HTML5 project.

To detect support, we will first need to check it see if the navigator object has a property of ‘Gamepads’ through various vendor ids like ‘webkit’ or ‘moz’, and then populate a boolean value. However, in the case of Chrome, we will also need to check to see that its ‘webkitGetGamepads’ property is also a function. Instead of a property that is an array, Chrome’s implementation is a function that returns an array of gamepads.

var Gamepad = function() {
...
self.supported = (navigator.webkitGetGamepads && navigator.webkitGetGamepads()) ||
!!navigator.webkitGamepads || !!navigator.mozGamepads ||
!!navigator.msGamepads || !!navigator.gamepads;
...
}
view raw Gamepad.js hosted with ❤ by GitHub

If gamepad support is found, we need to separate out the two different ways it is currently implemented. One, the Firefox way, uses two events, connect and disconnect, to signal if a new gamepad has been added or removed. The second, Chrome way, is to expose all current gamepads at all times, relying on the developer or user to check statuses when needed.

To blend both implementations, we can write functions that match the Firefox method and then set up polling on the gamepads detected by either browser using the window.requestAnimationFrame function.

if (self.supported) {
// Firefox
window.addEventListener('MozGamepadConnected',
onGamepadConnect, false);
window.addEventListener('MozGamepadDisconnected',
onGamepadDisconnect, false);
// Chrome
if (navigator.webkitGetGamepads && navigator.webkitGetGamepads()) {
startPolling();
}
}
view raw Gamepad.js hosted with ❤ by GitHub

If the browser supports the Firefox method of different events, we need two different functions. One will check to see when or if a gamepad has been added. The other will remove the corresponding entry from the internal gamepad listing. Each will start polling once done.

/**
* In Firefox only, adds a gamepad when connected and starts the polling
* @param {EventObject} event A 'MozGamepadConnected' event object
*/
function onGamepadConnect(event) {
var gamepad = event.gamepad;
self.gamepads[event.gamepad.id] = gamepad;
startPolling();
}
/**
* In Firefox only, sets a disconnected gamepad to 'null'
* @param {EventObject} event A 'MozGamepadDisconnected' event object
*/
function onGamepadDisconnect(event) {
self.gamepads[event.gamepad.id] = null;
if (self.gamepads.length === 0) {
stopPolling();
}
view raw Gamepad.js hosted with ❤ by GitHub

Polling the gamepads is straightforward, with the function erasing and resetting the internal listing to whatever gamepads happen to be detected during that tick. For whatever is reported by the browser, those will be the new gamepads available for checking if any of their buttons have been pressed.

/**
* Polls the navigator.*Gamepads object for all gamepads connected
*/
function pollGamepads() {
var rawGamepads =
(navigator.webkitGetGamepads && navigator.webkitGetGamepads()) ||
navigator.webkitGamepads || navigator.mozGamepads ||
navigator.msGamepads || navigator.gamepads;
if (rawGamepads) {
self.gamepads = [];
for (var i = 0; i < rawGamepads.length; i++) {
if (typeof rawGamepads[i] !== prevRawGamepadTypes[i]) {
prevRawGamepadTypes[i] = typeof rawGamepads[i];
}
if (rawGamepads[i]) {
self.gamepads.push(rawGamepads[i]);
}
}
}
}
view raw Gamepad.js hosted with ❤ by GitHub

We expose the ‘pressed’ function to be called from the outside. It takes as its arguments the number of the gamepad, starting at zero, and what button should be checked using the internal mapping. The most common button, ‘FACE_1’, would be something like the ‘A’ button on a Xbox controller or the ‘X’ button on a PlayStation one.

If any of the axis buttons are asked for, we test the current position value against the set dead zone threshold and return if it has exceed it.

(In my code, I check against the XInput dead zone values Microsoft publishes.)

/**
* Returns if a specific button on a certain gamepad was pressed
* @param {number} pad The Gamepad to check
* @param {string} buttonId The button to check
* @returns {boolean} If the button on the specific gamepad is currently pressed
*/
self.pressed = function(pad, buttonId) {
if (self.gamepads[pad] && BUTTONS[buttonId]) {
var buttonIndex = BUTTONS[buttonId];
if (buttonIndex === 4 || buttonIndex === 5) {
return self.gamepads[pad].buttons[buttonIndex] > self.SHOULDER0_BUTTON_THRESHOLD;
} else if (buttonIndex === 6 || buttonIndex === 7) {
return self.gamepads[pad].buttons[buttonIndex] > self.SHOULDER1_BUTTON_THRESHOLD;
} else {
return self.gamepads[pad].buttons[buttonIndex] > 0.5;
}
} else {
return false;
}
};
view raw Gamepad.js hosted with ❤ by GitHub

To get even more granular results from the axises, we use the ‘moved’ function and the arguments of which gamepad and axis to check. Assuming two joysticks on the controller, this allows for checking if the player has moved them only a smaller, set amount instead of if they are merely ‘pressed’ outside of their dead zone threshold.

/**
* Returns the amount of movement from the deadzone (-1 to 1)
* @param {number} pad The Gamepad to check
* @param {string} axisId The axis and dimension to check
* @returns {number} The amount of movement, if any
*/
self.moved = function(pad, axisId) {
if (self.gamepads[pad]) {
if (axisId === "LEFT_X") {
if (self.gamepads[pad].axes[0] < -self.LEFT_AXIS_THRESHOLD ||
self.gamepads[pad].axes[0] > self.LEFT_AXIS_THRESHOLD) {
return self.gamepads[pad].axes[0];
}
} else if (axisId === "LEFT_Y") {
if (self.gamepads[pad].axes[1] < -self.LEFT_AXIS_THRESHOLD ||
self.gamepads[pad].axes[1] > self.LEFT_AXIS_THRESHOLD) {
return self.gamepads[pad].axes[1];
}
} else if (axisId === "RIGHT_X") {
if (self.gamepads[pad].axes[2] < -self.RIGHT_AXIS_THRESHOLD ||
self.gamepads[pad].axes[2] > self.RIGHT_AXIS_THRESHOLD) {
return self.gamepads[pad].axes[2];
}
} else if (axisId === "RIGHT_Y") {
if (self.gamepads[pad].axes[3] < -self.RIGHT_AXIS_THRESHOLD ||
self.gamepads[pad].axes[3] > self.RIGHT_AXIS_THRESHOLD) {
return self.gamepads[pad].axes[3];
}
}
} else {
return 0;
}
};
view raw Gamepad.js hosted with ❤ by GitHub

 

At the bottom here, I’ve included the full code I’ve been using for projects as well as where I got my ideas and the dead zone values. I tend to lean more towards Chrome, so my solution combines both its and Firefox’s implementations. However, if you know you will only be using Firefox, you may want to consider its event-based model instead.

Note too that this code replies on the window.requestAnimationFrame function existing and without vendor prefixes. Usually, if you are combining this with another game library, that will be supplied for you. However, it isn’t, consider using Paul Irish’s rAF polyfill.

/**
* (Note: Depends on window.requestAnimationFrame for polling.)
*
* An experimental Gamepad object for detecting
* and parsing gamepad input.
*
* Current code borrows heavily from Marcin Wichary's work:
* http://www.html5rocks.com/en/tutorials/doodles/gamepad/
*
* Also uses deadzone values from
* http://msdn.microsoft.com/en-us/library/windows/desktop/ee417001(v=vs.85).aspx
* Left Stick: 8689/32767.0
* Right Stick: 7849.0/32767.0
* Shoulder0: 0.5
* Shoulder1: 30.0/255.0
*
* @property {boolean} supported If gamepads are supported in the current context
* @property {boolean} ticking If polling is currently taking place
* @property {array} gamepads The currently connected gamepads, if any
* @property {float} SHOULDER0_BUTTON_THRESHOLD The Shoulder0 ('LEFT_SHOULDER') deadzone for when a button is 'pressed'
* @property {float} SHOULDER1_BUTTON_THRESHOLD The Shoulder1 ('LEFT_SHOULDER_BOTTOM') deadzone for when a button is 'pressed'
* @property {float} LEFT_AXIS_THRESHOLD The left axis deadzone for when an analogue input has 'moved'
* @property {float} RIGHT_AXIS_THRESHOLD The right axis deadzone for when an analogue input has 'moved'
* if(gamepad.supported) {
* if(gamepad.pressed(0, "FACE_1") {
* //Depending on the layout, either 'X', 'A', or 'O' was pressed on the first controller
* }
* }
*/
var Gamepad = (function(self) {
self.supported = (navigator.webkitGetGamepads && navigator.webkitGetGamepads()) ||
!!navigator.webkitGamepads || !!navigator.mozGamepads ||
!!navigator.msGamepads || !!navigator.gamepads ||
(navigator.getGamepads && navigator.getGamepads());
self.ticking = false;
var BUTTONS = {
FACE_1: 0,
FACE_2: 1,
FACE_3: 2,
FACE_4: 3,
LEFT_SHOULDER: 4,
RIGHT_SHOULDER: 5,
LEFT_SHOULDER_BOTTOM: 6,
RIGHT_SHOULDER_BOTTOM: 7,
SELECT: 8,
START: 9,
LEFT_ANALOGUE_STICK: 10,
RIGHT_ANALOGUE_STICK: 11,
PAD_UP: 12,
PAD_DOWN: 13,
PAD_LEFT: 14,
PAD_RIGHT: 15,
CENTER_BUTTON: 16
};
self.SHOULDER0_BUTTON_THRESHOLD = .5;
self.SHOULDER1_BUTTON_THRESHOLD = 30.0 / 255.0;
self.RIGHT_AXIS_THRESHOLD = 7849.0 / 32767.0;
self.LEFT_AXIS_THRESHOLD = 8689 / 32767.0;
self.gamepads = [];
var prevRawGamepadTypes = [];
var prevTimestamps = [];
if (self.supported) {
// Older Firefox
window.addEventListener('MozGamepadConnected',
onGamepadConnect, false);
window.addEventListener('MozGamepadDisconnected',
onGamepadDisconnect, false);
//W3C Specification
window.addEventListener('gamepadconnected', onGamepadConnect, false);
window.addEventListener('gamepaddisconnected', onGamepadDisconnect, false);
// Chrome
if (navigator.webkitGetGamepads && navigator.webkitGetGamepads()) {
startPolling();
}
//CocoonJS
if(navigator.getGamepads && navigator.getGamepads()) {
startPolling();
}
}
/**
* Starts the polling
* @private
* @see onGamepadConnect
*/
function startPolling() {
if (!self.ticking) {
self.ticking = true;
tick();
}
}
/**
* Does one 'tick' and prepares for the next
* @private
* @see pollStatus
*/
function tick() {
pollStatus();
if (self.ticking) {
window.requestAnimationFrame(tick);
}
}
/**
* Stops the polling
* @private
*/
function stopPolling() {
self.ticking = false;
}
/**
* Compares timestamps for changes
* @see pollGamepads()
*/
function pollStatus() {
pollGamepads();
for (var i in self.gamepads) {
var gamepad = self.gamepads[i];
if (gamepad.timestamp &&
(gamepad.timestamp === prevTimestamps[i])) {
continue;
}
prevTimestamps[i] = gamepad.timestamp;
}
}
/**
* Polls the navigator.*Gamepads object for all gamepads connected
*/
function pollGamepads() {
var rawGamepads =
(navigator.webkitGetGamepads && navigator.webkitGetGamepads()) ||
navigator.webkitGamepads || navigator.mozGamepads ||
navigator.msGamepads || navigator.gamepads ||
(navigator.getGamepads && navigator.getGamepads());
if (rawGamepads) {
self.gamepads = [];
for (var i = 0; i < rawGamepads.length; i++) {
if (typeof rawGamepads[i] !== prevRawGamepadTypes[i]) {
prevRawGamepadTypes[i] = typeof rawGamepads[i];
}
if (rawGamepads[i]) {
self.gamepads.push(rawGamepads[i]);
}
}
}
}
/**
* Returns if a specific button on a certain gamepad was pressed
* @param {number} pad The Gamepad to check
* @param {string} buttonId The button to check
* @returns {boolean} If the button on the specific gamepad is currently pressed
*/
self.pressed = function(pad, buttonId) {
if (self.gamepads[pad] && BUTTONS[buttonId]) {
var buttonIndex = BUTTONS[buttonId];
if (buttonIndex === 4 || buttonIndex === 5) {
return self.gamepads[pad].buttons[buttonIndex] > self.SHOULDER0_BUTTON_THRESHOLD;
} else if (buttonIndex === 6 || buttonIndex === 7) {
return self.gamepads[pad].buttons[buttonIndex] > self.SHOULDER1_BUTTON_THRESHOLD;
} else {
return self.gamepads[pad].buttons[buttonIndex] > 0.5;
}
} else {
return false;
}
};
/**
* Returns the amount of movement from the deadzone (-1 to 1)
* @param {number} pad The Gamepad to check
* @param {string} axisId The axis and dimension to check
* @returns {number} The amount of movement, if any
*/
self.moved = function(pad, axisId) {
if (self.gamepads[pad]) {
if (axisId === "LEFT_X") {
if (self.gamepads[pad].axes[0] < -self.LEFT_AXIS_THRESHOLD ||
self.gamepads[pad].axes[0] > self.LEFT_AXIS_THRESHOLD) {
return self.gamepads[pad].axes[0];
}
} else if (axisId === "LEFT_Y") {
if (self.gamepads[pad].axes[1] < -self.LEFT_AXIS_THRESHOLD ||
self.gamepads[pad].axes[1] > self.LEFT_AXIS_THRESHOLD) {
return self.gamepads[pad].axes[1];
}
} else if (axisId === "RIGHT_X") {
if (self.gamepads[pad].axes[2] < -self.RIGHT_AXIS_THRESHOLD ||
self.gamepads[pad].axes[2] > self.RIGHT_AXIS_THRESHOLD) {
return self.gamepads[pad].axes[2];
}
} else if (axisId === "RIGHT_Y") {
if (self.gamepads[pad].axes[3] < -self.RIGHT_AXIS_THRESHOLD ||
self.gamepads[pad].axes[3] > self.RIGHT_AXIS_THRESHOLD) {
return self.gamepads[pad].axes[3];
}
}
} else {
return 0;
}
};
/**
* Adds a gamepad when connected and starts the polling
* @param {EventObject} event A 'MozGamepadConnected' or 'gamepadconnected' event object
*/
function onGamepadConnect(event) {
var gamepad = event.gamepad;
self.gamepads[event.gamepad.id] = gamepad;
self.startPolling();
}
/**
* Sets a disconnected gamepad to 'null'
* @param {EventObject} event A 'MozGamepadDisconnected' or 'gamepaddisconnected' event object
*/
function onGamepadDisconnect(event) {
self.gamepads[event.gamepad.id] = null;
if (self.gamepads.length === 0) {
stopPolling();
}
}
return self;
})(Gamepad || {});
view raw Gamepad.js hosted with ❤ by GitHub