DEV Community

F53
F53

Posted on

Basic Games in JavaScript

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;
    }
}); 
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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}]
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Apple Logic

let apple = {x:rand(GameSize), y:rand(GameSize)}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 {
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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%;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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_);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 (4)

Collapse
 
fillolins profile image
alexandr-create

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.

Collapse
 
nathalie590 profile image
NathalieJacob

Wow it's an amazing code for gamers. Can i integrate this code on my injector site.

Collapse
 
kipasguy profile image
George Beckam

I would recommend you to try the Stumble Guys game if you're an action game lover and wants to enjoy endless entertaining race.

Collapse
 
rootooner profile image
Rootooner

Very good code for the game.

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