HTML5 Snake source code walkthrough

05 Mar 2013 | Tags: , ,

Thank you HackerNews for the front-page love!


Last month I made my first game: html5-lightcycles. It is a Tron inspired lightcycle game, a humble recreation of the 1980s arcade classic. I wrote a blog post about the experience, you can check it out here. The whole truth behind the lightcycle game includes the tidbit that when I first started playing around with the creating that game, snake was my original intention.

For those that don't know it, snake is one of the oldest video games, with origins in the 1970s. Nokia put it on cell phones in 1998, and it became a staple of cell phone gaming. The concept is simple: you control a snake that grows when it consumes food, and dies when it hits a wall or itself. Some variants include obstacles, increased speed, and other mechanisms to increase difficulty.

Moving a box across the canvas was easy, and turning off the screen reset gave me lightcycles for free. Getting the snake to turn corners was not as easy as lightcycles, so I abandoned snake and moved on, eventually creating several proof of concept game-like creations. I thought about snake again, and it hit me: the snake is an array, and shift()ing and push()ing coordinates would let it move properly, and when it got food, it could just skip the shift(). The rest just came together.

The Game

If you are visiting this page in an HTML5 capable browser, and are not on a mobile device, then this game should work for you.

You can also view/play the game (in 640x480 mode) directly on jsfiddle.

The Source Code

For those that are interested, here is a quick overview of the code for this game.

First, we need a basic page setup with a canvas tag.

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>HTML5 snake - Canvas Snake Game</title>
<!--[if IE]>
<script src="http://html5shiv.googlecode.com/svn/trunk/html5.js"></script>
<![endif]-->
<link rel="stylesheet" href="page.css" type="text/css">
</head>
<body>

<h1>html5-snake</h1>
<p><a href="http://en.wikipedia.org/wiki/HTML5">HTML5</a> variation of the classic <a href="http://en.wikipedia.org/wiki/Snake_(video_game)">snake game</a>.</p>

<div>
  <canvas id="the-game" width="320" height="240">
</div>

<p>Control snake with arrow keys, WASD, or HJKL (vim keys).</p>

<p>New food may appear under snake, uncoil to reveal.</p>

<p>Collect the food to grow and increase speed.</p>

<p>&copy; 2013 - <a href="http://JDStraughan.com">JDStraughan.com</a> - <a href="https://github.com/JDStraughan/html5-snake">Source on GitHub</a></p>  
<script type="text/javascript" src="game.js"></script>
</body>
</html>

Nothing special here, just a standard page, canvas element, and some basic instructions and description of game play.

article, aside, details, figcaption, figure, footer, header,
hgroup, menu, nav, section { 
  display: block; 
}
body {
  background-color: #CCC;
}
h1 {
  text-align: center;
}
p {
  text-align: center;
}
canvas {
  display: block;
  margin: 0 auto;
  background-color: #666;
}

Again, very simple. You could really skip all of this and just drop a canvas element on a page, but some basic styling and semantic markup goes a long way.

HTML5 canvas games are javascript, like Soylent Green is people.

In this game, I have one javascript file to rule them all: game.js.

To make the code more bearable, I have organized the file into the following groups:

  • game (object)
  • snake (object)
  • food (object)
  • some helpers/service methods
  • an event listener
  • the game loop

Well, before all of that, we do have to grab the canvas context like so:

var canvas = document.getElementById("the-game");
var context = canvas.getContext("2d");

Now that we have that out of the way, lets take a walk thru something more fun.

Game Object

The game object contains controls for score, game state, and some display.

game = {
  
  score: 0,
  fps: 8,
  over: false,
  message: null,
  
  start: function() {
    game.over = false;
    game.message = null;
    game.score = 0;
    game.fps = 8;
    snake.init();
    food.set();
  },
  
  stop: function() {
    game.over = true;
    game.message = 'GAME OVER - PRESS SPACEBAR';
  },
  
  drawBox: function(x, y, size, color) {
    context.fillStyle = color;
    context.beginPath();
    context.moveTo(x - (size / 2), y - (size / 2));
    context.lineTo(x + (size / 2), y - (size / 2));
    context.lineTo(x + (size / 2), y + (size / 2));
    context.lineTo(x - (size / 2), y + (size / 2));
    context.closePath();
    context.fill();
  },
  
  drawScore: function() {
    context.fillStyle = '#999';
    context.font = (canvas.height) + 'px Impact, sans-serif';
    context.textAlign = 'center';
    context.fillText(game.score, canvas.width/2, canvas.height  * .9);
  },
  
  drawMessage: function() {
    if (game.message !== null) {
      context.fillStyle = '#00F';
      context.strokeStyle = '#FFF';
      context.font = (canvas.height / 10) + 'px Impact';
      context.textAlign = 'center';
      context.fillText(game.message, canvas.width/2, canvas.height/2);
      context.strokeText(game.message, canvas.width/2, canvas.height/2);
    }
  },
  
  resetCanvas: function() {
    context.clearRect(0, 0, canvas.width, canvas.height);
  }
  
};

Most of the property names should be self explanatory:

  • game.score keeps the current score of the game, initially 0
  • game.fps stores the frames per second (fps) the game is running at. This increases with difficulty.
  • game.over starts as true. It just sounds right when checking for game state.
  • game.message holds any messages to display to the user, for now there is only the "game over" message.

The game.start() method is pretty straightforward: it sets the game state via game.over = false and clears the game.message and game.score, resets the fps, and then runs an init() method on the snake object. We'll see what that does in a second, but we can assume it initializes the snake "game piece". The same goes for food.set().

The game.stop() method sets the game state to over and displays a friendly message explaining how to start a new game.

The next three methods draw things for the game: a box, a score, and a message. The box method is used to draw both food and snake parts, as a snake is just a line of boxes. The game.drawBox() method takes an x and y coordinate, a size, and a color. With these parameters, it can go to the x,y coordinates specified, and draw a box size high and wide, and fill it with the color desired.

drawScore() is a bit different, as it displays the score almost full hight of the canvas, with a color very close to the background color. This allows the score to be shown without having to sacrifice game board real-estate for the sole purpose of the score.

Our game.drawMessage() method also is nothing special, it checks if a game.message exists, and puts in on the canvas if so.

The last method in the game object is resetCanvas(). This clears the canvas in between draws to allow the score and messages to be displayed properly, and wiped when needed.

The game object sets up a lot of the underpinning for our game, but the real challenge for me, and the coolest part of this game in my opinion, is the snake object.

Snake Object

While it's no Mario, our snake is our hero.

snake = {
  
  size: canvas.width / 40,
  x: null,
  y: null,
  color: '#0F0',
  direction: 'left',
  sections: [],
  
  init: function() {
    snake.sections = [];
    snake.direction = 'left';
    snake.x = canvas.width / 2 + snake.size / 2;
    snake.y = canvas.height /2 + snake.size / 2;
    for (i = snake.x + (5 * snake.size); i >= snake.x; i-=snake.size) {
      snake.sections.push(i + ',' + snake.y); 
    }
  },
  
  move: function() {
    switch(snake.direction) {
      case 'up':
        snake.y-=snake.size;
        break;
      case 'down':
        snake.y+=snake.size;
        break;
      case 'left':
        snake.x-=snake.size;
        break;
      case 'right':
        snake.x+=snake.size;
        break;
    }
    snake.checkCollision();
    snake.checkGrowth();
    snake.sections.push(snake.x + ',' + snake.y);
  },
  
  draw: function() {
    for (i = 0; i < snake.sections.length; i++) {
      snake.drawSection(snake.sections[i].split(','));
    }    
  },
  
  drawSection: function(section) {
    game.drawBox(parseInt(section[0]), parseInt(section[1]), snake.size, snake.color);
  },
  
  checkCollision: function() {
    if (snake.isCollision(snake.x, snake.y) === true) {
      game.stop();
    }
  },
  
  isCollision: function(x, y) {
    if (x < snake.size/2 ||
        x > canvas.width ||
        y < snake.size/2 ||
        y > canvas.height ||
        snake.sections.indexOf(x+','+y) >= 0) {
      return true;
    }
  },
  
  checkGrowth: function() {
    if (snake.x == food.x && snake.y == food.y) {
      game.score++;
      if (game.score % 5 == 0 && game.fps < 60) {
        game.fps++;
      }
      food.set();
    } else {
      snake.sections.shift();
    }
  }
  
};

This object is significantly larger than the last one, and has a lot more coolness, also. The first thing the class does is initiate the snake.size property, and we notice it is dynamic: size: canvas.width / 40.

This game was designed to run on either a 320x240 or 640x480 canvas. This means our snake.size is directly proportional to our canvas, and at our predetermined canvas sizes, we'd end up with the following section sizes:

320 / 40 = 8

640 / 40 = 16

Note that here size refers to each section of the snake, so on a 320x240 canvas, our snake will be made up of a bunch of 8x8 pixel squares. Next we initialize the x and y coordinates for the snake, and set the default starting direction. Lastly, and most importantly, is the snake.sections array.

Unlike many game pieces, our snake does not just move, it crawls. As its head moves forward, its body continues to follow the same path, and turning a corner can take several "moves". To accomplish this, our snake is make up of an array of coordinates, and we will shift() and push() coordinates off and on to the array to update it's position.

Creating a new snake has a few parts, so there is an init() method to take care of getting it constructed. This method starts by ensuring the sections array is cleared and the starting direction is reset. It then places the snake in the middle of the game board, and again avoids hard-coded positions in favor of allowing for multiple canvas sizes.

snake.x = canvas.width / 2 + snake.size / 2;
snake.y = canvas.height /2 + snake.size / 2;

We need to remember that when game.drawBox() creates each section, it begins from the x,y coordinates, then creates a box based on the height and width with the x,y in the center. This means that a box will have a center that is 1/2 the width and 1/2 the height. To abide by these conditions, our snake is positioned accordingly.

Lastly, our init() method builds our starting snake:

for (i = snake.x + (5 * snake.size); i >= snake.x; i-=snake.size) {
  snake.sections.push(i + ',' + snake.y); 
}

This creates 5 array elements, each containing an x,y coordinate that is offset by the snake.size. Note that we are beginning the array with the tail, and ending it with the head. This will make shift() remove the tail, and we can use push() to advance the head position. The snake.x and snake.y coordinates always refer to the head position.

Next we have the snake.move() method. This checks the snake.direction, which is updated later in the script, and then manipulates the head of the snake accordingly. Upward movement requires, the y coordinate to decrease, down increases, left decreases the x coordinate, and inversely, right increases it. Notice the unit added or removed is snake.size, since the snake sections sizes are larger than 1px we need to move the head by the size of the sections.

After updating the head position, snake.move() then invokes snake.checkCollision(). We'll cover this method in a minute, just know it determines if the head as collided with the outside of the game area, or with an existing section of the snake. Either of these results in the game being stopped.

Next our move() method continues on to the snake.checkGrowth() method, which will allow the snake to grow if it is on the same position as a piece of food. Like snake.checkCollision(), we'll cover the internals of this function in more detail shortly.

The last thing move() does is push() the new snake.x and snake.y coordinates onto the snake.sections array.

Our next method, snake.draw(), iterates through the snake.sections array and then splits the values into arrays with the x and y coordinates, and passes them along to the next method in the class: snake.drawSection().

Here we circle back to the game.drawBox() function, and pass the x, y, size, and color of our snake. In turn, each section is drawn, and our array can be represented as a series of boxes on our canvas.

Moving on through the snake object, we see the checkCollision() and isCollision() functions. The first, checkCollision(), is really just a helper that calls isCollision() on our snake's coordinates, and stops the game if there are any collisions found.

Taking a look at snake.isCollision(), we find that determining if our snake if out of bounds or being cannibalistic is not very difficult:

  ...
  isCollision: function(x, y) {
    if (x < snake.size/2 ||
        x > canvas.width ||
        y < snake.size/2 ||
        y > canvas.height ||
        snake.sections.indexOf(x+','+y) >= 0) {
      return true;
    }
  },
  ...

The first four tests determine if the head of our snake has moved to a position that is outside the boundaries of our canvas. Again we are reminded to check for half of the size of the snake when looking for boundaries. If all those checks pass, we then see if the snake has attempted to eat itself by seeing if the coordinates exist in the snake.sections array.

The last method in our snake class is there to see if our snake needs to grow.

  ...
  checkGrowth: function() {
    if (snake.x == food.x && snake.y == food.y) {
      game.score++;
      if (game.score % 5 == 0 && game.fps < 60) {
        game.fps++;
      }
      food.set();
    } else {
      snake.sections.shift();
    }
  }
  ...

I could have probably named this method better, and future versions may see this refactored out into some more granular functions for readability and maintainability. For now, we'll dissect it as it is. The method starts off by determining if the head position of the snake is the same as the position of the food, the proverbial mushroom for our hero snake. If the head is on a food piece the function increases the game.score, calls food.set, and if the score is divisible by 5 it increases the the game speed by 1 FPS. If the snake is not on a food piece, then it shifts() off the tail section. Notice if the snake eats food, the shift() never happens. This is how our snake grows.

Food object

Our food object is short and sweet.

food = {
  
  size: null,
  x: null,
  y: null,
  color: '#0FF',
  
  set: function() {
    food.size = snake.size;
    food.x = (Math.ceil(Math.random() * 10) * snake.size * 4) - snake.size / 2;
    food.y = (Math.ceil(Math.random() * 10) * snake.size * 3) - snake.size / 2;
  },
  
  draw: function() {
    game.drawBox(food.x, food.y, food.size, food.color);
  }
  
};

Like the snake object, food must have a size, x, y, and color. The real magic in this object is in the set method.

When food.set is called, the first thing that happens is the size of the food is set to match the size of the snake. Next, we set this x and y coordinates for randomized food placement. Because our snake really moves around in a grid that is determined by the width of each section, not every pixel on the canvas is accessible by the center of the snake's head. To place food in a position where can be consumed, some care must be given.

food.x = (Math.ceil(Math.random() * 10) * snake.size * 4) - snake.size / 2;
food.y = (Math.ceil(Math.random() * 10) * snake.size * 3) - snake.size / 2;

We can determine that Math.ceil(Math.random() * 10 will return an integer between 1 and 10, and we can multiply that by snake.size and the ratio it has in relation to the overall canvas. To offset for the center of the snake head, we'll need to subtract the width, snake.size / 2. This looks complex, but it really just finds an x,y placement that is inside the canvas and accessible to our snake.

The last method in our food class is draw(). Predictably, this simply calls the game.drawBox() method, and passes along all the food parameters.

Helpers

To make this game work, we need to be able to utilize a few helpers.

inverseDirection = {
  'up':'down',
  'left':'right',
  'right':'left',
  'down':'up'
};

keys = {
  up: [38, 75, 87],
  down: [40, 74, 83],
  left: [37, 65, 72],
  right: [39, 68, 76],
  start_game: [13, 32]
};

Object.prototype.getKey = function(value){
  for(var key in this){
    if(this[key] instanceof Array && this[key].indexOf(value) >= 0){
      return key;
    }
  }
  return null;
};

We cannot allow our snake to move the opposite direction it is currently moving, and having a quick place to lookup the inverse of a direction is helpful, so we have a inverseDirection object where this can be accomplished easily.

We want our users to be able to use multiple sets of keys to control our snake, so we have a keys object literal that defines our four directions with arrays of char codes for the respective keys. We'll be accepting WASD, arrow keys, and the vim movement keys HJKL.

The last of our helpers adds a getKey() method to the Object prototype so we can see if a key code is present in our keys object, and if so return the key of the matched value. So really all it does it return a direction based on a key's char code. This makes our life a lot easier when we need to see if a key represents a direction.

We're on our way to a complete game. All that is left is a way to listen for new input, and the infamous game loop.

Event Listener

Javascript makes it very easy to listen for new inputs from the keyboard.

addEventListener("keydown", function (e) {
    lastKey = keys.getKey(e.keyCode);
    if (['up', 'down', 'left', 'right'].indexOf(lastKey) >= 0
        && lastKey != inverseDirection[snake.direction]) {
      snake.direction = lastKey;
    } else if (['start_game'].indexOf(lastKey) >= 0 && game.over) {
      game.start();
    }
}, false);

Now when a key is pressed, our event listener will fire, and these few lines of code with execute. Here we utilize our getKey() method to see if the key pressed was a movement key, and if so we also assert that it is not the inverseDirection of our current snake.direction. If these conditions are met, the snake.direction is updated and our snake will be off on a new bearing. If the key was not a direction key, the listener checks to see if it was a spacebar or enter key, and if it is the game.start() method is called. If the key is none of these, the key-press is ignored.

That is pretty simple, and our helpers made the listener much easier to read and follow along with.

Game loop

Here were are, the final stage. We've done all the heavy lifting of creating our game environment, our player, objectives, scoring, difficulty increases and even the drawing of our assets. Now we need to pull it all together and make it run.

var requestAnimationFrame =  window.requestAnimationFrame ||
      window.webkitRequestAnimationFrame ||
      window.mozRequestAnimationFrame;

function loop() {
  if (game.over == false) {
    game.resetCanvas();
    game.drawScore();
    snake.move();
    food.draw();
    snake.draw();
    game.drawMessage();
  }
  setTimeout(function() {
    requestAnimationFrame(loop);
  }, 1000 / game.fps);
};

requestAnimationFrame(loop);

For our game loop, I am using the requestAnimationFrame method, which requires setting the appropriate, browser specific function. For a detailed look at requestAnimationFrame, I recommend consulting The Book of Mozilla.

Once the requestAnimationFrame is set, it can be called to update our canvas and will keep our refresh rate at or below 60 FPS. The requestAnimationFrame accepts a callback that is invoked no more than 60 times per second, and in our case we'll use it in a recursive loop() function.

Looking inside loop(), we see the first thing that happens a check for the games state. If the game is active (not over) then a series of instructions is executed. Let's review what each of these do:

  • game.resetCanvas() - Clears the canvas of all drawings
  • game.drawScore() - Draws the game.score into the background of our game board
  • snake.move() - Calculates the new head position of our snake
  • food.draw() - Places a piece of food on the game board
  • snake.draw() - Draws our snake by iterating through the snake.sections array
  • game.drawMessage() - Displays game messages on screen, used for GAME OVER message

By executing these functions multiple times a second, our snake is able to move, and our game becomes alive. To do this, we'll create an infinite loop.

  ...
  setTimeout(function() {
    requestAnimationFrame(loop);
  }, 1000 / game.fps);
  ...

No human can control our snake advancing 60 times per second, so to slow down animation we can throttle the requestAnimationFrame to only fire n times per second by using javascript's native setTimeout() method. Remember we initialize game.fps to 8, so setTimeout() will allow requestAnimationFrame to execute 8 times per second, and it will increase along with game.fps to make the snake move faster over time. There are other methods available for controlling game speed, but I like the way this give the snake that vintage gaming feel, with a bit of a jerking motion when as it slithers to its next meal.

The script in completed with one final line of code that sets our loop() function in motion:

requestAnimationFrame(loop);

We now have a classic game recreated in javascript, in just a couple of hundred lines of code. The source is available on GitHub for your forking pleasure. For my second complete HTML5 game, I'm pretty happy and learned a lot about the canvas element, and game development in general. I look forward to your questions and constructive criticism in the comments section below.


Previous post: My first game - HTML5 lightcycles

Next post: Homemade Ginger Ale that is so easy (even a 7 year old can do it)


comments powered by Disqus
Related Posts