How to create Snake game in JavaScript

Muhammad Saqib Ilyas
26 min readJul 25, 2024

--

Learning to code is not easy. There is just so much to learn. So much more than just learning the syntax of a programming language. After learning a language syntax, you might find yourself, like me, totally lost when it comes to solving a programming problem. Where does one start? I’ve learned that creating projects helps overcome that challenge. What could be a better project than creating the clone of a popular game?

In this blog, we’ll learn how to create a clone of the Nokia Snake game in JavaScript. Of course, we’ll need a bit of HTML, and CSS, too. We’ll use the canvas API for rendering. Also, we’ll use object oriented programming and the class syntax of the modern form of JavaScript.

With object orientation, we create code artifacts that model the behavior and state of the “objects” that exist in the game. Each artifact, or class, is responsible for the state and behavior of only specific types of objects, which makes writing code easy. Complex functionality is realized when the objects send messages to each other by means of method calls.

Let’s begin!

An important observation

Play the snake game and observe what is really happening. Here’s what I observed:

  • You can cause the snake to turn — usually with arrow keys.
  • When turning, just the snake’s head is affected. No other part of its body is affected.
  • As the snake moves, whether it is turning or not, only two parts of its body are affected. The head is re-drawn at the next position (depending on the snake’s current direction). The tail disappears. The combination of these two actions creates the impression of movement. No other part of the snake’s body needs changing.
  • If the snake eats a food, its size grows. This can be accomplished by not removing its tail in the next “step.”

The last two observations are important in the sense that we can efficiently render the animation by changing the pixels at only two square blocks on the screen, rather than repainting the entire window.

Setting up the project

I recommend creating an empty repository on github, and then cloning it on your system from inside a terminal. Once that is done, switch to the project directory using the cd command. I assume that you have npm installed on your system. Tun the command npm init in the project’s top level directory. It asks you to make some choices. The defaults are usually fine. Once the tool has finished running, create a file named .gitignore in the project directory. Place the following in it.

node_modules
package-lock.json

This makes sure that we don’t commit these unnecessary files and directories to github. Next, open package.json file in the project’s top-level directory and modify the scripts and devDependencies sections so that it looks like the following:

{
"name": "ootetrisjs",
"version": "1.0.0",
"description": "An object oriented game of Tetris using HTML, CSS, and JavaScript",
"main": "index.js",
"scripts": {
"build": "webpack",
"start": "webpack serve --open"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.24.7",
"@babel/preset-env": "^7.24.7",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^12.0.2",
"html-webpack-plugin": "^5.6.0",
"webpack": "^5.92.1",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4"
}
}

The scripts specify the use of webpack to build and run the application. The dependencies are for babel and webpack tools for building and running the code. We also add plugins for copying the source files into the build directory when building the code.

Now, in the project’s top level directory, run the command npm install. This installs all the dependencies.

Now, on to the webpack configuration. Create a file named webpack.config.js in the project’s top level directory, and paste the following code in it.

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin')

module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'docs'),
clean: true,
},
mode: 'development', // or 'production'
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
},
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './index.html',
}),
new CopyWebpackPlugin({
patterns: [
{ from: 'style.css', to: '' }, // Copy style.css to the dist folder
],
}),
],
devServer: {
static: path.join(__dirname, 'docs'),
compress: true,
port: 9000,
},
};

It is OK if you don’t understand all of this. Essentially, it is telling webpack to:

  • Use src/index.js as the program’s entry point.
  • Place the built application in a file named bundle.js inside a directory named docs. If we choose this directory, and commit the code to github, we can use github pages to have our application deployed to the web easily. You can share the running game with your friends.
  • Use babel to build the JavaScript code.
  • Copy all the CSS files in the top-level directory to the built artifacts as well.
  • Use the index.html file as the HTML template, and copy it to the built artifact as well.
  • Build and run the game in the docs directory on port 9000 for debugging.

The HTML

We need a three column layout with the score and level on the left, the game board in the center, and buttons on the right.

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Changa:wght@200..800&display=swap" rel="stylesheet">

<title>Snake Game</title>
</head>
<body>
<h1>Snake</h1>
<div class="board">
<div class="panel">
<div>Socre: <span id="score">0</span></div>
<div>Level: <span id="level">1</span></div>
</div>
<canvas id="game"></canvas>
<div class="panel" id="buttons">
<button id="start">New game</button>
</div>
</div>
</body>
</html>

In the head section, we import the Changa font from Google Fonts. This gives the game a vintage look. In the body section, we start with a heading, followed by the main div element that has the three columns: a div element with the score and level, a canvas element that houses the game playing area itself, and a div element that has a start button.

The CSS

Let’s achieve the three column layout and style the rest of the elements. We start with a CSS reset and setting a light background color.

*, ::before, ::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}

body {
background-color: #f0f0f0;
}

Next, let’s style the heading element:

h1 {
text-align: center;
margin: 10px 0;
}

We center the heading horizontally on the page and give it a bit of margin on top and bottom. Next, let’s style the canvas element.

canvas {
border: 1px solid #333;
background-color: #fff;
width: 600px;
height: 600px;
}

We give the element a border, so it is easy to spot and focus on. We give it a white background color. We set its dimensions to be 600 pixels by 600 pixels square. Next, let’s style the main three column container.

.board {
margin: 10px auto;
width: 100%;
display: flex;
justify-content: space-between;
}

We give it a margin above and below so that it is spaced away from the elements above and below. We give it a width of 100%. We declare it a Flex container to control the positioning of its three child elements. We use the justify-content property to adjust the available space between the child elements. Let’s now focus on the three column layout.

.panel {
flex: 1;
font-size: 32px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20px;
}

The first and last columns both have a class of panel. We want them to grow and shrink depending on the available space in the parent, in equal proportion. So, we set the flex property to 1. We also want to control the layout of the elements inside these panels using Flexbox, so we set the display property accordingly. We want the elements in each of these columns in a single column, so we set flex-direction to column. To center the elements both horizontally and vertically, we set both justify-contents and align-items to center. We specify a gap of 20 pixels so that the elements inside these columns are separated from each other. Now, let’s style the score and level span elements.

#score, #level{
font-size: 32px;
display: inline-block;
width: 100px;
font-family: 'Changa', Arial, sans-serif;
text-align: right;
background-color: #655d73;
color: #ddd;
border-radius: 10px;
padding-right: 4px;
}

We set a large font size. We want to control the width of these elements so that the score and level are displayed right-aligned in a fixed width. But, a span element is an inline element, so, we set the display property to inline-block. We give the elements a width of 100 pixels. We select the Changa Google font. We specify right alignment. We specify a light text color and a contrasting background color. For rounded edges, we use border-radius. To space the score and level values from the edge of the span elements, we use some right padding. Now, let’s focus on the buttons on the right hand side column.

#buttons button {
font-size: 20px;
padding: 6px 10px;
border-radius: 6px;
width: 150px;
}

We target button elements that are descendants of an element with the ID of buttons. We specify a large font size, some padding all around, rounded edges, and a width of 150 pixels.

The classes

Let’s imagine the entities in the game and come up with classes. There’s a snake, of course. There’s a board on which the game is played. There’s an overall game entity that represents one game session. Let’s start with these.

The Snake class

The snake has a body. In that body, two points are special. One is the head and the other is the tail. Each body part is at a unique position that may be identified by 2D coordinates.

As far as behavior is concerned, it moves. It can turn upward, downward, left, or right. It eats food. It grows.

The Board class

The board is square in shape. It has a width and a height that are equal. We can consider it as being composed of several square shaped cells arranged in rows and columns. We’ll imagine rows numbered 0, 1, 2, … from left to right, starting at the top left edge of the board. We’ll also imagine columns numbered 0, 1, 2, … from top to bottom, starting at the top left edge of the board. This directionality is the same as that used by the Canvas API.

Each part of the snake’s body occupies one cell on the board. The board also sometimes has a food item displayed in an initially empty cell. As such, we can create instances of a snake and food inside a Board class object.

The Game class

The game class has an instance of the Board class. We can start the game, and watch for game over. Let’s start with that, and we can figure out the rest as we go.

The starter code

Let’s start writing code for the above classes and get this game implemented.

The Snake class

Create a file named snake.js in the src directory. Place the following contents there.

export class Snake {
constructor(x, y) {
this.body = [{position: {x: x, y: y}}, {position: {x: x + 1, y: y}}, {position: {x: x + 2, y: y}}]
}

get head() {
return this.body[0]
}

get tail() {
return this.body[this.body.length - 1]
}
}

The snake’s body attribute is implemented as an array of body segments. Initially, there are three segments. The first one serves as the head, the third one as the tail, and second one is in the middle. Initially, we want to have a three segment horizontal snake. That’s why the y coordinate for each entry in the body array is the same. Since the arguments to the constructor are supposed to be the coordinates for the head, and the x coordinates increase towards the right, the x coordinate are increasing by one for each entry in the body array. This means that the snake is pointing towards the left, and will eventually move in that direction.

Since each segment requires a coordinate with two attributes, we use an object for each segment. Now, we can access each segment’s coordinates as snake.body[i].position.x, and snake.body[i].position.y. We declare two getter methods head() and tail() which return the first and last elements, respectively, from the body array.

Note that this class has nothing to do with displaying the snake. It just keeps track of its whereabouts. We’ll handle display elsewhere. We need to implement more functionality in the Snake class, but this will do for now. Build the game incrementally, not in a big bang.

The Board class

Create a file named board.js in the src directory. Place the following contents there.

import { Snake } from "./snake"
export class Board {
constructor(canvaswidth, canvasheight, rows, columns, context) {
this.width = columns
this.height = rows
this.boxheight = canvasheight / rows
this.boxwidth = canvaswidth / columns
this.context = context
this.snake = new Snake(10, 10)
this.food = null
this.scoreObservers = []
this.gameObservers = []

}

addScoreObserver(observer) {
this.scoreObservers.push(observer)
}

removeScoreObserver(observer) {
this.scoreObservers = this.scoreObservers.filter(ob => ob !== observer)
}

notifyScoreObservers(score) {
this.scoreObservers.forEach(observer => observer.onScore(score))
}

addGameoverObserver(observer) {
this.gameoverObservers.push(observer)
}

removeGameoverObserver(observer) {
this.gameoverObservers = this.gameoverObservers.filter(ob => ob !== observer)
}

notifyGameoverObservers() {
this.gameoverObservers.forEach(observer => observer.end())
}

drawGrid() {
this.context.strokeStyle = '#ddd'
for (let i = 0 ; i < this.width ; i++) {
for (let j = 0 ; j < this.height ; j++) {
this.context.strokeRect(j * this.boxwidth, i * this.boxheight, this.boxwidth, this.boxheight)
this.context.stroke()
}
}
this.context.strokeStyle = 'black'
}

drawInitialSnake() {
for (let segment of this.snake.body) {
this.drawSegment(segment.position.x, segment.position.y, this.boxwidth, this.boxheight)
}
}

drawSegment(x, y) {
this.context.strokeStyle = 'black'
this.context.fillStyle = 'black'
this.context.fillRect(x * this.boxwidth, y * this.boxheight, this.boxwidth, this.boxheight)
this.context.stroke()
}
}

We start by importing the Snake class since we’ll need to create an instance of it. The constructor accepts the board’s dimensions as the number of pixels (the first two arguments). It also accepts the number of rows and columns on the grid. This will typically be much less than the dimensions of the board in terms of pixels. That way, each cell on the board is multiple pixels in width and height. The last argument is the Canvas API 2D drawing context.

In the constructor, we store all of the arguments as object attributes. We calculate each cell’s width and height and store those as boxwidth, and boxheight attributes. These values will come handy when drawing the snake.

We create an instance of the Snake class at row 10, column 10. We initialize a food attribute to null indicating that there’s no food. Food needs to be unpredictable and random. That’s why we are initializing it to null.

We’ll use the observer design pattern for other entities to watch for score events when the snake eats food, or game over events when the snake dies. For that, we need to declare two arrays — one for each event. For each event, we need to declare three methods. One to register for that event, one to unregister, and one to send notifications to the observers when that event occurs. We declare addScoreObserver() method as the register method for the score event. It accepts a function as argument and appends it to the scoreObservers array. The removeScoreObserver() method serves to unregister for the score event. It accepts a function as argument and uses the Array.filter() method to find and remove that observer from the scoreObservers array. The notifyScoreObservers() method iterates over the scoreObservers array and calls each function stored in it. The game over observer pattern is implemented similarly.

The drawGrid() method is just for debugging purposes. If you want, you can insert a call to it in the constructor. It draws squares for each cell on the board using the Canvas API. First, we select a light gray color for the stroke. Then, we iterate over the rows and columns in the board, and draw a square outline using the strokeRect() method.

The first two arguments to strokeRect() are the x and y coordinates, respectively, (in terms of pixels) for the top left corner. If you are drawing the square for the cell in row i, column j of the board, then you need to move j cells right along the x axis, and i cells down along the y axis. Since each cell’s width is this.boxwidth, and height is this.boxheight, the first argument is j * this.boxwidth, and the second argument is i * this.boxheight. The next two arguments to strokeRect() are the width and height of the rectangle to draw.

The stroke() method actually draws that outline. Before returning from this method, we set strokeStyle property to black so that it is consistent with the rest of the game.

The drawInitialSnake() is supposed to render the snake at its initial position on the screen before we start moving it around. It iterates over the body array of the snake, fetches the x and y coordinate of each segment and calls a drawSegment() method. The drawSegment() method uses the fillRect() method of the Canvas API to display a filled square at the corresponding location with a black outline and black fill.

The Game class

We are nearly ready to see the game in action. Let’s implement the wrapper class Game. Create a file named game.js in the src directory. Put the following code in it.

import { Board } from "./board";
export class Game {
constructor(context) {
this.board = new Board(600, 600, 30, 30, context)
}

start() {
this.board.drawInitialSnake()
}
}

See how small the implementation of the Game class is? That’s the benefit of breaking down the responsibilities of each class. Because we’ve implemented much of the functionality elsewhere, we just need to create appropriate objects and call appropriate methods.

We import the Board class, and create an instance of that class in the constructor. We request for a 600 pixels x 600 pixels board, with 30 rows and 30 columns. That means each of the board cells is 20 pixels x 20 pixels. The instance of the Board class encapsulates the snake and the food, so we don’t have to import those. The constructor expects the Canvas API 2D drawing context as argument.

We declare a start method to start a game. We just call the drawInitialSnake() method on the Board class object.

The program entry point

In webpack.config.js we set src/index.js as the program’s entry point, so let’s create that file. You might have guessed that now we just need to import and instantiate an instance of the Game class.

import { Game } from "./game";

const canvas = document.getElementById('game')
const context = canvas.getContext("2d");
canvas.width = 600
canvas.height = 600

const game = new Game(context)
game.start(context)

We import the Game class. We acquire an object referencing the canvas element. We use the getContext() method from the Canvas API to acquire a 2D drawing context. We set the width and height attributes of the canvas object equal to the pixel values we set in CSS. We create an instance of the Game class passing it the drawing context object. We call the start() method on the Game class object to start the game.

Switch to the terminal. From the project’s top level directory, issue the command npm start. The game opens in the default web browser with a snake displayed.

The result of the code written so far

Animating the snake

What would it take to move the snake? Well, the Snake class maintains the snake’s position. So, that’s where we’ll need code to update the body attribute. But that needs to be invoked periodically. We can have the Game class periodically instruct the Board class to ask the snake to take a step.

import { Board } from "./board";
export class Game {
constructor(context) {
this.board = new Board(600, 600, 30, 30, context)
this.boundUpdate = this.update.bind(this)
}

update() {
this.board.update()
}

start() {
this.board.drawInitialSnake()
setInterval(this.boundUpdate, 2000)
}
}

We add an update() method to the Game class. This method calls an update() method on the Board class object. We add a periodic call to this method every two seconds through the start() method using the setInterval() method. The update() method uses the this pointer which we would like to refer to the Game class object. However, if the update() method is called through setInterval(), this wouldn’t be the case. To make sure that the this pointer is as we want it to be, we bind a reference to this method with the correct this pointer in the constructor. We pass the resulting bound method reference to the setInterval() method in the start() method. Now, we need to implement the update() method in the Board class.

Before we look at the code for the update() method, it might be useful to visualize how drawing a square on the board works given our coordinate system and the Canvas API. The Canvas API has a fillRect() method that accepts the x and y coordinates of the top left corner of a rectangle to be drawn along with its width and height. Here’s an illustration that should help. It shows how to calculate the x and y coordinates of a square to be displayed at cell (6, 3) on our board (shown with a blue color).

Calculation of the coordinates for a square in cell in row 6, column 3

Each cell has a width of boxWidth, and a height of boxHeight. So, the x coordinate of the top left corner of cell (6, 3) is at 6 * boxWidth. Similarly, the y coordiante is 3 * boxHeight. We can generalize this formula for the update() method. Here’s the modified code.

import { Snake } from "./snake"
import { Util } from "./util"
export class Board {
/* Other code */

update() {
const tail = this.snake.tail
this.context.clearRect(tail.position.x * this.boxwidth, tail.position.y * this.boxheight, this.boxwidth, this.boxheight)
this.snake.step()
this.snake.moveTail()
this.render()
}

render() {
this.context.strokeStyle = 'black'
this.context.fillStyle = 'black'
this.drawSegment(this.snake.head.position.x, this.snake.head.position.y)
}
/* Other code */
}

Recall our discussion early in the blog that the animation requires erasing the cell at the tail and drawing the head at the next position. The update() method in the Board class obtains the tail coordinates, and uses the clearRect() method of the Canvas API to erase the tail. Then, we call a step() and moveTail() methods on the snake object, followed by a render() method. The render() method calls the drawSegment() method to draw the head at the new position. We expect the step() method to update the head, and move the tail, respectively, in the snake object. If that happens correctly, we’re good. Let’s implement these methods in the Snake class.

export class Snake {
constructor(x, y) {
this.body = [{position: {x: x, y: y}}, {position: {x: x + 1, y: y}}, {position: {x: x + 2, y: y}}]
this.direction = {x: -1, y: 0}
}
/* Other code */

step() {
const newHead = {position: {x: this.body[0].position.x + this.direction.x, y: this.body[0].position.y + this.direction.y}}
this.body.unshift(newHead)
}

moveTail() {
this.body.pop()
}
}

We add a direction attribute in the constructor. The x key of this object has a value of -1, and the y key has a value of 0. If we add -1 to the head’s x coordinate and 0 to the y coordinate, we get a movement towards the left. The step() method uses this approach, to create a new object (similar to the ones we create in the constructor) and adds it to the beginning of the body array. Removing the tail is a matter of removing the last element of the body array. Now, if you run the game, the snake should move left on the screen and eventually disappear into an abyss.

Turning the snake

Let’s implement the keyboard-driven turning of the snake. The Board class pulls the head coordinates from the Snake class object and renders it. So, all we need to do is to change the snake’s head coordinates depending on the arrow key that is pressed. Let’s implement the directional updates in the Snake class.

import _ from 'lodash'
export class Snake {
/* Other code */
turnLeft() {
if (!_.isEqual(this.direction, {x: 1, y: 0})) {
this.direction = {x: -1, y: 0}
}
}

turnRight() {
if (!_.isEqual(this.direction, {x: -1, y: 0})) {
this.direction = {x: 1, y: 0}
}
}

turnUp() {
if (!_.isEqual(this.direction, {x: 0, y: 1})) {
this.direction = {x: 0, y: -1}
}
}

turnDown() {
if (!_.isEqual(this.direction, {x: 0, y: -1})) {
this.direction = {x: 0, y: 1}
}
}
}

All we need to do is to make sure that the direction attribute has the right x and y values to get the desired movement. We already know how to move left. We use the same idea to implement all four directional movements. However, we only turn left if we are not currently moving right. Similarly, we can’t turn right if we are currently moving left, and so on. To determine the current direction we use the _.isEqual() method from the Lodash library. This attribute compares all the attributes of the two objects passed to it, for value equality. If we find that the current direction and the new direction don’t oppose each other, we update the value of the direction attribute so that the snake takes the next step in the right direction. Of course, we need to add the following the package.json file.

{
/* Other keys */
"devDependencies": {
/*Other dependencies */,
"@types/lodash": "^4.17.7"
}
}

Don’t forget to run the npm install command to install the Lodash library. Let’s modify the Board class to handle key presses.

import { Snake } from "./snake"
import { Util } from "./util"
export class Board {
constructor(canvaswidth, canvasheight, rows, columns, context) {
/* Other code */
this.registerKeyListener()
}

registerKeyListener() {
document.addEventListener('keydown', (event) => {
const key = event.key
const prevDirection = this.snake.direction
switch(key){
case "ArrowLeft":
this.snake.turnLeft()
break;
case "ArrowRight":
this.snake.turnRight()
break
case "ArrowUp":
this.snake.turnUp()
break
case "ArrowDown":
this.snake.turnDown()
break
}
})
}
}

We define a registerKeyListener() method and call it from the constructor. In this method, we add a key down event listener to the document object using an anonymous function. In the function, we obtain the key pressed using the event.key attribute. We compare this against the arrow key codes and call the appropriate method on the Snake class object.

Now, if you run the game, you should be able to move the snake around the board using the arrow keys.

Displaying food at random times

Let’s now implement showing food at random locations at random times. There is at most one piece of food on the screen at any given time. It has its screen coordinates, and that’s it. I am not going to define a class for this entity. We’ll just package it into the Board class.

import { Snake } from "./snake"
import { Util } from "./util"
export class Board {
constructor(canvaswidth, canvasheight, rows, columns, context) {
/* Other code */
this.food = null
this.foodVisible = false
}

drawFood() {
const x = Math.floor(Math.random() * this.width)
const y = Math.floor(Math.random() * this.height)
this.food = {x: x, y: y}
this.context.fillStyle = 'red'
this.context.fillRect(x * this.boxwidth + this.boxwidth / 4, y * this.boxheight + this.boxheight / 4, this.boxwidth / 2, this.boxheight / 2)
}

update() {
/* Other code */
const rand = Math.random()
if (rand > 0.5 && !this.foodVisible) {
this.foodVisible = true
this.drawFood()
}
}
}

The food isn’t always visible. There needs to be some randomness associated with its appearance. For that, we draw a random number in the update() method, and if it is a value above 0.5, we display food. This creates a 50% chance of there being food on the screen at any given time. However, if there’s already some food visible on the screen, we need not display more. To keep track of this all, we define a food attribute to hold the food’s coordinates. We initialize it to null in the constructor. We also define a foodVisible attribute and initialize it to false in the constructor. In the update() method, if the random value is above 0.5 and food isn’t currently visible, we set foodVisible to true, so that this if condition wouldn’t be true the next time update() is called. We then call a drawFood() method. That method draws random values for the x and y coordinates of the food item, stores these in the food attribute, and displays the food item using the Canvas API. Now, if you run the game, you should see food pop up at some point.

Eating the food

If the snake’s head comes in contact with the food, it should disappear and score should be incremented.

/* Other imports */
import _ from 'lodash'
export class Board {
/* Other code */
update() {
const tail = this.snake.tail
this.context.clearRect(tail.position.x * this.boxwidth, tail.position.y * this.boxheight, this.boxwidth, this.boxheight)
this.snake.step()
if (this.foodVisible && _.isEqual(this.snake.head.position, this.food)) {
this.notifyScoreObservers()
this.context.clearRect(this.snake.head.position.x * this.boxwidth, this.snake.head.position.y * this.boxheight, this.boxwidth, this.boxheight)
this.food = null
this.foodVisible = false
}
else {
this.snake.moveTail()
}
this.render()
const rand = Math.random()
if (rand > 0.5 && !this.foodVisible) {
this.foodVisible = true
this.drawFood()
}
}
/* Other code */
}

We import the Lodash library for object comparison. We update the update() method. Specifically, once the snake has taken a step, we check if its head is at the same position as the food using the Lodash library. If so, we notify the score observers. We use the clearRect() method to clear the rectangular region where the food is located. This causes the food to visually disappear from the screen. We set food back to null and foodVisible to false since there’s no food on the screen anymore. The call to moveTail() was unconditional before. But, now, we’ve put it in the else part. The reason is that if the snake eats the food, it needs to grow. To make that happen, we’ll only move the head forward, while not erasing the tail, meaning that the snake will grow in size. Now, on eating the food, the snake should grow and new food should pop up later.

Making food disappear if not eaten

If the snake doesn’t eat the food for some random amount of time, we should make the food disappear. Let’s update the Board class.

/* Imports */
export class Board {
constructor(canvaswidth, canvasheight, rows, columns, context) {
/* Other code */
this.foodInterval = null
}

drawFood() {
const min = 28000
const max = 54000
this.foodInterval = setTimeout(() => {
if (this.foodVisible) {
this.context.clearRect(this.food.x * this.boxwidth, this.food.y * this.boxheight, this.boxwidth, this.boxheight)
this.food = null
this.foodVisible = false
}
}, Math.floor(Math.random() * (max - min)) + min)

const x = Math.floor(Math.random() * this.width)
const y = Math.floor(Math.random() * this.height)
this.food = {x: x, y: y}
this.context.fillStyle = 'red'
this.context.fillRect(x * this.boxwidth + this.boxwidth / 4, y * this.boxheight + this.boxheight / 4, this.boxwidth / 2, this.boxheight / 2)
}

update() {
/* Other code */
if (this.foodVisible && _.isEqual(this.snake.head.position, this.food)) {
clearInterval(this.foodInterval)
this.notifyScoreObservers()
this.context.clearRect(this.snake.head.position.x * this.boxwidth, this.snake.head.position.y * this.boxheight, this.boxwidth, this.boxheight)
this.food = null
this.foodVisible = false
}
/* Other code */
}
/* Other code */
}

We add a foodInterval attribute in the constructor to hold an object for a timer to make the food disappear. We set this attribute to a timer object using the setInterval() method. We generate a random duration for the food visibility between 28 and 54 seconds. Feel free to modify these values to any other sane values. The anonymous method set to fire on the timer checks if the food is still visible. If so, it erases the cell on the board where the food was located, and resets the food and foodVisible attributes. In the update() method, if the snake eats the food, we clear the food disappear timer using the clearInterval() method.

Now, the food appears for a random amount of time. If the snake doesn’t eat the food before the timer runs out, the food disappears.

Not placing food where the snake is

Till now, we are not checking if we are placing the food in a cell where the snake is already located. Let’s implement that check.

/* Imports */
export class Board {
/* Other code */

drawFood() {
const min = 24000
const max = 54000
this.foodInterval = setTimeout(() => {
if (this.foodVisible) {
this.context.clearRect(this.food.x * this.boxwidth, this.food.y * this.boxheight, this.boxwidth, this.boxheight)
this.food = null
this.foodVisible = false
}
}, Math.floor(Math.random() * (max - min)) + min)
let x = null
let y = null
do {
x = Math.floor(Math.random() * this.width)
y = Math.floor(Math.random() * this.height)
this.food = {x: x, y: y}
} while (this.snake.checkOverlap(this.food))

this.context.fillStyle = 'red'
this.context.fillRect(x * this.boxwidth + this.boxwidth / 4, y * this.boxheight + this.boxheight / 4, this.boxwidth / 2, this.boxheight / 2)
}
/* Other code */
}

In the update() method of the Board class, we keep generating random coordinates for the food as long as the checkOverlap() method of the Snake class returns true. Let’s implement that method.

import _ from 'lodash'
export class Snake {
/* Other code */

checkOverlap(coordinates) {
return this.body.some( (segment) => {
return _.isEqual(segment.position, coordinates)
})
}
}

We use the Array.some() method to check if the anonymous function returns true for any element of the body array. The anonymous function itself compares the coordinates of the current segment of the snake’s body for equality with the food’s coordinates. If the food is at the same position as any of the snake’s segments, we return true, otherwise we return false.

Detecting the snake biting itself

If the snake bites itself, it should die. Let’s implement that functionality now.

/* Imports */
export class Board {
/* Other code */

update() {
const tail = this.snake.tail
this.context.clearRect(tail.position.x * this.boxwidth, tail.position.y * this.boxheight, this.boxwidth, this.boxheight)
this.snake.step()
if (this.snake.biteSelf()) {
this.notifyGameoverObservers()
}
else if (this.foodVisible && _.isEqual(this.snake.head.position, this.food)) {
clearInterval(this.foodInterval)
this.notifyScoreObservers()
this.context.clearRect(this.snake.head.position.x * this.boxwidth, this.snake.head.position.y * this.boxheight, this.boxwidth, this.boxheight)
this.food = null
this.foodVisible = false
}
else {
this.snake.moveTail()
}
this.render()
const rand = Math.random()
if (rand > 0.5 && !this.foodVisible) {
this.foodVisible = true
this.drawFood()
}
}
/* Other code */
}

In the update() method of the Board class, we call a biteSelf() on the Snake class object to check if the snake bit itself. If that method returns true, we notify the game over observers. Let’s register an object for this event.

import { Board } from "./board";
export class Game {
constructor(context) {
this.board = new Board(600, 600, 30, 30, context)
this.updateInterval = null
this.boundUpdate = this.update.bind(this)
}

end() {
clearInterval(this.updateInterval)
}

start() {
this.board.drawInitialSnake()
this.board.addGameoverObserver(this)
this.updateInterval = setInterval(this.boundUpdate, 2000)
}
}

In the constructor, we declare an attribute for the update timer. In the start() method, we store the object returned by setInterval() in this attribute. We request the Board class instance to register the Game class object as observer for the game over event. The observer pattern implementation in the Board class invokes an end() method of the game over event observers. So, we define an end() method in the Game class and use clearInterval() method to stop the update timer.

Let’s implement the biteSelf() method in the Snake class.

import _ from 'lodash'
export class Snake {
/* Other code */

biteSelf() {
for (let i = 1 ; i < this.body.length ; i++) {
if (_.isEqual(this.head.position, this.body[i].position))
return true
}
return false
}

checkOverlap(coordinates) {
return this.body.some( (segment) => {
return _.isEqual(segment.position, coordinates)
})
}
}

We iterate over all the segments of the snake’s body, except the head, and use the Lodash library to see if the head overlaps with any of the segments. If so, we return true. Otherwise, we return false.

Food for thought: Could you implement this method using Array.some(), or any of the other Array methods?

Now, if you run the game, eat a few pieces of food, and then the snake bites itself, it dies and the animation stops.

Detecting collisions with the walls

The snake dies if it collides with walls. The snake has collided with the left wall if the head’s x coordinate becomes negative. It has collided with the right wall if it’s head’s x coordinate has exceeded board.width — 1. Similarly, we have conditions for collisions with the top and bottom walls. We can implement these in the Board class, along with the if statement that checks if the snake bit itself. If either the snake bit itself or hit one of the walls, we send the game over event notification. Here’s the modified code.

import { Snake } from "./snake"
import { Util } from "./util"
import _ from 'lodash'
export class Board {
/* Other code */

update() {
const tail = this.snake.tail
this.context.clearRect(tail.position.x * this.boxwidth, tail.position.y * this.boxheight, this.boxwidth, this.boxheight)
this.snake.step()
if (this.snake.biteSelf() || this.hitWall()) {
this.notifyGameoverObservers()
}
else if (this.foodVisible && _.isEqual(this.snake.head.position, this.food)) {
clearInterval(this.foodInterval)
this.notifyScoreObservers()
this.context.clearRect(this.snake.head.position.x * this.boxwidth, this.snake.head.position.y * this.boxheight, this.boxwidth, this.boxheight)
this.food = null
this.foodVisible = false
}
else {
this.snake.moveTail()
}
this.render()
const rand = Math.random()
if (rand > 0.5 && !this.foodVisible) {
this.foodVisible = true
this.drawFood()
}
}

hitWall() {
return (this.snake.head.position.x < 0 || this.snake.head.position.y < 0 || this.snake.head.position.x >= this.width - 1 || this.snake.head.position.y > this.height - 1)
}
}

We add a method named hitWall() that compares the snake’s head’s x and y coordinates against the upper and lower bounds on the x and y coordinates of the board and returns the result of the comparison. If the snake has gone beyond the walls, then we return true, otherwise we return false. In the update() method, we combine a call to hitWall() with an OR alongside the call to biteSelf(). Now, if either the snake bites itself, or goes beyond the wall, we fire the game over event.

Keeping and displaying the score

We have implemented the observer pattern to keep track of score events when the snake eats a food element. However, we haven’t kept track of the score and we haven’t updated the display with the current score. Let’s fix that. We’ll get the Game class registered to receive the score events.

import { Board } from "./board";
export class Game {
constructor(context) {
this.context = context
this.updateInterval = null
this.boundUpdate = this.update.bind(this)
this.scoreElement = document.getElementById('score')
}

initBoard() {
this.board = new Board(600, 600, 30, 30, this.context)
this.board.clear()
if (this.updateInterval) {
clearInterval(this.updateInterval)
}
this.scoreElement.innerText = this.score
}

onScore() {
this.score += 1
this.scoreElement.innerText = this.score
}

start() {
this.score = 0
/* Other code */
}

/* Other code */
}

In the constructor, we acquire an object referencing the score span element using the getElementById() method. In the start() method, we set a score attribute to 0. This means that whenever a game starts, it starts with the score at 0. In the initBoard() method, we set the score span element to show the current score, which should be 0. In the onScore event handler, we increment the score by 1, and then display the current score in the span element.

Now, once you run the game, and the snake eats food, the score will also be increased on the screen. Also, if you start a new game, the score starts at 0.

Note that the notifyScoreObservers() method in the Board class accepts a score value as argument which is passed on to the observers. However, at the moment, we are ignoring that, and just incrementing the score by 1 each time. But if you wanted to implement a functionality whereby different food items (for instance, of different colors) carry different scores, this would be handy. You could even implement a feature whereby some food is poisonous, with a negative score. Wouldn’t that be cool?

That’s all folks!

I’ll conclude this blog here, even though there’s so much more we can do to enhance the game. Please keep learning, keep experimenting, and extend this game with the following and more features:

  • Food items with differing score values and colors
  • Multiple levels with varying snake speeds
  • Maintaining high score data using local storage or IndexedDB
  • Flashing the game elements on significant events like snake eating the food or dying

You may download the code for this game from this github repository.

--

--

Muhammad Saqib Ilyas

A computer science teacher by profession. I love teaching and learning programming. I like to write about frontend development, and coding interview preparation