Conways Game of Life: Part 1

Chris Rodgers · 21 June, 2015

One of the things I like to do in my spare time are Code Katas. For the uninitiated, code katas are simple exercises so you can practise coding. The exercise I have assigned myself is to create an implementation of Conway’s Game of Life using Javascript.

Conway’s Game of Life is described as a zero player game because, once the initial layout has been set, the game plays itself. It is a 2D grid based game where each cell has (up to) 8 neighbours and can be described as “dead” or “alive”. For each round of the game the following rules are applied to each cell;

  • If the cell is alive and has fewer than 2 living neighbours it dies due to underpopulation.
  • If the cell is alive and has more than 3 living neighbours it dies due to overpopulation.
  • If the cell is dead and has exactly 3 living neighbours it becomes live due to reproduction.

My implementation splits the code into 2 parts - the underlying game engine and the game/view component that interacts with the user interface. The game engine will accept a seed argument, which will be a 2D array of numbers, where 1 will represent a living node and 0 a dead node. It will also expose a property to change the seed and a function named mutate, which will run the rules against the board. I have used the revealing prototype pattern to expose only the members I want to expose and keep internal logic safely internal.

(function() {
  GameEngine = function(seed) {
    this.seed = seed;
  };

  GameEngine.prototype = (function() {
    var clone2DArray = function(array) {
      return array.slice().map(function (row) { return row.slice(); });
    };

    var calculateLiveNeighbours = function(array, x, y) {
      var previousRow = array[y-1] || [];
      var nextRow = array[y+1] || [];

      return [
        previousRow[x-1], previousRow[x], previousRow[x+1],
        array[y][x-1], array[y][x+1],
        nextRow[x-1], nextRow[x], nextRow[x+1]
      ].reduce(function(previous, current) {
        return previous + current;
      }, 0);
    };

    return {
      set seed(array) {
        this.height = array.length;
        this.width = array[0].length;
        this.board = clone2DArray(array);
      },
      mutate: function() {
        var board = clone2DArray(this.board);

        for (var y = 0; y < this.height; y++) {
          for (var x = 0; x < this.width; x++) {
            var liveNeighbours = calculateLiveNeighbours(board, x, y);
            var isAlive = !!this.board[y][x];

            if (isAlive) {
              if (liveNeighbours < 2 || liveNeighbours > 3) {
                this.board[y][x] = 0;
              }
            } else {
              if (liveNeighbours === 3) {
                this.board[y][x] = 1;
              }
            }
          }
        }
      }
    };
  })();
})();

The game itself requires the element in which it will run in the constructor plus the (optional) initial size and maximum size of the grid. The game creates all of the controls required, including a start/stop button, a reset button and a grid size select. The nodes are represented using (heavily styled) checkboxes and a table is used to organise them into a grid. The game exposes the size property and start, stop and reset methods.

(function() {
  function hasClass(element, className) {
    return element.className.match(new RegExp('(\\s|^)' + className + '(\\s|$)'));
  }

  function addClass(element, className) {
    if (!hasClass(element, className)) {
      element.className += " " + className;
    }
  }

  function removeClass(element, className) {
    if (hasClass(element, className)) {
      var regex = new RegExp('(\\s|^)' + className + '(\\s|$)');
      element.className = element.className.replace(regex, ' ');
    }
  }

  Game = (function() {
    var createTrigger = function(game) {
      var trigger = document.createElement('button');
      addClass(trigger, 'trigger');
      trigger.addEventListener('click', function() {
        if (game.started) {
          game.stop();
        } else {
          game.start();
        }
      });
      return trigger;
    };

    var createResetButton = function(game) {
      var resetButton = document.createElement('button');
      resetButton.innerText = 'Reset';
      resetButton.addEventListener('click', function() {
        game.reset();
      });
      return resetButton;
    };

    var createGrid = function(game) {
      var grid = document.createElement('table');
      grid.addEventListener('change', function(event) {
        if (event.target.nodeName.toLowerCase() === 'input') {
          game.stop();
        }
      });
      grid.addEventListener('keyup', function(event) {
        var node = event.target;
        if (node.nodeName.toLowerCase() === 'input') {
          var y = node.position[0];
          var x = node.position[1];
          switch (event.keyCode) {
            case 37:
              if (x > 0) {
                game.nodes[y][x-1].focus();
              }
              break;
            case 38:
              if (y > 0) {
                game.nodes[y-1][x].focus();
              }
              break;
            case 39:
              if (x < game.size - 1) {
                game.nodes[y][x+1].focus();
              }
              break;
            case 40:
              if (y < game.size - 1) {
                game.nodes[y+1][x].focus();
              }
              break;
          }
        }
      });
      return grid;
    };

    var createSizeSelector = function(game, initialSize, maxSize) {
      var selector = document.createElement('select');
      selector.setAttribute('name', 'size-selector');
      addClass(selector, 'size-selector');
      for (var i = initialSize; i <= maxSize; i++) {
        var option = document.createElement('option');
        if (i === initialSize) {
          option.setAttribute('selected', 'selected');
        }
        option.value = i;
        option.text = i;
        selector.appendChild(option);
      }
      selector.addEventListener('change', function() {
        game.size = selector.options[selector.selectedIndex].value;
      });
      var container = document.createElement('div');
      addClass(container, 'size-selector-container');
      var label = document.createElement('label');
      label.innerText = 'Grid size';
      label.setAttribute('for', 'size-selector');
      container.appendChild(label);
      container.appendChild(selector);
      return container;
    };

    return function(element, initialSize, maxSize) {
      initialSize = initialSize || 10;
      maxSize = maxSize || 25;

      this.started = false;
      this.element = element;
      addClass(this.element, 'conway-game');

      var fragment = document.createDocumentFragment();
      fragment.appendChild(createTrigger(this));
      fragment.appendChild(createResetButton(this));
      this.grid = createGrid(this);
      fragment.appendChild(this.grid);
      fragment.appendChild(createSizeSelector(this, initialSize, maxSize))
      this.element.appendChild(fragment);

      this.size = initialSize;
    };
  })();

  Game.prototype = (function() {
    var gridSize = 0,
        runner = null,
        createBoard = function(nodes) {
          return nodes.map(function (row) {
            return row.map(function (node) {
              return +node.checked;
            });
          });
        },
        initialise = function () {
          var fragment = document.createDocumentFragment();
          this.grid.innerHTML = '';
          this.nodes = [];

          for (var y = 0; y < this.size; y++) {
            var row = document.createElement('tr');
            this.nodes[y] = [];

            for (var x = 0; x < this.size; x++) {
              var node = document.createElement('input');
              node.type = 'checkbox';
              node.position = [y, x];
              this.nodes[y][x] = node;

              var cell = document.createElement('td');
              cell.appendChild(node);
              row.appendChild(cell);
            }
            fragment.appendChild(row);
          }

          this.grid.appendChild(fragment);
        };

    return {
      get size() {
        return gridSize;
      },
      set size(value) {
        if (this.size !== value) {
          gridSize = value;
          initialise.call(this);
        }
      },
      start: function() {
        var game = this;
        game.started = true;
        addClass(game.element, 'running');
        var engine = new GameEngine(createBoard(game.nodes));
        runner = setInterval(function() {
          engine.mutate();
          for (var y = 0; y < game.size; y++) {
            for (var x = 0; x < game.size; x++) {
              game.nodes[y][x].checked = !!engine.board[y][x];
            }
          }
        }, 1000);
      },
      stop: function() {
        if (runner) {
          clearInterval(runner);
          runner = null;
          this.started = false;
          removeClass(this.element, 'running');
        }
      },
      reset: function() {
        this.stop();
        for (var y = 0; y < this.size; y++) {
          for (var x = 0; x < this.size; x++) {
            this.nodes[y][x].checked = false;
          }
        }
      }
    }
  })();
})();

Finally, here it is in all it’s glory…

Twitter, Facebook