3
\$\begingroup\$

I've implemented Conway's Game Of Life with pure JavaScript and HTML5 Canvas. I'm not very good at JS patterns, so are there any ideas on how I can improve this code?

For Online demo refer to this link.

For the GitHub repository, refer to this link.

A sample screenshot of the code:

enter image description here

Here is the full code:

"use strict";
<!-- Written by Levent Divilioglu -->
<!-- 25.03.2017                   -->
<!-- En buyuk FENERBAHCE          -->
<!-- Fenerbahce SK #1             -->
    (function () {
        // globals : START
        var CANVAS              = document.getElementById('mainCanvas');
        var CTX                 = CANVAS.getContext("2d");
        var BUTTON              = document.getElementById('btn');
        var GENERATE_BUTTON     = document.getElementById('generateBtn');
        var RESTART_BUTTON      = document.getElementById('restartBtn');
        var CELL_SIZE_BUTTON    = document.getElementById('cellSizeBtn');

        var WIDTH               = CANVAS.width;
        var HEIGHT              = CANVAS.height;

        var CELL_SIZE           = 20;

        var CELL_WIDTH          = CELL_SIZE;
        var CELL_HEIGHT         = CELL_SIZE;
        var CELL_COLOR          = '#00FF00';
        var BACKGROUND_COLOR    = '#000000';

        var TIME_INTERVAL_IN_MS = 10;

        var globals = {
            ms:         TIME_INTERVAL_IN_MS,
            c:          CTX,
            w:          WIDTH,
            h:          HEIGHT,
            cellW:      CELL_WIDTH,
            cellH:      CELL_HEIGHT,
            color:      CELL_COLOR,
            bgColor:    BACKGROUND_COLOR,
            btn:        BUTTON,
            genBtn:     GENERATE_BUTTON,
            resBtn:     RESTART_BUTTON,
            cszBtn:     CELL_SIZE_BUTTON,
            animState:  false,
            anim:       '', // setInterval placeholder
            cells:      []  // array placeholder
        }
        // globals : END

        // initialization
        init(globals);
    })();

    function init(globals) {
        resetScreen(globals);

        globals.btn.innerText = "Start";

        // main button click event handler
        globals.btn.addEventListener("click", function() {
            if(!globals.animState) {
                globals.animState = true;
                globals.btn.innerText = "Pause";
                globals.anim = setInterval(function() {
                    mainLoop(globals);
                }, globals.ms);
                globals.genBtn.disabled = true;
                globals.cszBtn.disabled = true;
            } else {
                globals.animState = false;
                globals.btn.innerText = "Start";
                clearInterval(globals.anim);
                globals.genBtn.disabled = false;
                globals.cszBtn.disabled = false;
            }
        });

        // generate button click event handler
        globals.genBtn.addEventListener("click", function() {
            if(!globals.animState) {
                resetScreen(globals);
                randomizeCells(globals);
                printMatrix(globals);
            }
        });

        globals.cszBtn.addEventListener("change", function() {
            var cellSize = this.value - 0;  // string to number

            globals.cellW = cellSize;
            globals.cellH = cellSize;
            resetScreen(globals);
            randomizeCells(globals);
            printMatrix(globals);
        });

        randomizeCells(globals);
        printMatrix(globals);
    }

    /* pre-initialize cells */
    function randomizeCells(g) {
        var newArray = [];

        var maxRow = g.w / g.cellW;
        var maxCol = g.h / g.cellH;

        console.log("row: " + maxRow + ", col: " + maxCol);

        for(var i = 0; i < maxRow; i++) {
            for(var j = 0; j < maxCol; j++) {
                var cell = {
                    x: i,
                    y: j,
                    state: getRandomState() // 2 states: alive, dead
                }

                newArray.push(cell);
            }
        }

        g.cells = newArray;
    }

    /* close range: [0, max] */
    function getRandom(max) {
        return Math.floor(Math.random()*(max+1));
    }

    function getRandomBool() {
        return getRandom(1) === 1 ? true : false;
    }

    function getRandomState() {
        if(getRandomBool()) {
            return "alive";
        }

        return "dead";
    }

    /* g -> globals */
    function resetScreen(g) {
        clearScreen(g);
        printGrid(g);
    }

    function clearScreen(g) {
        g.c.fillStyle= g.bgColor;
        g.c.fillRect(0, 0, g.w, g.h);
    }

    function printGrid(g) {
        for(var i = 0; i < g.w; i+= g.cellW) {
            line(g, i, 0, i, g.h);
        }
        line(g, g.w, 0, g.w, g.h);

        for(var j = 0; j < g.h; j+= g.cellH) {
            line(g, 0, j, g.w, j);
        }
        line(g, 0, g.h, g.w, g.h);
    }

    function line(g, x0, y0, x, y) {
        g.c.beginPath();
        g.c.moveTo(x0, y0);
        g.c.lineTo(x , y) ;
        g.c.lineWidth = 3;

        // set line color
        g.c.strokeStyle = g.color;
        g.c.stroke();
    }

    function fillSquare(g, x, y) {
        g.c.fillStyle = g.color;
        g.c.fillRect(x*g.cellW, y*g.cellH, g.cellW, g.cellH);
    }

    function getState(g, x, y) {
        for(var i = 0; i < g.cells.length; i++) {
            if(g.cells[i].x === x && g.cells[i].y === y) {
                return g.cells[i].state;
            }
        }
    }

    function printMatrix(g) {
        for(var i = 0; i < g.cells.length; i++) {
            if(g.cells[i].state === "alive") {
                fillSquare(g, g.cells[i].x, g.cells[i].y);
            }
        }
    }

    function mainLoop(g) {
        processMatrix(g);
        resetScreen(g);
        printMatrix(g);
    }

    function processMatrix(g) {
        var newArray = [];

        for(var i = 0; i < g.cells.length; i++) {
            var newCell = {
                x: g.cells[i].x,
                y: g.cells[i].y,
                state: handleCellState(g, g.cells[i].x, g.cells[i].y)
            };

            newArray.push(newCell);
        }

        g.cells = newArray;
    }

    /*
        Any live cell with fewer than two live neighbours dies, as if caused by underpopulation.
        Any live cell with two or three live neighbours lives on to the next generation.
        Any live cell with more than three live neighbours dies, as if by overpopulation.
        Any dead cell with exactly three live neighbours becomes a live cell, as if by reproduction.
    */
    function handleCellState(g, x, y) {
        var count = countCellNeighboors(g, x, y);

        if(getState(g, x, y) === "alive") {
            if(count < 2) {
                return "dead";          // dead by underpopulation
            } else if (count <= 3) {
                return "alive";
            } else {
                return "dead";          // dead by overpopulation
            }
        } else if(getState(g, x, y) === "dead") {
            if(count === 3) {
                return "alive";
            } else {
                return "dead";
            }
        }

    }

    function countCellNeighboors(g, x, y) {
        var count = 0;

        for(var i = x-1; i <= x+1 ; i++) {
            for(var j = y-1; j <= y+1; j++) {
                if(i==x && j == y) {
                    continue;
                }
                if(getState(g, i, j) === "alive") { // for invalid cells like [-1,-1], undefined values will be ignored
                    count++;
                }
            }
        }

        return count;
    }
\$\endgroup\$
3
\$\begingroup\$

Performance:

Drawing each cell per context.fillRect() is slow. A faster approach is to assign each cell a single pixel on the canvas and stretch the canvas to the desired size. In order to prevent blurring caused by interpolation, you need to set the canvas's image-rendering style as detailed here.

Drawing a grid per printGrid is slow. A slightly faster and cleaner way to draw a grid is via:

function drawGrid(ctx, width, height, dx, dy) {
  ctx.beginPath();
  for (let x = 0; x <= width; x += dx) {
    ctx.moveTo(x, 0);
    ctx.lineTo(x, height);
  }
  for (let y = 0; y <= height; y += dy) {
    ctx.moveTo(0, y);
    ctx.lineTo(width, y);
  }
  ctx.stroke();
}

Still, calling this function each frame is slow. A much faster approach is to draw the static grid only once at startup on a background canvas beneath your transparent original canvas. Alternatively, you can set your original canvas's backgroundImage style to mimic a grid.

Having to search through all cells in order to get the state at position x, y via getState(g, x, y) is slow. An alternative approach is to create a sparse hash map from coordinate to state, e.g. via cells = {}; cell[x + y * width] = true; for only the living cells. Such a data structure would improve performance on big worlds with sparse population. Alternatively, fill an array cells = new Array(width * height) with 0 or 1 depending on whether the cell is dead or alive. Such a data structure would guarantee equal performance no matter how densely or sparsely populated your world is.

Counting each cell's neighbors individually is slow. Neighboring pixel have many neighbors in common. By exploiting this, we can speed up the computation of the world's next state. We can iterate through cell rows and accumulate the neighbors for those cells in an accumulator array acc. We then update the previous cell row based on that accumulator.

Globals:

Your usage of a globals object doesn't really change the fact that globals are problematic. Part of your globals can be moved to the world's or game's configuration. Another part actually holds your world's or game's internal state. The references to DOM elements should be moved to the part of your code which handles the UI / output.

It is a good practice to separate your world model from its representation on the canvas. You can encapsulate the world state (cells, width, height) and the state change logic in its own module or class. A separate part of your program then handles updating and drawing the model to your canvas.

Exemplary code:

This code features improvements and ideas suggested above:

class World {
  constructor(width, height) {
    this.width = width;
    this.height = height;
    this.cells = new Array(width * height);
    this.acc = new Array(3 * width);
  }
  randomize(density = 0.5) {
    let cells = this.cells,
        random = Math.random;
    
    for (let i = 0, length = cells.length; i < length; ++i) {
      cells[i] = random() + density | 0;
    }
  }
  clear() {
    let cells = this.cells;
    
    for (let i = 0, length = cells.length; i < length; ++i) {
      cells[i] = 0;
    }
  }
  step() {
    let cells = this.cells,
        height = this.height,
        width = this.width,
        acc = this.acc,
        accLength = acc.length;

    // Initialize first two accumulator rows with zero:
    for (let x = 0; x < width + width; ++x) {
      acc[x] = 0;
    }

    // Initialize third accumulator row with top cell row:
    acc[width + width] = cells[0] + cells[1];
    acc[width + width + width - 1] = cells[width - 2] + cells[width - 1];
    for (let x = 1; x < width - 1; ++x) {
      let sum = cells[x] + cells[x - 1] + cells[x + 1];
      acc[x + width + width] = sum;
    }

    // Iterate up to the second last cell row:
    for (let y = 0, end = height * width - width; y < end; y += width) {

      // Update accumulator rows:
      acc[y % accLength] = cells[y + width] + cells[1 + y + width];
      acc[(width - 1 + y) % accLength] = cells[width - 2 + y + width] + cells[width - 1 + y + width];
      for (let x = 1; x < width - 1; ++x) {
        let i = x + y + width;
        acc[(x + y) % accLength] = cells[i] + cells[i - 1] + cells[i + 1];
      }

      // Update cells:
      for (let x = 0; x < width; ++x) {
        let sum = acc[x] + acc[x + width] + acc[x + width + width] - cells[x + y];
        if (sum == 3) cells[x + y] = 1;
        else if (sum != 2) cells[x + y] = 0;
      }
    }

    // Update last cell row:
    let y = height * width - width;
    for (let x = 0; x < width; ++x) {
      let sum = acc[(x + y - width) % accLength] + acc[(x + y) % accLength] - cells[x + y];
      if (sum == 3) cells[x + y] = 1;
      else if (sum != 2) cells[x + y] = 0;
    }
  }
}

class Game {
  constructor(canvas, size = 10) {
    this.canvas = canvas;
    this.context = canvas.getContext("2d");
    this.initialize(size);
    this.interval = undefined;
  }
  initialize(size) {
    let cellSize = this.canvas.clientWidth / size;

    this.world = new World(size, size);
    this.world.randomize();
    
    this.canvas.width = size;
    this.canvas.height = size;
    this.canvas.style.backgroundImage = "repeating-linear-gradient(0deg, #cbb, transparent " + cellSize + "px),repeating-linear-gradient(-90deg, #cbb, transparent " + cellSize + "px)";
    
    this.draw(this.context);
  }
  update() {
    this.world.step();
    this.draw(this.context);
  }
  start(speed = 25) {
    clearInterval(this.interval);
    this.interval = setInterval(this.update.bind(this), speed);
  }
  stop() {
    clearInterval(this.interval);
    this.interval = undefined;
  }
  running() {
    return !!this.interval;
  }
  draw(context) {
    let imageData = context.getImageData(0, 0, this.world.width, this.world.height),
        data = imageData.data,
        cells = this.world.cells;
    
    for (let i = 0, length = data.length; i < length; i += 4) {
      if (cells[i >> 2]) {
        data[i    ] = 0;
        data[i + 1] = 0;
        data[i + 2] = 0;
        data[i + 3] = 200;
      } else {
        data[i + 3] = 0;
      }
    }
    context.putImageData(imageData, 0, 0);
  }
}

let canvas = document.getElementById('canvas'),
    game = new Game(canvas),
    startBtn = document.getElementById("btn-start"),
    generateBtn = document.getElementById("btn-generate"),
    sizeBtn = document.getElementById("btn-size");

startBtn.addEventListener("click", function(event) {
  if (game.running()) {
    game.stop();
    startBtn.textContent = "Start";
  } else {
    game.start();
    startBtn.textContent = "Stop";
  }
});

generateBtn.addEventListener("click", function(event) {
  game.initialize(+sizeBtn.value);
});

sizeBtn.addEventListener("change", function(event) {
  game.initialize(+sizeBtn.value);
});

game.initialize(+sizeBtn.value);
#stage {
  position: relative;
  width: 300px;
  height: 300px;
}

#stage canvas {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  image-rendering: optimizeSpeed;
  image-rendering: -moz-crisp-edges;
  image-rendering: -webkit-optimize-contrast;
  image-rendering: -o-crisp-edges;
  image-rendering: pixelated;
  -ms-interpolation-mode: nearest-neighbor;
}
<div id="stage">
  <canvas id="canvas"></canvas>
</div>
<button id="btn-generate">Generate</button>
<button id="btn-start">Start</button>
<select id="btn-size">
  <option value="10">10 x 10</option>
  <option value="50" selected>50 x 50</option>
  <option value="100">100 x 100</option>
  <option value="300">300 x 300</option>
</select>

\$\endgroup\$

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.