How to create Snake game in JavaScript
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 nameddocs
. 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.
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).
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 otherArray
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.