Programming Snake

snake

When I was a kid I enjoyed playing snake on a Nokia phone. I wanted to implemented it myself just as a kind of challenge to find out how much time I would need. If I learn something on the way even better.

These are the corner points for this coding exercise:

  • Plain Javascript – I’m not a big fan of it but it’s easy to embed on the web
  • Usable on the desktop using keyboard and mobile using buttons
  • concentrate on the implementation. No fancy UI (at least for this blog post)

Before diving into the details here is the implemented snake game. You can control with the keyboard keys w, a, d and s.

() Points: 0

Game board

The rules of snake are widely known. The snake moves inside a fixed area and tries to eat up as many goodies as possible. Each goodie eaten brings points but on the other hand the size of the snake increases each time it eats. This makes the further game harder as there is less space to move the snake without biting itself which would result in game over.

What we first need is a box where we can draw on. In html/js we can use canvas for this purpose. So we start with a 32x32 pixel box and a solid border:

<canvas id="board" width="32" height="32" style="border: solid;"></canvas>

That's a pretty small box so we want to make it responsive to be able to adapt to any screen size:

<canvas id="board" width="32" height="32" style="border: solid; width: 50%; height: 50%"></canvas>

Notice that the canvas.width and canvas.height are different from the canvas.style.width and canvas.style.height. The first pair defines the pixel dimension of the canvas and the latter defines the style dimension. That's the basis and we can use the defined id="board" to reference the canvas in JavaScript.

Here is the final index.html (notice the included snake.js script which will contain the game logic):

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Snake game</title>
</head>
<body>
    <canvas id="board" width="32" height="32"  style="border: solid; width: 50%; height: 50%;"></canvas>
    <script src="snake.js" type="text/javascript"></script>
</body>
</html>

Game model

Having the canvas now how to draw the snake and the goodies on it? The snake is in principle a dynamic sized line and the goodie is a single dot on the 32x32 pixel sized board. But first we need to reference the game board in JS, so opening the snake.js and hook into the DOM lifecycle:

document.addEventListener("DOMContentLoaded", startGame);

function startGame() {
    console.log("Starting snake.");
}

As soon as the DOM content is loaded we get a callback to the referenced function startGame().

From there we can reference the board and get the canvas context:

var board = document.getElementById("board");
var ctx = board.getContext("2d")

Now I played around how to draw the snake. A snake is in principle a line and canvas supports to draw lines but that's not actually what I wanted because the snake needs to move. Canvas also defines the fillRect(x, y, width, height) function.

With this function it's possible, as the name suggests, to fill a single rectangle inside the canvas grid. So I played around with the goal to draw something like a snake:

ctx.fillStyle = "black";

for(let x = 10; x < 20; x++) {
    ctx.fillRect(x, 10, PXL_SIZE, PXL_SIZE);
}

That looks like a snake but it's somehow blurry. Turns out I needed to set image-rendering: pixelated; in the canvas style. Now it looks like this:

Actually the same function can now be used to place a goodie on the board, before that we change the color to red to highlight the goodie:

ctx.fillStyle = "red"
ctx.fillRect(20, 20, PXL_SIZE, PXL_SIZE);

Moving the snake

As we now have the board and a way to draw a snake and place goodies we can now think of on how to implement a moving snake. For this purpose I've introduced some abstraction that will hide all main functionality of the game inside a class called, surprise, Snake. Bur first we need some model of the snake position and the direction, this is straight-forward:

const Directions = Object.freeze({"UP": 1, "DOWN": 2, "LEFT": 3, "RIGHT": 4});

class Pos {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }

    getX() {return this.x;}
    getY() {return this.y;}
    setX(x) {this.x = x;}
    setY(y) {this.y = y;}

    equals(other) {
        return this.x === other.x && this.y === other.y;
    }

    toString() {
        return "(x, y) = " + this.x + ", " + this.y;
    }
}

I started the Snake class with jus the draw() function at the beginning. In the constructor it takes the canvas context and some snake game properties like initial snake length and the starting position.

class Snake {

    constructor(board, props) {
        this.board = board;
        this.ctx = board.getContext("2d");;
        this.props = props;

        this.length = props.initialLength;
        this.snakeBody = [];
        this.direction = Directions.RIGHT; // default direction
        

        // init the snake body
        for (var x = props.startPos.x; x > (props.startPos.x- props.initialLength); x--) {
            this.snakeBody.push(new Pos(x, props.startPos.y));
        }

    }

    draw() {
        for (var i = 0; i < this.snakeBody.length; i++) {
            this.ctx.fillStyle = "black";
            this.ctx.fillRect(this.snakeBody[i].x, this.snakeBody[i].y, PXL_SIZE, PXL_SIZE);
        }
    }

}

Inside the constructor I defined a bunch of member variables. The snakeBody, which might be the most interesting one, is just a plain array and is initialized by pushing elements based on the starting position and initial length. By adding new elements to the array we can increase the snake length dynamically.

The draw() function just renders the snake by iterating through the snake body (which holds the position) and fills the position with a black rectangle. The startGame() now looks like:

function startGame() {
    console.log("Starting snake.");

    var board = document.getElementById("board");
    var ctx = board.getContext("2d");

    let snakeProps = {
        initialLength: 10,
        startPos: new Pos(20, 10)
    };

    let snake = new Snake(ctx, snakeProps);
    snake.draw();
}

Ok, now it's time to let the snake move. When the snake hits the border the head of the snake should move in from the opposite side. But before this we need a event loop, like every other game. I will just use the window.setInterval() function for this purpose.

function startGame() {
    console.log("Starting snake.");

    var board = document.getElementById("board");

    let snakeProps = {
        initialLength: 10,
        startPos: new Pos(20, 10)
    };

    let snake = new Snake(board, snakeProps);
    snake.draw();

    window.setInterval(function() {
        snake.move();
        snake.draw();
    }, 100);
}

I know that game logic and rendering on the canvas are two separate concerns but for the sake of this coding challenge I will keep it like this. Every 100 ms we just move the snake and then draw the current position. Here's the move() function added to the Snake class.

move() {
    console.log("move");
    switch(this.direction) {
        case Directions.UP:
            var nextYPos = (this.snakeBody[0].y - 1);
            if (nextYPos < 0) { nextYPos += this.board.height; }
            this.snakeBody.unshift(new Pos(this.snakeBody[0].x, nextYPos));
            break;
        case Directions.DOWN:
            var nextYPos = (this.snakeBody[0].y + 1) % this.board.height;
            this.snakeBody.unshift(new Pos(this.snakeBody[0].x, nextYPos));
            break;
        case Directions.LEFT:
            var nextXPos = (this.snakeBody[0].x - 1);
            if (nextXPos < 0) { nextXPos += this.board.width; }
            this.snakeBody.unshift(new Pos(nextXPos, this.snakeBody[0].y));
            break;
        case Directions.RIGHT:
            var nextXPos = (this.snakeBody[0].x + 1) % this.board.width;
            this.snakeBody.unshift(new Pos(nextXPos, this.snakeBody[0].y));
            break;
    }

    this.ctx.fillStyle = "white";
    var tail = this.snakeBody.pop();
    this.ctx.fillRect(tail.x, tail.y, PXL_SIZE, PXL_SIZE);
}

Basically for every move() call I added a new entry at the beginning of the snake array and removed the last entry of the snake array. By using the modular operator it's possible to detect when the snake hits the border. Together with the draw() operation the snake now "moves" but we cannot control it yet.

To be able to control the direction of the snake we need to listen to keyboard events. In JavaScript that can be done with event listener, in our case it's the "keydown" event to listen for:

document.addEventListener("keydown", function(event) {
    console.log("Received event: "+ event.key);
    switch (event.key) {
        case "a":
            snake.setDirection(Directions.LEFT);
            break;
        case "d":
            snake.setDirection(Directions.RIGHT);
            break;
        case "w":
            snake.setDirection(Directions.UP);
            break;
        case "s":
            snake.setDirection(Directions.DOWN);
            break;
    }
});

The setDirection() function is not defined yet. Here is the implementation inside the snake class:

setDirection(direction) {
    if (this.isOppositeDirection(direction)) {
        return;
    }
    console.log("New direction: " + direction);
    this.direction = direction;
}

isOppositeDirection(targetDirection) {
    switch(this.direction) {
        case Directions.UP:
            return targetDirection === Directions.DOWN;
        case Directions.DOWN:
            return targetDirection === Directions.UP;
        case Directions.LEFT:
            return targetDirection === Directions.RIGHT;
        case Directions.RIGHT:
            return targetDirection === Directions.LEFT;
    }
    return false;
}

Also added some check to prevent to be able to switch the snake direction immediately to the opposite. Otherwise it would be possible for example to change the direction from UP to DOWN without previously going to LEFT OR RIGHT. We use the 'w', 'a', 's' and 'd' keys to control the direction.

Nach oben scrollen