Objective

The objective is to create a snake like game with the following requirements and constraints:

  • The is a 200x200 pixel game board.
  • The player starts at a random safe location on the game board.
  • Eating food increases the length of the snake.
  • The game will be written using standard javascript web apis.

Drawing on the canvas

Given that we have a root <div> node to work with we will create and attach a child <canvas> element to act as our target container for the game. https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API

var root = document.querySelector('#game');
var canvas = document.createElement('canvas');
canvas.style.border = '1px solid';
canvas.width = 200;
canvas.height = 200;
ctx = canvas.getContext('2d');
root.appendChild(canvas);

ctx.fillStyle = 'green';
ctx.fillRect(player.x, player.y, 10, 10);

The game loop

I decided to make two primary functions for this game ready and update.

  • ready - serves as a function to initialize the game and set up event listeners
  • update - the game loop. Every iteration will update the state of the game and draw the results on the canvas
var ctx;

function ready() {
 // setup canvas and initial state;
 var root = document.querySelector('#game');
 var canvas = document.createElement('canvas');
 canvas.style.border = '1px solid';
 canvas.width = 200;
 canvas.height = 200;
 ctx = canvas.getContext('2d');
 root.appendChild(canvas);

 ctx.fillStyle = 'green';
 ctx.fillRect(player.x, player.y, 10, 10);

}

function update() {
 // update state and draw
}

ready();

Drawing the player

Our first iteration will be to draw a representation of the player on the board. To keep things simple we will represent the player as a green 10x10 pixel box.

function update() {
    ctx.fillStyle = 'green';
    ctx.fillRect(10, 10, 10, 10);
}

Since we eventaully would like to be able to move the player on the game board we can create a player variable that will hold the current state of where the player is located and the vector (dx,dy) which will tell us which direction the player is traveling each frame.

var player = {
  x: 10,
  y: 10,
  dx: 10,
  dy: 0
};

var tickrate = 200;

function update() {
    ctx.fillStyle = 'green';
    ctx.fillRect(10, 10, 10, 10);

    setTimeout(function() {
    window.requestAnimationFrame(update);
    }, tickrate);
}

If we were to play the game at this point we might notice green square fly off the screen to the right. We can slow this down by waiting a given amount of time by using setTimeout before requesting another frame.

Receiving Input

Now we want the player to be able to control the green snake friend.

function ready() {
    window.onkeydown = function(e) {
        e.preventDefault();
        e.stopPropagation();

        if(e.keyCode === 37 || e.key === "ArrowLeft") {
            player.dx = -10;
            player.dy = 0;
        }

        if(e.keyCode === 39 || e.key === "ArrowRight") {
            player.dx = 10;
            player.dy = 0;
        }

        if(e.keyCode === 38 || e.key === "ArrowUp") {
            player.dx = 0;
            player.dy = -10;
        }

        if(e.keyCode === 40 || e.key === "ArrowDown") {
            player.dx = 0;
            player.dy = 10;
        }
    }
}

Collecting Items

We need an goal for the player to achieve. In this type of game it involves collecting or “eating” some food.

First we will start by just drawing something to represent the food.

var food = {
  x: 90,
  y: 90,
};

function update() {
    ctx.globalCompositeOperation = 'destination-over';
    ctx.clearRect(0, 0, 200, 200);

    player.x += player.dx;
    player.y += player.dy;

    ctx.fillStyle = 'green';
    ctx.fillRect(player.x, player.y, 10, 10);

    ctx.fillStyle = 'red';
    ctx.fillRect(food.x, food.y, 10, 10);

    setTimeout(function() {
        window.requestAnimationFrame(update);
    }, tickrate);
}

Next we should detect when the player occupies the same space to eat the food. We can accomplish this by adding some logic to detect when the player’s x and y coordinate are the same.

var food = {
  x: 90,
  y: 90,
};

function update() {
    if(player.x === food.x && player.y === food.y) {
        food.x = 40;
        food.y = 40;
        player.size += 1;
    }

    ctx.globalCompositeOperation = 'destination-over';
    ctx.clearRect(0, 0, 200, 200);

    player.x += player.dx;
    player.y += player.dy;

    ctx.fillStyle = 'green';
    ctx.fillRect(player.x, player.y, 10, 10);

    ctx.fillStyle = 'red';
    ctx.fillRect(food.x, food.y, 10, 10);

    setTimeout(function() {
        window.requestAnimationFrame(update);
    }, tickrate);
}

Keeping score

We also want to keep score and have the tail of the snake’s grow as it eats food.

First we will add another variable score

var score = 0;

We will increment this number when we eat the food by 10.

if(player.x === food.x && player.y === food.y) {
  score += 10;
}

Displaying the score is important for the player to know. We can use the fillText function on the canvas object.

ctx.font = '10px sans-serif';
ctx.fillText("score - " + score, 0, 200);

Adding Randomness

Currently we have the same start point for our food and snake. We also have a known location as to where the food will end up. Having the same expected state makes for a boring game. We can add some randomness by leveraging Math.random();

Because of the contraints in our game we should place our objects in a visible location on the board. It should also be a number divisible by 10. Doing a little bit of math, out board is 200x200 pixels, divide by 10 and we get 20. We can find a random integer between 0 and 19 (inclusive) to find a random location on the board.

First let us write our random integer function:

function getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1) + min);
}

When the game starts we should choose a safe location for the player and the first food item. We can do this in our ready function.

player.x = getRandomInt(0,19) * 10;
player.y = getRandomInt(0,19) * 10;

food.x = getRandomInt(0, 19) * 10;
food.y = getRandomInt(0, 19) * 10;

Then when the player eats the food we should call the random integer function to place the food

if(player.x === food.x && player.y === food.y) {
    food.x = getRandomInt(0, 19) * 10;
    food.y = getRandomInt(0, 19) * 10;
    // ...
}

Growing the tail

I chose to keep the track of how long to draw the tail by having an array of tail coordinates that I can push and pop off depending on the state.

First we start off with an empty array for the tail

var tail = [
];

Every frame I push the current player’s coordinates onto the tail array. This should be done before updating the player’s coordinates to make a proper trail.

tail.push({ x: player.x, y: player.y });

If the length of the tail array exceeds the player’s size then I remove the last element in the array via shift.

if(tail.length > player.size) {
  tail.shift();
}

Finally I draw all of the tail elements on the screen.

for(var i=0; i<tail.length; i++) {
    ctx.fillStyle = 'green';
    ctx.fillRect(tail[i].x, tail[i].y, 10, 10);
}

End State

In this game the end state is when the player runs into a section of its own tail.

First lets make a varible gameover to detect if the game is over.

We will check the value of the gameover flag every update tick. If the value is true then we will draw the game over screen.

if(gameover) {
    ctx.clearRect(0, 0, 200, 200);
    ctx.fillStyle = 'red';
    ctx.font = '20px sans-serif'
    ctx.fillText("GAME OVER", 38, 100);
    ctx.fillText("score - " + score, 0, 200);
    return ;
}

Before we start drawing the tail we will check to see if the head of the snake occupies the same space. We will then force a frame update so that we show the game over screen immediatley.

for(var i=0; i<tail.length; i++) {
    if(tail[i].x === player.x && tail[i].y === player.y) {
        gameover = true;
        window.requestAnimationFrame(update);
        return;
    }
}

We also need to restrict the movement of the snake head if there is a tail. We will change the controls in window.onkeydown to detect when the player attempts to move in the opposite direction without trying to “turn” first.

window.onkeydown = function(e) {
    e.preventDefault();
    e.stopPropagation();

    if(e.keyCode === 37 || e.key === "ArrowLeft") {
        if(tail.length > 0 && player.dx > 0) {
            return;
        }

        player.dx = -10;
        player.dy = 0;
    }

    if(e.keyCode === 39 || e.key === "ArrowRight") {
        if(tail.length > 0 && player.dx < 0) {
            return;
        }

        player.dx = 10;
        player.dy = 0;
    }

    if(e.keyCode === 38 || e.key === "ArrowUp") {
        if(tail.length > 0 && player.dy > 0) {
            return;
        }

        player.dx = 0;
        player.dy = -10;
    }

    if(e.keyCode === 40 || e.key === "ArrowDown") {
        if(tail.length > 0 && player.dy < 0) {
            return;
        }

        player.dx = 0;
        player.dy = 10;
    }
}

Source Code

<div id='game'></div>

<script>
var ctx;

var tickrate = 200;

var score = 0;

var player = {
  dx: 10,
  dy: 0,
  x: 0,
  y: 0,
  size: 0,
};

var tail = [
];

var food = {
  x: 90,
  y: 90,
};

var gameover = false;

function getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1) + min);
}

function ready() {
    var root = document.querySelector('#game');
    var canvas = document.createElement('canvas');
    canvas.style.border = '1px solid';
    canvas.width = 200;
    canvas.height = 200;
    ctx = canvas.getContext('2d');
    root.appendChild(canvas);

    player.x = getRandomInt(0,19) * 10;
    player.y = getRandomInt(0,19) * 10;

    food.x = getRandomInt(0,19) * 10;
    food.y = getRandomInt(0,19) * 10;

    window.onkeydown = function(e) {
        e.preventDefault();
        e.stopPropagation();

        if(e.keyCode === 37 || e.key === "ArrowLeft") {
            if(tail.length > 0 && player.dx > 0) {
                return;
            }

            player.dx = -10;
            player.dy = 0;
        }

        if(e.keyCode === 39 || e.key === "ArrowRight") {
            if(tail.length > 0 && player.dx < 0) {
                return;
            }

            player.dx = 10;
            player.dy = 0;
        }

        if(e.keyCode === 38 || e.key === "ArrowUp") {
            if(tail.length > 0 && player.dy > 0) {
                return;
            }

            player.dx = 0;
            player.dy = -10;
        }

        if(e.keyCode === 40 || e.key === "ArrowDown") {
            if(tail.length > 0 && player.dy < 0) {
                return;
            }

            player.dx = 0;
            player.dy = 10;
        }
    }

    window.requestAnimationFrame(update);
}

function update() {
    ctx.globalCompositeOperation = 'destination-over';
    ctx.clearRect(0, 0, 200, 200);

    if(gameover) {
        ctx.clearRect(0, 0, 200, 200);
        ctx.fillStyle = 'red';
        ctx.font = '20px sans-serif'
        ctx.fillText("GAME OVER", 38, 100);
        ctx.fillText("score - " + score, 0, 200);
        return ;
    }

    if(player.x === food.x && player.y === food.y) {
        food.x = getRandomInt(0, 19) * 10;
        food.y = getRandomInt(0, 19) * 10;
        score += 10;
        player.size += 1;
    }


    for(var i=0; i<tail.length; i++) {
        if(tail[i].x === player.x && tail[i].y === player.y) {
            gameover = true;
            window.requestAnimationFrame(update);
            return 
        }
    }

     tail.push({ x: player.x, y: player.y });

    if(tail.length > player.size) {
        tail.shift();
    }

    player.x += player.dx;
    player.y += player.dy;

    ctx.fillStyle = 'green';
    ctx.fillRect(player.x, player.y, 10, 10);

    for(var i=0; i<tail.length; i++) {
        ctx.fillStyle = 'green';
        ctx.fillRect(tail[i].x, tail[i].y, 10, 10);
    }

    ctx.fillStyle = 'red';
    ctx.fillRect(food.x, food.y, 10, 10);

    ctx.font = '10px sans-serif'
    ctx.fillText("score - " + score, 0, 200);

    setTimeout(function() {
        window.requestAnimationFrame(update);
    }, tickrate);
}

ready();

</script>

Resources

  • https://stackoverflow.com/questions/21996456/generate-a-random-number-which-is-divisible-by-10
  • https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation
  • https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Basic_animations