6
\$\begingroup\$

I'm trying to reach good understanding of how to build scalable modular application structure. Recently I've been practicing with canvas and rewriting one of the popular snake game realizations. I've decided to try to split its code to modules. The codebase is not big and it seems to be a pretty nice exercise to me.

The game is a classic snake game - user controls the snake with arrow keys, when snake collides with food it grows, when the snake collides with screen border or itself it dies and the game's over.

The game involves a game object, snake object and food object. I've tried to split the code to store each of these objects in a separate module and import all of these in the main application file, so I have the main main.js file, snake.js file, game.js and food.js.

snake.js represents the snake object and its behavior

import game from './game';
import food from './food';

let canvas = document.getElementById("game_field");
let context = canvas.getContext("2d");

let snake = {
    size: canvas.width / 40,
    x: null,
    y: null,
    color: '#0F0',
    direction: 'left',
    sections: [],

    init: function () {
        snake.sections = [];
        snake.direction = 'left';
        snake.x = canvas.width / 2 + snake.size / 2;
        snake.y = canvas.height / 2 + snake.size / 2;
        for (let i = snake.x + (5 * snake.size) ; i >= snake.x; i -= snake.size) {
            snake.sections.push(i + ',' + snake.y);
        }
    },

    move: function () {
        let directions = {
            'up': function () {
                snake.y -= snake.size;
            },
            'down': function () {
                snake.y += snake.size;
            },
            'left': function () {
                snake.x -= snake.size;
            },
            'right': function () {
                snake.x += snake.size;
            }
        };
        directions[snake.direction]();
        snake.checkCollision();
        snake.checkGrowth();
        snake.sections.push(snake.x + ',' + snake.y);
    },

    draw: function () {
        for (let i = 0; i < snake.sections.length; i++) {
            snake.drawSection(snake.sections[i].split(','));
        }
    },

    drawSection: function (section) {
        game.drawBox(parseInt(section[0]), parseInt(section[1]), snake.size, snake.color);
    },

    checkCollision: function () {
        if (snake.isCollision(snake.x, snake.y) === true) {
            game.stop();
        }
    },

    isCollision: function (x, y) {
        if (x < snake.size / 2 ||
            x > canvas.width ||
            y < snake.size / 2 ||
            y > canvas.height ||
            snake.sections.indexOf(x + ',' + y) >= 0) {
            return true;
        }
    },

    checkGrowth: function () {
        if (snake.x == food.x && snake.y == food.y) {
            game.score++;
            if (game.score % 5 == 0 && game.fps < 60) {
                game.fps++;
            }
            food.set();
        } else {
            snake.sections.shift();
        }
    }
};

export default snake;

food.js represents food for snake and its spawning

import game from './game';
import snake from './snake';

let food = {
    size: null,
    x: null,
    y: null,
    color: '#0FF',

    set: function () {
        food.size = snake.size;
        food.x = (Math.ceil(Math.random() * 10) * snake.size * 4) - snake.size / 2;
        food.y = (Math.ceil(Math.random() * 10) * snake.size * 3) - snake.size / 2;
    },

    draw: function () {
        game.drawBox(food.x, food.y, food.size, food.color);
    }
};
export default food;

game.js is responsible for the game itself:

import snake from './snake';
import food from './food';

let canvas = document.getElementById("game_field");
let context = canvas.getContext("2d");

let game = {
    score: 0,
    fps: 8,
    over: false,
    message: null,

    start: function () {
        game.over = false;
        game.message = null;
        game.score = 0;
        game.fps = 8;
        snake.init();
        food.set();
    },

    stop: function () {
        game.over = true;
        game.message = 'GAME OVER';
    },
    // draws a square box with leg length of size around (x, y) point on canvas
    drawBox: function (x, y, size, color) {
        context.fillStyle = color;
        context.beginPath();
        context.moveTo(x - (size / 2), y - (size / 2));
        context.lineTo(x + (size / 2), y - (size / 2));
        context.lineTo(x + (size / 2), y + (size / 2));
        context.lineTo(x - (size / 2), y + (size / 2));
        context.closePath();
        context.fill();
    },
    // places score to notification area
    drawScore: function () {
        message_area.innerHTML = game.score;
    },
    // places message to notification area
    drawMessage: function () {
        if (game.message !== null) {
            message_area.innerHTML = game.message;
        }
    },
    // clears the whole canvas region
    resetCanvas: function () {
        context.clearRect(0, 0, canvas.width, canvas.height);
    }
};

export default game;

main.js is an entry point

import game from './game';
import snake from './snake';
import food from './food';

window.onload = function () {

    let inverseDirection = {
        'up': 'down',
        'left': 'right',
        'right': 'left',
        'down': 'up'
    };

    let keys = {
        up: [38, 75, 87],
        down: [40, 74, 83],
        left: [37, 65, 72],
        right: [39, 68, 76],
        start_game: [13, 32],
        getKey: function (value) {
            for (let key in this) {
                if (this[key] instanceof Array && this[key].indexOf(value) >= 0) {
                    return key;
                }
            }
            return null;
        }
    };

    window.addEventListener("keydown", function (e) {
        let lastKey = keys.getKey(e.keyCode);
        if (['up', 'down', 'left', 'right'].indexOf(lastKey) >= 0
            && lastKey != inverseDirection[snake.direction]) {
            snake.direction = lastKey;
        } else if (['start_game'].indexOf(lastKey) >= 0 && game.over) {
            game.start();
        }
    }, false);

    var requestAnimationFrame = window.requestAnimationFrame ||
          window.webkitRequestAnimationFrame ||
          window.mozRequestAnimationFrame;

    function loop() {
        if (game.over == false) {
            game.resetCanvas();
            game.drawScore();
            snake.move();
            food.draw();
            snake.draw();
            game.drawMessage();
        }
        setTimeout(function () {
            requestAnimationFrame(loop);
        }, 1000 / game.fps);
    };

    requestAnimationFrame(loop);
};

As you can see, the code is not split in fact. I currently need to import all the other modules to every module. I also need to get canvas context in almost all of them. This looks lame. What decisions, ideas and patterns could help me to make this truly split scalable and modular and to get better understanding of these things? What changes would you suggest?

\$\endgroup\$
  • \$\begingroup\$ Welcome to Code Review! We all want to make our code better and more reusable, you should write a title that says something about what your code does. \$\endgroup\$ – Phrancis Feb 10 '16 at 21:18
1
\$\begingroup\$

Think of what would remain from the game if the only way to interact with it would be through function calls:

var game = new SnakeGame({rows: 20, cols: 20});
game.getFoodLocation(); // => {row: 3, col: 8}
game.isOver(); // => false
game.getSnakeLength(); // => 5
game.getSnakeSegmentLocation(0); // => {row: 8, col: 10}
game.getSnakeSegmentLocation(1); // => {row: 9, col: 10}
game.setSnakeDirection('left');
game.advance();

This is something that incorporates the rules and the state of the game without details such as how you want to render it, or how you want to control it.

Now you can have another module, responsible only for drawing the current state of the game. It doesn't know the rules, it doesn't know where the food or the snake is right now, but it can ask the game object. This module can have submodules responsible for drawing the snake, the box, the food.

You also need to make changes to the game based on keypresses. So you have another module that listens to the keyboard events, and calls game.setSnakeDirection().

This separation of responsibilities is at the core of Model-View-Controller design pattern. The modules can be defined absolutely independently, without importing each other. Of course, the view and the controller need to access the model, but you can connect them in your main program:

import SnakeGame from 'snake-game';
import SnakeController from 'snake-controller';
import SnakeView from 'snake-view';

var game = new SnakeGame({rows: 20, cols: 20});
var controller = new SnakeController(game);
var view = new SnakeView(game, document.querySelector('canvas'));

controller.activate();
view.render();

function loop() {
    game.advance();
    view.render();
}

I've implemented the snake game using this approach before, and it worked really well. Since the graphic representation was completely separated from the game logic, it was really easy to add replay. And because the model didn't contain any code related to time or smooth movement (which was implemented in the view), it was easy to reuse the model for the needs of AI.

\$\endgroup\$
1
\$\begingroup\$
setTimeout(function () {
  requestAnimationFrame(loop);
}, 1000 / game.fps);

If you are modifying the rate the game plays, I suggest you modify the rate you update state rather than manipulate the game loop speed. If you've seen videos of Fallout 4, you fall faster at 120fps than in 60fps - and you don't want that. You want a consistently progressing game.

There are a lot of ways to update state consistently without being affected by fps. Suggesting you read about "delta time", where position is adjusted depending on actual time elapsed rather than about frames elapsed.

const jobQueue = [];
let lastFrame = Date.now();

function start(thisFrame){
  requestAnimationFrame(start);

  const elapsed = thisFrame - lastFrame;
  const animationScale= elapsed / (1000/60);
  lastFrame = thisFrame;

  // Execute everything in the queue.
  jobQueue.forEach(t => t.call(null, animationScale));
}

export { jobQueue, start }

The case above is a simple game loop. It has jobQueue which is an array of things to do per frame. Then the loop itself, which is just a recursive function. We then calculate animationScale which we'll use to adjust rendering depending on fluctuations of the framerate. The slower the frames, the higher the scale to compensate for the lag. So a snake running 60 units per second will still run 60 units per second even at 40fps.

Now in order to split up your code, I suggest you create objects that only have 2 methods, update and render. Each is called on every frame, update updates the object's state while render does object-specific rendering. The following sections are their own modules.

// Define snake
const snake = {
  direction: ...,
  speed: ...,
  x: ...,
  y: ...,
  update(animationScale){}, // update x and y depending on direction and speed
  render(context){},
};

// Define food
const food = {
  x: ...,
  y: ...,
  update(animationScale){},
  render(context){},
};

// Define the canvas
const canvas = ...,
const context = ...,

// Define the keyboard event handlers
// and so on...

All of these are unaware of each other. To make them all meet, you have your game object which houses the mechanics of your game. It knows about the snake and the food, spawns food, modifies snake's state, knows if food and snake collide, if snake and wall collide, what keys were pressed, the canvas, the score etc.

import snake
import food
import canvas
import keyboard
import gameloop

const game = {
  canvas: ...,
  keyPressed: ...,
  snake: ...,
  food: ...,
  update(animationScale){
    /* update snake state */
    /* update food state */
    /* check collisions and do other stuff */
  },
  render(){
    /* render snake */
    /* render food */
    /* render other stuff */
  },
  init(){
    /* listen for kb events and update keyPressed */
    /* register to game loop */
    gameLoop.jobQueue.push(this.update, this.render);
    gameLoop.start();
  }
}

That's about it.

\$\endgroup\$
  • \$\begingroup\$ Maybe it's possible to get rid of defining the canvas in every module, and inject it as a dependency somehow, make things totally abstract? Or maybe it's not a good idea? \$\endgroup\$ – Tristan Tzara Feb 11 '16 at 10:21
  • \$\begingroup\$ And also, do you think that rendering should belong to objects like snake and food themselves or maybe it's a better idea to place it into separate view layer, as described in Alexey Lebedev's reply below? \$\endgroup\$ – Tristan Tzara Feb 11 '16 at 10:33
  • \$\begingroup\$ And thank you for that game loop stuff, it's really cool and important info! \$\endgroup\$ – Tristan Tzara Feb 11 '16 at 11:04

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.