Creating a Javascript Typescript snake game clone

- 5 mins
Hello1
ViewSource code

Intro

Now comes the fun part, in this tutorial we are going to take everything we have covered so far to create a basic snake game, you can behold the finished game in all its glory by tapping the example button above or build it gradually yourself as a surprise.

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.

Meet Blake, a pixel art block snake I created using the awesome Piskel app.

blake

We are going to create a basic snake clone game starring Blake as the main character. Because this tutorial builds off all the previous tutorials so far I won't explain the code line by line. In fact a great way to solidify what you have learnt so far would be to view the example above and see if you can create it from scratch using the previous tutorials as a reference if you get stuck.

The project uses 5 different images as sprites, the four snake direction assets and the apple food asset which can be downloaded at blake-the-snake-game.

Download these assets and place them in the src directory. Note, the package.json build script automatically copies any .png files found in the src directory to the build directory.

Now you should have everything you need to start coding!

Coding the game

By the end of this tutorial less than 150 lines of code are required in the project's main GameRuntime.ts file to create Blake the snake!

Loading the images

Add the following lines at the start of GameRuntime.ts to load the 5 required image assets outside of the GameRuntime class.

const snakeUp = new Image();
snakeUp.src = "snake-up.png";
const snakeRight = new Image();
snakeRight.src = "snake-right.png";
const snakeDown = new Image();
snakeDown.src = "snake-down.png";
const snakeLeft = new Image();
snakeLeft.src = "snake-left.png";
const apple = new Image();
apple.src = "apple.png";

Initialising the state

Next we will initialise the GameState required to describe the game.

Add the following lines of code below where we loaded the images previously.

We are creating a Direction type to model the direction Blake is facing. We have also defined a Position type to model a simple x, y coordinate.

type Direction = 'left' | 'up' | 'right' | 'down'

type Position = {
  x: number
  y: number
}

type GameState = {
  playerSize: number
  playerPosition: Position
  playerSprite: HTMLImageElement
  foodPosition: Position
  direction: Direction
  speed: number
  gameOver: boolean
  score: number
  foodSize: number
}

Next we will set some reasonable initial values to represent the game's initial state.

In the GameRuntime class add the following lines of code to the top to define the initial game state.

static gameState: GameState = {
  playerSize: 48,
  playerPosition: { x: 0, y: 0},
  playerSprite: snakeRight,
  foodPosition: { x: 100, y: 100},
  direction: 'right',
  speed: 1,
  gameOver: false,
  score: 0,
  foodSize: 24
}

The GameState's fields and default values are described below.

  • playerSize: Blake's square length (48).
  • playerPosition: Blake's current position (0, 0).
  • playerSprite: Blake's current sprite (snakeRight).
  • foodPosition: Current position of food (100, 100).
  • direction: Blake's current direction ('right').
  • speed: Blake's current movement speed (1).
  • gameOver: Whether or not the game is in the gameOver state (false).
  • score: Current game score (0).
  • foodSize: The apple's square length (24).

Finally add the following code to the top of GameRuntime's loop method to destructure the initial GameState as below.

const {gameOver, playerPosition, foodPosition, direction, speed, playerSize, foodSize, score} = GameRuntime.gameState

Drawing the gameState

Update GameRuntime.ts draw method to be the same as below.

private static draw = (context: CanvasRenderingContext2D, canvasWidth: number, canvasHeight: number, gameState: GameState) => {
  const {score, speed, gameOver, playerSize, playerPosition, playerSprite, foodPosition, foodSize} = gameState

  context.font = '18px arial'
  context.clearRect(0, 0, canvasWidth, canvasHeight)
  context.fillText(`Blake the Snake - Score: ${score}, Speed: ${speed}`, 30, 30)
  context.drawImage(playerSprite, playerPosition.x, playerPosition.y, playerSize, playerSize);
  context.drawImage(apple, foodPosition.x, foodPosition.y, foodSize, foodSize);
  gameOver && context.fillText('GAMEOVER', canvasWidth / 2 - 50, canvasHeight / 2)
}

The first line inside the draw method is using JavaScript's object destructuring feature to extract to various gameState fields.

We are using the context to draw the title, current gameState (score, speed), Blake and apple as well as a conditional GAMEOVER text.

Build and run the game and you should see the title, score, speed, Blake facing right and an apple on the screen. Currently though Blake is unable to be controlled.

Controlling Blake the snake

In the GameRuntime class add the following keyboard event handler method which will update the gameState's direction property when the respective arrowKey is pressed.

static keyDownHandler = (event: KeyboardEvent) => {
  switch(event.code) {
    case 'ArrowLeft':
      GameRuntime.gameState.direction = 'left'
      break;
    case 'ArrowUp':
      GameRuntime.gameState.direction = 'up'
      break;
    case 'ArrowRight':
      GameRuntime.gameState.direction = 'right'
      break;
    case 'ArrowDown':
      GameRuntime.gameState.direction = 'down'
      break;
  }
}

In the GameRuntime class add the following newPlayerPos method which will determine Blake's new position given the current direction. The respective x or y coordinate is incremented or decremented by speed based on Blake's direction.

static newPlayerPos = (posX: number, posY: number, direction: Direction, speed: number): Position => {
  switch(direction) {
    case 'left':
      return {x: posX - speed, y: posY}
    case 'up':
      return {x: posX, y: posY - speed}
    case 'right':
      return {x: posX + speed, y: posY}
    case 'down':
      return {x: posX, y: posY + speed}
  }
}

Add the following code in the GameRuntime's loop method to determine Blake's new position.

const newPosition = GameRuntime.newPlayerPos(playerPosition.x, playerPosition.y, direction, speed)

Below that add the following code to update the GameState with Blake's new position.

GameRuntime.gameState = {
  ...GameRuntime.gameState,
  playerPosition: newPosition
}

Run the game and you should now be able to control Blake via the arrow keys! You may notice a few things though, Blake can leave the canvas, Blake's sprite is not updated and Blake's insatiable appetite for apples cannot be satisfied! Lets see if we can fix this.

Adding gameOver state

We want the gameOver state to be triggered when Blake exits the canvas.

Add the following code below the newPosition const declaration in the GameRuntime's loop method. This will check if Blake is outside of the canvas bounds (taking into account Blake's size) or if the game is already in the gameOver state.

const isGameOver = newPosition.x < 0 || newPosition.x > (canvasWidth - playerSize) || newPosition.y < 0 || newPosition.y > (canvasHeight - playerSize) || gameOver

Next we need to update the code where we are updating the GameState to handle the gameOver case.

if (isGameOver) {
  GameRuntime.gameState = {
    ...GameRuntime.gameState,
    gameOver: true
  }
} else {
  GameRuntime.gameState = {
    ...GameRuntime.gameState,
    playerPosition: newPosition
  }
}

Here we are checking if the game is in the gameOver state. If it is we are not making any additional updates to the GameState. If it isn't we will set the current playerPosition to the new newPosition as before.

Run the game and this time when Blake exits the canvas the GAMEOVER message should be displayed and Blake should freeze. To restart the game reload the page.

Updating Blake's sprite

Add the following code in the GameRuntime.ts file outside of the GameRuntime class.

static getSnakeImage = (direction: Direction): HTMLImageElement => {
  switch(direction) {
    case 'left':
      return snakeLeft
    case 'up':
      return snakeUp
    case 'right':
      return snakeRight
    case 'down':
      return snakeDown
  }
}

This will determine the correct playerSprite to show.

Update the else condition of the GameState update in the loop method when GameOver state is not activated with the following code.

GameRuntime.gameState = {
  ...GameRuntime.gameState,
  playerPosition: newPosition,
  playerSprite: GameRuntime.getSnakeImage(direction)
}

Run the game and this time when an arrow key is pressed Blake's sprite should change to reflect Blake's direction.

Satisfying Blake's insatiable appetite for apples

When Blake overlaps with an apple we want to...

  • clear away the apple
  • Increment the score by 1
  • Increment the speed by 1
  • Generate a new apple position

The problem of checking if Blake is overlapping with an apple can be generalised to checking if two squares are overlapping. Add the below static method in the GameRuntime.ts file to do this.

static squaresOverlap = (p1: Position, l1: number, p2: Position, l2: number): boolean => {
  const p1LeftOfP2 = (p1.x + l1) < p2.x;
  const p1RightOfP2 = p1.x > (p2.x + l2);
  const p1AboveP2 = (p1.y + l1) < p2.y;
  const p1BelowP2 = p1.y > (p2.y + l2);

  return !( p1LeftOfP2 || p1RightOfP2 || p1AboveP2 || p1BelowP2 );
}

The squaresOverlap method will determine given two positions and there respective lengths, whether any part of them are overlapping.

We also need a method to determine the new apple position. Add the static method below.

static newFoodPos = (canvasWidth: number, canvasHeight: number): Position => {
  return {
    x: Math.random() * (canvasWidth - GameRuntime.gameState.foodSize),
    y: Math.random() * (canvasHeight - GameRuntime.gameState.foodSize)
  }
}

The newFoodPos method will return a random position within the canvas for the apple, taking into account the apple's dimensions.

Under the isGameOver declaration in the loop method add the following code.

const foodEaten = GameRuntime.squaresOverlap(playerPosition, playerSize, foodPosition, foodSize)
const newFoodPosition = foodEaten ? GameRuntime.newFoodPos(canvasWidth, canvasHeight) : foodPosition

The foodEaten represents whether Blake is currently overlapping with an apple. The new foodPosition will be unchanged if foodEaten is false and set to a new position if foodEaten is true.

Update the else condition of the GameState update in the loop method when GameOver state is not activated with the following code.

GameRuntime.gameState = {
  ...GameRuntime.gameState,
  playerPosition: newPosition,
  foodPosition: newFoodPosition,
  score: foodEaten ? score + 1 : score,
  speed: foodEaten ? speed + 1 : speed,
  playerSprite: GameRuntime.getSnakeImage(direction)
}

Here we increment the score and speed if the food has been eaten as required and also set the foodPosition to the newFoodPosition.

Run the game and notice that Blake can now eat all of the apple's. Also notice that every time an apple is eaten Blake's speed and score increase!

Summary

In less than 150 lines of code we have created many of the hallmarks that makeup a game (My top score is 17), inputs, outputs, score, increasing difficulty, gameOver state, lovable character and a captivating story arc.

Congrats if you made it this far you have now finished my intro typescript game development tutorial series. Hats off if you managed to recreate Blake the Snake just from the example above too!

The next challenge is to take what you know and customise Blake the snake to make it your own, add power-ups, obstacles, enemies the options are endless. Better yet use the concepts learnt so far to create an entirely new game.

I'd love to see a link to what fun things you create in the comments below!

Well that's it for this tutorial series, I hope you enjoyed it. This series has given us a foundation that we will build off in future tutorials to cover more advanced and specific gaming concepts, look forward to seeing you there!