Intro
I was bored and wanted to make a thing. How about snake?
Snake is a simple game that works on a grid.
- once you start, the snake is always moving
- when the snake runs into itself it dies.
- eating apples expands the snake
- apples are placed randomly
The goal of the game is to eat as many apples as possible without dying.
Game Controls
We gotta have a way for a user to change the direction our snake is facing, so they can play the game.
There are two "obvious" control layouts for this:
- WASD: Your standard controls for every pc game
- W "up/forward"
- A "left"
- S "down/backward"
- D "right"
- Arrow keys: its pretty intuitive to have these keys mapped to directions
- Up "up"
- Left "left"
- and so on...
I didn't want to force people to choose one layout, so I just decided to implement both.
Setting up these controls is brutally simple, just adding an event listener to keypresses, with a bit of logic from a switch.
let Facing = ""
// Game Controls
document.body.addEventListener("keydown", (e) => {
switch(e.code) {
case "KeyW":
case "ArrowUp":
Facing = "up"
break;
case "KeyA":
case "ArrowLeft":
Facing = "left"
break;
case "KeyD":
case "ArrowRight":
Facing = "right"
break;
case "KeyS":
case "ArrowDown":
Facing = "down"
break;
}
});
A clever way to wait until a user's first input
The game will run in "ticks", using a similar interval thing to the pointcloud code. I chose 50ms randomly.
setInterval(()=>{gameTick();gameRender()}, 50);
We don't want our game to instantly start without the user being ready. Because we initialize our Facing
variable as an empty string, we can just check if the string is empty or not.
function gameTick() {
if (Facing != "") {
// do game logic
}
}
Snake Storage
You could rack your brain for hundreds of ways to store the positions of a snake, I went with this.
We will have this array of objects I will call snakeparts for simplicity.
snake = [{x:13, y:15}, {x:14, y:15}, {x:15, y:15}]
The very last snakepart in the array is the "head", every gametick we will add a snakepart after this head, offsetting it from the prior head given the direction the snake is facing.
let oldHead = snake[snake.length-1]
switch (Facing) {
case "up":
snake.push({x:mod(oldHead.x, GameSize), y:mod(oldHead.y-1,GameSize)})
break;
case "left":
snake.push({x:mod(oldHead.x-1,GameSize), y:mod(oldHead.y, GameSize)})
break;
case "right":
snake.push({x:mod(oldHead.x+1,GameSize), y:mod(oldHead.y, GameSize)})
break;
case "down":
snake.push({x:mod(oldHead.x, GameSize), y:mod(oldHead.y+1,GameSize)})
break;
}
We use mod here for the exact same reason we used mod to let the points wrap around the canvas in the pointcloud.
const mod = (num, val) => ((num % val) + val) % val;
The very first snakepart is the "tail", every gametick we will remove it from the array if the snake hasn't eaten.
if (__Snake_Ate_Apple__) {
// put apple in new position
} else {
// remove the snake's tail
snake.splice(0,1);
}
Apple Logic
let apple = {x:rand(GameSize), y:rand(GameSize)}
Our apple will be stored in a single object, identical in formatting to a single snakepart.
Our random here is different from the one we used in PointCloud because we are working on a small grid, so want integers, and we will also always have a min of 0
const rand = (max) => parseInt(Math.random() * max); // random int between 0 and max
Checking if our snake has "eaten" the apple is super simple, we just look to see if the head is in the same position as it.
let headPos = snake[snake.length-1]
// check if successfully ate apple
if (headPos.x == apple.x && headPos.y == apple.y) {
// new apple
} else {
// remove the snake's tail
snake.splice(0,1);
}
After we have eaten the apple, we need a new apple. This is a bit complex.
While we could just say a new random location, like this:
if (headPos.x == apple.x && headPos.y == apple.y) {
// new apple
apple = {x:rand(GameSize), y:rand(GameSize)}
} else {
This new location could be covered by a part of the snake's body, leading to confusion.
To fix this, we use the following logic
- Make a new apple at a random location
- Check if any part of the snake is overlapping
- If it's overlapping, try a new location
let invalidApplePos = true;
while (invalidApplePos) {
invalidApplePos = false
apple = {x:rand(GameSize), y:rand(GameSize)}
// make sure snake isn't overlapping apple
for (let i = 0; i < snake.length; i++) {
if (snake[i].x == apple.x && snake[i].y == apple.y) {
invalidApplePos = true
}
}
}
The general "win" condition for snake is when you have covered the whole board.
This is great, because it stops the user from playing anymore once they have won because the while loop never terminates
Snake Death
The last core part of the game we need to implement is a way for our snake to "die"
We can just add a simple iterator for this after our apple logic.
for (let i = 0; i < snake.length-1; i++) {
if (headPos.x == snake[i].x && headPos.y == snake[i].y) {
// die
}
}
But what happens when we die?
Game restarts
Instead of initializing all of our game variables with an initial value, we can do this:
let Facing
let snake
let apple
function setGameVars() {
Facing = ""
snake = [{x:GameSize/2-1, y:GameSize/2}]
apple = {x:rand(GameSize), y:rand(GameSize)}
}
setGameVars()
With this, when we want our game to end, we can call setGameVars()
to reset everything.
Putting the game logic together
With everything we have gone over, we have our game!
// Math Functions
const rand = (max) => parseInt(Math.random() * max); // random int between 0 and max
const mod = (num, val) => ((num % val) + val) % val; // modulus operator, what % should be.
// Game Variables
const GameSize = 30
let Facing
let snake
let apple
function setGameVars() {
Facing = ""
snake = [{x:GameSize/2-1, y:GameSize/2}]
apple = {x:rand(GameSize), y:rand(GameSize)}
}
setGameVars()
// Game Controls
document.body.addEventListener("keydown", (e) => {
switch(e.code) {
case "KeyW":
case "ArrowUp":
if (Facing != "down")
Facing = "up"
break;
case "KeyA":
case "ArrowLeft":
if (Facing != "right")
Facing = "left"
break;
case "KeyD":
case "ArrowRight":
if (Facing != "left")
Facing = "right"
break;
case "KeyS":
case "ArrowDown":
if (Facing != "up")
Facing = "down"
break;
}
});
function gameTick() {
if (Facing != "") {
let oldHeadPos = snake[snake.length-1]
switch (Facing) {
case "up":
snake.push({x:mod(oldHeadPos.x, GameSize), y:mod(oldHeadPos.y-1,GameSize)})
break;
case "left":
snake.push({x:mod(oldHeadPos.x-1,GameSize), y:mod(oldHeadPos.y, GameSize)})
break;
case "right":
snake.push({x:mod(oldHeadPos.x+1,GameSize), y:mod(oldHeadPos.y, GameSize)})
break;
case "down":
snake.push({x:mod(oldHeadPos.x, GameSize), y:mod(oldHeadPos.y+1,GameSize)})
break;
}
let headPos = snake[snake.length-1]
// check if successfully ate apple
if (headPos.x == apple.x && headPos.y == apple.y) {
// put apple in new position
let invalidApplePos = true;
while (invalidApplePos) {
invalidApplePos = false
apple = {x:rand(GameSize), y:rand(GameSize)}
// make sure snake isn't overlapping apple
for (let i = 0; i < snake.length; i++) {
if (snake[i].x == apple.x && snake[i].y == apple.y) {
invalidApplePos = true
}
}
}
} else {
snake.splice(0,1);
}
// check for overlaps
for (let i = 0; i < snake.length-1; i++) {
if (headPos.x == snake[i].x && headPos.y == snake[i].y) {
// die
setGameVars()
}
}
}
}
setInterval(()=>{gameTick();gameRender()}, 50);
That is snake in just below 100 lines of javascript, but we are forgetting something.
Rendering the game
I left this part last because it was the hardest part for me. I wanted to go for a Pixel Art rendering style, with a small 30x30 pixel canvas.
Rendering this way is super duper easy and self explanatory.
We initialize our canvas the same way as we did in my pointcloud code
// Canvas Initialization
const canvas = document.getElementById('SnakeGame');
canvas.width = GameSize;
canvas.height = GameSize;
const ctx = canvas.getContext('2d');
Then in our render function, we fill the canvas black, then draw in our red apple and white snake.
function gameRender() {
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, GameSize, GameSize);
ctx.fillStyle = "red"
ctx.fillRect(apple.x,apple.y,1,1)
ctx.fillStyle = "white";
for (let i = 0; i < snake.length; i++) {
ctx.fillRect(snake[i].x, snake[i].y, 1,1)
}
}
Making our render visible
This is all super simple and easy, but there is one huge problem, or maybe it would be better to call it a very "small" problem.
Our game is only 30px by 30px, absolutely TINY.
I expected fixing this problem to be a cakewalk, with a little css, we can stretch our canvas to fit the height of the screen
#SnakeGame {
width: auto;
height: 100%;
}
Or atleast I thought it would be that easy, nope, that doesn't work.
No amount of !important
or nested div nonsense I could think of could get this game to render big.
Fixing this one thing took me nearly 4 hours of nonstop googling for documentation of resizing canvases, pixel upscaling, and Mozilla's docs on css width/height. Throughout this time, I simply refused to look up a proper tutorial on doing pixel art in javascript.
Eventually though, I cracked and searched for a prewritten solution.
I thought doing this would instantly get me to the results I needed, but no, it didn't help at all. But eventually, I got lucky and ran across this one gist.
This was it. After hours of searching, this one post solved all of my problems, instantly.
I cut his implementation down to the bare essentials, something I could just paste at the bottom of my code.
// We no longer take in an argument because we defined it in our render code.
function FitCanvas(){
// we do our calculations for X and Y inplace
let scale = {
x: (window.innerWidth) / canvas.width,
y: (window.innerHeight) / canvas.height
};
// we then say we will stretch our canvas to whichever scale is less
// this makes our square never get cut off by the edges of the window
if (scale.x < scale.y) {
scale = scale.x + ', ' + scale.x;
} else {
scale = scale.y + ', ' + scale.y;
}
// We then add a bunch of css to our canvas
// I will break this down outside of this function
canvas.setAttribute('style', canvas.getAttribute("style") + ' -ms-transform: scale(' + scale + '); -webkit-transform: scale3d(' + scale + ', 1); -moz-transform: scale(' + scale + '); -o-transform: scale(' + scale + '); transform: scale(' + scale + ');');
}
// After defining this, we call it once to fit our canvas
FitCanvas()
// And add an event listener to window size changes so our canvas always fits.
window.addEventListener('resize', function () {FitCanvas();}, false);
It turns out the world of "universal standards" in web development is made up.
There are 5 different methods for transforming a canvas.
#canvas {
-ms-transform: scale(_calculated_scale_);
-webkit-transform: scale3d(_calculated_scale_, 1);
-moz-transform: scale(_calculated_scale_);
-o-transform: scale(_calculated_scale_);
transform: scale(_calculated_scale_);
}
That wasn't all the css the fullscreenify method initially contained, I just split out the constant css into my actual css file.
#SnakeGame {
/* 5 different standards for the exact same thing */
-ms-transform-origin: center top;
-webkit-transform-origin: center top;
-moz-transform-origin: center top;
-o-transform-origin: center top;
transform-origin: center top;
/* extra css fullscreenify didn't include */
display: block;
/* Makes it not upscale like Garbage */
image-rendering: pixelated;
/* Centers the thing */
margin-left: auto;
margin-right: auto;
}
Conclusion
You can play the completed game at f53.dev/snake.
I didn't plan on making a blog for this, so I wrote this in retrospect. Given that, I don't feel too happy with how this one reads, but please let me know what you think!
I didn't go anywhere near as indepth with this one because I am now at the point where my brain just speaks javascript.
Top comments (5)
No matter what anyone says, it's hard to spend your whole life at work. It is very bad for our psyche and emotional state. We should also be able to relax, it is important for every person. I like to spend my time playing games. Mostly I play free solitaire. Try to find time at least on weekends and just distract yourself.
Very good code for the game.
Wow it's an amazing code for gamers. Can i integrate this code on my injector site.
I would recommend you to try the Stumble Guys game if you're an action game lover and wants to enjoy endless entertaining race.
Wow it's an amazing code for gamers. Can i integrate this code on my injector site.
Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more