11
\$\begingroup\$

My code uses a canvas element to create a live snake game on a plane!

<!DOCTYPE html>
    <html id="game">

      <head>
        <title>Snake!</title>
      </head>

      <body>
        <canvas id="canvas" width="400" height="400"></canvas>
        <script src="https://code.jquery.com/jquery-2.1.0.min.js"></script>
        <p id="error" style="color:red"></p>
        <script id="script">
          document.getElementById("game").onkeydown=function(e){
          var keyCode = e.keyCode
            if(keyCode === 192){
              showlag = !showlag
            }else if(keyCode === 55) {
              showhighscore = !showhighscore
            }else if(keyCode === 56){
              showmaxspeed = !showmaxspeed
            }
          }
          var canvas = document.getElementById("canvas")
          var ctx = canvas.getContext("2d")
          if(true){
            this.highscore=0
          } else {
            highscore = 0 // localStorage.getItem("2dhighscore")
          }
          if(true){
            this.showlag=false
          } else {
            this.showlag = false //  localStorage.getItem("showlag")
          }
          if(true){
            this.maxspeed=100
          } else {
            maxspeed = 100 // localStorage.getItem("2dmaxspeed")
          }
          if(this.showmaxspeed==undefined){
            this.showmaxspeed=false
          }
          if(this.showhighscore==undefined){
            this.showhighscore=true
          }
          function floor(a){return a>>0}
          var width = canvas.width
          var height = canvas.height
          var blockSize = 10
          var widthInBlocks = width / blockSize
          var heightInBlocks = height / blockSize
          var score = 0 
          var drawBorder = function() {
            ctx.fillStyle = "Gray";
            ctx.fillRect(0,0,width,blockSize)
            ctx.fillRect(0,height-blockSize,width,blockSize)
            ctx.fillRect(0,0,blockSize,height)
            ctx.fillRect(width-blockSize,0,blockSize,height)
          }
          var extrasIndex = {
            1:50,
            2:70,
            3:90,
          }
          ctx.font = "20px Verdana"
          ctx.fillStyle = "Black"
          ctx.textAlign = "left"
          ctx.textBaseline = "top"
          ctx.save()
          function drawScore() {
            var extras = 0
            ctx.restore();
            ctx.save();
            var offset = blockSize+4
            ctx.fillText("Score: "+score,offset,blockSize)
            ctx.fillText("Speed: "+(floor(100/speed*100)/100),offset,3*blockSize)
            if(score>highscore){
              highscore=score
              // localStorage.setItem("2dhighscore", highscore);
            }
            if(speed<maxspeed){
              maxspeed=speed
              // localStorage.setItem("2dmaxspeed", maxspeed);
            }

            if(showhighscore==true){
              extras++
              ctx.fillText("Highscore: "+highscore,offset,extrasIndex[extras])
            };
            if(showmaxspeed==true){
              extras++
              ctx.fillText(`Highest Speed: ${(floor(100/maxspeed*100)/100)}`,offset,extrasIndex[extras])
            }
            if(showlag==true){
              extras++
              ctx.fillText(`Lag: ${lag}ms`,offset,extrasIndex[extras])
            }
          }
          //========================
          // Block
          //========================
          var Block = function (col,row) {
            this.col = col;
            this.row = row;
          }
          Block.prototype.drawSquare = function (color) {
            var x = this.col * blockSize
            var y = this.row * blockSize
            ctx.fillStyle = color
            ctx.fillRect(x,y,blockSize,blockSize)
          }
          Block.prototype.drawCircle = function (color) {
            var centerX = this.col * blockSize + blockSize / 2
            var centerY = this.row * blockSize + blockSize / 2
            ctx.fillStyle = color
            circle(centerX,centerY,blockSize/2,true)
          }
          Block.prototype.equal = function (otherBlock) {
            return this.col === otherBlock.col && this.row === otherBlock.row;
          }
          //========================
          // Circle
          //========================
          var PIx2 = Math.PI * 2
          var circle = function (x,y,radius,fillCircle) {
            ctx.beginPath();
            ctx.arc(x,y,5,0,PIx2, fillCircle);
            if(fillCircle){
              ctx.fill()
            } else {
              ctx.stroke()
            }
          }
          //========================
          // Snake
          //========================
          var Snake = function () {
            this.segments = [
              new Block(7, 5),
              new Block(6, 5),
              new Block(5, 5)
            ];
            this.direction = "right"
            this.nextDirection = "right"
          }
          Snake.prototype.setDirection = function (newDirection) {
            var currentDirection = this.direction
            if(currentDirection === "up" && newDirection === "down") {
              return;
            } else if(currentDirection === "right" && newDirection === "left") {
              return;
            } else if(currentDirection === "down" && newDirection === "up") {
              return;
            } else if(currentDirection === "left" && newDirection === "right") {
              return;
            }
            this.nextDirection = newDirection
          }
          Snake.prototype.checkCollision = function(head) {
          var headCol = head.col
          var headRow = head.row
            var leftCollision = (headCol == 0 )
            var topCollision = (headRow == 0)
            var rightCollision = (headCol == widthInBlocks - 1)
            var bottomCollision = (headRow == heightInBlocks - 1)

            var wallCollision = leftCollision || topCollision || rightCollision || bottomCollision

            var selfCollision = false

            this.segments.forEach(function(item){
              if(head.equal(item)) {
                selfCollision = true
              }
            })
            return wallCollision || selfCollision
          }
          Snake.prototype.draw = function () {
            var j = this.segments.length
            this.segments.forEach(function(item,index){
              item.drawSquare(`rgb(${((255-(floor(255/j)*index)+randomNumberBetween(0-(j-index),j-index)))},00,00)`)
            })
          }
          Snake.prototype.move = function() {
            var head = this.segments[0]
            var newHead;
            this.direction = this.nextDirection
            var currentDirection = this.direction
            if(currentDirection === "right" ) {
              newHead = new Block(head.col + 1,head.row)
            } else if (currentDirection === "down") {
              newHead = new Block(head.col,head.row + 1)
            } else if (currentDirection === "left") {
              newHead = new Block(head.col - 1,head.row)
            } else if (currentDirection === "up") {
              newHead = new Block(head.col, head.row-1)
            }
            if(this.checkCollision(newHead)) {
              gameOver()
              return;
            }
            this.segments.unshift(newHead)
            if(newHead.equal(apple.position)) {
              score++;
              speed = 0.95 * speed
              if(apple.type === "superapple"){
                speed = 1.10*speed
                score += 4
              }
              apple.move()
            } else {
              this.segments.pop()
            }
          }
          //========================
          // Apple
          //========================
          var Apple = function() {
            this.position = new Block(10,10)
          }
          Apple.prototype.draw = function() {
            if(this.type === "superapple"){
              this.position.drawCircle("gold")
            } else {
              this.position.drawCircle("limegreen")
            }
          }
          Apple.prototype.move = function() {
            var randomCol = floor(Math.random() * (widthInBlocks - 4)) + 2
            var randomRow = floor(Math.random() * (heightInBlocks - 4)) + 2
            this.position = new Block(randomCol, randomRow)
            snake.segments.forEach(function(item,index){
              if(item.equal(apple.position)){
                apple.move()
              }
            })
            if((randomNumberBetween(1,11) === 1) && (score > 15)){                	
              this.type = "superapple"
            } else {
              this.type = "apple"
            }
          }
          var speed = 100
          //========================
          // Misc
          //========================
          function randomNumberBetween(a,b){
            return a+floor(Math.random()*(b-a))
          }
          var directions = {
            37: "left",
            38: "up",
            39: "right",
            40: "down",
            65:"left",
            87:"up",
            68:"right",
            83:"down"
          }
          function newGame(key){
            if(key === 82 || key === 114){
              s=function(){}
              eval(document.getElementById("script").innerHTML)
            }
          }
          function gameOver() {
            clearTimeout(timeOutId)
            ctx.font = "60px Verdana"
            ctx.fillStyle = "Black"
            ctx.textAlign = "center"
            ctx.textBaseline = "middle"
            ctx.fillText("Game Over", width / 2, height / 2)
            s=function(){}
            document.getElementById("game").onkeydown=function(e){
              newGame(e.keyCode)    
            }
          }
          //========================
          // JQuery
          //========================
          $("body").keydown(function(event){
            const newDirection = directions[event.keyCode]
            if(newDirection !== undefined) {
              snake.setDirection(newDirection)
            }
          })
          //========================
          // Game Loop
          //========================
          var lag = 0
          var s = function(){
            var start = performance.now()
            ctx.clearRect(0,0,width,height)
            ctx.fillStyle="lightgrey"
            ctx.fillRect(0,0,width,height)
            drawScore()
            snake.move()
            snake.draw()
            apple.draw()
            drawBorder()
            
            timeOutId = setTimeout(function(){requestAnimationFrame(s)},speed)
            var end = performance.now()
            lag = floor((end-start))
          }
          var snake = new Snake()
          var apple = new Apple()
          apple.move()
          timeOutId = setTimeout(function(){requestAnimationFrame(s)},speed)
          var timeOutId

        </script>
      </body>

    </html>

May not work on all keyboards.

\$\endgroup\$

1 Answer 1

8
\$\begingroup\$

Code review

I like the name "Snake on a plane" and was hoping to see some wings and the like. Maybe that's to come.

Not bad if this is your first game. I have gone over it from top to bottom and jotted down points as I found them.

Some points

  • Don't timeout requestAnimationFrame because you will get inconsistent frame rates. Use a frame counter and skip frames to speed up or slow down the game. This will ensure that you have a consistent number of frames between updates.

  • Use semicolons

  • Don't use jQuery if you don't need to. Its a lot to load and you used it only to query the DOM which is more code than using standard direct reference.

  • Always use === or !==. NEVER use == or != unless you know why you should never use them.

  • Use function statements "function funcName(){ ...code... }" rather than function expressions var funcName = function(){ ...code... }

  • Add "use strict" to the first line of the JavaScript file or script tag.

  • Keyboard event.KeyCode is depreciated use event.code or event.key

  • Add event listeners via element.addEventListener

  • You can access DOM elements by their Id directly if the ID is unique to the page and you have not defined it in JS. eg document.getElementById("game").innerHTML is the same as game.innerHTML


Note. Direct element reference via the global window object is part of the W3 HTML5 standard and is supported by all browsers.

Many believe it is non standard, eg Quote by RoToRa "It's not standardized" in comments below.

As with all variable declarations care must be taken to ensure uniqueness of the name across the scope of the document and javascript context.


  • If a condition statement returns then you should not use an else after it.

  • The function floor should be inline, as you lose some to all of the advantage the bitwise operation gives you by needing to call the function. You might as well use Math.trunc Also you can also use he shorter form num | 0 rather than num >> 0

  • Use const to define constants. eg const PI2 = Math.PI * 2;

  • Array.forEach is a lot slower than using standard loops. If you don't need the index use for(const item of items){ or if you need the index use for(let i = 0; i < items.length; i++)

  • Last argument of ctx.arc is to indicate direction. If you are drawing a full circle (360deg) you don't need the last argument. You had ctx.arc(x, y, 5, 0, PIx2, fillCircle); I don't know why you put fillCircle there

  • Use the shorter syntax when you can. See snippets A, B.

  • Try to avoid creating variables that you only use once. Put them inline, break function calls up into multiple lines to maintain readability. See snippets A, B.

You had

Snippet A

var Block = function (col, row) {
    this.col = col;
    this.row = row;
}
Block.prototype.drawSquare = function (color) {
    var x = this.col * blockSize;
    var y = this.row * blockSize;
    ctx.fillStyle = color;
    ctx.fillRect(x, y, blockSize, blockSize);
}
Block.prototype.drawCircle = function (color) {
    var centerX = this.col * blockSize + blockSize / 2;
    var centerY = this.row * blockSize + blockSize / 2;
    ctx.fillStyle = color;
    circle(centerX, centerY, blockSize / 2, true);
}
Block.prototype.equal = function (otherBlock) {
    return this.col === otherBlock.col && this.row === otherBlock.row;
}

Could be

Snippet B

function Block (col, row) {
    this.col = col;
    this.row = row;
}

Block.prototype = {
    drawSquare(color) {
        ctx.fillStyle = color;
        ctx.fillRect( this.col * blockSize,  this.row * blockSize,  blockSize, blockSize );
    }, // <<<<<<<<<<<< IMPORTANT seperate with comma
    drawCircle(color) {
        ctx.fillStyle = color;
        circle(
            this.col * blockSize + blockSize / 2, 
            this.row * blockSize + blockSize / 2, 
            blockSize / 2, 
            true
        );
    },
    equal(block) {
        return this.col === block.col && this.row === block.row;
    }
}

Tips

Setup the game using a function so you don't have to use the very hacky eval script tag. See the rewrite.

For games polling IO state as needed is far better than reacting to IO events as they happen. The event listeners just set the state of an abstract controller. This lets you easily change the type of input. See rewrite for example.

Use objects to simplify code. For example the snake can not move back on its self so you have a set of four if statements to check. That can be changed to

const blockMoves = {
    left : "right",
    right : "left",
    down : "up",
    up : "down",
}
// then in the code
setDirection(newDirection) {
    if (blockMoves[newDirection] !== this.direction) {
        return;
    }
    this.nextDirection = newDirection;
}    

See rewrite for similar example.

Do your best to reduce memory thrashing and GC hits. Reuse object rather than delete and create a new one while in time critical code. As an example Each time the snake moves you create a new head, and delete the tail. It is more efficient to just move the tail to the head position and only create a new tail each time you eat an apple.

Try to avoid using the canvas context functions save and restore. You can get better performance if you code in such a way as to know what states need to be changed and which are known.

Control the game stages (states) by using a variable to hold the state function. You then just change the function to change the game state. I have added a slightly more complex game state system. See the rewrite for more info.

The rewrite

I tried to stay as close to your original as possible, I dropped a few things that to me looked like debugging code. There may be some slight variations to game speeds. I also added a ready to play game state.

Use this as just an example, it is not how I would write such a game but there would be no point in a total rewrite, and not much to learn if your basic structure where not still being used.

"use strict";

// Game controls
const controls = {
    up : false,
    down : false,
    left : false,
    right : false,
    any : false, // this is true after any key down event. Must be reset manualy
    clear(){
        for(const key of Object.keys(controls)){
            if (key !== "clear") { controls[key] = false }
        }
    }
}
// keyboard mapped to controls
const keys = {
    ArrowUp : "up",
    ArrowLeft : "left",
    ArrowRight : "right",
    ArrowDown : "down",
};
function keyboardEvent(event){
    if(keys[event.code] !== undefined){
        controls[keys[event.code]] = event.type === "keydown";
        event.preventDefault();
    }
    controls.any = event.type === "keydown" ? true : controls.any;
}



// Enumerated directions is better than named
const directions = {
    up : 0,
    down : 1,
    left : 2,
    right : 3,
};

// Details about each move direction. Not they are in same order as directions above
const moves = [{
        name : "up",
        blockMove : directions.down, 
        movement : [0,-1],
    },{
        name : "down",
        blockMove : directions.up, 
        movement : [0,1],
    },{
        name : "left",
        blockMove : directions.right, 
        movement : [-1,0],
    },{
        name : "right",
        blockMove : directions.left, 
        movement : [1,0],
    }
];


// Game variables
var snake;
var apple;

// Note canvas is direct reference of DOM element by its ID
const ctx = canvas.getContext("2d");

var frameTime = 100;
var nextFrameAfter = 0;
const width = canvas.width;
const height = canvas.height;
const blockSize = 10;
const widthInBlocks = (width / blockSize) - 2;
const heightInBlocks = (height / blockSize) - 2;
var score = 0;
const PIx2 = Math.PI * 2;    


["keydown", "keyup"].forEach(name => document.addEventListener(name,keyboardEvent));
requestAnimationFrame(mainLoop);
document.body.focus();  // Focus the keyboard input


// manage the game states play, gameOver, and readyToStart
// change state by setting the state name eg
// gameState.state = "play"
const gameStates = {
    animate : {
        play(time){
            snake.checkControls();
            if(time >= nextFrameAfter){
                nextFrameAfter = time + frameTime;

                ctx.fillStyle = "lightgrey";
                ctx.fillRect(0, 0, width, height);
                drawScore();
                snake.move();
                snake.draw();
                apple.draw();
                drawBorder();
            }
        },
        gameOver(){        
            if(controls.any){ gameStates.state = "play" }
        },
        readyToStart(){  
            if(controls.any){ gameStates.state = "play" }
        },
    },
    fireUp : { // functions called befor each state 
        play(){
            snake = new Snake();
            apple = new Apple();
            frameTime = 100;
            nextFrameAfter = 0;
            score = 0;
        },
        
        gameOver() {
            centeredText("Game Over",30,-20);
            centeredText("any key to start", 20, 40);
        },      
        readyToStart() {
            centeredText("Snakes on a plane",30,-20);
            centeredText("any key to start", 20, 40);
        },         
    },
    
    dustDown : { // functions called after each state
        play(){},
        gameOver(){},
        readyToStart(){},
    },
    
    get state() { gameStates.currentName },
    set state(name){ gameStates.nextState = name },
    currentName : null,
    nextState : "readyToStart",
    checkState(){
        if(gameStates.nextState !== null){
            if(gameStates.state){
                gameStates.dustDown[gameStates.state](); 
            }
            controls.clear();
            gameStates.fireUp[gameStates.nextState](); 
            gameStates.current = gameStates.animate[gameStates.nextState] ;
            gameStates.currentName = gameStates.nextState;
            gameStates.nextState = null;
        }
            
    },
    current(){},    
};

function mainLoop (time) {
    gameStates.checkState()
    gameStates.current(time)
    requestAnimationFrame(mainLoop);
}


function randInt(min, max) {
    return Math.random() * (max - min) + min | 0;
}

function drawBorder() {
    ctx.fillStyle = "Gray"; 
    ctx.fillRect(0, 0, width, blockSize);
    ctx.fillRect(0, height - blockSize, width, blockSize);
    ctx.fillRect(0, 0, blockSize, height);
    ctx.fillRect(width - blockSize, 0, blockSize, height);
}
function centeredText(text, size = 30, offset = 0){
    ctx.font = size + "px Verdana";
    ctx.fillStyle = "Black";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(text, width / 2, height / 2 + offset);    
}
function drawScore() {
    var offset = blockSize + 4;
    ctx.font = "20px Verdana";
    ctx.fillStyle = "Black";
    ctx.textAlign = "left";
    ctx.textBaseline = "top";
    ctx.fillText("Score: " + score, offset, blockSize);
}
function circle(x, y, fillCircle) {
    ctx.beginPath();
    ctx.arc(x, y, blockSize / 2, 0, PIx2);
    fillCircle ? (ctx.fill()) : (ctx.stroke());
}
//========================
// Block
//========================
var Block = function (col, row) {
    this.col = col;
    this.row = row;
}
Block.prototype = {
    drawSquare(color) {
        ctx.fillStyle = color;
        ctx.fillRect(
            this.col * blockSize, 
            this.row * blockSize, 
            blockSize, blockSize
        );
    }, 
    drawCircle(color) {
        ctx.fillStyle = color;
        circle(
            this.col * blockSize + blockSize / 2, 
            this.row * blockSize + blockSize / 2, 
            true
        );
    },
    equal(block) {
        return this.col === block.col && this.row === block.row;
    }
};

function Snake() {
    this.segments = [ new Block(7, 5), new Block(6, 5), new Block(5, 5) ];
    this.direction = directions.right;
    this.nextDirection = directions.right;
}
Snake.prototype = { 
    checkControls() {
        var i;
        for (i = 0; i < 4; i++) {

            if (controls[moves[i].name] && this.direction !== moves[i].blockMove) {
                this.nextDirection = i;
                return;
            }
        }
    },
    checkBodyAt(pos){
        for (const part of this.segments) {
            if (pos !== part && part.row === pos.row && part.col === pos.col) { return true }
        }
        return false;
    },
    checkCollision(head) {
        if (head.col <= 0 || head.row <= 0 ||  head.col > widthInBlocks ||  head.row > heightInBlocks) { 
            return true;
        }
        return this.checkBodyAt(head);
    },
    draw() {
        var i, len = this.segments.length;
        for (i = 0; i < len; i ++) {
            this.segments[i].drawSquare(`rgb(${((255 - ((255 / len | 0) * i) + randInt(0 - (len - i), len - i)))}, 0, 0)`);
        }
    },
    move() {
        this.direction = this.nextDirection;
        const move = moves[this.direction].movement;
        const newHead = this.segments.pop();
        const tRow = newHead.row;  // keep pos incase you eat an apple and need to grow.
        const tCol = newHead.col;
        newHead.col = this.segments[0].col + move[0];
        newHead.row = this.segments[0].row + move[1];
        if (this.checkCollision(newHead)) {
            gameStates.state = "gameOver";
        } else {
            this.segments.unshift(newHead)
            if (newHead.equal(apple.position)) {
                this.segments.push(new Block( tCol,tRow));
                score++;
                frameTime *= 0.95;
                if (apple.type === "superapple") {
                    frameTime *= 1.10;
                    score += 4;
                }
                apple.reset();
            }
        }
    }
};

function Apple() {
    this.position = new Block(10, 10);
}
Apple.prototype = {
    draw() {
        this.position.drawCircle(this.type === "superapple" ? "gold" : "limegreen");
    },
    reset() {
        var looking = true;  // looking for clear location
        while(looking) {
            this.position.row = Math.random() * (widthInBlocks - 4) + 2 | 0;
            this.position.col = Math.random() * (heightInBlocks - 4) + 2 | 0;
            looking = snake.checkBodyAt(this.position);
        }
        this.type = ((randInt(1, 11) === 1) && (score > 15)) ? "superapple" : "apple";
    }
};
<canvas id="canvas" width ="200" height="200"></canvas>

\$\endgroup\$
7
  • 2
    \$\begingroup\$ Regarding: "You can access DOM elements by their Id directly..." Never do this. It's not standardized and can be easily broken if you accidentally use a variable of the same name. Always use getElementById. \$\endgroup\$
    – RoToRa
    May 11, 2018 at 14:22
  • \$\begingroup\$ @RoToRa The arguments against direct reference do not hold water. Any variable can be overwritten, (apart from const). All browsers support direct reference and it forces you to ensure that ID's and names are unique to the page. If you need to use getElementById then you have something wrong on the page and should fix it \$\endgroup\$
    – Blindman67
    May 11, 2018 at 14:44
  • \$\begingroup\$ I also found you can't modify an element's style by its global variable... So I'm sticking with getElementById Apart from that, everything in this answer was helpful. \$\endgroup\$ May 11, 2018 at 14:44
  • \$\begingroup\$ @FreezePhoenix You can modify the element's style when you use direct reference. It is identical to any element located via a query. The only difference is that the direct reference query is done under the hood and it is faster and less prone to error. \$\endgroup\$
    – Blindman67
    May 11, 2018 at 14:48
  • \$\begingroup\$ @Blindman67 canvas.style == undefined //true Seems more prone to error to me. \$\endgroup\$ May 11, 2018 at 14:49

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

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