dan

dan

groovin'

Fixing some unexpected behavior

Our game is off to a great start - unless you begin the game by hitting the left arrowkey, that is. We assume the player knows which direction the snake starts in. This bug persists regardless of where the snake is in the grid. By telling the snake to move backwards, the player will immidiately die! That makes our game a little less fun.

The first part of our solution is to introduce a map of deniedKeys. We want the game to know that if the player is currently travelling to the right, he cannot move to the left, and so on.

let deniedKey = {
  "ArrowLeft": "ArrowRight",
  "ArrowRight": "ArrowLeft",
  "ArrowUp": "ArrowDown",
  "ArrowDown": "ArrowUp",
  "undefined": "ArrowLeft"
}

Now, let's change our eventListener to use this denied list, so that only allowed keys get recorded in the lastKey variable.

document.addEventListener("keydown", function (e) {
  if(e.key == "ArrowLeft" || e.key == "ArrowRight" || e.key == "ArrowUp" || e.key == "ArrowDown") {
    if(!(deniedKey[lastKey] == e.key)) {
      lastKey = e.key;
    }
  }
  else if(gameOver && e.key == " ") {
    lastKey = e.key;
  }
});

If you run the game now, you will notice that it sort of has the correct affect. The player is not able to immidiately die by pressing the left arrow anymore, but a subtle bug in the code remains. When moving to the right, try pressing up and left very quickly. Or when moving up, try pressing left and down as fast as you can, and so on. Both of these examples are valid moves. We shouldn't be getting a gameover unless the head of the snake hits another tile that the snake occupies.

The reason this bug is happening is that our keydown event listener is running out-of-band from the update function. Because the game has such a low framerate, it is possible for the player to quickly hit multiple keys before the update function is called, and that can subvert the logic we just wrote to disallow players from making the snake go backwards to accidentally get a gameover.

Buffering the keyboard controls

In order to fix this, we must buffer the input, and only update lastKey in the update function. To begin, let's create a variable to hold our bufferedKey. Then we can simplify our event listener to update the buffer whenever a key is pressed.

let bufferedKey;
 
document.addEventListener("keydown", function (e) {
  bufferedKey = e.key;
});

We still want all of the logic that was in our event listener. Let's factor it out into a new function so we can use it to update lastKey whenever we want to. Since this code is no longer living in an event listener, we will replace e.key with the bufferedKey variable.

function getKey() {
  if(bufferedKey == "ArrowLeft" || bufferedKey == "ArrowRight" || bufferedKey == "ArrowUp" || bufferedKey == "ArrowDown") {
    if(!(deniedKey[lastKey] == bufferedKey)) {
      lastKey = bufferedKey;
    }
  }
  else if(gameOver && bufferedKey == " ") {
    lastKey = bufferedKey;
  }
}

To put the finishing touch on this feature, we need to call the getKey function at the beginning of the update loop.

function update() {
  getKey();
  // ...
}

Great!

Now the game seems much more fluid. There's nothing a player hates more than an unfair game. On the flip side, there's nothing a player loves more than beating his friends. So...

Keeping score

The last feature of Snake that our game is missing is keeping score! In Snake, the player's length is usually the score, although you could get fancy and factor in the amount of time that the player has stayed alive as well.

A challenger approaches

At this point in the course, you have all of the tools that you need to implement keeping track of the score and drawing it on the screen. If you're up for a challenge, you can pause here and try to write the code yourself - you might even like your solution better than mine! My solution will be waiting for you until you're ready...

Let's begin with a distinction between the current score and the highscore. The current score will just be player.length, and the highscore will be set if the current score is greater than the highscore. We could set the highscore once gameOver == true in the update function; however, I prefer to set the highscore in the setup function. This may sound counter-intuitive, but hear me out. When the player is focused while playing, he may not realize that he has beaten the highscore. If the gameover screen shows that the current score is higher than the highscore, it's plainly obvious that the player beat the highscore. Then when a new game starts, the highscore will be updated.

let highscore = 0;
 
function setup() {
  if(player.length > highscore)
    highscore = player.length;
// ...
}

Now that we have the score and highscore, it's time to render them on the screen! Let's position the score in the top-left, and the highscore in the top-right of the screen both during the game and after the game. You can write these lines of code at the very bottom of the render function. In order to keep the score out of the player's way, we'll make it 20 pixels.

ctx.font = "20px sans serif";

Yellow will make the text stand out on both the blue grid and black gameover screen.

ctx.fillStyle = "yellow";

In order to render the the current score in the top left, we will set the textAlign to left. Putting the text at 10 pixels from the left will give it a little padding so that it's not jammed up against the left side of the canvas. I'll set the y axis origin at 20 pixels since our font is 20 pixels.

ctx.textAlign = "left";
ctx.fillText(`Score: ${player.length}`, 10, 20);

Since we want to put the highscore on the right side of the screen, let's set the textAlign to right. This means that we can conviniently set the x and y parameters of fillText to the same values that we used previously and the text layout will be mirrored on the opposite side of the screen.

ctx.textAlign = "right";
ctx.fillText(`Highscore: ${highscore}`, width - 10, 20);

Finished game

Congrats for sticking with it!

Great job creating a classic arcade-style game. If this is your first HTML5 game, I want to appreciate what an achievement you've just made! Take a look at my other tutorials here on Scipress if you want to level up your game programming skills.

Final note

The entire source code is below in case you want to copy-paste it. Here is a link to the codepen if you want to quickly hack on the completed version of this game right in your browser.

<html>
	<head>
		<title>Snake game</title>
	</head>
	<body>
		<canvas id="canvas" width="500" height="500"></canvas>
		<script src="game.js"></script>
	</body>
</html>
 
let canvas = document.getElementById("canvas");
let ctx    = canvas.getContext("2d");
 
const width       = Number(canvas.width);
const height      = Number(canvas.height);
const tileWidth   = 50;
const tileHeight  = 50;
const tilesX      = Math.round(width / tileWidth);
const tilesY      = Math.round(height / tileHeight);
const totalTiles  = tilesX * tilesY;
const borderWidth = 2;
 
let gameOver = false;
let eaten    = false;
 
let player = [];
let apple;
let highscore = 0;
 
// Keyboard controls
let lastKey;
let bufferedKey;
let deniedKey = {
  "ArrowLeft": "ArrowRight",
  "ArrowRight": "ArrowLeft",
  "ArrowUp": "ArrowDown",
  "ArrowDown": "ArrowUp",
  "undefined": "ArrowLeft"
}
 
document.addEventListener("keydown", function (e) {
  bufferedKey = e.key;
});
 
function getKey() {
  if(bufferedKey == "ArrowLeft" || bufferedKey == "ArrowRight" || bufferedKey == "ArrowUp" || bufferedKey == "ArrowDown") {
    if(!(deniedKey[lastKey] == bufferedKey)) {
      lastKey = bufferedKey;
    }
  }
  else if(gameOver && bufferedKey == " ") {
    lastKey = bufferedKey;
  }
}
 
// Setup the game - called once each time we init the game.
function setup() {
  if(player.length > highscore)
    highscore = player.length;
  
  gameOver = false;
  lastKey = undefined;
  player = [
    {
      x: 4,
      y: 5
    },
    {
      x: 3,
      y: 5
    },
    {
      x: 2,
      y: 5
    },
    {
      x: 1,
      y: 5
    }
  ];
 
  createApple();
}
 
// Update - called every frame
function update() {
  getKey();
 
  if(lastKey == undefined)
    return;
 
  if(gameOver) {
    if(lastKey == " ") {
      setup();
    }
    return;
  }
 
  // Remove the last element in the array
  if(!eaten)
    player.pop();
  else
    eaten = false;
 
  // This is a confusing line of code. We are copying the head of the snake at player[0]
  // into an empty object {} and returning it to the variable `head`.
  // If we use `head = player[0]`, we would be referencing `player[0]` instead of copying it.
  let head = Object.assign({}, player[0]);
 
  if(lastKey == "ArrowLeft")
    --head.x;
  else if(lastKey == "ArrowRight")
    ++head.x;
  else if(lastKey == "ArrowUp")
    --head.y;
  else if(lastKey == "ArrowDown")
    ++head.y;
 
  // Add the head of the snake to the beginning of the array
  player.unshift(head);
 
  for(let i = 0; i < player.length; ++i) {
    // Check if the snake has hit itself
    if(i > 0 && head.x == player[i].x && head.y == player[i].y) {
      gameOver = true;
    }
    
    // Check if the snake is out of bounds
    if(player[i].x == tilesX)
      player[i].x = 0;
    else if(player[i].x == -1)
      player[i].x = tilesX -1;
    
    if(player[i].y == tilesY) 
      player[i].y = 0;
    else if(player[i].y == -1)
      player[i].y = tilesY -1;
  }
 
  // Check if the snake hit the apple
  if(head.x == apple.x && head.y == apple.y) {
    eaten = true;
    createApple();
  }
}
 
// Render - called every frame
function render() {
  // Draw background
  ctx.fillStyle = "black";
  ctx.fillRect(0, 0, width, height);
 
  if(!gameOver) {
    // Draw grid
    for(var i = 0; i < tilesX; ++i) {
      for(var j = 0; j < tilesY; ++j) {
 
        // Set the default tile color
        ctx.fillStyle = "blue";
 
        // Detect if the player is in this tile
        for(var k = 0; k < player.length; ++k) {
          if(player[k].x == i && player[k].y == j) {
            ctx.fillStyle = "green";
            break;
          }
        }
        
        drawTile(i,j);
      }
    }
 
    // Draw apple
    ctx.fillStyle = "red";
    drawTile(apple.x, apple.y);
  }
  else {
    // Draw GameOver screen
    ctx.font = "50px serif";
    ctx.fillStyle = "red";
    ctx.textAlign = "center";
    ctx.fillText("GAME OVER", width / 2, height / 2);
    ctx.font = "25px serif";
    ctx.fillText("Press Space to Restart", width / 2, height / 2 + 50);
  }
 
  // Draw the score
  ctx.font = "20px sans serif";
  ctx.fillStyle = "yellow";
  ctx.textAlign = "left";
  ctx.fillText(`Score: ${player.length}`, 10, 20);
  ctx.textAlign = "right";
  ctx.fillText(`Highscore: ${highscore}`, width - 10, 20);
}
 
// The entry point for the game
function main() {
  update();
  render();
}
 
// Utility functions
function randomTile(max) {
  // Generate a random number between 0 and max
  let randomFloat = Math.random() * max;
 
  // Convert float (decimal) to integer
  return Math.floor(randomFloat);
}
 
function drawTile(x, y) {
  // Calculate the x, y, w, h for each tile
  x = x * tileWidth + borderWidth;
  y = y * tileHeight + borderWidth;
  let w = tileWidth - borderWidth * 2;
  let h = tileHeight - borderWidth * 2;
 
  // Draw each tile
  ctx.fillRect(x, y, w, h);
}
 
function createApple() {
  let collision;
 
  do {
    collision = false;
 
    // Choose a random tile for the Apple
    apple = {
      x: randomTile(tilesX),
      y: randomTile(tilesY)
    }
 
    // Check to see if the apple was generated on a tile the player occupies
    for(let i = 0; i < player.length; ++i) {
      if(player[i].x == apple.x && player[i].y == apple.y) {
        collision = true;
        break;
      }
    }
  } while(collision);
}
 
// Setup the game before starting the game loop
setup();
 
// This will call the function main at 10fps and start the game
setInterval(main, 1000 / 10);