JavaScript Development Space

Build a Classic Snake Game from Scratch with Pure JavaScript!

Add to your RSS feed10 October 202441 min read
Build a Classic Snake Game from Scratch with Pure JavaScript!

In this article, we will develop the classic Snake game using pure JavaScript. Snake is a simple game where a snake moves around a board, eating apples. As the snake eats apples, it grows longer. Additionally, we'll introduce a new feature: bombs. If the snake touches a bomb, it will die.

Experience the classic Snake game in action! Watch as the snake navigates the board, collects food, and grows longer. Test your skills and see how high you can score in this engaging demo. Dive in and enjoy the nostalgic gameplay!

You can download the assets, and sounds files from Google Drive. View the full code on github.

Step 1: Setting Up the Skeleton for Your Snake Game

Let’s kick off the game development by creating the basic skeleton:

  1. Download all necessary game assets from here and store them in a folders named images, and sounds.
  2. Create an index.html file, adding standard HTML structure while linking to style.css and app.js.
  3. Your index.html file should resemble the following structure:
html
1 <!DOCTYPE html>
2 <head>
3 <meta charset="UTF-8">
4 <meta http-equiv="X-UA-Compatible" content="IE=edge">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>HTML5 Snake in Pure JavaScript</title>
7 <link rel="stylesheet" href="style.css">
8 <meta charset="utf-8">
9 </head>
10 <body>
11 <canvas></canvas>
12 <script src="app.js" type="module"></script>
13 </body>
14 </html>

Pay special attention to how we included the app.js file:

html
1 <script src="app.js" type="module"></script>

We are connecting our main JavaScript file (app.js) with the type="module" attribute. This allows us to use imports of other JavaScript files into our app.js. In other words, other JavaScript files will be imported here, and we won’t need to reference them in index.html.

Now, let's create a style.css file and add some styles:

css
1 * {
2 padding: 0;
3 margin: 0;
4 box-sizing: border-box;
5 }
6 body {
7 background-color: #E7DDA9;
8 text-align: center;
9 }
10 canvas {
11 width: 100%;
12 position: absolute;
13 top: 50%;
14 left: 50%;
15 transform: translate(-50%, -50%);
16 -o-transform: translate(-50%, -50%);
17 -ms-transform: translate(-50%, -50%);
18 -moz-transform: translate(-50%, -50%);
19 -webkit-transform: translate(-50%, -50%);
20 }

With the help of CSS, we centered our canvas and added a background color of #E7DDA9. You can choose any other color, but this one matches the color of our background sprite.

All that's left is to create a js folder, add a game.js file to it, and connect it in our main app.js file:

js
1 /// app.js
2
3 import Game from './js/game.js';

In the game.js file, which is located in the js folder, we will insert an empty class with an export for now.

js
1 export default class Game {
2 constructor() {
3 }
4 }

Moving forward, we'll only be modifying the files within the js folder. The files in the main folder (index.html, style.css, app.js) will remain unchanged.

The final structure of our initial template should look like the image provided.

Start Template Structure

Step 2: Canvas Initialization and Background Rendering

In the Game class, we will create two methods: init() and create(), and call them in the constructor.

js
1 export default class Game {
2 constructor() {
3 this.init();
4 this.create();
5 }
6 init() {
7 }
8 create() {
9 }
10 }

In the init() method, as you might have guessed, we will initialize all the necessary variables for the class, and in the create() method, we will create our entities. First, let's initialize the canvas.

js
1 init() {
2 // Connect the canvas
3 this.canvas = document.querySelector('canvas');
4 // Get the context
5 this.context = this.canvas.getContext('2d');
6 // Set the width and height
7 this.context.canvas.width = 640;
8 this.context.canvas.height = 360;
9 // Define the center of the screen
10 this.centerX = this.context.canvas.width / 2;
11 this.centerY = this.context.canvas.height / 2;
12 }

In the code above, we initialized the Canvas, acquired the Canvas context, set its height and width, and saved the center coordinates in the variables centerX and centerY.

Next, we'll display the background. To accomplish this, we will create a new method called createBg and invoke it from the create() function.

js
1 create() {
2 this.createBg();
3 }
4 createBg() {
5 }

To display the background on the Canvas, we need to:

  1. Create a new image using the JavaScript method new Image().
  2. Set the image's src property to the path of our background image.
  3. Attach an event listener for the load event to ensure the image has fully loaded.
  4. Render the background on the Canvas.

Here's how this looks in code:

js
1 createBg() {
2 // Create a new image and set its path
3 this.bg = new Image();
4 this.bg.src = '../images/background.png';
5 // Listen for the load event
6 this.bg.addEventListener('load', () => {
7 // Redraw the page
8 window.requestAnimationFrame(() => {
9 // Draw the background
10 this.context.drawImage(this.bg, 0, 0);
11 });
12 });
13 }

After loading the image, we call the requestAnimationFrame() function to notify the browser that it needs to redraw the page before executing the code in the callback function. In this callback, we draw our background on the canvas context using the drawImage() method, to which we pass the image and the X and Y coordinates. In this example, we position the image at coordinates (0, 0), which means it will be displayed in the top-left corner. Since we centered our canvas on the page (in the style.css file), the image will appear in the middle of the screen.

The result of our work in the browser looks like this:

Bg on canvas

We just need to organize a preload() method in which we'll load all the images. After all, we don’t want to create a new Image() for each sprite, set the src, and track the loading process each time. Instead, we’ll extract this code into a separate method to load all the necessary images for the game at once.

Let's create the preload() method and add it to the constructor:

js
1 constructor() {
2 this.init();
3 this.preload();
4 this.create();
5 }
6 preload() {}

We'll also divide the preloader into methods. For now, there will be just one method - preloadImages(), but we plan to add more methods to it in the future.

js
1 preload() {
2 this.preloadImages();
3 }
4 preloadImages(){
5 }

Now we need to create the preloadImages() method. This method will accept a path along with X and Y coordinates, then create our sprite and return the image. This approach will allow us to store the necessary sprites in the class's global variables using the this context.

js
1 preloadImages() {
2 this.background = this.preloadImage('../images/background.png', 0, 0);
3 }
4 preloadImage(path, x, y) {
5 let image = new Image();
6 image.src = path;
7 image.addEventListener('load', () => {
8 window.requestAnimationFrame(() => {
9 this.context.drawImage(image, x, y);
10 });
11 });
12 return image;
13 }

We can remove the create() and createBg() methods. The preloadImages() function still needs some refinement, specifically adding asynchronous functionality. The simplest way to ensure that all assets are fully loaded is to use a Promise. Here's how our code will look with the Promise implementation:

js
1 async preloadImage(path, x, y) {
2 let image = new Image();
3 await new Promise((resolve, reject) => {
4 image.src = path;
5 image.addEventListener('load', () => {
6 // If the asset has loaded, draw it
7 window.requestAnimationFrame(() => {
8 this.context.drawImage(image, x, y);
9 });
10 // Return the image
11 resolve(image);
12 });
13 image.addEventListener('error', () => {
14 // If there's an error, throw the error into reject
15 reject(new Error("Couldn't load image"));
16 });
17 });
18 // Return the image
19 return image;
20 }

We added a standard Promise and handled errors by incorporating an 'error' event listener. Additionally, we included the async keyword before the function name. Now, we need to modify the preloadImages() function by also adding async, which will allow us to use the await operator. This way, we can retrieve the image path string just like we did before. If we omit await, the preloadImage() method will return a Promise instead.

js
1 async preloadImages() {
2 this.background = await this.preloadImage(
3 '../images/background.png',
4 0,
5 0,
6 );
7 }

To make the preloadImage() function more versatile, we will add default values for X and Y.

js
1 async preloadImage(path, x = -100, y = -100) {
2 ...
3 }

This will allow us to load assets without specifying absolute coordinates. We passed -100 as the argument to ensure that the sprites remain hidden on the canvas.

Let’s test our new method by adding another image, cell.png, and displaying it at the coordinates centerX and centerY.

js
1 async preloadImages() {
2 this.background = await this.preloadImage(
3 '../images/background.png',
4 0,
5 0,
6 );
7 this.cell = await this.preloadImage(
8 '../images/cell.png',
9 this.centerX,
10 this.centerY,
11 );
12 }

If you see two images on the screen, everything is working fine! If not, please check the code. Here’s what our entire Game class looks like now:

js
1 export default class Game {
2 constructor() {
3 this.init();
4 this.preload();
5 }
6 init() {
7 this.canvas = document.querySelector('canvas');
8 this.context = this.canvas.getContext('2d');
9 this.context.canvas.width = 640;
10 this.context.canvas.height = 360;
11 this.centerX = this.context.canvas.width / 2;
12 this.centerY = this.context.canvas.height / 2;
13 }
14 preload() {
15 this.preloadImages();
16 }
17 async preloadImages() {
18 this.background = await this.preloadImage(
19 '../images/background.png',
20 0,
21 0,
22 );
23 this.cell = await this.preloadImage(
24 '../images/cell.png',
25 );
26 }
27 async preloadImage(path, x, y) {
28 let image = new Image();
29 await new Promise((resolve, reject) => {
30 image.src = path;
31 image.addEventListener('load', () => {
32 window.requestAnimationFrame(() => {
33 this.context.drawImage(image, x, y);
34 });
35 resolve(image);
36 });
37 image.addEventListener('error', () => {
38 reject(new Error("Couldn't load image"));
39 });
40 });
41 return image;
42 }
43 }

Step 3: Creating a Board Matrix

Our game board will consist of two entities: the controller and the model. In the js folder, we will create two new folders: models and controllers. Inside the models folder, we will create a file called board.js to define our board:

js
1 export default class Board {
2 constructor() {
3 this.init();
4 this.create();
5 }
6
7 init() {
8 // Initialize an empty array that we will fill later
9 this.cells = [];
10 // Board width
11 this.boardWidth = 15;
12 // Board height
13 this.boardHeight = 15;
14 }
15
16 create() {
17 // Loop through the width
18 for (let x = 0; x < this.boardWidth; x++) {
19 // Loop through the height
20 for (let y = 0; y < this.boardHeight; y++) {
21 // Add the cell coordinates to the array
22 this.cells.push({ x, y });
23 }
24 }
25 }
26 }

We have already established the familiar init() and create() methods, which we call within the constructor. In the init() method, we initialize our cell array as well as the width and height of the matrix (feel free to experiment with the dimensions). In the create() function, we populate our array with X and Y coordinates. These are not the final coordinates, but rather the indices of the cells. We will handle all the logic of our board in the controller, where we will calculate the exact coordinates. This way, we completely isolate the logic from the model.

Creating the Board Controller

js
1 import Board from './../models/board.js';
2
3 export default class BoardController {
4 constructor(context, cell) {
5 this.init();
6 this.render(context, cell);
7 }
8 init() {
9 this.board = new Board();
10 }
11 render(context, cell) {
12 }
13 }

In the boardController.js file, which we created in the controllers folder, we import our board and render it. To render the board, we will need the canvas context and the cell sprite, which we will pass to the constructor from the game.js file. We will then forward these arguments to the render() method.

Now, let’s proceed to draw all the cells:

js
1 render(context, cell) {
2 // Add one pixel to the width and height of the sprite for padding
3 const cellWidth = cell.width + 1;
4 const cellHeight = cell.height + 1;
5 // Iterate over the array of cells
6 this.board.cells.forEach((cellCoords) => {
7 window.requestAnimationFrame(() => {
8 context.drawImage(
9 cell,
10 // Multiply the cell index by the width
11 cellCoords.x * cellWidth,
12 // Multiply the cell index by the height
13 cellCoords.y * cellHeight,
14 );
15 });
16 });
17 }

We have created a board with cells, and now we need to integrate it into the Game class. This should be done after all sprites have been loaded. To keep things simple and avoid wrapping the loading code in another Promise, we'll simply call the create() method at the end of the preloadImages() function. Within the create() method, we will instantiate our Board Controller and store it in a variable.

js
1 this.boardController = new BoardController(this.context, this.cell);

Next, we need to pass the necessary arguments (context and cell) and import the boardController.js file from the controllers folder.

In the browser, our board now appears as follows:

Board on Canvas

To draw our board in the center, we need to calculate offsetX and offsetY.

js
1 const offsetX = (context.canvas.width - cellWidth * this.board.boadWidth) / 2;

Here, we subtract the total width of the board (cell width multiplied by the number of cells) from the canvas width and divide by two.

js
1 const offsetY = (context.canvas.height - cellHeight * this.board.boadHeight) / 2;

We do the same calculation for the height, subtracting the total height of the board from the canvas height and dividing by two.

Now we need to add these offsets to both sides.

js
1 render(context, cell) {
2 const cellWidth = cell.width + 1;
3 const cellheight = cell.height + 1;
4 const offsetX =
5 (context.canvas.width - cellWidth * this.board.boadWidth) / 2;
6 const offsetY =
7 (context.canvas.height - cellheight * this.board.boadHeight) / 2;
8 this.board.cells.forEach((cellCoords) => {
9 window.requestAnimationFrame(() => {
10 context.drawImage(
11 cell,
12 cellCoords.x * cellWidth + offsetX,
13 cellCoords.y * cellheight + offsetY,
14 );
15 });
16 });
17 }

And our board will be centered.

Board on Canvas in Center

Here’s how the entire boardController.js file looks now.

js
1 import Board from './../models/board.js';
2
3 export default class BoardController {
4 constructor(context, cell) {
5 this.init();
6 this.render(context, cell);
7 }
8 init() {
9 this.board = new Board();
10 }
11 render(context, cell) {
12 const cellWidth = cell.width + 1;
13 const cellheight = cell.height + 1;
14 const offsetX =
15 (context.canvas.width - cellWidth * this.board.boadWidth) / 2;
16 const offsetY =
17 (context.canvas.height - cellheight * this.board.boadHeight) / 2;
18 this.board.cells.forEach((cellCoords) => {
19 window.requestAnimationFrame(() => {
20 context.drawImage(
21 cell,
22 cellCoords.x * cellWidth + offsetX,
23 cellCoords.y * cellheight + offsetY,
24 );
25 });
26 });
27 }
28 }

Step 4: Limiting the Canvas Size

After creating our matrix, we need to set limits on the maximum and minimum sizes of our canvas. This ensures that our board is always fully rendered and not cut off. In the Game class, we will rename the width and height variables to maxWidth and maxHeight in the init method. Then, after creating the boardController in the create method, we will call a new function called resizeCanvas.

js
1 create() {
2 this.boardController = new BoardController(this.context, this.cell);
3 this.resizeCanvas();
4 }
5 resizeCanvas() {
6 ...
7 }

In the resizeCanvas() method, we will calculate the new height and width for the canvas. First, we need to set the minimum values. To ensure that our board is always fully rendered, the minimum width will be equal to the board's width, and the minimum height will correspond to the board's height. Here's how this looks in code:

js
1 this.minWidth = (this.boardController.board.boadWidth + 1) * (this.cell.width + 1);
2 this.minHeight = (this.boardController.board.boadHeight + 1) * (this.cell.height + 1);

As before, we add one pixel for padding. Now, let's calculate the width, as the height of the board will depend on this value.

js
1 this.width = Math.floor(
2 (window.innerWidth * this.maxHeight) / window.innerHeight,
3 );
4 this.width = Math.min(this.width, this.maxWidth);
5 this.width = Math.max(this.width, this.minWidth);

In the code above, we calculate the width three times. First, we determine the ratio between the current width and the maximum height based on the current height. Second, we use the Math.min() method to ensure that the width does not exceed the maximum limit. Finally, we check that the width is not less than the minimum value; if it is, we set it to the minimum.

Now, let's calculate the height:

js
1 this.height = Math.floor((this.width * window.innerHeight) / window.innerWidth);

Once we have computed the new height and width, we can assign these values to our canvas:

js
1 this.context.canvas.width = this.width;
2 this.context.canvas.height = this.height;

You can remove those lines from the init() method now. Next, we need to separate our width and height calculation method. Depending on whether our screen is narrower or wider, we will stretch the canvas in width or height. To achieve this, we will create two new methods: fitWidth() and fitHeight().

js
1 resizeCanvas() {
2 this.minWidth =
3 (this.boardController.board.boadWidth + 1) * (this.cell.width + 1);
4 this.minHeight =
5 (this.boardController.board.boadHeight + 1) *
6 (this.cell.height + 1);
7 // Определяем экран шире или уже
8 if (
9 window.innerWidth / window.innerHeight >
10 this.maxWidth / this.maxHeight
11 ) {
12 this.fitWidth();
13 } else {
14 this.fitHeight();
15 }
16 this.context.canvas.width = this.width;
17 this.context.canvas.height = this.height;
18 this.drawBackground();
19 this.boardController &&
20 this.boardController.render(this.context, this.cell);
21 }
22 fitWidth() {
23 this.height = Math.round(
24 (this.width * window.innerHeight) / window.innerWidth,
25 );
26 this.height = Math.min(this.height, this.maxHeight);
27 this.height = Math.max(this.height, this.minHeight);
28 this.width = Math.round(
29 (window.innerWidth * this.height) / window.innerHeight,
30 );
31 this.canvas.style.width = '100%';
32 }
33 fitHeight() {
34 this.width = Math.round(
35 (window.innerWidth * this.maxHeight) / window.innerHeight,
36 );
37 this.width = Math.min(this.width, this.maxWidth);
38 this.width = Math.max(this.width, this.minWidth);
39 this.height = Math.round(
40 (this.width * window.innerHeight) / window.innerWidth,
41 );
42 this.canvas.style.height = '100%';
43 }

All that’s left is to redraw our background and re-render our matrix. Let’s create a method called drawBackground() and place it above the resizeCanvas() method.

js
1 drawBackground() {
2 this.context.drawImage(
3 this.background,
4 (this.width - this.background.width) / 2,
5 (this.height - this.background.height) / 2,
6 );
7 }

Previously, we drew the background at coordinates 0, 0. Now, we dynamically calculate the center with an offset, allowing us to render the background in the center of the canvas.

In the resizeCanvas() method, we only need to call this function and notify the BoardController class to render the matrix with the new data.

js
1 this.drawBackground();
2 // Ensure that boardController is already created
3 this.boardController &&
4 this.boardController.render(this.context, this.cell);

Earlier, we called the render() method in the constructor of the BoardController class, but this call can now be removed.

We also need to make a few adjustments to our CSS, specifically:

  • Remove the property width: 100%;
  • Add the property image-rendering: pixelated;

Here’s how the style.css file looks now:

css
1 * {
2 padding: 0;
3 margin: 0;
4 box-sizing: border-box;
5 }
6 body {
7 background-color: #E7DDA9;
8 text-align: center;
9 }
10 canvas {
11 position: absolute;
12 top: 50%;
13 left: 50%;
14 image-rendering: pixelated;
15 transform: translate(-50%, -50%);
16 -o-transform: translate(-50%, -50%);
17 -ms-transform: translate(-50%, -50%);
18 -moz-transform: translate(-50%, -50%);
19 -webkit-transform: translate(-50%, -50%);
20 }

We won't be making any further changes to it.

Step 5: Creating the Snake

Let's start by loading the images using the preloadImages() method in the Game class.

js
1 this.snakeBody = await this.preloadImage('../images/body.png');
2 this.snakeHead = await this.preloadImage('../images/head.png');

Next, we'll create the snake.js model and the snakeController.js controller in the models and controllers folders, respectively. In the Snake class, we will initially call empty init() and create() methods, while in the SnakeController class, we will call init() and render(), similar to how we created the model and controller for the board.

In the model for our snake, we will initialize two arrays. One array will store the initial coordinates, while the other will remain empty.

js
1 export default class Snake {
2 constructor() {
3 this.init();
4 this.create();
5 }
6 init() {
7 this.snakeCoords = [];
8 this.snakeStartCoords = [
9 { x: 3, y: 12 },
10 { x: 3, y: 13 },
11 ];
12 }
13 create() {}
14 }

The snakeCoords array will be populated later, and the create method will remain empty for now, as we might not need it at all.

Now, let's move on to the SnakeController class. Here, we need to pass the canvas context, boardController, and the sprites for the snake's head and body. In the init() method, we will create the snake model right away.

js
1 // Importing the Snake model
2 import Snake from '../models/snake.js';
3
4 export default class SnakeController {
5 constructor(context, boardController, snakeBody, snakeHead) {
6 this.init(boardController);
7 // Passing all arguments directly to the render method
8 this.render(context, boardController, snakeBody, snakeHead);
9 }
10
11 init(boardController) {
12 // Initializing the Snake model
13 this.snake = new Snake();
14 }
15
16 render(context, boardController, snakeBody, snakeHead) {
17 ...
18 }
19 }

In the init() method, we need to determine the coordinates of our snake. To achieve this, we'll make some enhancements to our BoardController. It's most efficient to calculate these coordinates there since we'll be rendering all objects on top of our grid. We'll create a method called getCell(x, y) that will return the cell on our board. This way, all calculations related to offsets, width, and height will be contained within a single class.

js
1 getCell(x, y) {
2 return this.board.cells.find((c) => c.x === x && c.y === y);
3 }

Let's return to our SnakeController and focus on the init() method. Now, we'll find the necessary coordinates for the snake and store them in the empty (for now) snakeCoords array that we created in the Snake model. Here's how this looks in code:

js
1 init(boardController) {
2 this.snake = new Snake();
3
4 for (let coord of this.snake.snakeStartCoords) {
5 let cell = boardController.getCell(coord.x, coord.y);
6 this.snake.snakeCoords.push(cell);
7 }
8 }

In the code above, we iterate through the starting coordinates, identify them on the board, and add them to the coordinates array. Currently, the snakeCoords array should contain two objects with the snake's coordinates. We can verify that the array is being populated correctly with:

js
1 console.log(this.snake.snakeCoords);

Now, let's move on to the render() method. Here, we need to iterate through all the coordinates of our snake and render them. We'll pass both the snake's body and its head to this method. We'll place the head in the array slot at index 0 and render the body in the remaining slots.

js
1 render(context, boardController, snakeBody, snakeHead) {
2 this.snake.snakeCoords.forEach((cell, i) => {
3 window.requestAnimationFrame(() => {
4 context.drawImage(
5 // Определяем номер массива
6 i === 0 ? snakeHead : snakeBody,
7 // Высчитываем ширину с оффсетом
8 cell.x * boardController.cellWidth +
9 boardController.offsetX,
10 // Высчитываем длину с оффсетом
11 cell.y * boardController.cellheight +
12 boardController.offsetY,
13 );
14 });
15 });
16 }

Here is the complete SnakeController class:

js
1 import Snake from '../models/snake.js';
2
3 export default class SnakeController {
4 constructor(context, boardController, snakeBody, snakeHead) {
5 this.init(boardController);
6 this.render(context, boardController, snakeBody, snakeHead);
7 }
8 init(boardController) {
9 this.snake = new Snake();
10
11 for (let coord of this.snake.snakeStartCoords) {
12 let cell = boardController.getCell(coord.x, coord.y);
13 this.snake.snakeCoords.push(cell);
14 }
15 }
16 render(context, boardController, snakeBody, snakeHead) {
17 this.snake.snakeCoords.forEach((cell, i) => {
18 window.requestAnimationFrame(() => {
19 context.drawImage(
20 i === 0 ? snakeHead : snakeBody,
21 cell.x * boardController.cellWidth +
22 boardController.offsetX,
23 cell.y * boardController.cellheight +
24 boardController.offsetY,
25 );
26 });
27 });
28 }
29 }

All that’s left is to call it in the game.js file and pass all the necessary arguments. We will do this in the create method:

js
1 create() {
2 this.boardController = new BoardController(this.context, this.cell);
3 this.resizeCanvas();
4 this.snake = new SnakeController(
5 this.context,
6 this.boardController,
7 this.snakeBody,
8 this.snakeHead,
9 );
10 }

In the browser, we should see the snake.

Snake on Board

Step 6: Moving the Snake

To make our snake move, we need to enhance our SnakeController class. We will add the move() and getNextCell() methods, but first, let’s store our BoardController in the render method:

js
1 this.boardController = boardController;

We will use this variable later, and since the render() method will be called on every frame, our boardController will also update its data.

Now, let’s create the move() method.

js
1 move() {
2 // Find the next cell
3 let cell = this.getNextCell();
4 if (cell) {
5 // Add the cell to the beginning
6 this.snake.snakeCoords.unshift(cell);
7 // Remove the last cell
8 this.snake.snakeCoords.pop();
9 }
10 }

Now, in the getNextCell() method, we need to determine the new cell. For now, we will simply decrease the Y value by one, keeping the X value the same. We'll utilize the existing getCell() method that we created in the BoardController.

js
1 getNextCell() {
2 let head = this.snake.snakeCoords[0];
3 return this.boardController.getCell(head.x, head.y - 1);
4 }

We will call the move() method from our Game class at an interval of 150 milliseconds. Additionally, before each canvas rendering, we need to clear it, redraw the background, then the board, and finally render the snake in its new coordinates. To accomplish this, we will create two new methods in the Game class: start() and update(). We will call the start method in the create() function after initializing all the necessary entities.

js
1 this.start();

It will contain a setInterval() function, where we will call our update() method in the callback.

js
1 start() {
2 setInterval(() => {
3 this.update();
4 }, 150);
5 }

In the update() method, we will redraw the entire canvas with each move of the snake.

js
1 update() {
2 // Move the snake
3 this.snakeController.move();
4 // Clear the canvas
5 this.context.clearRect(
6 0,
7 0,
8 this.context.canvas.width,
9 this.context.canvas.height,
10 );
11 // Draw the background
12 this.drawBackground();
13 // Redraw the board
14 this.boardController.render(this.context, this.cell);
15 // Redraw the snake
16 this.snakeController.render(
17 this.context,
18 this.boardController,
19 this.snakeBody,
20 this.snakeHead,
21 );
22 }

Now our snake can move!

Snake can move

We just need to learn how to stop the snake. We want the snake to start moving only after one of the keys is pressed. Therefore, we will add a flag isMoving to the snake model. Initially, it will be set to false. We'll add this in the init() method:

js
1 this.isMoving = false;

In the Snake class, we also have an empty create() method. Let's rename it to startMoving() and update the boolean value in it:

js
1 startMoving() {
2 this.isMoving = true;
3 }

In the snake controller, we will prevent movement while the boolean isMoving is set to false. To do this, we'll add code at the very beginning of the move() method:

js
1 if (!this.snake.isMoving) {
2 return;
3 }

Let's go back to the Game class. We need to detect any key press and change the flag to isMoving = true. We will do this in the create() method. Before starting the game, we will set up our event listeners in a method called createListeners() and invoke it.

js
1 this.createListeners();
js
1 createListeners() {
2 window.addEventListener('keydown', () => {
3 this.snakeController.snake.startMoving();
4 });
5 this.start();
6 }

In the createListeners() function, we change the isMoving flag and start the game.

This approach will allow us to stop and start various objects in our scene in the future. While it might be possible to use a single global flag for the entire game in this small project, we will implement individual flags for all dynamic objects as a good practice.

Before we begin tracking the control buttons, we need to enable the snake to move in different directions. In the SnakeController class, we will introduce new variables, deltaX and deltaY, which will initially be set to zero. We will initialize them in the init() method.

js
1 init(boardController) {
2 this.deltaX = 0;
3 this.deltaY = 0;
4 ...
5 }

In the getNextCell() function, we will now simply add these values to the X and Y coordinates of the snake's head:

js
1 getNextCell() {
2 let head = this.snake.snakeCoords[0];
3 return this.boardController.getCell(
4 head.x + this.deltaX,
5 head.y + this.deltaY,
6 );
7 }

Here’s how it works:

If we set deltaX to 1, the snake will move to the right. With a value of -1, it will move to the left.

The same logic applies to deltaY. A value of 1 will make the snake move down, while -1 will make it move up, as it was set previously. Therefore, we will initially set the direction to upward:

js
1 this.deltaX = 0;
2 this.deltaY = -1;

Now, let's return to the Game class and the createListeners() method. We need to detect which key is pressed and update the direction values accordingly. Here’s how this looks in code:

js
1 window.addEventListener('keydown', (e) => {
2 const { key } = e;
3 if (key === 'ArrowUp') {
4 this.snakeController.deltaX = 0;
5 this.snakeController.deltaY = -1;
6 } else if (key === 'ArrowDown') {
7 this.snakeController.deltaX = 0;
8 this.snakeController.deltaY = 1;
9 } else if (key === 'ArrowLeft') {
10 this.snakeController.deltaX = -1;
11 this.snakeController.deltaY = 0;
12 } else if (key === 'ArrowRight') {
13 this.snakeController.deltaX = 1;
14 this.snakeController.deltaY = 0;
15 }
16 this.snakeController.snake.startMoving();
17 });

Each time we set deltaY, we reset deltaX to zero, and vice versa. This ensures that our snake can only move in one direction at a time.

Now, our snake is capable of moving in all directions on the board.

Stage 7: Adding Food for the Snake

Let’s start by adding the food sprite in the preloadImages() method of the Game class:

js
1 this.food = await this.preloadImage('../images/food.png');

Next, we need to pass the food sprite to the render() method in the BoardController. We’ll add the sprite as an argument in the update() and resizeCanvas() methods:

js
1 this.boardController.render(this.context, this.cell, this.food);

In the render() method, we'll accept the food argument, and we can clean up the constructor by removing all arguments. Now, we can initialize an empty BoardController:

js
1 this.boardController = new BoardController();

Next, we'll call the addFood() function, which we haven’t created yet:

js
1 this.boardController.addFood();

Now, let’s create the addFood() function.

js
1 addFood() {
2 let cell = this.board.cells[0];
3 cell.hasFood = true;
4 }

In the code above, we retrieve the first cell of our board and mark it with hasFood. Now, we just need to check in the render() method whether the hasFood label is present and draw the food accordingly. Here’s the rendering code:

js
1 if (cellCoords.hasFood) {
2 context.drawImage(
3 food,
4 cellCoords.x * this.cellWidth + this.offsetX,
5 cellCoords.y * this.cellheight + this.offsetY,
6 );
7 }

Only one argument has changed — the sprite. The entire render() method now looks like this:

js
1 render(context, cell, food) {
2 this.cellWidth = cell.width + 1;
3 this.cellheight = cell.height + 1;
4 this.offsetX =
5 (context.canvas.width - this.cellWidth * this.board.boadWidth) / 2;
6 this.offsetY =
7 (context.canvas.height - this.cellheight * this.board.boadHeight) /
8 2;
9 this.board.cells.forEach((cellCoords) => {
10 window.requestAnimationFrame(() => {
11 context.drawImage(
12 cell,
13 cellCoords.x * this.cellWidth + this.offsetX,
14 cellCoords.y * this.cellheight + this.offsetY,
15 );
16 if (cellCoords.hasFood) {
17 context.drawImage(
18 food,
19 cellCoords.x * this.cellWidth + this.offsetX,
20 cellCoords.y * this.cellheight + this.offsetY,
21 );
22 }
23 });
24 });
25 }

If you followed all the steps correctly, you should see an apple in the top-left cell.

Apple on Board

Naturally, we want to draw the food at random, free coordinates. To do this, we'll replace the line:

js
1 let cell = this.board.cells[0];

with

js
1 getAvailableCell(){
2 return this.board.cells[0];
3 }
4 addFood() {
5 let cell = this.getAvailableCell();
6 cell.hasFood = true;
7 }

In the getAvailableCell() method, we will implement the logic for obtaining a free cell. However, we will actually create another function called getRandomCell(), where we will write the formula for generating a random number. Here's how it looks in code:

js
1 getRandomCell(min, max) {
2 return Math.floor(Math.random() * (max + 1 - min) + min);
3 }

Now we can obtain a random cell from our board.

js
1 getAvailableCell() {
2 let idx = this.getRandomCell(0, this.board.cells.length - 1);
3 return this.board.cells[idx];
4 }

In the previously created getRandomCell() method, we pass 0 as the minimum number and this.board.cells.length - 1 as the maximum.

The result:

Apple on a random Cell

We just need to check whether the snake is currently occupying the cell. To do this, we will pass the SnakeController to the addFood() method, and then we’ll pass it to the getAvailableCell() method.

js
1 getAvailableCell(snakeController) {
2 ...
3 }
4 addFood(snakeController) {
5 let cell = this.getAvailableCell(snakeController);
6 cell.hasFood = true;
7 }

Just to remind you, in the SnakeController class, we create the snake and store its coordinates in the snakeCoords array. Using the filter method, we can check if a cell is free from the snake. Here's how this looks in code:

js
1 getAvailableCell(snakeController) {
2 // Get an array of free cells
3 const availableCells = this.board.cells.filter((cell) => {
4 return !snakeController.snake.snakeCoords.includes(cell);
5 });
6 // Return a random cell from the new array
7 let idx = this.getRandomCell(0, availableCells.length - 1);
8 return availableCells[idx];
9 }

Now our board can render food while checking the coordinates against the snake. This ensures that we reliably render the food in a free cell.

Stage 8: Snake Eating Food

The logic for eating food will be very simple. Each time the snake moves in the move() method of the SnakeController class, we will "trim" the snake's tail using the pop() method.

js
1 move() {
2 if (!this.snake.isMoving) {
3 return;
4 }
5 let cell = this.getNextCell();
6 if (cell) {
7 this.snake.snakeCoords.unshift(cell);
8 // Отрезаем змее хвост
9 this.snake.snakeCoords.pop();
10 }
11 }

All we need to do is reverse this action if the snake eats the food.

js
1 if (cell) {
2 this.snake.snakeCoords.unshift(cell);
3 if (cell.hasFood) {
4 // If the cell contains food, exit the method
5 return;
6 }
7 this.snake.snakeCoords.pop();
8 }

Let’s enhance this method a bit. Specifically, we need to first remove the old apple and then render the new one.

js
1 if (cell) {
2 this.snake.snakeCoords.unshift(cell);
3 if (cell.hasFood) {
4 // Remove the eaten apple
5 this.boardController.removeFood(cell);
6 // Render a new apple
7 this.boardController.addFood(this);
8 return;
9 }
10 this.snake.snakeCoords.pop();
11 }

Now, let's create the removeFood() function in the BoardController class:

js
1 removeFood(cell) {
2 cell.hasFood = false;
3 }

Now, our snake can eat apples!

Snake eating apple

Stage 9: Rotating the Snake's Head

Snake head rotate

We can implement the rotation of the snake's head using two methods:

  1. Create four images facing different directions and switch between them based on the current direction.
  2. Dynamically rotate the snake's head based on its movement.
Snake rotated

In this simple game, we will use the second method. To implement this, we need to modify the render() method in the SnakeController class.

First, we’ll add a degree variable in the init method:

js
1 this.degree = 180;

We set the default value to 180 so that the snake initially faces upward. We will later adjust the rotation of the snake's head by changing this value. To achieve this, we need to save the canvas context, rotate it, draw the head, and then restore the saved context.

Now, our render() method looks like this:

js
1 render(context, boardController, snakeBody, snakeHead) {
2 this.boardController = boardController;
3 // Save half the width (which is also the height)
4 const halfHeadSize = snakeHead.width / 2;
5 this.snake.snakeCoords.forEach((cell, i) => {
6 window.requestAnimationFrame(() => {
7 // Find the head
8 if (i === 0) {
9 // Save the context
10 context.save();
11 // Move to the head's position
12 context.translate(
13 cell.x * boardController.cellWidth + boardController.offsetX,
14 cell.y * boardController.cellheight + boardController.offsetY,
15 );
16 // Move to the center of the cell
17 context.translate(halfHeadSize, halfHeadSize);
18 // Rotate the context
19 context.rotate((this.degree * Math.PI) / 180);
20 // Draw the head
21 context.drawImage(snakeHead, -halfHeadSize, -halfHeadSize);
22 // Restore the context without rotations
23 context.restore();
24 } else {
25 context.drawImage(
26 snakeBody,
27 cell.x * boardController.cellWidth + boardController.offsetX,
28 cell.y * boardController.cellheight + boardController.offsetY,
29 );
30 }
31 });
32 });
33 }

This method first saves the current context, then translates and rotates it according to the head's coordinates and rotation degree. After drawing the head, it restores the context, allowing the rest of the snake's body to be drawn without any transformations.

We will change the degree variable in the createListeners() method of the Game class. To make the snake look down, we set the value to 0; for up, it’s 180; for left, it’s 90 degrees; and for right, it’s 270. The entire method code now looks like this:

js
1 createListeners() {
2 window.addEventListener('keydown', (e) => {
3 const { key } = e;
4 if (key === 'ArrowUp') {
5 this.snakeController.deltaX = 0;
6 this.snakeController.deltaY = -1;
7 this.snakeController.degree = 0;
8 } else if (key === 'ArrowDown') {
9 this.snakeController.deltaX = 0;
10 this.snakeController.deltaY = 1;
11 this.snakeController.degree = 180;
12 } else if (key === 'ArrowLeft') {
13 this.snakeController.deltaX = -1;
14 this.snakeController.deltaY = 0;
15 this.snakeController.degree = 270;
16 } else if (key === 'ArrowRight') {
17 this.snakeController.deltaX = 1;
18 this.snakeController.deltaY = 0;
19 this.snakeController.degree = 90;
20 }
21 this.snakeController.snake.startMoving();
22 });
23 this.start();
24 }

The head of our snake can now turn in all directions.

Snake with rotated head

Stage 10: Rendering Bombs

As always, let's start by adding our sprite. In the Game class, within the preloadImages() method, we will add the following line:

js
1 this.bomb = await this.preloadImage('../images/bomb.png');

Next, in the create() method, we'll call the function addBomb(), which we haven't created yet:

js
1 this.boardController.addBomb(this.snakeController);

The addBomb() method is identical to the addFood() function:

js
1 this.boardController.addFood(this.snakeController);
2 this.boardController.addBomb(this.snakeController);

Now, let's move to the BoardController class, where we will implement the addBomb() method similarly to addFood():

js
1 addBomb(snakeController) {
2 let cell = this.getAvailableCell(snakeController);
3 cell.hasBomb = true;
4 }

We need to make a small adjustment to the getAvailableCell() function to check if the cell contains a bomb or food.

Here’s the updated method code:

js
1 getAvailableCell(snakeController) {
2 const availableCells = this.board.cells.filter((cell) => {
3 // Exclude cells with food and bombs
4 if (cell.hasFood || cell.hasBomb) {
5 return;
6 }
7 return !snakeController.snake.snakeCoords.includes(cell);
8 });
9 let idx = this.getRandomCell(0, availableCells.length - 1);
10 return availableCells[idx];
11 }

Adding bombs is almost complete. Thanks to the versatility of our code, we can easily add new objects. We just need to render our bombs in the render() method. First, we need to pass the bomb sprite to this function. In the resizeCanvas() and update() methods, we should add our argument:

js
1 this.boardController.render(this.context, this.cell, this.food, this.bomb);

In the render() method of the BoardController class, we need to accept the bomb argument:

js
1 render(context, cell, food, bomb) { }

After rendering the food, we will write identical code for rendering the bombs.

js
1 if (cellCoords.hasBomb) {
2 context.drawImage(
3 bomb,
4 cellCoords.x * this.cellWidth + this.offsetX,
5 cellCoords.y * this.cellheight + this.offsetY,
6 );
7 }

You should see a bomb displayed in the browser.

Bomb on Board

If you don't see the bomb, please double-check the code:

js
1 render(context, cell, food, bomb) {
2 this.cellWidth = cell.width + 1;
3 this.cellheight = cell.height + 1;
4 this.offsetX =
5 (context.canvas.width - this.cellWidth * this.board.boadWidth) / 2;
6 this.offsetY =
7 (context.canvas.height - this.cellheight * this.board.boadHeight) /
8 2;
9 this.board.cells.forEach((cellCoords) => {
10 window.requestAnimationFrame(() => {
11 context.drawImage(
12 cell,
13 cellCoords.x * this.cellWidth + this.offsetX,
14 cellCoords.y * this.cellheight + this.offsetY,
15 );
16 if (cellCoords.hasFood) {
17 context.drawImage(
18 food,
19 cellCoords.x * this.cellWidth + this.offsetX,
20 cellCoords.y * this.cellheight + this.offsetY,
21 );
22 }
23 if (cellCoords.hasBomb) {
24 context.drawImage(
25 bomb,
26 cellCoords.x * this.cellWidth + this.offsetX,
27 cellCoords.y * this.cellheight + this.offsetY,
28 );
29 }
30 });
31 });
32 }

Since the addBomb() and addFood() methods are identical, we can combine them into a new function called addObject(). We'll pass the SnakeController and the type of object as arguments. Here's how it looks in code:

js
1 addObject(snakeController, type) {
2 let cell = this.getAvailableCell(snakeController);
3 if (type === 'food') {
4 cell.hasFood = true;
5 }
6 if (type === 'bomb') {
7 cell.hasBomb = true;
8 }
9 }

In the create() method of the Game class, we will update the calls to the old methods. Now it looks like this:

js
1 this.boardController.addObject(this.snakeController, 'food');
2 this.boardController.addObject(this.snakeController, 'bomb');

We will also modify the removeFood() function and rename it to removeObject():

js
1 removeObject(cell, type) {
2 if (type === 'food') {
3 cell.hasFood = false;
4 }
5 if (type === 'bomb') {
6 cell.hasBomb = false;
7 }
8 }

In the move() method of the SnakeController class, we now remove food like this:

js
1 this.boardController.removeObject(cell, 'food');

You can now remove the addBomb() and addFood() methods from the BoardController class.

The logic for spawning bombs will differ from the logic for spawning food. While we eat food and then generate a new apple, we will add bombs using setTimeout() every 5 seconds. When adding a new bomb, we will erase the old one. However, we don't have the exact coordinates for the bomb, as we don’t eat it. Therefore, we need to create a new method that will iterate through the array of cells and clear the bombs. We will call this method removeBombs:

js
1 removeBombs() {
2 this.board.cells.forEach((cell) => (cell.hasBomb = false));
3 }

In the addObject() method, before adding a new bomb, we will call the removeBombs() function:

js
1 if (type === 'bomb') {
2 this.removeBombs();
3 cell.hasBomb = true;
4 }

Now everything works as it should. Bombs appear every 5 seconds. All that remains is to handle collisions when the snake hits them.

Stage 11: Game Over

First, let's outline all the conditions under which our game should end:

  1. The snake has left the boundaries of the board.
  2. The snake has collided with itself.
  3. The snake has eaten a bomb.

The trigger for losing the game will always be our snake. Therefore, we need to check all conditions in the SnakeController class. In the move() method, after getting the new cell, we will perform the checks:

js
1 // Get the next cell
2 let cell = this.getNextCell();
3 // Check if the cell exists and whether it belongs to the snake
4 if (!cell || this.snake.snakeCoords.includes(cell)) {
5 console.log('Game Over');
6 return;
7 }

When the snake attempts to move outside the board or collides with itself, we currently log "Game Over" to the console. Instead, let's create a separate method that will handle stopping the game. In the game.js file, we'll add a static method called gameOver():

js
1 static gameOver() {
2 console.log('Game Over');
3 // Additional logic to stop the game, such as clearing intervals or displaying game over UI
4 }

We make this method static so that we can call it directly without needing to create an instance of the Game class. This way, in the SnakeController class, we can stop the game like this:

js
1 if (!cell || this.snake.snakeCoords.includes(cell)) {
2 Game.gameOver(); // Stop the game
3 return;
4 }

Don't forget to import the Game class into SnakeController:

js
1 import Game from '../game.js';

Now, instead of just logging "Game Over" to the console, the game will properly stop using this static gameOver() method.

So far, we're only checking whether the snake has moved outside the board or collided with itself. However, we also need to check if the snake has encountered a bomb. To do this, after confirming that the cell exists, we will add a bomb check in the move() method of the SnakeController class.

Here's how to modify the method:

js
1 move() {
2 if (!this.snake.isMoving) {
3 return;
4 }
5 let cell = this.getNextCell();
6 if (!cell || this.snake.snakeCoords.includes(cell)) {
7 Game.gameOver();
8 return;
9 }
10
11 if (cell) {
12 this.snake.snakeCoords.unshift(cell);
13 if (cell.hasFood) {
14 this.boardController.removeObject(cell, 'food');
15 this.boardController.addFood(this);
16 return;
17 }
18 if (cell.hasBomb) {
19 Game.gameOver();
20 }
21 this.snake.snakeCoords.pop();
22 }
23 }

Now, we should receive a message in the console whenever any of the game-over conditions are met.

Game over in console

To stop the game, we need to clear the intervals created in the start() method. First, let's store these intervals in variables.

js
1 start() {
2 this.updateInterval = setInterval(() => {
3 this.update();
4 }, 150);
5 this.bombInterval = setInterval(() => {
6 this.boardController.addObject(this.snakeController, 'bomb');
7 }, 5000);
8 }

Earlier, we made the gameOver() method static, but to clear the intervals, we need a regular method. Static methods are created first, and at the moment the gameOver method is created, our intervals haven't been initialized yet, meaning we can't stop them. We'll remove the static keyword from the gameOver() method and clear the intervals within it.

js
1 gameOver() {
2 clearInterval(this.updateInterval);
3 clearInterval(this.bombInterval);
4 }

We also need to adjust the logic for calling the gameOver() function. Specifically, we need a way to inform the Game class from the SnakeController class that the game is over. The simplest solution is to add a gameOver() property to our SnakeController class.

js
1 if (!cell || this.snake.snakeCoords.includes(cell)) {
2 this.gameOver = true;
3 return;
4 }

We'll do the same for when the snake eats a bomb.

js
1 if (cell.hasBomb) {
2 this.gameOver = true;
3 }

Now, in the update() method, we will check if the gameOver() variable has been set in the SnakeController.

js
1 if (this.snakeController.gameOver) {
2 this.gameOver();
3 }

Now, the game simply stops without informing us of what happened. We will fix this by displaying an alert message and then reloading the page. We will add two lines of code to the gameOver() method:

js
1 gameOver() {
2 clearInterval(this.updateInterval);
3 clearInterval(this.bombInterval);
4 alert('Game Over');
5 window.location.reload();
6 }

This will alert the player that the game is over and then refresh the page to restart the game.

Now we have the game ending functionality implemented.

Game Over

##Stage 12: Loading Sounds

You can find four sound files in the "sounds" folder of the GitHub repository. The files are named snakecharmer.wav, bomb.wav, food.wav, and game-over.wav.

Sound folder
js
1 async preloadSound(path) {
2 let sound = new Audio();
3 await new Promise((resolve, reject) => {
4 sound.src = path;
5 sound.load();
6 sound.addEventListener(
7 'canplaythrough',
8 () => {
9 resolve(sound);
10 },
11 { once: true },
12 );
13 sound.addEventListener('error', () => {
14 reject(new Error("Couldn't load sound"));
15 });
16 });
17 return sound;
18 }

As you may have noticed, we aren't rendering anything on the canvas. Instead, we wait for the canplaythrough event and return the object from the promise.

Now, in the preloadSounds() method, we'll load all the sound files.

js
1 async preloadSounds() {
2 this.bombSound = await this.preloadSound('../sounds/bomb.wav');
3 this.foodSound = await this.preloadSound('../sounds/food.wav');
4 this.gameOverSound = await this.preloadSound('../sounds/game-over.wav');
5 this.snakeSound = await this.preloadSound('../sounds/snakecharmer.wav');
6 this.snakeSound.loop = true;
7 }

We set the loop property to true for our main sound, which will play continuously.

Additionally, we'll modify our preload() method to wait for all assets asynchronously before calling the create() function.

js
1 async preload() {
2 await this.preloadImages();
3 await this.preloadSounds();
4 this.create();
5 }

We will also fix the error in the createListeners() method. At the bottom of this method, we call the start function and initiate our intervals. We should only do this after the game has started. To track the beginning of the game, we will create a new flag, gameIsStarted, and change its value after the first key press. The entire createListeners() method now looks like this:

js
1 createListeners() {
2 let gameisStarted = false;
3 window.addEventListener('keydown', (e) => {
4 if (!gameisStarted) {
5 gameisStarted = true;
6 this.start();
7 }
8 const { key } = e;
9 if (key === 'ArrowUp') {
10 this.snakeController.deltaX = 0;
11 this.snakeController.deltaY = -1;
12 this.snakeController.degree = 0;
13 } else if (key === 'ArrowDown') {
14 this.snakeController.deltaX = 0;
15 this.snakeController.deltaY = 1;
16 this.snakeController.degree = 180;
17 } else if (key === 'ArrowLeft') {
18 this.snakeController.deltaX = -1;
19 this.snakeController.deltaY = 0;
20 this.snakeController.degree = 270;
21 } else if (key === 'ArrowRight') {
22 this.snakeController.deltaX = 1;
23 this.snakeController.deltaY = 0;
24 this.snakeController.degree = 90;
25 }
26 this.snakeController.snake.startMoving();
27 });
28 }

In the start() method, we will play our main sound, which we named snakeSound:

js
1 this.snakeSound.play();

Now we have background music that plays continuously in a loop. Let's add the game-over sound to the gameOver() method.

js
1 gameOver() {
2 // Stop the main sound
3 this.snakeSound.pause();
4 // Play the game-over sound
5 this.gameOverSound.play();
6 clearInterval(this.updateInterval);
7 clearInterval(this.bombInterval);
8 alert('Game Over');
9 window.location.reload();
10 }

Now we have background sounds and a game-over sound. We need to add sounds for the food and the bomb. The collisions with these objects are handled in the SnakeController class. This is where we need to add the variables playFood and playBomb.

In the move() method of the SnakeController class, we will add the following lines of code:

js
1 if (cell.hasFood) {
2 this.playFood = true;
3 this.boardController.removeObject(cell, 'food');
4 this.boardController.addFood(this);
5 return;
6 }
7 if (cell.hasBomb) {
8 this.playBomb = true;
9 this.gameOver = true;
10 }

Let's return to the update() method of the Game class and add checks for our new fields.

js
1 if (this.snakeController.playBomb) {
2 this.bombSound.play();
3 this.snakeController.playBomb = false;
4 }
5 if (this.snakeController.playFood) {
6 this.foodSound.play();
7 this.snakeController.playFood = false;
8 }

As you noticed, after playing the sound, we set the playback flags to false. This ensures that our sounds are played only once.

Now we have explosion and apple consumption sounds. To make them more audible, we need to lower the volume of the main sound a bit. Let's return to the preloadSounds() method and add the following line at the end:

js
1 this.snakeSound.volume = 0.1;

We have implemented the sounds. In future tutorials, we will create a full-fledged Audio Manager, but for a small game, what we have done is just right.

Stage 12: Displaying the Score

To display the score, we need to create two methods. In the first method, createFont(), we will set up our font. This function will be called once during the initialization of the Game class. The second function, createScore(), will be continuously rendered with each move of the snake. Let's start by creating the font.

js
1 createFont() {
2 this.context.font = '20px Roboto';
3 this.context.fillStyle = '#747474';
4 }

And we will call this method from the create() function:

js
1 this.createFont();

In the init() method, let's create a variable score and set its initial value to 0:

js
1 this.score = 0;

By incrementing this variable, we will increase our score. But first, we need to create the createScore() method.

js
1 createScore() {
2 this.context.fillText(`Score: ${this.score}`, 25, 25);
3 }

We will also add a call to this method in the update() function so that the score is rendered every 150 milliseconds:

js
1 this.createScore();

Where should we increment the score variable? It's quite simple: we will do it in the same place where we track the sound playback, in the update() method.

js
1 if (this.snakeController.playFood) {
2 this.score++;
3 this.foodSound.play();
4 this.snakeController.playFood = false;
5 }

And that's it... our score is now incrementing, and the game is fully functional!

The final step is to wait for the HTML document to load completely before starting the game. To do this, open the app.js file and listen for the DOMContentLoaded event before launching the game.

js
1 import Game from './js/game.js';
2
3 window.addEventListener('DOMContentLoaded', () => {
4 const game = new Game();
5 });

Now it's all done! 😊 Congratulations on creating your first game using pure JavaScript!

You can find the complete code and assets on our GitHub git.

Conclusion

Congratulations on successfully building your own classic Snake game using pure JavaScript! Throughout this tutorial, you learned how to create a dynamic and interactive game from scratch, covering essential concepts such as game loops, object management, collision detection, and sound integration.

By implementing features like food generation, score tracking, and game-over conditions, you gained valuable insights into game development principles and JavaScript programming techniques. This project not only reinforces your coding skills but also provides a strong foundation for exploring more complex game mechanics and designs.

Feel free to experiment with your game by adding new features, enhancing graphics, or even creating unique gameplay elements. The skills you've acquired here can serve as a springboard for your future projects.

Thank you for following along, and we hope you enjoyed the journey of creating your very own Snake game!

JavaScript Development Space

© 2024 JavaScript Development Space - Master JS and NodeJS. All rights reserved.