We continue our adventure with snake started at the previous post. Now there is time to present pre-release version 0.2 available on

https://github.com/gustawdaniel/snake_js/releases/tag/v0.2

We will continue from the last commit from the previous article to commit connected directly with release 0.2

git diff ae194969e7d2a555c9dc7ed2fb57c81b56775b62..5eb5cd18880be6db4e77f69f6fd3096912d8100e --stat
 README.md     |   8 +++++
 css/style.css |  25 ++++++++++++---
 index.html    |  48 +++++++++++++++++++++++------
 js/app.js     | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
 4 files changed, 181 insertions(+), 38 deletions(-)

Changes out of JavaScript

For the sake of simplicity, I will present the final state of files index.html, README.md and css/style.css. Next, we will focus on changes in js/app.js commit by commit. So because of pausing of the game was added it was documented and REDAME.md looks like this

README.md
# snake_js
Snake game is written in javascript using objects. 


# Installation

To install dependencies

    yarn install

To run

    node node_modules/http-server/bin/http-server 

# Game

To game run

    firefox localhost:8080
    
Press space to start and use arrows to control the snake. 

We reorganized index.html placing all description on the bottom of the page.

index.html
<html>
<head>
    <title>Snake - game dedicated for Sylwia Dainecka - my girlfriend!</title>
    <link rel="stylesheet" href="css/style.css">
</head>
<body>
    <header>
        <h1>I love Sylwia <3</h1>
        <p>To start or pause press space</p>
        <!--<br>-->
        <p class="info center"><span class="points">0</span><span class="state">PAUSED</span><span class="counter">0</span></p>
    </header>
    <main>
        <div id="map"></div>
    </main>
    <footer>
        <ul class="history"></ul>
        <hr>
        <h4>TODO:</h4>
        <h5>v0.1</h5>
        <ol>
            <li style="text-decoration: line-through">Add map</li>
            <li style="text-decoration: line-through">Add snake</li>
            <li style="text-decoration: line-through">Add events</li>
        </ol>
        <h5>v0.2</h5>
        <ol>
            <li style="text-decoration: line-through">Add apples</li>
            <li style="text-decoration: line-through">Add boundaries</li>
            <li style="text-decoration: line-through">Add scores</li>
        </ol>
        <h5>Future (proposed)</h5>
        <ol>
            <li>Add bad apples</li>
            <li>Add two players</li>
            <li>Add network gaming</li>
            <li>Add tests</li>
            <li>Add CI</li>
            <li>Create snake as module</li>
            <li>Use sass instead of css</li>
            <li>Add webpack</li>
            <li>Add user account</li>
            <li>Fix bug connected with appearing simultaneously many apples</li>
            <li>Special color of head</li>
            <li>Fix bug connected with changes direction many time in one round that allow bump int snake with length 3</li>
            <li>Add login by google</li>
            <li>Make it mobile friendly</li>
        </ol>
    </footer>
    <script src="node_modules/jquery/dist/jquery.min.js"></script>
    <script src="js/app.js"></script>
</body>
</html>

As you can see on the list of proposed features I think there is potential to create a multiplayer game.

We have only some lines of styles. It is the result of one of the assumptions of project - simplicity. This
excludes using external libraries at this stage of development.

css/style.css
.rect {
    width: 30px;
    height: 30px;
    background-color: #dca6d1;
    display: inline-block;
    margin: 2px;
    /*border-radius: 4px;*/
    /*border: solid 1px #dc6f91;*/
}

footer {
    padding-top: 3vh;
}

.info {
    border: 1px solid black;
    padding: 7px;
    text-align: center;
}

.points {
    float: left;
}

.counter {
    float: right;
}

footer ul.history:not(:empty) {
    border: 1px solid black;
    padding: 7px;
}

footer ul.history li {
    list-style: none;
}

Evolution of JavaScript logic

Now we focus on small changes of JavaScript logic of the game.

Apples

We start by creating apple when the game is initialized

git diff ae194969e7d2a555c9dc7ed2fb57c81b56775b62..3056362ad24cf3523bb2931a992be45e41dbf58e js/app.js
@@ -5,12 +5,23 @@
         mapHeight: 10,
         snakeColor: "#8165f3",
         mapColor: "#dca6d1",
+        appleColor: "#dc5c61",
         roundTime: 1000
     };
 
     let map = {
         width: config.mapWidth,
         height: config.mapHeight,
+        apples: [],
+        addApple: function () {
+            let apple = {
+                x: Math.floor(Math.random() * this.width),
+                y: Math.floor(Math.random() * this.height)
+            };
+            this.apples.push(apple);
+            $(`div.rect[data-x="${apple.x}"][data-y="${apple.y}"]`).css('background-color',config.appleColor);
+            // console.log(this.apples);
+        },
         init: function () {
             let mapDiv = $('#map');
             for(let i=0; i<this.width; i++) {
@@ -20,6 +31,7 @@
                 }
                 mapDiv.append(rowDiv);
             }
+            this.addApple()
         }
     };

But there is one problem. Apple can be placed in a body of the snake. To prevent this catastrophe we randomizing apple position until it lands out of the snake. We added also function for remove apple.

git diff 3056362ad24cf3523bb2931a992be45e41dbf58e..faef0fa66dea476c761ebf45f87cf2742bcbed18 js/app.js
@@ -18,9 +18,18 @@
                 x: Math.floor(Math.random() * this.width),
                 y: Math.floor(Math.random() * this.height)
             };
-            this.apples.push(apple);
-            $(`div.rect[data-x="${apple.x}"][data-y="${apple.y}"]`).css('background-color',config.appleColor);
-            // console.log(this.apples);
+            if(snake.containsCoordinates(apple)) { // apple is on snake  then repeat
+                console.log("appleOnSnake");
+                this.addApple();
+            } else {
+                this.apples.push(apple);
+                $(`div.rect[data-x="${apple.x}"][data-y="${apple.y}"]`).css('background-color',config.appleColor);
+            }
+        },
+        removeApple: function (toRemove) {
+            this.apples = this.apples.filter((apple) => {
+                return apple.x !== toRemove.x && apple.y !== toRemove.y
+            });
         },
         init: function () {
             let mapDiv = $('#map');

Snake gets a number of his points and method to check if given coordinates belong to him.

git diff 3056362ad24cf3523bb2931a992be45e41dbf58e..faef0fa66dea476c761ebf45f87cf2742bcbed18 js/app.js
@@ -36,7 +45,12 @@
     };
 
     let snake = {
+        points: 0,
         body: [{x:5,y:2},{x:4,y:2},{x:3,y:2}],
+        containsCoordinates: function (inspected) {
+            return this.body.filter(function (part) {
+                return part.x === inspected.x && part.y === inspected.y }).length
+        },
         draw: function() {
             this.body.forEach(function (part) {
                 $(`div.rect[data-x="${part.x}"][data-y="${part.y}"]`).css('background-color',config.snakeColor);

Finally we added eating apples to snake move function:

git diff 3056362ad24cf3523bb2931a992be45e41dbf58e..faef0fa66dea476c761ebf45f87cf2742bcbed18 js/app.js
@@ -57,10 +71,23 @@
             this.body.unshift(head);
             $(`div.rect[data-x="${head.x}"][data-y="${head.y}"]`)
                 .css('background-color',config.snakeColor);
-            let mapCoordinates  = this.body.pop();
-            $(`div.rect[data-x="${mapCoordinates.x}"][data-y="${mapCoordinates.y}"]`)
-                .css('background-color',config.mapColor);
-
+            if(!this.eatApple()) {
+                let mapCoordinates  = this.body.pop();
+                $(`div.rect[data-x="${mapCoordinates.x}"][data-y="${mapCoordinates.y}"]`)
+                    .css('background-color',config.mapColor);
+            }
+        },
+        eatApple: function () {
+            if(map.apples.filter((part) => {
+                return part.x === this.body[0].x && part.y === this.body[0].y }).length
+            ) {
+                this.points ++;
+                $('.points').text(this.points);
+                console.log("eatApple");
+                map.removeApple(this.body[0]);
+                map.addApple();
+                return true;
+            }
         }
     };

Last change in this step is the shortening the time of loop to add more dynamism to a game.

git diff 3056362ad24cf3523bb2931a992be45e41dbf58e..faef0fa66dea476c761ebf45f87cf2742bcbed18 js/app.js
@@ -6,7 +6,7 @@
         snakeColor: "#8165f3",
         mapColor: "#dca6d1",
         appleColor: "#dc5c61",
-        roundTime: 1000
+        roundTime: 500
     };

Game Over

Now we will make our game impossible to game. The earlier snake was immortal, now he dies after always after 6. We need this feature to see logs with scores and easily test game reset.

git diff faef0fa66dea476c761ebf45f87cf2742bcbed18..2e843cc0bf5c895bac529b60942baa3e94435939 js/app.js
@@ -93,15 +97,20 @@
 
     let game = {
         counter: 0,
-        direction: 'right', // right, left, up, down
+        direction: 'right', // right, left, up, down,
+        timeout: undefined,
         run: function () {
             snake.move(this.direction);
         },
         init: function () {
+            this.counter = 0;
             map.init();
-            snake.draw();
-            setInterval(() => {
+            snake.init();
+            this.timeout = setInterval(() => {
                 this.counter ++;
+                if(this.counter === 6) {
+                    this.gameOver();
+                }
                 $('.counter').text(this.counter);
                 this.run();
             },config.roundTime);

In the main loop of the game, we set the counter to 0, draw the snake, set interval and assign it to timeout property. Finally, when the counter reaches 6 we run function game over that is presented below

git diff faef0fa66dea476c761ebf45f87cf2742bcbed18..2e843cc0bf5c895bac529b60942baa3e94435939 js/app.js
@@ -118,6 +127,15 @@
                         this.direction = this.direction === "left" ? this.direction : "right"; break;
                 }
             })
+        },
+        logResult: function () {
+            $('ul.history').prepend($(`<li>${performance.now().toFixed(2)} - ${snake.points} - ${this.counter} - ${(snake.points/this.counter).toFixed(4)}</li>`));
+        },
+        gameOver: function () {
+            clearInterval(this.timeout);
+            this.timeout = undefined;
+            this.logResult();
+            this.init();
         }
     };

Game overrun function logResult that save scores and some other statistics on the bottom of the page in
ul with class history. We have now two bugs to fix after these changes: a resetting of map and state of a snake. To fix this behavior we can change the script in the following way

git diff faef0fa66dea476c761ebf45f87cf2742bcbed18..2e843cc0bf5c895bac529b60942baa3e94435939 js/app.js
@@ -33,6 +32,7 @@
         },
         init: function () {
             let mapDiv = $('#map');
+            mapDiv.html("");
             for(let i=0; i<this.width; i++) {
                 let rowDiv =$('<div>', {class: "row"});
                 for(let j=0; j<this.width; j++) {
@@ -46,7 +46,12 @@
 
     let snake = {
         points: 0,
-        body: [{x:5,y:2},{x:4,y:2},{x:3,y:2}],
+        body: [],
+        init: function () {
+            this.body = [{x:5,y:2},{x:4,y:2},{x:3,y:2}];
+            this.points = 0;
+            this.draw();
+        },
         containsCoordinates: function (inspected) {
             return this.body.filter(function (part) {
                 return part.x === inspected.x && part.y === inspected.y }).length

Boundaries detection

It is time to make game over more realistic. Game should be ended when snake goes out of map, not after 6 turns so we need function that check if snake is out of map:

git diff 2e843cc0bf5c895bac529b60942baa3e94435939..fda66af34beae7c28d0b064d95d2f3a15a00fbbd js/app.js
@@ -30,6 +30,10 @@
                 return apple.x !== toRemove.x && apple.y !== toRemove.y
             });
         },
+        outOfMap: function (inspected) {
+            return inspected.x < 0 || inspected.x >= map.width
+                || inspected.y < 0 || inspected.y >= map.height;
+        },
         init: function () {
             let mapDiv = $('#map');
             mapDiv.html("");

We want also reset scores after snake resurrection

git diff 2e843cc0bf5c895bac529b60942baa3e94435939..fda66af34beae7c28d0b064d95d2f3a15a00fbbd
js/app.js
@@ -50,6 +54,7 @@
         init: function () {
             this.body = [{x:5,y:2},{x:4,y:2},{x:3,y:2}];
             this.points = 0;
+            $('.points').text(this.points);
             this.draw();
         },
         containsCoordinates: function (inspected) {

Now snake move can be continued if the snake is not out of the map or has not contained his head

git diff 2e843cc0bf5c895bac529b60942baa3e94435939..fda66af34beae7c28d0b064d95d2f3a15a00fbbd
js/app.js
@@ -73,13 +78,17 @@
                 case "right":
                     head.y = head.y + 1; break;
             }
-            this.body.unshift(head);
-            $(`div.rect[data-x="${head.x}"][data-y="${head.y}"]`)
-                .css('background-color',config.snakeColor);
-            if(!this.eatApple()) {
-                let mapCoordinates  = this.body.pop();
-                $(`div.rect[data-x="${mapCoordinates.x}"][data-y="${mapCoordinates.y}"]`)
-                    .css('background-color',config.mapColor);
+            if (map.outOfMap(head) || this.containsCoordinates(head)) {
+                game.gameOver();
+            } else {
+                this.body.unshift(head);
+                $(`div.rect[data-x="${head.x}"][data-y="${head.y}"]`)
+                    .css('background-color', config.snakeColor);
+                if (!this.eatApple()) {
+                    let mapCoordinates = this.body.pop();
+                    $(`div.rect[data-x="${mapCoordinates.x}"][data-y="${mapCoordinates.y}"]`)
+                        .css('background-color', config.mapColor);
+                }
             }
         },
         eatApple: function () {

Finally, we can remove 6 turns constrain of snake life and for debugging assign all objects to the window

git diff 2e843cc0bf5c895bac529b60942baa3e94435939..fda66af34beae7c28d0b064d95d2f3a15a00fbbd js/app.js
@@ -104,13 +113,11 @@
         },
         init: function () {
             this.counter = 0;
+            this.direction = 'right';
             map.init();
             snake.init();
             this.timeout = setInterval(() => {
                 this.counter ++;
-                if(this.counter === 6) {
-                    this.gameOver();
-                }
                 $('.counter').text(this.counter);
                 this.run();
             },config.roundTime);
@@ -141,5 +148,8 @@
 
     game.init();
 
-    
+    window.snake = snake;
+    window.map = map;
+    window.game = game;
+
 })();

Pause

Gaming in snake all time without any break can be fatiguing. So our new feature will be state of pause.

git diff fda66af34beae7c28d0b064d95d2f3a15a00fbbd..bd87fd2ffa8f69e841fe553a7cd16b317ad771a8
js/app.js
@@ -106,6 +106,7 @@
 
     let game = {
         counter: 0,
+        state: 'paused', // paused, active
         direction: 'right', // right, left, up, down,
         timeout: undefined,
         run: function () {
@@ -114,15 +115,13 @@
         init: function () {
             this.counter = 0;
             this.direction = 'right';
+            this.state = 'paused';
+            $(".state").text(this.state.toUpperCase());
             map.init();
             snake.init();
-            this.timeout = setInterval(() => {
-                this.counter ++;
-                $('.counter').text(this.counter);
-                this.run();
-            },config.roundTime);
+
             document.addEventListener('keypress',(e) => {
-                console.log(e.key);
+                console.log({key: e.key, code: e.keyCode});
                 switch (e.key) {
                     case "ArrowUp":
                         this.direction = this.direction === "down" ? this.direction : "up"; break;
@@ -132,6 +131,16 @@
                         this.direction = this.direction === "right" ? this.direction : "left"; break;
                     case "ArrowRight":
                         this.direction = this.direction === "left" ? this.direction : "right"; break;
+                    case "Enter":
+                        if(this.state === 'paused') {
+                            this.state = 'active';
+                            $(".state").text(this.state.toUpperCase());
+                            this.timeout = setInterval(() => {
+                                this.counter ++;
+                                $('.counter').text(this.counter);
+                                this.run();
+                            },config.roundTime);
+                        }
                 }
             })
         },

As you can see we added a state to the game, moved setting timeout to code executed after detection of ENTER button clicking, so you have additional time to know the game before a start.

Prevent scrolling on keypress detection

When we added a list of features page is now long enough to be scrollable. Arrows have the same default behavior like scrolling so to prevent this we added also this code

git diff bd87fd2ffa8f69e841fe553a7cd16b317ad771a8..d0776a2fd5fd26fcccc1664f9261fa19fc90c280
js/app.js
@@ -142,6 +142,9 @@
                             },config.roundTime);
                         }
                 }
+                if([0, 32, 37, 38, 39, 40].indexOf(e.keyCode) > -1) {
+                    e.preventDefault();
+                }
             })
         },
         logResult: function () {

Prevent cheating by using pause to change direction

A new feature sometimes means new problems. In our case pausing of the game does not prevent to change a direction of a snake, so can be used in unfairly way. To prevent this we should detect a state of the game and based on it allow or disallow to change direction. In this step, we changed pause/active button to more intuitive - SPACE instead of ENTER and replaced keyCode by key to making it more readable in the detection of scrolling.

git diff d0776a2fd5fd26fcccc1664f9261fa19fc90c280..274a49d6556d8fa8cd4c3e4dc8f14e3c5f56d68b js/app.js
@@ -44,6 +44,7 @@
                 }
                 mapDiv.append(rowDiv);
             }
+            snake.init();
             this.addApple()
         }
     };
@@ -118,20 +119,19 @@
             this.state = 'paused';
             $(".state").text(this.state.toUpperCase());
             map.init();
-            snake.init();
 
             document.addEventListener('keypress',(e) => {
                 console.log({key: e.key, code: e.keyCode});
                 switch (e.key) {
                     case "ArrowUp":
-                        this.direction = this.direction === "down" ? this.direction : "up"; break;
+                        this.direction = this.direction === "down" || this.state === "paused" ? this.direction : "up"; break;
                     case "ArrowDown":
-                        this.direction = this.direction === "up" ? this.direction : "down"; break;
+                        this.direction = this.direction === "up" || this.state === "paused" ? this.direction : "down"; break;
                     case "ArrowLeft":
-                        this.direction = this.direction === "right" ? this.direction : "left"; break;
+                        this.direction = this.direction === "right" || this.state === "paused" ? this.direction : "left"; break;
                     case "ArrowRight":
-                        this.direction = this.direction === "left" ? this.direction : "right"; break;
-                    case "Enter":
+                        this.direction = this.direction === "left" || this.state === "paused" ? this.direction : "right"; break;
+                    case " ":
                         if(this.state === 'paused') {
                             this.state = 'active';
                             $(".state").text(this.state.toUpperCase());
@@ -140,9 +140,15 @@
                                 $('.counter').text(this.counter);
                                 this.run();
                             },config.roundTime);
+                        } else {
+                            this.state = 'paused';
+                            $(".state").text(this.state.toUpperCase());
+                            clearInterval(this.timeout);
+                            this.timeout = undefined;
                         }
                 }
-                if([0, 32, 37, 38, 39, 40].indexOf(e.keyCode) > -1) {
+                if([" ", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].indexOf(e.key) > -1) {
+                    console.log("CAT",e);
                     e.preventDefault();
                 }
             })

Bug with listener fixing

We can detect the second interesting bug. After any game over game add next event listeners. The code needs some refactoring. We moved setting listeners to an independent method that is called in init but is not called in case of reset of game.

git diff 274a49d6556d8fa8cd4c3e4dc8f14e3c5f56d68b..cf7f8e7455f72ea0ec9cdd7c58c1b5bbf0872d9a js/app.js
@@ -113,13 +113,7 @@
         run: function () {
             snake.move(this.direction);
         },
-        init: function () {
-            this.counter = 0;
-            this.direction = 'right';
-            this.state = 'paused';
-            $(".state").text(this.state.toUpperCase());
-            map.init();
-
+        setListeners: function () {
             document.addEventListener('keypress',(e) => {
                 console.log({key: e.key, code: e.keyCode});
                 switch (e.key) {
@@ -153,6 +147,17 @@
                 }
             })
         },
+        init: function () {
+            this.reset();
+            this.setListeners();
+        },
+        reset: function () {
+            this.counter = 0;
+            this.direction = 'right';
+            this.state = 'paused';
+            $(".state").text(this.state.toUpperCase());
+            map.init();
+        },
         logResult: function () {
             $('ul.history').prepend($(`<li>${performance.now().toFixed(2)} - ${snake.points} - ${this.counter} - ${(snake.points/this.counter).toFixed(4)}</li>`));
         },
@@ -160,7 +165,7 @@
             clearInterval(this.timeout);
             this.timeout = undefined;
             this.logResult();
-            this.init();
+            this.reset();
         }
     };

This is the end of changes in JavaScript. Two next commits introduce changes in README.md and index.html and these lines was presented in the previous chapter.

Now the game is functional and can be used to have fun. I hope this differential manner of presentation evolution of code has more educational value and will helpful for adepts of JavaScript.