How I Charmed the Classic ?? Snake!

How I Charmed the Classic ?? Snake!

The snake game is a classic, while I don't know how long it's been around, I know it since the Nokia dynasty's reign in the mobile industry. It's incredibly addictive, making you play it in your mind even after you've stopped.

In programming beginners' circles, the snake game is a common project to tackle after mastering the basics of a language, often following a YouTube tutorial. There are countless tutorials available, each offering a unique approach to the problem.

However, for me, it was a challenge that lingered in the back of my mind for the future. Now, I swear on the name of the gods of programming that I didn't follow any tutorials to create my version of this game. In fact, I've set this program aside for a day like this, instead of following someone else's solution on YouTube.

Enough with the swearing, let's get on with it, shall we?!

?? Pre-requisites

The programming languages and tools used are:

  • TypeScript
  • HTML5
  • CSS
  • Vite (my favorite build tool for frontend web applications)

While you might not be familiar with TypeScript, think of it as a disciplined version of JavaScript. You might see me type annotate some parameters on functions or occasionally explicitly put a return type on a function. If you have a good understanding of JavaScript, you should be able to follow along.

We have Canvas in HTML5 that lets us draw anything that is 2D or 3D on it, including images, shapes, lines, and texts. I will not go back to the fundamentals of how to draw certain shapes or lines in Canvas. I will keep this article a bit high level and talk more about the problem and my approach to solving it.

You can find the complete project here.

??? The Game Area

When we think of the Snake Game and take a look at the environment that the snake moves in and eats the fruits, we can imagine that it is a grid where the fruits appear in a random square. I got this idea from playing the snake game on Chinese feature phones with low-density screens. If you've played this game on those phones and looked closely at the fruits, you'd see they are drawn on a specific number of pixels forming a square.

A closer look at the Snake Game Area. A Grid Cell with Apple in it.

For now, we don't need to worry about the exact pixel dimensions of each square, but rather think of them as grids. You can imagine a snake approaching from any direction in a series of squares, directly perpendicular to the fruit's position, and eating it.


The snake approaching the fruit, in the grid system.

This way, it is also possible to calculate:

  • The overlap of the snake's head with the fruit's square and consider the snake ate it.

The overlap of the Snakes' head with the fruit's position.

  • The overlap of the snake's head with the rest of its body to determine if the player failed.

Overlap of Snake's head with the Snake's body

  • The extent of the snake's head out of the last and first square in the grid to calculate if the snake should teleport to the opposite side of the grid.

Snake's head going out of one grid cell and coming out in the other direction

The grid model is only a mental and a guide to render the fruit and the snake in their proper places. Therefore, we don't need to actually draw the lines. The following code exactly accomplishes that:

const _DEBUG = true;
const canv = document.querySelector('#canv') as HTMLCanvasElement;
canv.width = 1000;
canv.height = 600;
const ctx = canv.getContext('2d') as CanvasRenderingContext2D;

if (_DEBUG) {
    // Draw vertical grid lines
    for (let index = 0; index < 50; index++) {
        ctx.beginPath();
        ctx.moveTo(20 * index, 0);
        ctx.lineTo(20 * index, 600);
        ctx.stroke();
    }
    // Draw horizontal grid lines
    for (let index = 0; index < 30; index++) {
        ctx.beginPath();
        ctx.moveTo(0, 20 * index);
        ctx.lineTo(1000, 20 * index);
        ctx.stroke();
    }
}        

The condition checks if it is actually in debug mode because we don't need to see the grids if we are in actual play mode. This proved very necessary as I continued working on the project. The above two for loops draw the horizontal and vertical lines shown in the above images. The squares in a grid are each 20 pixels in height and width. 20 is a magic number in this case, therefore we can replace it with a constant

GRID_CELL_SIZE = 20        

As for how many rows of squares and columns of squares we need on the canvas, we can define them as

GRID_ROW_COUNT = 30;
GRID_COLUMN_COUNT = 50;        

Last but not least, the other magic number we are using is the size of the game area. This can be obtained by

GRID_WIDTH = GRID_ROW_COUNT * GRID_CELL_SIZE; //which gives us 600
GRID_HEIGHT = GRID_COLUMN_COUNT * GRID_CELL_SIZE; //which gives us 1000        

The above constants are set assuming we are using all the available canvas space for the game only and not to show scores, the amount of life the player has, or other elements.

?? The Fruit Positioning Problem

Next up will be drawing the fruit inside one of the cells randomly. For this, we need to generate a pseudo-random number between 0 and GRID_WIDTH for the X_POSITION of the fruit and between 0 and GRID_HEIGHT for the Y_POSITION of the fruit. So, we can define a function as follows:

function generateRandomPosition() {
    let x = Math.floor(Math.random() * GRID_WIDTH);
    let y = Math.floor(Math.random() * GRID_HEIGHT);
    return { RANDOM_X: x, RANDOM_Y: y };
}        

This function generates random integers that are points on the canvas, ensuring the fruit appears at a random position within the grid.

Now let's run the code and I noticed sometimes the Fruit is drawing at an incorrect position.

The fruit overlaps with the grid line

Even though it generates random integers that are points on the canvas correctly, the above function has a problem. The fruit might appear at any random position in the entire canvas, which contradicts our initial approach of considering the canvas as a grid of squares and positioning everything inside the grid. This problem might also occur later when we try to draw the snake. Therefore, we need to find a way to validate that the position is at the center of a single cell in the grid. If the generated random number is not divisible by GRID_CELL_SIZE, then it must be adjusted to align with the grid cells. Here's the corrected function:

function generateRandomPosition() {
    let x = Math.floor(Math.random() * GRID_WIDTH);
    if (x % GRID_CELL_SIZE !== 0) {
        x = (x - (x % GRID_CELL_SIZE)) + GRID_CELL_SIZE / 2;
    } else {
        x = (x + GRID_CELL_SIZE / 2);
    }

    let y = Math.floor(Math.random() * GRID_HEIGHT);
    if (y % GRID_CELL_SIZE !== 0) {
        y = (y - (y % GRID_CELL_SIZE)) + GRID_CELL_SIZE / 2;
    } else {
        y = (y + GRID_CELL_SIZE / 2);
    }

    return { RANDOM_X: x, RANDOM_Y: y };
}        

This code ensures that the fruit's position is exactly at the center of a given random cell in the grid. The + GRID_CELL_SIZE / 2 addition at the end of the correction resets the position to the center of the cell.

?? The Snake

The snake is comprised of three parts: the Head, the Tail, and the Body. If we want to make it visually appealing, we can focus on these distinct sections.


Snake body parts

If we decide to go with a simpler approach, the snake can be entirely constructed of just the body. One important thing to note here is that the position of the head is crucial, as the rest of the body follows the head's position.

?? Separation of Concern

To separate all of the concerns, put all the data and functions into the classes and modules they belong to. Let's create classes for what we have discussed so far:

  • SnakeGame or Game: This will encompass the Grid and the Game area, as we might later use it to render animations in the game.
  • Fruit: This class will represent the fruit.
  • Snake: As we discussed, the snake is constructed of three parts, so we can create three classes and a single Snake class to assemble them:
  • Body
  • Head
  • Tail

With this separation of concern, we can achieve as much customization as we want for each part of the game.

?? Putting it all together

The SnakeGame has a minimum of three methods:

start()        
update()        
stop()        

  • start(): Initializes all the snake properties. The initial state of the snake has a single head, body, and tail. Therefore, upon creating the object of the snake class, it will create these three objects internally by default.
  • update(): This is where all the magic happens in this class.

update(timeStamp) {
    const deltaTime = timeStamp - lastTimeStamp;
    if (deltaTime >= 1000 / FRAME_RATE) {
        ctx.clearRect(0, 0, canv.width, canv.height); // clearing entire canvas
        // updates to the snake’s movement
        lastTimeStamp = timeStamp;
    }
    requestAnimationFrame(this.update.bind(this));
}        

This condition ensures the updates happen at a consistent frame rate across devices. In addition to this, we make updates to the snake's head position, check to see if the head undergoes the three conditions above (game area section), and take the intended actions.

Note: We need to bind the context upon calling

requestAnimationFrame(this.update.bind(this));        

because, by default, the callback mechanism of

requestAnimationFrame        

binds another context to the passed function.

  • stop(): This method collects all the garbage and stops all the animation.

?? Conclusion

The overlap problem discussed above was fun to solve, and structuring the project in an object-oriented way was helpful in reimagining the problem. This approach allowed for better organization and customization, making the development process more efficient and enjoyable. By breaking down the game into distinct classes and methods, I was able to tackle each aspect of the game systematically and ensure a smooth and engaging gameplay experience.

Throughout this article, I explored the fundamentals of creating a Snake game, from understanding the game area as a grid to generating random positions for the fruit and constructing the snake with its head, body, and tail. I also emphasized the importance of separating concerns by organizing the code into classes and modules, which not only made the code more manageable but also allowed for greater flexibility and customization.

By implementing a consistent frame rate and using debug mode to visualize the grid, we ensured that the game runs smoothly across different devices. The use of constants for grid size and game area dimensions helped maintain clarity and consistency in the code.

Overall, this project was a great exercise in problem solving and applying object-oriented programming principles. It demonstrated how breaking down a complex problem into smaller, manageable parts can lead to a well-structured and functional solution. I hope you found this journey through the Snake game development process as enjoyable and insightful as I did.

Hope It Resonated!

要查看或添加评论,请登录

Dawit Kebebe的更多文章

社区洞察

其他会员也浏览了