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.
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!