6
\$\begingroup\$

This a snake game I wrote about 1 month ago. It's working well. Looking for any suggestions for my code, I want my coding improved.

jsfiddle link http://jsfiddle.net/geswhvdc/

/*snake game by Chase last edited 4/5/2018*/

/*lib*/
let getRandomInt=max=>Math.floor(Math.random() * Math.floor(max));
let getBetweenInt=(min, max)=> {
	min = Math.ceil(min);
	max = Math.floor(max);
	return Math.floor(Math.random() * (max - min + 1)) + min; 
}

let posMatch=(pos1,pos2)=>{
	if(pos1.x==pos2.x&&pos1.y==pos2.y) return true;
}

let arrayMatch=(array,item)=>{
	for(let x of array) if(x.x==item.x&&x.y==item.y) return true;
}

/*initial*/

let multiplier=10;
let speed=200;
let canvas={
	offset:{
		x:10,
		y:10
	},
	width:myCanvas.width/multiplier,
	height:myCanvas.height/multiplier,
	penColor:color=>ctx.fillStyle=color,
	fill:(x,y)=>ctx.fillRect(x*multiplier,y*multiplier,multiplier,multiplier)
}

let food={
	pos:{},
	create:()=>{
		food.pos={
			x:getRandomInt(canvas.width),
			y:getRandomInt(canvas.height)		
		}
		if(arrayMatch(snake.body,food.pos)) food.create();
		else food.draw(food.pos);
	},
	draw:({x,y})=>{
		canvas.penColor("yellow");
		canvas.fill(x,y);
	}
};

let snake={
	head:{},
	getTail:x=>x=snake.body[0],
	dir:{
		current:"",
		pre:""
	},
	grow:false,
	body:[],
	length:5,
	draw:({x,y})=>{
		canvas.penColor("white");
		snake.body.push({x,y});
		canvas.fill(x,y);
	},
	erase:({x,y})=>{
		canvas.penColor("gray");
		snake.body.splice(0,1);
		canvas.fill(x,y);		
	},
	next:{}
};

let ctx = myCanvas.getContext("2d");
let dir=["up","down","left","right"];

let move=()=>{
	let crash;
	switch(snake.dir.current){
		case "up":
			snake.next={
				x:snake.head.x,
				y:snake.head.y-1
			};
			crash=snake.next.y<0;
			snake.dir.pre="up";
			break;
		case "down":
			snake.next={
				x:snake.head.x,
				y:snake.head.y+1
			};
			crash=snake.next.y==canvas.height;		
			snake.dir.pre="down";
			break;
		case "left":
			snake.next={
				x:snake.head.x-1,
				y:snake.head.y
			};
			crash=snake.next.x<0;		
			snake.dir.pre="left";
			break;
		case "right":
			snake.next={
				x:snake.head.x+1,
				y:snake.head.y
			};
			crash=snake.next.x==canvas.width;		
			snake.dir.pre="right";
			break;
		}

	if(crash||arrayMatch(snake.body,snake.next)){
		clearInterval(intv);
		start.disabled="";
		return;
	};		

	snake.draw(snake.head=snake.next);
	if(snake.grow){
		snake.grow=false;
		score.innerText=Number(score.innerText)+1;
		food.create();
	}
	else snake.erase(snake.getTail());
}

let initCreate=()=>{
	switch(snake.dir.current){
		case "up":
			snake.draw({x:snake.head.x, y:--snake.head.y});
			break;
		case "down":
			snake.draw({x:snake.head.x, y:++snake.head.y});
			break;
		case "left":
			snake.draw({x:--snake.head.x, y:snake.head.y});
			break;
		case "right":
			snake.draw({x:++snake.head.x, y:snake.head.y});
			break;
		}
}

onkeydown=e=>{
	switch(e.keyCode){
		case 37:
			if(snake.dir.pre=="up"||snake.dir.pre=="down") snake.dir.current="left";
			break;
		case 38:
			if(snake.dir.pre=="left"||snake.dir.pre=="right") snake.dir.current="up";
			break;
		case 39:
			if(snake.dir.pre=="up"||snake.dir.pre=="down") snake.dir.current="right";
			break;
		case 40:
			if(snake.dir.pre=="left"||snake.dir.pre=="right") snake.dir.current="down";
			break;
	}
}

let intv;

start.onclick=()=>{
	start.disabled="disabled";

	canvas.penColor("gray");
	ctx.fillRect(0,0,myCanvas.width,myCanvas.height);
	snake.body=[];

	snake.head={
		x:getBetweenInt(canvas.offset.x,canvas.width-canvas.offset.x),
		y:getBetweenInt(canvas.offset.y,canvas.height-canvas.offset.y)
	};

	snake.dir.current=dir[getRandomInt(dir.length)];
	food.create();

	for(let i=0;i<snake.length;i++) initCreate();

	intv=setInterval(()=>{
		if(posMatch(snake.head,food.pos)) snake.grow=true;
		move();
	},speed);

}

start.click();
<!DOCTYPE html>
<html>
<head>
	<title>Snake 0.8</title>
</head>
<body>
<div>
	<canvas id="myCanvas" width="300" height="300" style="border:1px solid black;background:gray;"></canvas>
</div>
<div>
	<button id="start">Start</button>
	<span>Score:<span id="score">0</span></span>
</div>
<script src="js/main.js"></script>
</body>
</html>

\$\endgroup\$
5
\$\begingroup\$
  • Potential Bug: the score doesn't appear to reset - should it go back to 0 when the button labeled Start is clicked??
  • const can be used for any variable that is never re-assigned. Some argue const should be the default keyword and then if and only if re-assignment is necessary then use let
  • posMatch can be simplified - from:

    let posMatch=(pos1,pos2)=>{
        if(pos1.x==pos2.x&&pos1.y==pos2.y) return true;
    }
    

    To

    const posMatch=(pos1,pos2)=> pos1.x==pos2.x&&pos1.y==pos2.y;
    
  • The large switch statement in initCreate could be simplified by using Function partials:

    const directionMapping = {
        'up': snake.draw.bind(null, {x:snake.head.x, y:--snake.head.y}),
        'down': snake.draw.bind(null, {x:snake.head.x, y:++snake.head.y}),
        'left': snake.draw.bind(null, {x:--snake.head.x, y:snake.head.y}),
        'right': snake.draw.bind(null, {x:++snake.head.x, y:snake.head.y})
    };
    

    And then within the function:

    const initCreate=_=>{
        if (snake.dir.current in directionMapping) {
            directionMapping[snake.dir.current]();
        }
    
  • Minor point: While it may only save one character, arrow functions with no parameters could be simplified to use a single parameter like _

Rewrite:

/*snake game by Chase last edited 4/5/2018*/

/*lib*/
const getRandomInt=max=>Math.floor(Math.random() * Math.floor(max));
const getBetweenInt=(min, max)=> {
	min = Math.ceil(min);
	max = Math.floor(max);
	return Math.floor(Math.random() * (max - min + 1)) + min; 
}

const posMatch=(pos1,pos2)=>pos1.x==pos2.x&&pos1.y==pos2.y;

const arrayMatch=(array,item)=>{
	for(let x of array) if(x.x==item.x&&x.y==item.y) return true;
}

/*initial*/

const multiplier=10;
const speed=200;
const canvas={
	offset:{
		x:10,
		y:10
	},
	width:myCanvas.width/multiplier,
	height:myCanvas.height/multiplier,
	penColor:color=>ctx.fillStyle=color,
	fill:(x,y)=>ctx.fillRect(x*multiplier,y*multiplier,multiplier,multiplier)
}

const food={
	pos:{},
	create:_=>{
		food.pos={
			x:getRandomInt(canvas.width),
			y:getRandomInt(canvas.height)		
		}
		if(arrayMatch(snake.body,food.pos)) food.create();
		else food.draw(food.pos);
	},
	draw:({x,y})=>{
		canvas.penColor("yellow");
		canvas.fill(x,y);
	}
};

const snake={
	head:{},
	getTail:x=>x=snake.body[0],
	dir:{
		current:"",
		pre:""
	},
	grow:false,
	body:[],
	length:5,
	draw:({x,y})=>{
		canvas.penColor("white");
		snake.body.push({x,y});
		canvas.fill(x,y);
	},
	erase:({x,y})=>{
		canvas.penColor("gray");
		snake.body.splice(0,1);
		canvas.fill(x,y);		
	},
	next:{}
};

const ctx = myCanvas.getContext("2d");
const dir=["up","down","left","right"];

let move=_=>{
	let crash;
	switch(snake.dir.current){
		case "up":
			snake.next={
				x:snake.head.x,
				y:snake.head.y-1
			};
			crash=snake.next.y<0;
			snake.dir.pre="up";
			break;
		case "down":
			snake.next={
				x:snake.head.x,
				y:snake.head.y+1
			};
			crash=snake.next.y==canvas.height;		
			snake.dir.pre="down";
			break;
		case "left":
			snake.next={
				x:snake.head.x-1,
				y:snake.head.y
			};
			crash=snake.next.x<0;		
			snake.dir.pre="left";
			break;
		case "right":
			snake.next={
				x:snake.head.x+1,
				y:snake.head.y
			};
			crash=snake.next.x==canvas.width;		
			snake.dir.pre="right";
			break;
		}

	if(crash||arrayMatch(snake.body,snake.next)){
		clearInterval(intv);
		start.disabled="";
		return;
	};		

	snake.draw(snake.head=snake.next);
	if(snake.grow){
		snake.grow=false;
		score.innerText=Number(score.innerText)+1;
		food.create();
	}
	else snake.erase(snake.getTail());
}
const directionMapping = {
'up': snake.draw.bind(null, {x:snake.head.x, y:--snake.head.y}),
'down': snake.draw.bind(null, {x:snake.head.x, y:++snake.head.y}),
'left': snake.draw.bind(null, {x:--snake.head.x, y:snake.head.y}),
'right': snake.draw.bind(null, {x:++snake.head.x, y:snake.head.y})
};
const initCreate=_=>{
  if (snake.dir.current in directionMapping) {
    directionMapping[snake.dir.current]();
    }
}

onkeydown=e=>{
	switch(e.keyCode){
		case 37:
			if(snake.dir.pre=="up"||snake.dir.pre=="down") snake.dir.current="left";
			break;
		case 38:
			if(snake.dir.pre=="left"||snake.dir.pre=="right") snake.dir.current="up";
			break;
		case 39:
			if(snake.dir.pre=="up"||snake.dir.pre=="down") snake.dir.current="right";
			break;
		case 40:
			if(snake.dir.pre=="left"||snake.dir.pre=="right") snake.dir.current="down";
			break;
	}
}

let intv;

start.onclick=_=>{
	start.disabled="disabled";

	canvas.penColor("gray");
	ctx.fillRect(0,0,myCanvas.width,myCanvas.height);
	snake.body=[];

	snake.head={
		x:getBetweenInt(canvas.offset.x,canvas.width-canvas.offset.x),
		y:getBetweenInt(canvas.offset.y,canvas.height-canvas.offset.y)
	};

	snake.dir.current=dir[getRandomInt(dir.length)];
	food.create();

	for(let i=0;i<snake.length;i++) initCreate();

	intv=setInterval(()=>{
		if(posMatch(snake.head,food.pos)) snake.grow=true;
		move();
	},speed);

}

start.click();
<!DOCTYPE html>
<html>
<head>
	<title>Snake 0.8</title>
</head>
<body>
<div>
	<canvas id="myCanvas" width="300" height="300" style="border:1px solid black;background:gray;"></canvas>
</div>
<div>
	<button id="start">Start</button>
	<span>Score:<span id="score">0</span></span>
</div>
<script src="js/main.js"></script>
</body>
</html>

\$\endgroup\$
2
\$\begingroup\$

Just adding a couple of things to what has already been said:

  • You may have already noticed that the key presses in the snippet actually make the window scroll. That's because you haven't prevented the default behavior, which can easily do if you add a preventDefault() for the keys that you handle:

    onkeydown=e=>{
        //... the switch code
    
        if (e.keyCode >= 37 && e.keyCode <= 40){
            e.preventDefault();
        }
    }
    
  • The arrayMatch function:

    let arrayMatch=(array,item)=>{
        for(let x of array) if(x.x==item.x&&x.y==item.y) return true;
    }
    

    Can be simplified using Array.prototype.some():

    const arrayMatch=(array,item)=> array.some(x => x.x==item.x&&x.y==item.y);
    

    Which has a subtle benefit of returning false when it doesn't matching anything instead of returning undefined.

  • The move switch has an assignment that you can simplify for all cases, this one:

    switch(snake.dir.current){
        case "up":
            ...
            snake.dir.pre="up"; // <--
            break;
        case "down":
            ...     
            snake.dir.pre="down"; // <--
            break;
        case "left":
            ...
            snake.dir.pre="left"; // <--
            break;
        case "right":
            ... 
            snake.dir.pre="right"; // <--
            break;
        }
    

    Since the new value is always equal to the switched value, you can take the assignment off the switch and do it afterwards:

    snake.dir.pre = snake.dir.current;
    

    Also note that while your indentation is mostly consistent, the closing brace for this switch is mispositioned.

  • getBetweenInt treats both min and max as floats by ceiling and flooring them:

    min = Math.ceil(min);
    max = Math.floor(max);
    

    But the values you pass in come from canvas.offset.x, canvas.offset.y, canvas.width and canvas.height which are always integer values. So there is no need to do that, you can simply use them directly.

    Also the name is a bit odd for me, and i would rename it to getIntBetween.

\$\endgroup\$
2
\$\begingroup\$

Quick review turned into a snake game

Some important points missed by the other answers

  • Always use requestAnimationFrame to render any animation.

  • Keyboard Event.keyCode is a depreciated property and should not be used. Use Event.code or Event.key

  • To make the game more portable encapsulate the whole thing into a function.

  • Use a settings constant at the top of the code to keep all the game settings in one place, This makes it easier to make modifications and fine tune the game play.

  • Use lookups and mapping to simplify complex sets of conditions

  • The naming is too verbose. Names should be the minimum needed to understand, in context, what the name holds. eg getRandomInt the get is implied randomInt is better and does not clutter up code. getBetweenInt gives little information on what it does. randomIntBetween would be better.

  • DONT!!! use statement blocks without delimiting them. You invite very hard to spot bugs when later you make modification and forget the delimit the blocks.

    e.g.:

    // Never do
    if (foo) bar = 0;
    
    for (i = 0; i < 10; i ++) something(i);
    
    // Much safer to delimit them
    
    if (foo) { bar = 0 }   // NOTE that the ; is not needed if the line ends with }
    
    for (i = 0; i < 10; i ++) { something(i) }
    
  • Use === or !==, they are faster and much safer. Forget that JS even has == or != and never use them again.

  • Put spaces between operators and spaces after commas.

  • Use const (This has been pointed out in the other answers but needs to be reinforced.)

The following line is an example of the above 4 points

You have...

let posMatch=(pos1,pos2)=>{
    if(pos1.x==pos2.x&&pos1.y==pos2.y) return true;
}

... is much easier to read as.

const posMatch = (pos1, pos2) => {
    if (pos1.x === pos2.x && pos1.y === pos2.y) { return true }
}

However the function has two return types, true and undefined it can be improved and provide a consistent return type.

const posMatch = (pos1, pos2) => pos1.x === pos2.x && pos1.y === pos2.y;

// I would go further 
const isPointSame = (p1, p2) => p1.x === p2.x && p1.y === p2.y;

Game code design.

Realtime games offer a special coding challenge. They need to be performant which for JavaScript has some particular requirements.

Jank, performance, and managed memory in JS

Jank is when the browser hangs for a moment and can destroy the play-ability of a game, the most common cause of jank is JS games is memory management.

Performance in JS is always a challenge, as it is much slower than native code. Poor performance means there is less you can do in a given time. Poor performance will not cause jank.

JS manages all the memory for you, when you create an object the memory need is allocated from free RAM. When you grow an array it new memory every time it doubles in size again from RAM.

There are another types of allocation, from the heap, or context stack (depending on the JS engine that all have different names and methods) but generally heaps or stacks are much faster than normal allocation and release. This is because the JS engine knows when a particular entry in a heap is free and does not have to mess about checking is its safe to delete.

Every variable you create gets memory from the heap. For example var A = {b:0} the object {b: 0} get memory from RAM the variable A (the reference to the object) gets its memory from the heap.

Generally manage memory allocation is fast and not too noticeably, it will not cause jank and will not impact performance that for small allocations

When you no longer need the memory, ie you dereference an object, or reduce an array length by more than half, that memory remains unusable until it is cleaned up.

The part of JS that cleans up memory is call garbage collection (GC) and it is the cause of the jank and can reduce performance. The reason is that freeing memory is very complicated. You don't tell it what is free, it must work it out by checking all the references and determining if they are still in scope.

Though the heap is a lot faster when deleting, its down side is that it is slower at allocations. Creating a new heap/context allocation is the complicated part and is the source of poor performance in JS. Every time you call a function there is a heap allocation.

To reduce Jank and increase Performance in JS games you need to reduce the frequency of heap assignments, and limit how much work GC must do.

  • Reduce the number of function calls.
  • Reduce the number of new scopes
  • Generally function scope variables are quicker than many nested block scope variables as block scope variables require heap allocation each time they enter scope. However this is a very small overhead and only needs attention if you are pushing for max performance.
  • DON'T Delete, reuse, is the number ONE rule for reducing jank.

Some examples

function test(a) { return a === "something" }  // when called needs a new context allocation
if(test(value)) { ... do something }  // The value is copied to the new context and needs heap allocation

// Much faster as
if(a === "something") { ...do something }  // No heap context allocation needed

var array = [1,2,3,4,5,6,7,8];
var c = 0;
array.forEach((a)=> c += a); // THe callback needs a new context for each item in the array

// Much faster as
for(const a of array) { c += a }  // there is no context created for each item
                                  // But for each item you do need a new heap allocation for the const a

// Very slightly faster (< 0.5%)                                      
var a;
for(a of array) { c += a }   // As a is already declared it does not require heap allocation.



// A standard object as a function                                   
function MyObj(value){
    this.value = value;
}

var obj = new MyObj("abc");  // is allocated memory
// do something with it

// done with it and get a new one
obj = new MyObj("def"); // Old one still exists and new one needs new allocation
                        // At some unknown time the old one will get removed by GC and 
                        // cause a performance hit then

// rather than create a new one reuse it
obj.value = "def"; // only need a new heap allocation and there is no GC overhead

To a rewrite.

Games are exercises in performance. The balance between readability and performance is hard to master and will depend on the game requirements (note that the game should run on the slowest target device)

I have written the following with a mind to medium performance, with a favor to code readability, as the game is not really taxing the system

The singleton

From the top the first thing is to encapsulate all the game code with a singleton

;(() => {  // If a line starts with ( always put a ; before it
    // code in here
})();  // This bit calls the function and runs all the code in its own private scope.

Utilities

Define utility functions, some variations on what you used

    // Replacement random function for you getRandomInt and getBetweenInt
    // Random int has 3 signitures
    // randInt(); // returns 0 or 1
    // randInt(number); // returns integer from 0 to number - 1
    // randInt(numberA, numberB) // returns integer from numberA to numberB - 1
    // Use bitwise or to floor the returned number         
    const randInt = (min = 2, max = min + (min = 0)) => Math.random() * (max - min) + min | 0;
    const randItem = array => array[Math.random() * array.length | 0];
    const point = (() => {
        function Point(x = 0, y = 0) { this.x = x; this.y = y }
        Point.prototype = {  // p is used to denote a point, Can have post fix number
            isSame(p) { return this.x === p.x && this.y === p.y },
            as(x, y = x.y + ((x = x.x), 0)) { return (this.x = x, this.y = y, this) },
            add(p) { this.x += p.x; this.y += p.y },
        }
        return (x, y) => new Point(x, y);
    })();

The other answer suggested that you use Array.some For performance code you should not do that as it is a lot slower and has a GC cost. GC cost means you add to the likelihood of the game jank. Creating your own some function with callback is faster than Array.some

    const arrayAny = (predicate, array, count = array.length) => {
        for (const item of array) {
            if ( predicate(item) === true ) { return true }
            if (!(--count)) { break }
        }
        return false;
    }

Settings in one place

Now collect all the magic numbers and settings

    const settings = { // settings should alway have the units and limits in the comments
        tiles : 10,  // in pixels, size of a block, AKA multiplier from question


        snakeBodyMin : 5, // start number body parts
        snakeMoveEvery : 15, // number of frames between snake moves
        newGameWaitFrameCount : 60, // in frames 1/60th second
        foodScore : 1,
        foodColor : "yellow",
        backgroundColor : "gray", 
        snakeColor : "white",

Lookups and mapping

To simplify the many condition and switch yoou have the following maps out the moves and provides a way to do many of the conditions via a named object.

        directions : "up,down,left,right".split(","),
        moves : {   // mapping
            up : {name : "up", key : "ArrowUp", op : "down",  vec : point(0, -1)},
            down : {name : "down", key : "ArrowDown", op : "up", vec : point(0, 1)},
            left : {name : "left", key : "ArrowLeft", op : "right", vec : point(-1, 0)},
            right : {name : "right", key : "ArrowRight", op : "left",  vec : point(1, 0)},
        },
    }

UI

Here is a better keyboard handler that will map settings.directions to the keyboard.

    const keys = (() => {
        const keys = {any : false};
        for (const name of settings.directions) { keys[settings.moves[name].key] = false }
        function keyEvent(event) {
            if (keys[event.code] !== undefined) {
                keys[event.code] = event.type === "keydown";
                event.preventDefault();
                if (event.type === "keydown") { keys.any = true }

            }
        }
        addEventListener("keydown", keyEvent);
        addEventListener("keyup", keyEvent);
        focus(); // get focus
        return keys;
    })();
    keys.reset = () => {
        for (const keyName of settings.direction) { keys[settings.moves[keyName].key] = false }
    };

Game objects

Next you define all the game objects and variables

Canvas becomes playfield

    const ctx = canvas.getContext("2d");
    const playfield = (() => {
        const size = settings.tiles;
        const API = {
            columns : canvas.width / size,
            rows : canvas.height / size,
            fillBox(point, col = ctx.fillStyle) {
                ctx.fillStyle = col;
                ctx.fillRect(point.x * size, point.y * size, size, size);
            },
            isInside(p) { return ! (p.x < 0 || p.x >= API.rows || p.y < 0 || p.y >= API.columns) },
        };
        return API;
    })();

One instance of food

    const food = (() => {
        const col = settings.foodColor;
        const pos = point();
        return {
            reset() { // for performance dont use recursion
                var searching = true;   // Though not needed, some optimiser do not optimise code wrapped in a
                                        // loop with not exit condition eg while(true) so always include
                                        // an exit condition even if you dont need or use it
                while (searching) {
                    pos.x = randInt(playfield.columns);
                    pos.y = randInt(playfield.rows);
                    if (! snake.isOverPoint(pos)) {
                        searching = false;
                        break;
                    }
                }
            },
            draw() { playfield.fillBox(pos, col) },
            isAt(point) { return pos.isSame(point) },
        };
    })();

The snake

To reduce GC hits the snake body array will never have items removed. A length variable will determin how many items are used. If there is a need for more body parts they are created if needed, else existing ones are reused. The will in effect decrease overaly memory use as you will not have deleted items taking up memory while they wait for JC.

Update There was a bug in the first post of this example that let the snake double back on its self if you quickly pressed two keys between moves.

To fix I added a pendingDir that holds the next direction until the next time the snake moves.

I also forgot to bind the point in the function snake.isOverPoint that prevented the snake running over its self.

    const snake = (() => {
        const col = settings.snakeColor;
        const body = [point()];
        const head = point(); // just a temp to do tests
        var length;
        const moves = settings.moves;
        var dir;
        var pendingDir;
        var index = 0;
        const each = cb => {index = 0; while (index < length) { cb(body[index++]) } };
        return {
            isOverPoint(point) { return arrayAny(point.isSame.bind(point), body, length) },
            set direction(value) { pendingDir = dir.op !== value ? moves[value] : dir },
            reset() {
                var i;
                dir = moves[randItem(settings.directions)];

                length = settings.snakeBodyMin;
                body[0].as(playfield.columns / 2 | 0, playfield.rows / 2 | 0);
                for (i = 1; i < length ; i++) {
                    if (body[i] === undefined) { body[i] = point() }
                    body[i].as(body[i-1]).add(moves[dir.op].vec);
                }
            },
            draw() {
                ctx.fillStyle = settings.snakeColor;
                each(playfield.fillBox);
            },
            update(canMove) {
                for (const dirName of settings.directions) { 
                    if (keys[moves[dirName].key]) { snake.direction = dirName } 
                }
                if (!canMove) { return }
                if (pendingDir !== undefined) { 
                    dir = pendingDir;
                    pendingDir = undefined;
                }

                head.as(body[0]).add(dir.vec);
                if (!snake.isOverPoint(head) && playfield.isInside(head)) {
                    if (food.isAt(head)) {
                        length += 1;
                        if (body.length <= length) { body.push(point()) }
                        game.score = settings.foodScore;
                        body.unshift(body.pop().as(head));
                        food.reset();
                    } else { body.unshift(body.pop().as(head)) }
                } else { game.state = "gameOver" }
            },
        };
    })();

Game and state wraps it up

To manage the game create a game state object that has all the different game states, eg play, game over, get ready, etc

All you do is name the next state and it will state as needed.

    const game = (() => {
        var frameCount;
        var score = 0;
        var started = false;
        var currentName = ""; // not used ATM
        var current = null;
        var nextState;

        const game = {
            set score(value) {
                if (value === 0) { score = 0 }
                else { score += value }
                scoreElement.textContent = score;
            },
            set text(value){
                if (value === "") {
                    textElement.classList.add("hide");
                } else {
                    textElement.classList.remove("hide");
                    textElement.textContent = value;
                }
            },        
            newGame() {
                game.score = 0;
                snake.reset();
                food.reset();
                ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
                food.draw();
                snake.draw();
                nextState = "play";
                game.state = "waitForFrames";
                game.text = "Get ready";

            },
            gameOver() {
                nextState = "newGame";
                game.state = "waitForFrames";
                game.text = "Game Over";
            },
            play() {
                ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
                snake.update(frameCount % settings.snakeMoveEvery === settings.snakeMoveEvery -1);
                food.draw();
                snake.draw();
            },
            waitForFrames() {
                if (frameCount >= settings.newGameWaitFrameCount) {
                     game.state = nextState;
                }
            },
            set state(stateName) {
                frameCount = 0;
                if (game[stateName] !== undefined) {
                    current = game[stateName];
                    currentName = stateName;
                } else {
                    current  = game.newGame;
                    currentName = "newGame";
                }
                game.text = "";
                if (!started) {
                    requestAnimationFrame(game.mainLoop);
                    started = true;
                }

            },
            mainLoop() {
                current();
                frameCount += 1;
                requestAnimationFrame(game.mainLoop)
            }
        }
        return game;
    })();
    // all done so start a new game;
    game.state = "newGame";
})();

Try it.

as a running example

;(() => {
    const randInt = (min = 2, max = min + (min = 0)) => Math.random() * (max - min) + min | 0;
    const randItem = array => array[Math.random() * array.length | 0];
    const point = (() => {
        function Point(x = 0, y = 0) { this.x = x; this.y = y }
        Point.prototype = {  // p is used to denote a point, Can have post fix number
            isSame(p) { return this.x === p.x && this.y === p.y },
            as(x, y = x.y + ((x = x.x), 0)) { return (this.x = x, this.y = y, this) },
            add(p) { this.x += p.x; this.y += p.y },
        }
        return (x, y) => new Point(x, y);
    })();
    const arrayAny = (predicate, array, count = array.length) => {

        for (const item of array) {
            if ( predicate(item) === true ) { return true }
            if (!(--count)) { break }
        }
        return false;
    }
    const settings = { // settings should always have the units and limits in the comments
        tiles : 10,  // in pixels size of a block. AKA multiplier from question
       
        snakeBodyMin : 5, // start number body parts
        snakeMoveEvery : 15, // number of frames between snake moves
        newGameWaitFrameCount : 60, // in frames 1/60th second
        foodScore : 1,
        foodColor : "yellow",
        backgroundColor : "gray",
        snakeColor : "white",
        directions : "up,down,left,right".split(","),
        moves : {   // mapping
            up : {name : "up", key : "ArrowUp", op : "down",  vec : point(0, -1)},
            down : {name : "down", key : "ArrowDown", op : "up", vec : point(0, 1)},
            left : {name : "left", key : "ArrowLeft", op : "right", vec : point(-1, 0)},
            right : {name : "right", key : "ArrowRight", op : "left",  vec : point(1, 0)},
        },
    }
    const keys = (() => {
        const keys = {any : false};
        for (const name of settings.directions) { keys[settings.moves[name].key] = false }
        function keyEvent(event) {
            if (keys[event.code] !== undefined) {
                keys[event.code] = event.type === "keydown";
                event.preventDefault();
                if (event.type === "keydown") { keys.any = true }

            }
        }
        addEventListener("keydown", keyEvent);
        addEventListener("keyup", keyEvent);
        focus(); // get focus
        return keys;
    })();
    keys.reset = () => {
        for (const keyName of settings.direction) { keys[settings.moves[keyName].key] = false }
    };

    canvas.style.background =  settings.backgroundColor;
    const ctx = canvas.getContext("2d");
    const playfield = (() => {
        const size = settings.tiles;
        const API = {
            columns : canvas.width / size,
            rows : canvas.height / size,
            fillBox(point, col = ctx.fillStyle) {
                ctx.fillStyle = col;
                ctx.fillRect(point.x * size, point.y * size, size, size);
            },
            isInside(p) { return ! (p.x < 0 || p.x >= API.rows || p.y < 0 || p.y >= API.columns) },
        };
        return API;
    })();
    const food = (() => {
        const col = settings.foodColor;
        const pos = point();
        return {
            reset() { // for performance dont use recursion
                var searching = true;   // Though not needed, some optimiser do not optimise code wrapped in a
                                        // loop with not exit condition eg while(true) so always include
                                        // an exit condition even if you dont need or use it
                while (searching) {
                    pos.x = randInt(playfield.columns);
                    pos.y = randInt(playfield.rows);
                    if (! snake.isOverPoint(pos)) {
                        searching = false;
                        break;
                    }
                }
            },
            draw() { playfield.fillBox(pos, col) },
            isAt(point) { return pos.isSame(point) },
        };
    })();
    const snake = (() => {
        const col = settings.snakeColor;
        const body = [point()];
        const head = point(); // just a temp to do tests
        var length;
        const moves = settings.moves;
        var dir;
        var pendingDir;
        var index = 0;
        const each = cb => {index = 0; while (index < length) { cb(body[index++]) } };
        return {
            isOverPoint(point) { return arrayAny(point.isSame.bind(point), body, length) },
            set direction(value) { pendingDir = dir.op !== value ? moves[value] : dir },
            reset() {
                var i;
                dir = moves[randItem(settings.directions)];

                length = settings.snakeBodyMin;
                body[0].as(playfield.columns / 2 | 0, playfield.rows / 2 | 0);
                for (i = 1; i < length ; i++) {
                    if (body[i] === undefined) { body[i] = point() }
                    body[i].as(body[i-1]).add(moves[dir.op].vec);
                }
            },
            draw() {
                ctx.fillStyle = settings.snakeColor;
                each(playfield.fillBox);
            },
            update(canMove) {
                for (const dirName of settings.directions) { if (keys[moves[dirName].key]) { snake.direction = dirName } }
                if (!canMove) { return }
                if (pendingDir !== undefined) { 
                    dir = pendingDir;
                    pendingDir = undefined;
                }
                
                
                head.as(body[0]).add(dir.vec);
                
                if (!snake.isOverPoint(head) && playfield.isInside(head)) {
                    if (food.isAt(head)) {
                        length += 1;
                        if (body.length <= length) { body.push(point()) }
                        game.score = settings.foodScore;
                        body.unshift(body.pop().as(head));
                        food.reset();
                    } else { body.unshift(body.pop().as(head)) }
                } else { game.state = "gameOver" }
            },
        };
    })();
    const game = (() => {
        var frameCount;
        var score = 0;
        var started = false;
        var currentName = ""; // not used ATM
        var current = null;
        var nextState;
        
        const game = {
            set score(value) {
                if (value === 0) { score = 0 }
                else { score += value }
                scoreElement.textContent = score;
            },
            set text(value){
                if (value === "") {
                    textElement.classList.add("hide");
                } else {
                    textElement.classList.remove("hide");
                    textElement.textContent = value;
                }
            },        
            newGame() {
                game.score = 0;
                snake.reset();
                food.reset();
                ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
                food.draw();
                snake.draw();
                nextState = "play";
                game.state = "waitForFrames";
                game.text = "Get ready";
                 
            },
            gameOver() {
                nextState = "newGame";
                game.state = "waitForFrames";
                game.text = "Game Over";
            },
            play() {
                ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
                snake.update(frameCount % settings.snakeMoveEvery === settings.snakeMoveEvery -1);
                food.draw();
                snake.draw();
            },
            waitForFrames() {
                if (frameCount >= settings.newGameWaitFrameCount) { game.state = nextState }
            },
            set state(stateName) {
                frameCount = 0;
                if (game[stateName] !== undefined) {
                    current = game[stateName];
                    currentName = stateName;
                } else {
                    current  = game.newGame;
                    currentName = "newGame";
                }
                game.text = "";
                if (!started) {
                    requestAnimationFrame(game.mainLoop);
                    started = true;
                }
               
            },
            mainLoop() {
                current();
                frameCount += 1;
                requestAnimationFrame(game.mainLoop)
            }
        }
        return game;
    })();
    // all done so start a new game;
    game.state = "newGame";
})();
body {
   font-family : arial;
}
canvas {
  position : absolute;
  top : 0px;
  left : 0px;  
  background : gray;
  z-index :-1;
}
#scoreElement {
  position : absolute;
  top : 4px;
  left : 4px;
}
#textElement {
  position : absolute;
  top : 60px;
  left : 0px;  
  width :160px;
  text-align : center;
}
.hide {
   display : none;
}
<canvas id="canvas" width="160" height="160"> </canvas>
<div id="scoreElement"></div>
<div class ="hide" id="textElement">dsdfsdfsf</div>

\$\endgroup\$
  • \$\begingroup\$ @Chase You're welcome. I just noticed I had two minor bugs related to the snake object's movement and collision test, I have updated the answer to fix the problems. \$\endgroup\$ – Blindman67 Jun 17 '18 at 10:14

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.