How to make Slither.io with JavaScript: Part 2 - Snake

7/4/17

This is the second part of our tutorial series on creating Slither.io with JavaScript and Phaser! Take a look at Part 1 if you haven't already.

Look at the example and also look at the source code for this part.

Assets

Open the asset folder. This is where all of our images are. We will not use everything there in this part. We will just be using tile.png for the background, and circle.png for snake sections.

index.html

Now open index.html. Here we initialize our game from a self-executing anonymous function:

(function() {
    var game = new Phaser.Game(800, 500, Phaser.AUTO, null);

    game.state.add('Game', Game);
    game.state.start('Game');
})();

We add a state called 'Game' and pass in the Game function, then we start it.

game.js

Open up game.js in the src folder. Our Game function is defined here:

Game = function(game) {}
Game.prototype = {
	...
}

In the Game prototype, we load our assets with the preload phase:

preload: function() {
    //load assets
    this.game.load.image('circle','asset/circle.png');
	this.game.load.image('background', 'asset/tile.png');
}

In the create phase, we set world bounds, add a background, start P2 physics, and add a snake:

create: function() {
    var width = this.game.width;
    var height = this.game.height;

    this.game.world.setBounds(-width, -height, width*2, height*2);
	this.game.stage.backgroundColor = '#444';

    //add tilesprite background
    var background = this.game.add.tileSprite(-width, -height,
        this.game.world.width, this.game.world.height, 'background');

    //initialize physics and groups
    this.game.physics.startSystem(Phaser.Physics.P2JS);

    this.game.snakes = [];

    //create player
    var snake = new Snake(this.game, 'circle', 0, 0);
    this.game.camera.follow(snake.head);
}

We make the camera follow the snake's head, because we made large world bounds. When a snake object is created, it is added to the array this.game.snakes. This array just makes it convenient to access the current snakes in many places.

Finally, we plan to have an update method in our snake class, so we need to call it for all the snakes in the game from our main update loop:

update: function() {
    //update game components
    for (var i = this.game.snakes.length - 1 ; i >= 0 ; i--) {
        this.game.snakes[i].update();
    }
}

Now let's take a look at our Snake class.

snake.js

Open snake.js. We start out by taking the game object, sprite key, and position of the head as parameters, then initialize lots of variables:

Snake = function(game, spriteKey, x, y) {
    this.game = game;
    //create an array of snakes in the game object and add this snake
    if (!this.game.snakes) {
        this.game.snakes = [];
    }
    this.game.snakes.push(this);
    this.debug = false;
    this.snakeLength = 0;
    this.spriteKey = spriteKey;

    //various quantities that can be changed
    this.scale = 0.6;
    this.fastSpeed = 200;
    this.slowSpeed = 130;
    this.speed = this.slowSpeed;
    this.rotationSpeed = 40;

    //initialize groups and arrays
    this.collisionGroup = this.game.physics.p2.createCollisionGroup();
    this.sections = [];
    //the head path is an array of points that the head of the snake has
    //traveled through
    this.headPath = [];
    this.food = [];

    this.preferredDistance = 17 * this.scale;
    this.queuedSections = 0;

    this.sectionGroup = this.game.add.group();
    //add the head of the snake
    this.head = this.addSectionAtPosition(x,y);
    this.head.name = "head";
    this.head.snake = this;

    this.lastHeadPosition = new Phaser.Point(this.head.body.x, this.head.body.y);
    //add 30 sections behind the head
    this.initSections(30);

    this.onDestroyedCallbacks = [];
    this.onDestroyedContexts = [];
}

Most importantly, we add the head by calling addSectionAtPosition,

then we add 30 sections directly below the head by calling initSections. The initSections method is only meant to be called once, after the head is created. Later, we instead use the convenient method addSectionsAfterLast, so we don't have to worry about their new positions. Let's go through these methods one at a time.

Adding Sections

Using the method addSectionAtPosition, we can add a single section:

addSectionAtPosition: function(x, y) {
    //initialize a new section
    var sec = this.game.add.sprite(x, y, this.spriteKey);
    this.game.physics.p2.enable(sec, this.debug);
    sec.body.setCollisionGroup(this.collisionGroup);
    sec.body.collides([]);
    sec.body.kinematic = true;

    this.snakeLength++;
    this.sectionGroup.add(sec);
    sec.sendToBack();
    sec.scale.setTo(this.scale);

    this.sections.push(sec);

    //add a circle body to this section
    sec.body.clearShapes();
    sec.body.addCircle(sec.width*0.5);

    return sec;
}

Now we need to be able to easily add sections at the proper position behind the snake. We first use the initSections method to add sections directly behind the snake's head:

initSections: function(num) {
    //create a certain number of sections behind the head
    //only use this once
    for (var i = 1 ; i <= num ; i++) {
        var x = this.head.body.x;
        var y = this.head.body.y + i * this.preferredDistance;
        this.addSectionAtPosition(x, y);
        //add a point to the head path so that the section stays there
        this.headPath.push(new Phaser.Point(x,y));
    }

}

this.headPath is an array of points that the snake's head has passed through. At the beginning it has not passed through any points, so we add some points where we have added our initial sections. But we also need a convenient method to add new sections at the back of the snake. We will use the method addSectionsAfterLast:

addSectionsAfterLast: function(amount) {
    this.queuedSections += amount;
}

Later, we will see where we add new sections when the queuedSections property is greater than zero.

Update

Take a look at the update method. This is the method that is being called from the main update loop. In this method, we will be moving the snake forward.

We will decide where to place sections using the path of the head (the headPath array). Each time the update method is called, we add a point at the front of the array that is the new head position. We have a preferredDistance number between sections which will let us determine where to place the rest of the sections along the path. Here is a visual explaining this:

Usually sections will be placed more than two points apart within the headPath, but I did this for the sake of a clear example. Also, "L" will be a small decimal that we have to calculate each time. When we add them up, they will not fit perfectly into the preferredDistance, so we have to simply choose the point that offers the closest distance to the preffered one.

Now, let's see how we start off the update method. First we move the head, remove the last point in the headPath, and place the new head position at the front of the array:

var speed = this.speed;
this.head.body.moveForward(speed);

var point = this.headPath.pop();
point.setTo(this.head.body.x, this.head.body.y);
this.headPath.unshift(point);

Place Sections

Next, we need to place sections at suitable points along the head path:

var index = 0;
var lastIndex = null;
for (var i = 0 ; i < this.snakeLength ; i++) {

    this.sections[i].body.x = this.headPath[index].x;
    this.sections[i].body.y = this.headPath[index].y;

    //hide sections if they are at the same position
    if (lastIndex && index == lastIndex) {
        this.sections[i].alpha = 0;
    }
    else {
        this.sections[i].alpha = 1;
    }

    lastIndex = index;
    //this finds the index in the head path array that the next point
    //should be at
    index = this.findNextPointIndex(index);
}

The findNextPointIndex method that we call uses the distance formula of points to see where to place the next section, based on where the previous section was. We will look at that method in a moment.

Adjust headPath Array Size

Next in the update loop, we add a point to the headPath if the array is too short, and remove points if it is too long (because we are not reaching the last index of the array as we place sections):

//continuously adjust the size of the head path array so that we
//keep only an array of points that we need
if (index >= this.headPath.length - 1) {
    var lastPos = this.headPath[this.headPath.length - 1];
    this.headPath.push(new Phaser.Point(lastPos.x, lastPos.y));
}
else {
    this.headPath.pop();
}

onCycleComplete

Now, we want to call the method onCycleComplete each time the second section reaches where the first section (head) was at the previous call of this method:

var i = 0;
var found = false;
while (this.headPath[i].x != this.sections[1].body.x &&
this.headPath[i].y != this.sections[1].body.y) {
    if (this.headPath[i].x == this.lastHeadPosition.x &&
    this.headPath[i].y == this.lastHeadPosition.y) {
        found = true;
        break;
    }
    i++;
}
if (!found) {
    this.lastHeadPosition = new Phaser.Point(this.head.body.x, this.head.body.y);
    this.onCycleComplete();
}

But why do we need this? When we add sections at the end, we do not want to add them all at the same time. We want to add a section each time all the sections have moved forwards a substantial distance. This is why we have the queuedSections variable, and we add one of those queued sections in the onCycleComplete method:

onCycleComplete: function() {
    if (this.queuedSections > 0) {
        var lastSec = this.sections[this.sections.length - 1];
        this.addSectionAtPosition(lastSec.body.x, lastSec.body.y);
        this.queuedSections--;
    }
}

Find Best headPath Point with Distance Formula

Now let's look at the findNextPointIndex method that we were using earlier:

findNextPointIndex: function(currentIndex) {
    var pt = this.headPath[currentIndex];
    //we are trying to find a point at approximately this distance away
    //from the point before it, where the distance is the total length of
    //all the lines connecting the two points
    var prefDist = this.preferredDistance;
    var len = 0;
    var dif = len - prefDist;
    var i = currentIndex;
    var prevDif = null;
    //this loop sums the distances between points on the path of the head
    //starting from the given index of the function and continues until
    //this sum nears the preferred distance between two snake sections
    while (i+1 < this.headPath.length && (dif === null || dif < 0)) {
        //get distance between next two points
        var dist = Util.distanceFormula(
            this.headPath[i].x, this.headPath[i].y,
            this.headPath[i+1].x, this.headPath[i+1].y
        );
        len += dist;
        prevDif = dif;
        //we are trying to get the difference between the current sum and
        //the preferred distance close to zero
        dif = len - prefDist;
        i++;
    }

    //choose the index that makes the difference closer to zero
    //once the loop is complete
    if (prevDif === null || Math.abs(prevDif) > Math.abs(dif)) {
        return i;
    }
    else {
        return i-1;
    }
}

We call Util.distanceFormula to get a distance between points, then we add it to a running total. We use this total to compare to the preferred distance. We will look at our Util object shortly.

Scale

We will want to change the scale of the snake as a whole, so we will have a setScale method:

setScale: function(scale) {
    this.scale = scale;
    this.preferredDistance = 17 * this.scale;

    //scale sections and their bodies
    for (var i = 0 ; i < this.sections.length ; i++) {
        var sec = this.sections[i];
        sec.scale.setTo(this.scale);
        sec.body.data.shapes[0].radius = this.game.physics.p2.pxm(sec.width*0.5);
    }
}

We update the scale of the sprites, and of their physics bodies. Now, we can increment the snake's size like this:

incrementSize: function() {
    this.addSectionsAfterLast(1);
    this.setScale(this.scale * 1.01);
}

Destroy

Although we have nothing destroying the snake yet, we will need a destroy method to handle this for later. We will also need to handle callbacks when the snake is destroyed, because other parts of our program will want to know when a snake is destroyed later. First we can set callbacks by managing an array of them:

addDestroyedCallback: function(callback, context) {
    this.onDestroyedCallbacks.push(callback);
    this.onDestroyedContexts.push(context);
}

Then, we can destroy everything and call these callbacks like this:

destroy: function() {
    this.game.snakes.splice(this.game.snakes.indexOf(this), 1);
    this.sections.forEach(function(sec, index) {
        sec.destroy();
    });

    //call this snake's destruction callbacks
    for (var i = 0 ; i < this.onDestroyedCallbacks.length ; i++) {
        if (typeof this.onDestroyedCallbacks[i] == "function") {
            this.onDestroyedCallbacks[i].apply(
                this.onDestroyedContexts[i], [this]);
        }
    }
}

Utilities (util.js)

Finally, look at util.js. Util is just an object that has some useful functions. We have the distance formula and a random integer generator in this object:

const Util = {
    /**
     * Generate a random number within a closed range
     * @param  {Integer} min Minimum of range
     * @param  {Integer} max Maximum of range
     * @return {Integer}     random number generated
     */
    randomInt: function(min, max) {
        min = Math.ceil(min);
        max = Math.floor(max);
        return Math.floor(Math.random() * (max - min + 1)) + min;
    },
    /**
     * Calculate distance between two points
     * @param  {Number} x1 first point
     * @param  {Number} y1 first point
     * @param  {Number} x2 second point
     * @param  {Number} y2 second point
     */
    distanceFormula: function(x1, y1, x2, y2) {
        var withinRoot = Math.pow(x1-x2,2) + Math.pow(y1-y2,2);
        var dist = Math.pow(withinRoot,0.5);
        return dist;
    }
};

Conclusion

Our snake is functional, but it can't turn yet! Don't worry, that is just a matter of rotating the snake's head. We will handle those turning controls in Part 3. In the meantime, test the snake. notice that if you change the speed, it will still maintain approximately the same distance between sections. Part 3 will show you how to extend the snake to be the player's snake or a bot!

Until next time,

Loonride

Up Next

Subscribe

  • No spam
  • Update information
  • Free newsletters

Invalid Email

Subscribed

Try Again Later