Keyboard input player controller for a 2D js ts game

- 5 mins
Hello1
ViewSource code

Intro

Welcome to the first of a series of tutorials focusing on creating a generic input handling framework.

In this tutorial we will revisit handling keyboard input (See below articles for previous keyboard hmtl5 input events tutorial if you need a refresher).

I originally envisaged this as a single tutorial to simply cover setting up a gamepad but quickly found the problem of keeping the code isolated and clean challenging and fun. I also saw the benefits of being able to refer to an easy framework for further input concerns in future tutorials too hard to resist and so the result is this course!

I find input handling a deceptively tricky and vast topic, that can plague any game with complicated spaghetti code if not handled with care. A large part of this course was spent refactoring the input handling framework, poking and prodding it to see what patterns jumped out.

I am happy with the end result and excited to be teaching you what I learned along the way. Below are some of the topics this course will cover.

  • What is the command pattern
  • Handling various input events
  • Standardising game inputs
  • Differentiating between inputs and game state results
  • Keyboard support
  • Isolated input command framework
  • Multiplayer support
  • Decoupled control mappings
  • Mapping multiple inputs to the same action
  • Gamepad support
  • Set current control type for a player
  • Building a framework capability

By the end of this course we will have covered all of the above and you will have created an isolated input command framework that will be used throughout future tutorials.

Setup

This tutorial will use the typescript-html5-game-loop-starter project as a base. If you need help setting that up or a refresher on the different files head over to typescript game loop project setup otherwise if you're familar with the code and zip file can be found at typescript-html5-game-loop-starter, unzip, change to the project's root directory and open the code in your favourite IDE.

What are we building

We will approach this course by first building the basic functionality of a game and then we will carefully refactor and extend to cover and support the concepts above.

By the end of this post we will have the following.

  • A square displayed on the screen representing the player.
  • The square will be controlled with the wasd keys of the keyboard.
  • The square will support 4 actions which will be differentiated by displaying smaller coloured squares inside the player square.

Don't be put off by these humble beginnings though as this will only lay the foundation, before you know it things will become more challenging and I don't want to say too much but a certain second square may even join the fray!

Also because this part of the course is fairly foundational I will try my best to sprinkle some tidbits of useful aside information that I have found useful when developing games. Onwards and upwards!

The foundations

We are going to start by defining the types we require (These will grow and change). We will require many different types so lets create a separate types folder inside the src folder to isolate them. Next create a types.ts file inside the types folder.

Setting up the types

Lets have a think what game state types we need to capture to satisfy the requirements mentioned above.

We'll need to keep track of the...

  • x and y position for the player (square).
  • state of the squares 4 actions and whether or not they are enabled.
  • player size, this will be stored as a constant for simplicity as it won't change.
  • player's current direction, up, down, left, right.

For the player's position we can represent this as 2D vector type with an x and y value.

The 4 different action states can be represented by a boolean.

The player's current direction could be represented in many ways, for this example I have chosen to use a finite number set, I'll explain why I chose this format over using a string union type representing the 4 directions later on.

Below are the equivalent typescript types mentioned above, as well as the new GameState. Go ahead and update the types.ts file with the following types.

export type Vector2DType = {
  x: number,
  y: number
}

export type DirectionState = -1 | 0 | 1

export type DirectionsState = {
  horizontal: DirectionState,
  vertical: DirectionState
}

export type PlayerState = {
  position: Vector2DType,
  direction: DirectionsState,
  top: boolean,
  right: boolean,
  bottom: boolean,
  left: boolean
}

export type GameState = {
  player1: PlayerState
}

The DirectionState is explained below.

  • -1: Represents the negative direction (up (canvas y decreases as we move up) or left (canvas x decreases as we move left))
  • 0: This represents stationary motion in the given direction (horizontal or vertical)
  • 1: Represents the positive direction (down (canvas y increases as we move down) or right (canvas x increases as we move right))

These are all the types we will need for now.

Lets remove the existing empty GameState type from GameRuntime.ts.

The export prefix in the code above defines this type as public, without it we would not be able to import this type from a different file which is exactly what we are going to do next.

Next add the below import statement at the top of GameRuntime.ts to import the GameState type.

import { GameState } from "./types/types.js"

Note the .js suffix, if we use .ts the application will not compile. This is due to the fact that all of our typescript .ts files get transformed to .js files at buildtime as this is the only format that the browser understands. So importing .ts files will cause a runtime error.

Setting up constants

Our game will have a few constant variables that we will centralise in a separate file. Create a GameConstants.ts file in the src directory and add the following code.

import { GameState, PlayerState } from "./types/types.js"

export const InitialPlayerState: PlayerState = {
  position: {
    x: 100,
    y: 100
  },
  direction: {
    horizontal: 0,
    vertical: 0
  },
  top: false,
  right: false,
  bottom: false,
  left: false
}

export const playerColour = 'black'
export const playerSize = 50

export const InitialGameState: GameState = {
  player1: InitialPlayerState
}

Here we set an initial player state with some default properties for the player's position, direction and top, right, bottom, left fields which represent the four actions the player can perform other than movement.

Now in GameRuntime.ts update the gameState static variable to be initialised as below.

static gameState: GameState = InitialGameState

Debugging

Our inputs will constantly be changing as new input events are fired from the keyboard which in turn will continuously update the player state.

It would be helpful for debugging purposes to log this information to the canvas to indicate the current player state (This has helped me countless times).

I prefer to debug on the canvas directly over writing console logs for game applications because games are constantly looping, and trying to make sense of console logs holding positional information or something similar is a nightmare.

Add the following line to the end of the draw method in GameRuntime.ts to print out the current player state. We'll use this as feedback as we update the code to verify things are still working as expected.

    context.fillText(`P1: ${JSON.stringify(GameRuntime.gameState.player1)}`, 0, 20)

We're progressing well and have hit our first checkpoint, run the app and confirm you can see the static player state printed on the canvas.

Handling key events

Next we're going handle keyboard events and update the player state.

Add a new KeyboardManager.ts class with the following code.

import GameRuntime from "./GameRuntime.js"

export class KeyboardManager {

  private static keyDown = (event: KeyboardEvent) => {
    if (event.key === 'f') {
      GameRuntime.gameState.player1.bottom = true
    }

    if (event.key === 'g') {
      GameRuntime.gameState.player1.right = true
    }

    if (event.key === 'h') {
      GameRuntime.gameState.player1.left = true
    }
    
    if (event.key === 'j') {
      GameRuntime.gameState.player1.top = true
    }

    if (event.key === 'w') {
      GameRuntime.gameState.player1.direction.vertical = -1
    }
  
    if (event.key === 's') {
      GameRuntime.gameState.player1.direction.vertical = 1
    }
  
    if (event.key === 'a') {
      GameRuntime.gameState.player1.direction.horizontal = -1
    }
  
    if (event.key === 'd') {
      GameRuntime.gameState.player1.direction.horizontal = 1
    }
  }

  private static keyUp = (event: KeyboardEvent) => {
    if (event.key === 'f') {
      GameRuntime.gameState.player1.bottom = false
    }

    if (event.key === 'g') {
      GameRuntime.gameState.player1.right = false
    }

    if (event.key === 'h') {
      GameRuntime.gameState.player1.left = false
    }
    
    if (event.key === 'j') {
      GameRuntime.gameState.player1.top = false
    }

    if (event.key === 'w') {
      GameRuntime.gameState.player1.direction.vertical = 0
    }
  
    if (event.key === 's') {
      GameRuntime.gameState.player1.direction.vertical = 0
    }
  
    if (event.key === 'a') {
      GameRuntime.gameState.player1.direction.horizontal = 0
    }
  
    if (event.key === 'd') {
      GameRuntime.gameState.player1.direction.horizontal = 0
    }
  }

  constructor() {
    window.addEventListener('keydown', KeyboardManager.keyDown)
    window.addEventListener('keyup', KeyboardManager.keyUp)
  }
}

Add the following KeyboardManager initialiser code to the GameRuntime.ts's setup method.

new KeyboardManager()

If you finished the previous keyboard hmtl5 input events tutorial below the code above should look fairly familiar. Here we are listening to keyboard events for the keyboard keys we support w, a, s, d, f, g, h, j and setting the appropriate player state.

When a directional key is released we're setting that direction to 0 (stationary) and when an action is released we are setting that action to false. When a directional key is pressed we're setting that direction to 1 if it is a postive direction on the canvas (right or down) and -1 if it is a negative direction on the canvas (left or up). When an action is pressed we are setting that action to true.

Now if the above code makes you want to close this tutorial, please refrain, I wager your faith will be restored by the end of the course!

Run the application and we should now be able to see the appropriate playerState change when we press the w, a, s, d, f, g, h and j keys.

Drawing the player

Now it's time to draw the player and reflect the change in player state on the canvas.

Add a new Draw.ts file copy the following code into it.

import { playerColour, playerSize } from "./GameConstants.js";
import { PlayerState, Vector2DType } from "./types/types.js";

export class Draw {

  static drawPlayer = (context: CanvasRenderingContext2D, playerState: PlayerState) => {

    Draw.drawSquare(context, playerState.position, playerSize, playerColour)
    
    const miniSquareSize = 15
    if (playerState.top) {
      Draw.drawSquare(context, {x: playerState.position.x, y: playerState.position.y - playerSize / 2}, miniSquareSize, 'yellow')
    }
    if (playerState.right) {
      Draw.drawSquare(context, {x: playerState.position.x + playerSize / 2, y: playerState.position.y}, miniSquareSize, 'red')
    }
    if (playerState.bottom) {
      Draw.drawSquare(context, {x: playerState.position.x, y: playerState.position.y + playerSize / 2}, miniSquareSize, 'green')
    }
    if (playerState.left) {
      Draw.drawSquare(context, {x: playerState.position.x - playerSize /2, y: playerState.position.y}, miniSquareSize, 'blue')
    }
  }

  static drawSquare = (context: CanvasRenderingContext2D, pos: Vector2DType, size: number, colour: string) => {
    context.beginPath();
    context.strokeStyle = colour
    context.fillStyle = colour
    context.rect(pos.x - size / 2, pos.y - size / 2, size, size);
    context.stroke()
    context.fill()
  }

  static resetCanvas = (context: CanvasRenderingContext2D, canvasWidth: number, canvasHeight: number) => {
    context.font = '10px arial'
    context.fillStyle = 'black'
    context.strokeStyle = 'black'
    context.clearRect(0, 0, canvasWidth, canvasHeight)
  }
}

The drawPlayer method takes the canvas context and some PlayerState, it then draws the player square and conditionally draws the smaller action coloured squares inside the player square depending on if the respective action state is enabled or not.

We've also extracted our reset canvas functionality which sets some default context values and clears the canvas so go ahead and remove the equivalent lines in the GameRuntime.ts draw method and replace them with the code below.

Draw.resetCanvas(context, canvasWidth, canvasHeight)

Add the following lines to the end of the draw method.

Draw.drawPlayer(context, gameState.player1)

Run the application and confirm you can see the player square. Pressing f, g, h and j will show a small coloured square green, red, blue and yellow respectively which represents one of the players 4 actions being enabled. Try enabling multiple actions at a time.

Updating the players position

We're close to the end of this tutorial but our square is a little static, it's time to make our player move!

Add a new file called Player.ts with the following code.

import { DirectionsState, PlayerState, Vector2DType } from "./types/types.js"

export class Player {
  static getPlayerState = (currentState: PlayerState): PlayerState => {

    const playerPosition = Player.updatePlayerPosition(currentState.position, currentState.direction)

    return {
      ...currentState,
      position: playerPosition
    }
  }

  static updatePlayerPosition = (position: Vector2DType, direction: DirectionsState): Vector2DType => {
    const speed = 10
    const x = position.x + direction.horizontal * speed
    const y = position.y + direction.vertical * speed

    return {x, y}
  }
}

This code may look more complicated than it needs to be but we are setting things up for the next tutorial by adding the getPlayerState method.

The getPlayerState method takes PlayerState and returns a new PlayerState by updating the position.

The position is updated via the upatePlayerPosition method by using the DirectionsState and hardcoded speed value to determine and return the new position of the player.

Hopefully you can see that representing the players direction via the discrete number set -1, 0, 1 makes computing the new player position a lot easier than if we had represented it as a union string type.

In GameRuntime.ts add the following code at the start of the loop method to update the player state as below.

const player1State = Player.getPlayerState(GameRuntime.gameState.player1)
GameRuntime.gameState.player1 = player1State

Run the application and you should now be able to see the square move around when you press the w, a, s, d as well as the players position state upate in the canvas debug text!

Summary

That brings us to end of this tutorial. The resulting outcome may look simple but the key here is to let your imagination run wild with what the coloured squares represent, pressing the action buttons can result in any effect to the player or game state you can dream up!

Before moving onto the next tutorial I want to leave you to ponder these questions.

How easy would it be to...

  • introduce a second player?
  • add another key based action, how about 10?
  • have a second key perform the f key action?
  • make the f key perform a different action?
  • add a gamepad as an input?
  • have a player configure their key mappings?
  • disable the keyboard when the gamepad is connected?

You may have also witnessed some perhaps not so desirable game play when you try press and then let go of multiple direction keys. Does the player move as you expect or do they halt awkwardly?

If you seek answers to the questions above make sure to checkout the next tutorial in the course when it is available!