Gamepad input for a javascript game

- 5 mins
Hello1
ViewSource code

Intro

In the previous two tutorials we explored the command pattern and then we implemented a lightweight version to handle keyboard input for our Javascript / Typescript game project. In this tutorial we are going to reap the rewards the command design pattern gives us by adding support for a gamepad. To complete this tutorial you will need a HTML supported gamepad, most USB gamepad controller's should be recognised by the gamepad API.

By the end of this tutorial you will be able to use the Gamepad to play our mini Game.

Connecting to a gamepad using the HTML gamepad API

The great news is the browser already supports gamepads out of the box, no further libraries are required!

Follow the steps below to connect a gamepad.

  • Plug the gamepad into a USB slot on your device.
  • Ensure the gamepad is turned on
  • Open Chrome browser to any page
  • Right click and select inspect, this will open up the Chrome dev tools
  • Select the Console tab
  • Press a few buttons or wiggle the joystick on the gamepad
  • Type navigator.getGamepads() into the browser console

If everything worked you should see an array of optional gamepads returned.

Something similar to this [null, Gamepad, null, null].

The Gamepad in the array shows that the Gamepad was detected correctly, if you were to connect another Gamepad and run the command above then the Array would contain two Gamepad's.

If you select the dropdown next to the console Array result and then select the Gamepad dropdown you will see various information about the Gamepad, such as the current axes (Joystick / D-pad) and buttons state.

Try holding down some buttons or the joystick to the left and then running the command above again. If you look at the Gamepad state you should see in the buttons Array some of the buttons will have a pressed state and the axes should have a value other than 0. We will use these states when we poll for our Gamepad input.

If you don't see the Gamepad in the Array then you may need to go over the above steps. If you still don't see it and you have your gamepads model number you can Google Connecting Model number to Gamepad API and see if anyone else is able to get that Gamepad to work, from my experience the Browser finds most Gamepad's I've tried to plugin.

Here is the MDN Gamepad API docs if you want to learn more about the API in detail.

Now that we can connect the gamepad it's time to incorporate Gamepad input into our game via the command pattern.

Supporting multiple input devices

Before we add Gamepad support we will first add a new InputType to the PlayerInput type. This will allow us to set the player's current input device and allow them to switch between Keyboard and Gamepad.

Add the following new types to the input types.ts file.

export type Gamepad = {id: 0} | {id: 1} | {id: 2} | {id: 3}
export type InputType = 'keyboard' | Gamepad

The we only have one keyboard to support it is represented by a simple typescript string type. We will support 4 Gamepads (This is the amount of Gamepads the browser supports) and so we will represent them by an object with an Id set to 0, 1, 2 or 3.

Next add the current of type InputType to the PlayerInput type which will look as below.

export type PlayerInput = {
  current: InputType,
  keyboardMapping: GameKeyboardMapping
}

In the inputs.playerInputMappings.player1 field of the InitialGameState constant in the GameConstants.ts file add a current field set to keyboard as below.

current: 'keyboard'

In the InputManager add a new method called getStandardGameInput and add the following code.

private static getStandardGameInput = (playerInput: PlayerInput, inputState: InputState): StandardGameInput => {   
  if (playerInput.current === 'keyboard') {
    return KeyboardTransformer.transform(inputState.keyboard, playerInput.keyboardMapping)
  } else {
    return StandardGameInputStateNeutral
  }
}

The above code check the playerInput's current value, if it's keyboard it will call the KeyboardTransformer as before to transform the low level input to the high level StandardGameInput, if it's not it will return the StandardGameInputStateNeutral state for now.

Finally update the InputManager's getInputs method to call the getStandardGameInput method instead of calling the KeyboardTransformer directly, it should look like the code below.

static getInputs = (playerInput: PlayerInput): StandardGameInput => {
  const inputState: InputState = InputStateManager.getInputs()

  return InputManager.getStandardGameInput(playerInput, inputState)
}

Now run the app and check that the keyboard input is still working, direction keys should move the square and the f, g, h, j keys should activate the different actions (coloured squares).

Adding gamepad mappings

Okay I promise that was the last bit of setup code, we're now going to add the Gamepad!

We'll start by introducing a few more Gamepad specific types to the input types.ts file as below.

export type SupportedButtons = 0 | 1 | 2 | 3
export type AxisDirections = 'horizontal' | 'vertical'

export type GamepadMapping = {
  direction: {
    horizontal: AxisDirections,
    vertical: AxisDirections
  },
  actions: {
    primary: SupportedButtons[],
    secondary: SupportedButtons[],
    button3: SupportedButtons[],
    button4: SupportedButtons[]
  }
}

The HTML Gamepad API maps the buttons of a Gamepad to a number as you experienced when we tested the Gamepad. We'll use the SupportedButtons type to represent these numbers, we'll only support the first 4 buttons but it should be easy to add support for the rest if you wish.

the AxisDirections will be used to support joystick inversion.

The GamepadMapping type allows a player to map their desired Gamepad input. Similar to the keyboard. The four actions can be mapped to one or more of the SupportedButtons and each axes can be mapped to an AxisDirection.

Update the PlayerInput type to include the gamepadMapping field as below.

export type PlayerInput = {
  current: InputType,
  gamepadMapping: GamepadMapping,
  keyboardMapping: GameKeyboardMapping
}

This will allow a player to set their desired gamepadMapping.

Add a new constant called GamepadMappingNeutral to the Constants.ts file in the input directory as below.

export const GamepadMappingNeutral: GamepadMapping = {
  direction: {
    horizontal: 'horizontal',
    vertical: 'vertical'
  },
  actions: {
    primary: [0],
    secondary: [1],
    button3: [2],
    button4: [3]
  }
}

This will be our standard GamepadMapping.

In the GameConstants.ts file update the InitialGameState inputs.playerInputMappings.player1 to include the new field gamepadMapping and set it to GamepadMappingNeutral as below.

gamepadMapping: GamepadMappingNeutral

Run the app and check that everything still works. We have added gamepad support to our GameState but we aren't using them yet.

Adding a GamepadTransformer

Similar to the keyboard we will need a GamepadTransformer to transform the low level gamepad input into high level StandardGameInput. Instead of creating our own GamepadManager to store the low level gamepad inputs we will rely on the HTML Gamepad API for simplicity.

Add a new GamepadTransformer.ts file to the inputs folder and add the code below.

import { ButtonState, DirectionState, GamepadMapping, StandardGameInput, SupportedButtons } from "./types.js"

export class GamepadTransformer {
  
  static transform = (gamepad: Gamepad, playerGamepadMappings: GamepadMapping): StandardGameInput => {

    const primaryActive = GamepadTransformer.getButtonState(playerGamepadMappings.actions.primary, gamepad)
    const secondaryActive = GamepadTransformer.getButtonState(playerGamepadMappings.actions.secondary, gamepad)
    const button3Active = GamepadTransformer.getButtonState(playerGamepadMappings.actions.button3, gamepad)
    const button4Active = GamepadTransformer.getButtonState(playerGamepadMappings.actions.button4, gamepad)

    const x = playerGamepadMappings.direction.horizontal === 'horizontal' ? gamepad.axes[0] : gamepad.axes[1]
    const y = playerGamepadMappings.direction.vertical === 'horizontal' ? gamepad.axes[0] : gamepad.axes[1]
    const discreteX = GamepadTransformer.getAxisDirection(x)
    const discreteY = GamepadTransformer.getAxisDirection(y)

    return {
      axis: {
        discrete: {
          x: discreteX,
          y: discreteY
        }
      },
      actions: {
        primary: primaryActive,
        secondary: secondaryActive,
        button3: button3Active,
        button4: button4Active
      }
    }
  }

  private static getButtonState = (keys: SupportedButtons[], gamepad: Gamepad): ButtonState => {

    const keyStates: ButtonState[] = keys.map(key => {
      const keyState = gamepad.buttons[key].pressed ? 'pressed' : 'not-pressed' 
      return keyState
    })

    return keyStates.find(key => key === 'pressed') ? 'pressed' : 'not-pressed'
  }

  static getAxisDirection = (n: number): DirectionState => {
    if (n < 0) {
      return -1
    } else if (n > 0) {
      return 1
    } else {
      return 0
    }
  }
}

The getButtonState method is very similar to the KeyboardTransformer's getButtonState method, given a Gamepad and some SupportedKey Array it checks if any of the SupportedKeys are pressed.

The getAxisDirection method, converts the gamepad's continuous axis values into discrete ones.

The transform method computes the relevant button states and axis values and uses them to construct a StandardGameInput which is returned. It is also mapping the direction by checking the players direction mappings which is what enables the inversion support.

Update the InputManager's getStandardGameInput method with the following code to support Gamepad.

private static getStandardGameInput = (playerInput: PlayerInput, inputState: InputState, gamepads: (Gamepad | null)[]): StandardGameInput => {
  
  if (playerInput.current === 'keyboard') {
    return KeyboardTransformer.transform(inputState.keyboard, playerInput.keyboardMapping)
  } else {
      const gamepad = gamepads[playerInput.current.id]
    if (gamepad !== null) {
      return GamepadTransformer.transform(gamepad, playerInput.gamepadMapping)
    } else {
      return StandardGameInputStateNeutral
    }
  }
}

Because the Gamepad can be unplugged and become unavailable the Gamepad API returns either a Gamepad or null. If the Gamepad is null we will return the StandardGameInputStateNeutral. In a real game we would want to model this using some error flag to let the game know a gamepad cannot be found. The Gamepad api also has a gamepadDisconnected handler we could listen to and show a menu asking for the Gamepad to be reconnected but this is out of scope for this tutorial. If this happens in our game the player will stop moving as the neutral Gamepad state has the axis set to 0 in both directions, which is also a pretty standard game response when a controller is unplugged.

Next we need to update where we call getStandardGameInput to include the new gamepads argument. In getInputs method retrieve the gamepads via the navigator and pass it into the getStandardGameInput method call as an argument the getInputs method should match below.

static getInputs = (playerInput: PlayerInput): StandardGameInput => {
  const inputState: InputState = InputStateManager.getInputs()
  const gamepads = navigator.getGamepads()

  return InputManager.getStandardGameInput(playerInput, inputState, gamepads)
}

Testing the Gameapad

In the GameConstants.ts file update the InitialGameState's inputs.playerInputMappings.player1.current to a Gamepad with id equal to zero as below.

current: {id: 0}

Run the app and try using the Gamepad. You should be able to control our square friend with the Joystick and trigger the actions (coloured squares) with the first four buttons of your controller. Note if your gamepad is set to a different index in the Gamepad Array you logged in the browser console then make sure that index is used instead of id: 0.

The first time I connected a Gamepad to one of the web games I was working on was amazing! The Games just feel instantly more responsive and experimenting with physics (which we'll do in a future tutorial) has many possibilities with a gamepad and joystick input.

Adding a controller joystick threshold

You may have noticed that when you let go on your gamepad the player might continue to move. This is because the joystick is not perfectly centred, it will still have some small x and y axis value that causes the square to gradually move. If you flick the joystick to the left and let go the square probably moves to the right and vice versa. We can add a threshold to treat small values (values under the threshold as 0).

Update GamepadTransformer's getAxisDirection method as below.

static getAxisDirection = (n: number, threshold: number): DirectionState => {

  if (Math.abs(n) <= threshold) {
    return 0
  } else if (n > threshold) {
    return 1
  } else {
    return -1
  }
  }

We have added a new threshold parameter and are using it instead of 0 to check the DirectionState. In the first condition we are taking the absolute value of n and returning 0 if it is below the threshold.

In the transform method add a new constant threshold of value 0.15 and update the code calling the getAxisDirection method to include a new threshold as below.

const threshold = 0.15
const discreteX = GamepadTransformer.getAxisDirection(x, threshold)
const discreteY = GamepadTransformer.getAxisDirection(y, threshold)

Run the app again and see if the square will stop dead in its tracks now when you let go of the joystick, that's better! You can tweak the threshold to your liking.

Adding continuous

Currently we only support a finite number of discrete axis values -1, 0, 1, this works for our game but it would be nice to support continuous direction supplied by the joystick as well, we'll go ahead and add that support now.

Inside the axis object of the StandardGameInput add a new continuous field, it should be on the same level as the discrete field as below.

continuous: {
  x: number,
  y: number
}

In the Constants.ts file in the inputs directory add the following continuous object aside the discrete field in the StandardGameInputStateNeutral.

continuous: {
  x: 0,
  y: 0
}

In the GamepadTransformer's transform method add a continuous object aside the discrete field setting the x and y values to the computed constants x and y.

continuous: {
  x: x,
  y: y
}

In KeyboardTransformer's transform method add a continuous object aside the discrete field setting the x and y values to the computed constants x and y.

continuous: {
  x: x,
  y: y
}

That's it, we're not going to do anything with these continuous values in this tutorial though you could log them to the canvas to confirm they are being set if you wanted to. In future tutorials though we will make use of them as they will come in handy in dealing with realistic joystick movement and 2D physics!

Summary

That's everything for this tutorial. Even though it was another long one I hope you can appreciate that the majority of the code is to do with how we handle the Gamepad inputs and not how we support inputs in general. Hopefully this is starting to demonstrate the power of the command design pattern!

In the next tutorial we will extend the framework to support 4 players all using different inputs.