What is better than playing in a snake? Playing in a snake with friends. Now there is time to present version of the game for two players that use one computer. The first player uses arrows, second WSAD.

This is the same game like before but the project was totally refactored. The code was divided into ES6 modules, a dependency of jQuery or Zepto was removed and replaced by Vue. Finally, instead of snake we now have a collection of snakes.

As you can see below practically all code was rewritten so there is no sense to use the differential presentation of changes.


git diff 5eb5cd18880be6db4e77f69f6fd3096912d8100e..ed1af7dae6d480331f738ae94fac1255900be5be --stat

 .browserslistrc                         |   3 +

 .gitignore                              |  23 ++++++-

 README.md                               |  72 ++++++++++++++++++++--

 babel.config.js                         |   5 ++

 css/style.css                           |  36 -----------

 js/app.js                               | 178 -----------------------------------------------------

 package.json                            |  17 +++--

 postcss.config.js                       |   5 ++

 public/css/style.css                    |  58 +++++++++++++++++

 public/favicon.ico                      | Bin 0 -> 1150 bytes

 public/index.html                       |  19 ++++++

 src/App.vue                             |  28 +++++++++

 src/Event.js                            |   3 +

 src/assets/logo.png                     | Bin 0 -> 6849 bytes

 index.html => src/components/Footer.vue |  72 ++++++++++------------

 src/components/Header.vue               |  16 +++++

 src/components/Main.vue                 |  74 ++++++++++++++++++++++

 src/components/main/Board.vue           |  66 ++++++++++++++++++++

 src/components/main/Results.vue         |  40 ++++++++++++

 src/components/main/State.vue           |  25 ++++++++

 src/game/Board.js                       |  54 ++++++++++++++++

 src/game/Config.js                      |   5 ++

 src/game/Game.js                        |  26 ++++++++

 src/game/Snake.js                       |  97 +++++++++++++++++++++++++++++

 src/main.js                             |   8 +++

 yarn.lock                               | 129 --------------------------------------

 26 files changed, 665 insertions(+), 394 deletions(-)

You can download this 0.3.1 pre-release from github.

Code presentation

The project is organized in the following files


.

├── babel.config.js

├── LICENSE

├── package.json

├── package-lock.json

├── postcss.config.js

├── public

│   ├── css

│   │   └── style.css

│   ├── favicon.ico

│   └── index.html

├── README.md

└── src

    ├── App.vue

    ├── assets

    │   └── logo.png

    ├── components

    │   ├── Footer.vue

    │   ├── Header.vue

    │   ├── main

    │   │   ├── Board.vue

    │   │   ├── Results.vue

    │   │   └── State.vue

    │   └── Main.vue

    ├── Event.js

    ├── game

    │   ├── Board.js

    │   ├── Config.js

    │   ├── Game.js

    │   └── Snake.js

    └── main.js

We present also statistics of code lines number

cloc $(git ls-files)
      22 text files.
      22 unique files.                              
       5 files ignored.

github.com/AlDanial/cloc v 1.74  T=0.02 s (777.8 files/s, 27673.3 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Vuejs Component                  7             30              0            264
JavaScript                       8             24              0            179
Markdown                         1             13              0             68
CSS                              1              9              0             49
JSON                             1              0              0             21
HTML                             1              1              1             17
-------------------------------------------------------------------------------
SUM:                            19             77              1            598
-------------------------------------------------------------------------------

Index and styles

Let's start with css. We removed colors form JavaScript config and now colors are defined in CSS. Displaying of snake or apple on the map is now controlled by classes, not inline styles. We introduced also some flex rules. If you do not know flex, I strongly recommend to learn it. Flex fixes many problems that css with position absolute/relative has.

public/css/style.css
#map .row {
    text-align: center;
}

.rect {
    width: 30px;
    height: 30px;
    background-color: #dca6d1;
    display: inline-block;
    margin: 2px;
}

.rect.out-map {
    background-color: #c1d0dc;
}

.rect.snake-0 {
    background-color: #8165f3;
}

.rect.snake-1 {
    background-color: #eff36a;
}

.rect.apple {
    background-color: #97dcd5;
}

.info {
    border: 1px solid black;
    padding: 7px;
    margin-bottom: 1em;
    text-align: center;
    justify-content: space-between;
    display: flex;
}

main .logs {
    display: flex;
    justify-content: space-evenly;
}

main .logs tr.best{
    background-color: whitesmoke;
}

main .logs tr.best td.points{
    font-weight: bold;
}

main .history {
    border: 1px solid black;
    padding: 7px;
    margin: 2vh 5px 0 5px;
    width: 100%;
}

main .history table {
    width: 100%;
}

Now because we applied Vue, index.html is much smaller.

public/index.html


<!DOCTYPE html>

<html lang="en">

  <head>

    <meta charset="utf-8">

    <meta http-equiv="X-UA-Compatible" content="IE=edge">

    <meta name="viewport" content="width=device-width,initial-scale=1.0">

    <link rel="icon" href="<%= BASE_URL %>favicon.ico">

    <link rel="stylesheet" href="css/style.css">



    <title>Snake - game dedicated for Sylwia Daniecka!</title>

  </head>

  <body>

    <noscript>

      <strong>We're sorry but local doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>

    </noscript>

    <div id="app"></div>

    <!-- built files will be auto injected -->

  </body>

</html>

Game and development instruction is divede into sections about project development, gaming, planned features and changelog.

README.md


# snake_js

Snake game is written in javascript using objects. 



## Project setup

```
npm install
```

### Compiles and hot-reloads for development
```
npm run serve
```

### Compiles and minifies for production
```
npm run build
```

### Run your tests
```
npm run test
```

### Lints and fixes files
```
npm run lint
```

### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).


# Game

To game run

    firefox localhost:8080
    
Press space to start and use arrows to control snake first snake or `WSAD` to control the second one. 

[![Zrzut_ekranu_z_2018-02-18_04-36-10.png](https://i.imgur.com/fnkcp2e.png)](https://i.imgur.com/fnkcp2e.png)

// there is also changelog, but it is presented during the discussion of Footer.vue file.

To start run dev server we are using the command npm run dev instead of http-server from v0.2. We can see what does it means it in package.json file

package.json


{

  "name": "snake_js",

  "description": "Simple javascript snake game.",

  "version": "0.3.1",

  "repository": "git@github.com:gustawdaniel/snake_js.git",

  "author": "Daniel Gustaw <gustaw.daniel@gmail.com>",

  "license": "MIT",

  "private": true,

  "scripts": {

    "serve": "vue-cli-service serve",

    "build": "vue-cli-service build"

  },

  "dependencies": {

    "vue": "^2.5.21"

  },

  "devDependencies": {

    "@vue/cli-plugin-babel": "^3.2.2",

    "@vue/cli-service": "^3.2.3",

    "vue-template-compiler": "^2.5.21"

  }

}

Now I would like to mention about last basic static file that

was added to project - LICENCE. I decided to use the MIT Licence.

ES6 modules

Old file js/app.js is now decoupled into four files src/game/Board.js, src/game/Config.js, src/game/Game.js

and src/game/Snake.js.

Config

Config is simplified. We removed colors from this file. Time again is shorted from half second to 200 ms.

src/game/Config.js

export default {

    mapWidth: 10,

    mapHeight: 10,

    roundTime: 200

};

Snake

Snake absorbed some game methods. For example game over for single player was more connected with a state of the game. Now, game over of one snake does not break game of his competitor.

Snake gets also new class world from ES6 and real constructor. There is also footprint from the previous version (method init) but it shows the advantage of Vue - progressive approach that allows but not forces applying Vue methods of update frontend.

Last change is connected with logging. Any snake has his own array of logs. Logs were earlier only in HTML, without connection with the data model. Now logs are stored in the data model and are assigned to the snake, not to all game.

src/game/Snake.js


import config from './Config';

import Board from './Board';

import game from './Game';



export default class Snake {

    constructor(index,body,direction) {

        this.index = index;

        this.points = 0;

        this.body = body;

        this.direction = direction; // right, left, up, down,

        this.inGame = false; // check if snake goes to game area, when snake fail hi is out of game, when enter to game area hi is in game

        this.age = 0; // TODO increment snake age

        this.initialConfig = {

            body: body.slice(),

            direction: direction

        };

        this.logs = [];

    }



    init() {

        this.draw();

    }



    containsCoordinates(inspected) {

        return this.body.filter(function (part) {

            return part.x === inspected.x && part.y === inspected.y }).length

    }



    draw() {

        this.body.forEach((part) => {

            document.querySelector(`div.rect[data-x="${part.x}"][data-y="${part.y}"]`).classList.add(`snake-${this.index}`);

        })

    }



    move(direction) {

        let head = Object.assign({}, this.body[0]);

        switch (direction) {

            case "up":

                head.x = head.x -1; break;

            case "down":

                head.x = head.x + 1; break;

            case "left":

                head.y = head.y - 1; break;

            case "right":

                head.y = head.y + 1; break;

        }

        if (Board.outOfExtendedMap(head) || this.inGame && (Board.outOfMap(head) || this.containsCoordinates(head))) {

            this.gameOver();

        } else {

            if(!this.inGame && !Board.outOfMap(head)) { this.inGame = true; }



            this.body.unshift(head);

            document.querySelector(`div.rect[data-x="${head.x}"][data-y="${head.y}"]`).classList.add(`snake-${this.index}`);

            if (!this.eatApple()) {

                let mapCoordinates = this.body.pop();

                document.querySelector(`div.rect[data-x="${mapCoordinates.x}"][data-y="${mapCoordinates.y}"]`)

                    .classList.remove(`snake-${this.index}`);

            }

        }

    }



    eatApple() {

        if(game.map.apples.filter((part) => {

            return part.x === this.body[0].x && part.y === this.body[0].y }).length

        ) {



            this.points ++;

            game.map.removeApple(this.body[0]);

            game.map.addApple();

            return true;

        }

    }



    gameOver() {

        game.map.clearPositions(this.body);

        this.logResult();

        this.age = 0;

        this.points = 0;

        this.inGame = false;

        this.body = this.initialConfig.body.slice(); // fastest way of cloning array https://stackoverflow.com/questions/3978492/javascript-fastest-way-to-duplicate-an-array-slice-vs-for-loop

        this.direction =  this.initialConfig.direction;



        this.body.forEach(el => document.querySelector(`div.rect[data-x="${el.x}"][data-y="${el.y}"]`).classList.add(`snake-${this.index}`));

    }



    logResult() {



        if(this.inGame) {

            this.logs.unshift({

                now: performance.now().toFixed(2),

                points: this.points,

                age: this.age,

                counter: game.counter

            });

        }

    }

};

Board

Board lost some of his responsibility. For example, displaying apples are totally out of this code. In Board object, we only adding apples or removing them. For communication with the layer of view, there is responsible Vue.

But because of two snakes will play together map changed shape. Now it is divided into game area 10x10

and on the left area of spawn first snake, finally on the right area of spawn second snake.

src/game/Board.js


import config from './Config';

import game from './Game';



export default class Board {

    constructor() {

        this.width = config.mapWidth;

        this.height = config.mapHeight;

        this.apples = [];

    }



    addApple() {

        let apple = {

            x: Math.floor(Math.random() * this.width),

            y: Math.floor(Math.random() * this.height)

        };

       if(game.snakes[0].containsCoordinates(apple) || game.snakes[1].containsCoordinates(apple)) { // apple is on snake  then repeat

           this.addApple();

       } else {

           this.apples.push(apple);

       }

    }



    removeApple(toRemove) {



        this.apples = this.apples.filter((apple) => {

            return apple.x !== toRemove.x && apple.y !== toRemove.y

        });

    }



    static outOfMap(inspected) {

        return inspected.x < 0 || inspected.x >= config.mapWidth

            || inspected.y < 0 || inspected.y >= config.mapHeight;

    }



    static outOfExtendedMap(inspected) {

        return inspected.x < 0 || inspected.x >= config.mapWidth

            || inspected.y < 0-3 || inspected.y >= config.mapHeight+3;

    }



    clearPositions(positions) {

        positions.forEach(position => {

            const el = document.querySelector(`div.rect[data-x="${position.x}"][data-y="${position.y}"]`);

            el.classList.remove('snake-0');

            el.classList.remove('snake-1');

        });

    }



    init() {

        console.log(game.snakes[0]);

        game.snakes[0].init();

        game.snakes[1].init();

        this.addApple()

    }

}

Game

The game object is extremely simplified. All logic connected with event handling is delegated to Vue component. Game over is placed in Snake instances. In this case, all program always use one instance of the game so we do not need use new keyword. The game defines two snakes and gives then in constructor initial parameters.


import Snake from './Snake';

import Board from './Board';



export default {

    counter: 0,

    timeout: undefined,

    snakes: [

        new Snake(0,[{x:9,y:-3}],"up"), // ,{x:8,y:-3},{x:7,y:-3}

        new Snake(1,[{x:0,y:12}],"down") // ,{x:1,y:12},{x:2,y:12}

    ],

    map: new Board(),

    state: "paused",

    run: function () {

       this.snakes[0].move(this.snakes[0].direction);

       this.snakes[1].move(this.snakes[1].direction);

    },

    init: function () {

        this.reset();

    },

    reset: function () {

        this.counter = 0;

        this.state = 'paused';

        this.map.init();

    }

};

Vue

Now there is an opportunity to present the role of Vue framework in this project. The entry point for the project is selected as cat src/main.js so I will start from this file

cat src/main.js


import Vue from 'vue'

import App from './App.vue'



new Vue({

    el: '#app',

    render: h => h(App)

});

If you remember package.json Vue is the only dependency in a production environment. We import them and use

to create new Vue instance connected with #app element from index.html and we see that there is rendered component App

src/App.vue
<template>

    <div id="app">

        <Header></Header>

        <Main></Main>

        <Footer></Footer>

    </div>

</template>

<script>

    import Footer from './components/Footer.vue';
    import Header from './components/Header.vue';
    import Main from './components/Main.vue';

    export default {
        name: 'app',
        components: {
            Header, Main, Footer
        }
    }

</script>

This component only assembly components Header, Main and Footer and place them in one view.

The header is dedicated to the inventor of this project

src/components/Header.vue


<template>



    <header>

        <h1>I love Sylwia <3</h1>

        <p>To start or pause press space</p>

    </header>



</template>



<script>



    export default {

        name: "Header"

    }



</script>

The footer contains change log and ideas to introduce in the future.

src/components/Footer.vue

<template>

    <footer>

        <hr>

        <h4>Future (proposed)</h4>

        <ol>

            <li><strong>Add network gaming</strong></li>

            <li>Use sass instead of css</li>

            <li>Add CI</li>

            <li>Add bad apples</li>

            <li>Add tests</li>

            <li>Add user account</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>Add sounds</li>

            <li>Make it mobile friendly (how to swipe when we have two snakes?)</li>

            <li>Fix bug connected with appearing simultaneously many apples (probably fixed)</li>

        </ol>

        <h4>Change Log</h4>

        <h5>v0.3</h5>

        <ol>

            <li style="text-decoration: line-through">Add webpack</li>

            <li style="text-decoration: line-through">Create snake as module</li>

            <li style="text-decoration: line-through">Add two players</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>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>

    </footer>

</template>



<script>

    export default {

        name: 'Footer'

    }

</script>

So most interesting is Main. Main again contains three children but has also some logic. When main is

mounted there is executed method game.init(), when is created event listeners are added. Now for 9, not

5 buttons. Pause is still allowed.

src/components/Main.vue


<template>

    <main>

        <State></State>

        <Board></Board>

        <Results></Results>

    </main>

</template>



<script>



    import State from './main/State.vue'

    import Board from './main/Board.vue'

    import Results from './main/Results.vue'

    import Event from '../Event';



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

    import config from '../game/Config';



    export default {

        name: "Main",

        data() {

            return {

                game

            }

        },

        mounted() {

            game.init();

        },

        components: {

            State, Board, Results

        },

        created() {

            window.addEventListener('keydown', (e) => {

                console.log({key: e.key, code: e.keyCode});

                switch (e.key) {

                    case "ArrowUp":

                        game.snakes[0].direction = game.snakes[0].direction === "down" || game.state === "paused" ? game.snakes[0].direction : "up"; break;

                    case "ArrowDown":

                        game.snakes[0].direction = game.snakes[0].direction === "up" || game.state === "paused" ? game.snakes[0].direction : "down"; break;

                    case "ArrowLeft":

                        game.snakes[0].direction = game.snakes[0].direction === "right" || game.state === "paused" ? game.snakes[0].direction : "left"; break;

                    case "ArrowRight":

                        game.snakes[0].direction = game.snakes[0].direction === "left" || game.state === "paused" ? game.snakes[0].direction : "right"; break;

                    case "w":

                        game.snakes[1].direction = game.snakes[1].direction === "down" || game.state === "paused" ? game.snakes[1].direction : "up"; break;

                    case "s":

                        game.snakes[1].direction = game.snakes[1].direction === "up" || game.state === "paused" ? game.snakes[1].direction : "down"; break;

                    case "a":

                        game.snakes[1].direction = game.snakes[1].direction === "right" || game.state === "paused" ? game.snakes[1].direction : "left"; break;

                    case "d":

                        game.snakes[1].direction = game.snakes[1].direction === "left" || game.state === "paused" ? game.snakes[1].direction : "right"; break;

                    case " ":

                        if(game.state === 'paused') {

                            game.state = 'active';

                            game.timeout = game.timeout || setInterval(() => {

                                game.counter ++;

                                game.snakes.forEach(s => s.age++);

                                game.run();

                            },config.roundTime);

                        } else {

                            game.state = 'paused';

                            clearInterval(game.timeout);

                            game.timeout = undefined;

                        }

                }

                if([" ", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].indexOf(e.key) > -1) {

                    e.preventDefault();

                }

            });

        }

    };





</script>

There is also one interesting element Event - an instance of vue used to proxy events between poorly related

Vue components.

src/Event.js

import Vue from 'vue';



export default Event = new Vue();

Come back to Main and look into his children. Let's start from State. Now the state is directly bound into view and updated on any change of game object - practically any turn.

src/components/main/State.vue

<template>

    <div class="info center">

        <span>{{game.snakes[0].points}}<br>{{game.snakes[0].age}}</span>

        <span>{{state}}<br>{{game.counter}}</span>

        <span>{{game.snakes[1].points}}<br>{{game.snakes[1].age}}</span>

    </div>

</template>



<script>

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



    export default {

        name: "State",

        data() {

            return {

                game

            }

        },

        computed: {

            state() {

                return game.state.toUpperCase();

            }

        }

    }

</script>

Board is more complicated. We create a double loop to create .rect divs. We use :ref property to prevent

of searching these elements any time when changes are done.

src/components/main/Board.vue
<template>
    <div id="map" v-if="show">
        <div v-for="i in range('rows')" class="row">
            <div v-for="j in range('cols')" class="rect" :class="isOutMap(j)" :data-x="i" :data-y="j" :ref="cordsToIndex(i,j)"></div>
        </div>
    </div>
</template>

<script>

    import Event from '../../Event';
    import game from '../../game/Game';

    export default {
        name: "Board",
        data() {
            return { show:true, game: game }
        },
        computed: {
            apples() {
                return this.game.map.apples;
            }
        },
        methods: {
            indexToCords(index) {
                return { x: index.splice("_")[0], y: index.splice("_")[1] };
            },
            cordsToIndex(i, j) {
                return `${i}_${j}`;
            },
            isOutMap(j) {
                return j<0 || j>=10 ? "out-map" : "";
            },
            range(direction) {
                if(direction === 'rows') {
                    return (new Array(10)).fill(1).map((e, i)=>{return i})
                } else if(direction === 'cols') {
                    return (new Array(10+6)).fill(1).map((e, i)=>{return i-3})
                } else {
                    throw new Error("not known direction, possible: rows and cols");
                }
            },
            rerender(){
                this.show = false;
                this.$nextTick(() => {
                    this.show = true;
                    console.log('re-render start');
                    this.$nextTick(() => {
                        console.log('re-render end')
                    })
                })
            }
        },
        created: function () {
            Event.$on("reset_map", () => {
                this.rerender();
            });
        },
        watch: {
            apples: function(n, o) {
                o.forEach(a => this.$refs[this.cordsToIndex(a.x,a.y)][0].classList.remove('apple'));
                n.forEach(a => this.$refs[this.cordsToIndex(a.x,a.y)][0].classList.add('apple'));
            }
        }
    }
</script>

Finally last component - Results.vue that presents historical results of players and make bold best scores of player.

src/components/main/Results.vue

<template>



    <div class="logs">

        <div v-for="list in logs" class="history">

            <table>

                <thead>

                    <tr><th>Age</th><th>Counter</th><th>Points</th><th>Time</th><th>Age/Points</th></tr>

                </thead>

                <tbody>

                    <tr v-for="log in list" :class="best(list,log.points)">

                        <td>{{log.age}}</td>

                        <td>{{log.counter}}</td>

                        <td class="points">{{log.points}}</td>

                        <td>{{log.now}}</td>

                        <td v-text="(log.age / log.points).toFixed(2)"></td>

                    </tr>

                </tbody>

            </table>

        </div>

    </div>

</template>



<script>

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



    export default {

        name: "Results",

        data() {

            return {

                logs: game.snakes.map(s => s.logs)

            }

        },

        methods: {

            best(list, points) {

                console.log("LIST",list, points);

                return Math.max(...(list.map(l => l.points))) === points ? "best" : ""

            }

        }

    }

</script>

There are presented all files from src directory. It is time to mention the building process. You can create an application to deploy thanks to command:


npm run build

Happy eating apples. If do you think any feature out of this list

  1. Add network gaming
  2. Use sass instead of css
  3. Add CI
  4. Add bad apples
  5. Add tests
  6. Add user account
  7. Special color of head
  8. Fix bug connected with changes direction many time in one round that allow bump int snake with length 3
  9. Add login by google
  10. Add sounds
  11. Make it mobile friendly (how to swipe when we have two snakes?)
  12. Fix bug connected with appearing simultaneously many apples (probably fixed)

would be nice, please don't hesitate and add comment or issue in official repository :D