So I've implemented a simple Game of Life in Javascript and canvas, and I thought it was working perfectly (fixed timestep, a temporary 'next board' to store changes until they're needed, etc.) but when I added a 'glider' pattern it didn't behave as expected. They shift slightly but then stop.
I've gone over the code a hundred times and can't see anything wrong, but I'm sure it's a simple error I'm making somewhere. Code below. Any advice much appreciated!
UPDATE:
I was failing to deep copy the array, as Jonas pointed out below. I've fixed that now, and the simulation now works as the Game of Life is supposed to. (Thanks Jonas!)
Updated code below. Unfortunately the glider issue is still there - they move correctly for the first frame of the simulation and then stop completely. If anyone can spot the remaining error I'd be very grateful.
let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d');
const tableSize = 64;
const cellSize = 4;
let tickDelay = 60;
let table = [];
let loop;
let deadChance = 0.5;
const colors = {
alive: '#f2b630',
dead: '#333'
};
function init() {
//build table
table = [];
for (let y = 0; y < tableSize; y++) {
let row = [];
for (let x = 0; x < tableSize; x++) {
let randomAlive = true;
if (Math.random() > deadChance) {
randomAlive = false;
}
let cell = new Cell(x, y, randomAlive);
row.push(cell);
}
table.push(row);
}
}
function tick() {
console.log("tick");
table = table.map(row => row.map(cell => cell.tick()));
render();
}
function render() {
for (let y = 0; y < tableSize; y++) {
for (let x = 0; x < tableSize; x++) {
table[x][y].draw();
}
}
}
function start() {
console.log("Starting");
loop = setInterval(tick, tickDelay);
}
function stop() {
console.log("Stopping");
clearInterval(loop);
}
function reset() {
console.log("Resetting");
clearInterval(loop);
init();
render();
}
class Cell {
constructor(x, y, isAlive) {
//The x and y values are table indices, not pixel values
this.x = x;
this.y = y;
this.isAlive = isAlive;
}
tick() {
let currentNeighbours = getNeighbours(this.x, this.y);
let numAliveNeighbours = 0;
for (let i = 0; i < currentNeighbours.length; i++) {
if (currentNeighbours[i].isAlive) {
numAliveNeighbours++;
}
}
switch (numAliveNeighbours) {
case 0: this.makeDead(); break;
case 1: this.makeDead(); break;
case 2: break;
case 3: this.makeAlive(); break;
case 4: this.makeDead(); break;
case 5: this.makeDead(); break;
case 6: this.makeDead(); break;
case 7: this.makeDead(); break;
case 8: this.makeDead(); break;
}
return new Cell(this.x, this.y, this.isAlive);
}
draw() {
if (this.isAlive) {
ctx.fillStyle = colors.alive;
} else {
ctx.fillStyle = colors.dead;
}
let margin = 1;
ctx.fillRect(this.x * cellSize + (this.x * margin), this.y * cellSize + (this.y * margin), cellSize, cellSize);
}
makeAlive() {
this.isAlive = true;
}
makeDead() {
this.isAlive = false;
}
}
//Helper functions
function getNeighbours(x, y) {
//return a list of all eight neighbours of this cell in North-East-South-West (NESW) order
let result = [];
//wrap at the edges of the table for each neighbour
let targetX;
let targetY;
//get NORTH neighbour
targetX = x;
targetY = y-1;
if (targetY < 0)
targetY = tableSize-1;
result.push(table[targetX][targetY]);
//get NORTHEAST neighbour
targetX = x+1;
targetY = y-1;
if (targetY < 0)
targetY = tableSize-1;
if (targetX > tableSize-1)
targetX = 0;
result.push(table[targetX][targetY]);
//get EAST neighbour
targetX = x+1;
targetY = y;
if (targetX >= tableSize)
targetX = 0;
result.push(table[targetX][targetY]);
//get SOUTHEAST neighbour
targetX = x+1;
targetY = y+1;
if (targetY > tableSize-1)
targetY = 0;
if (targetX > tableSize-1)
targetX = 0;
result.push(table[targetX][targetY]);
//get SOUTH neighbour
targetX = x;
targetY = y+1;
if (targetY >= tableSize)
targetY = 0;
result.push(table[targetX][targetY]);
//get SOUTHWEST neighbour
targetX = x-1;
targetY = y+1;
if (targetY > tableSize-1)
targetY = 0;
if (targetX < 0)
targetX = tableSize-1;
result.push(table[targetX][targetY]);
//get WEST neighbour
targetX = x-1;
targetY = y;
if (targetX < 0)
targetX = tableSize-1;
result.push(table[targetX][targetY]);
//get NORTHWEST neighbour
targetX = x-1;
targetY = y-1;
if (targetY < 0)
targetY = tableSize-1;
if (targetX < 0)
targetX = tableSize-1;
result.push(table[targetX][targetY]);
return result;
}
//Patterns
function pattern() {
//Set up the board using a random preset pattern
console.log("Creating pattern");
clearInterval(loop);
//build dead table
table = [];
for (let y = 0; y < tableSize; y++) {
let row = [];
for (let x = 0; x < tableSize; x++) {
let cell = new Cell(x, y, false);
row.push(cell);
}
table.push(row);
}
//add living cells for patterns
//Blinker
table[1][0].isAlive = true;
table[2][0].isAlive = true;
table[3][0].isAlive = true;
/*
//Glider
table[1][1].isAlive = true;
table[2][2].isAlive = true;
table[2][3].isAlive = true;
table[3][2].isAlive = true;
table[3][1].isAlive = true;
table[12][12].isAlive = true;
table[13][13].isAlive = true;
table[14][13].isAlive = true;
table[13][14].isAlive = true;
table[12][14].isAlive = true;
*/
render();
}
//Build board and render initial state
init();
render();
html {
background: slategray;
}
.game {
background: #ddc;
border-radius: 2px;
padding-left: 0;
padding-right: 0;
margin-left: auto;
margin-right: auto;
margin-top: 10%;
display: block;
}
h1 {
color: white;
text-align: center;
font-family: sans-serif;
}
button {
text-align: center;
padding: 12px;
border-radius: 2px;
font-size: 1.2em;
margin-left: auto;
margin-right: auto;
margin-top: 12px;
display: block;
}
.controls {
display: flex;
width: 300px;
margin: auto;
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Conway's Game of Life</title>
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<h1>Conway's Game of Life</h1>
<canvas id="canvas" class="game" width="319px" height="319px"></canvas>
<div class="controls">
<button onclick='start()'>Start</button>
<button onclick='stop()'>Stop</button>
<button onclick='reset()'>Reset</button>
<button onclick='pattern()'>Pattern</button>
</div>
<script src="js/game.js"></script>
</body>
</html>
Throughout the code, you're accessing the grid by x
, then y
coordinate. In order for that to work, the grid should be defined as an array of columns, not array of rows.
As a quick fix, I simply swapped x
and y
when defining the grid in init()
and pattern()
. You'll probably want to rename your variables to reflect the intention.
There's a big issue with this part of the tick
function. You're changing the value of isAlive
property of a tile before the other tiles were checked for their future states.
switch (numAliveNeighbours) { case 0: this.makeDead(); break; case 1: this.makeDead(); break; case 2: break; case 3: this.makeAlive(); break; case 4: this.makeDead(); break; case 5: this.makeDead(); break; case 6: this.makeDead(); break; case 7: this.makeDead(); break; case 8: this.makeDead(); break; } return new Cell(this.x, this.y, this.isAlive);
I fixed it with the following one liner as a personal preference, you can keep the switch statement as long as you're not directly changing the existing tile.
const isAlive = this.isAlive ? (numAliveNeighbours === 2 || numAliveNeighbours === 3) : (numAliveNeighbours === 3)
return new Cell(this.x, this.y, isAlive);
let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d');
const tableSize = 64;
const cellSize = 4;
let tickDelay = 60;
let table = [];
let loop;
let deadChance = 0.5;
const colors = {
alive: '#f2b630',
dead: '#333'
};
function init() {
//build table
table = [];
for (let y = 0; y < tableSize; y++) {
let row = [];
for (let x = 0; x < tableSize; x++) {
let randomAlive = true;
if (Math.random() > deadChance) {
randomAlive = false;
}
let cell = new Cell(y, x, randomAlive);
row.push(cell);
}
table.push(row);
}
}
function tick() {
//console.log("tick");
table = table.map(row => row.map(cell => cell.tick()));
render();
}
function render() {
for (let y = 0; y < tableSize; y++) {
for (let x = 0; x < tableSize; x++) {
table[x][y].draw();
}
}
}
function start() {
console.log("Starting");
loop = setInterval(tick, tickDelay);
}
function stop() {
console.log("Stopping");
clearInterval(loop);
}
function reset() {
console.log("Resetting");
clearInterval(loop);
init();
render();
}
class Cell {
constructor(x, y, isAlive) {
//The x and y values are table indices, not pixel values
this.x = x;
this.y = y;
this.isAlive = isAlive;
}
tick() {
let currentNeighbours = getNeighbours(this.x, this.y);
let numAliveNeighbours = 0;
for (let i = 0; i < currentNeighbours.length; i++) {
if (currentNeighbours[i].isAlive) {
numAliveNeighbours++;
}
}
const isAlive = this.isAlive ? (numAliveNeighbours === 2 || numAliveNeighbours === 3) : (numAliveNeighbours === 3)
return new Cell(this.x, this.y, isAlive);
}
draw() {
if (this.isAlive) {
ctx.fillStyle = colors.alive;
} else {
ctx.fillStyle = colors.dead;
}
let margin = 1;
ctx.fillRect(this.x * cellSize + (this.x * margin), this.y * cellSize + (this.y * margin), cellSize, cellSize);
}
makeAlive() {
this.isAlive = true;
}
makeDead() {
this.isAlive = false;
}
}
//Helper functions
function getNeighbours(x, y) {
//return a list of all eight neighbours of this cell in North-East-South-West (NESW) order
let result = [];
//wrap at the edges of the table for each neighbour
let targetX;
let targetY;
//get NORTH neighbour
targetX = x;
targetY = y-1;
if (targetY < 0)
targetY = tableSize-1;
result.push(table[targetX][targetY]);
//get NORTHEAST neighbour
targetX = x+1;
targetY = y-1;
if (targetY < 0)
targetY = tableSize-1;
if (targetX > tableSize-1)
targetX = 0;
result.push(table[targetX][targetY]);
//get EAST neighbour
targetX = x+1;
targetY = y;
if (targetX >= tableSize)
targetX = 0;
result.push(table[targetX][targetY]);
//get SOUTHEAST neighbour
targetX = x+1;
targetY = y+1;
if (targetY > tableSize-1)
targetY = 0;
if (targetX > tableSize-1)
targetX = 0;
result.push(table[targetX][targetY]);
//get SOUTH neighbour
targetX = x;
targetY = y+1;
if (targetY >= tableSize)
targetY = 0;
result.push(table[targetX][targetY]);
//get SOUTHWEST neighbour
targetX = x-1;
targetY = y+1;
if (targetY > tableSize-1)
targetY = 0;
if (targetX < 0)
targetX = tableSize-1;
result.push(table[targetX][targetY]);
//get WEST neighbour
targetX = x-1;
targetY = y;
if (targetX < 0)
targetX = tableSize-1;
result.push(table[targetX][targetY]);
//get NORTHWEST neighbour
targetX = x-1;
targetY = y-1;
if (targetY < 0)
targetY = tableSize-1;
if (targetX < 0)
targetX = tableSize-1;
result.push(table[targetX][targetY]);
return result;
}
//Patterns
function pattern() {
//Set up the board using a random preset pattern
console.log("Creating pattern");
clearInterval(loop);
//build dead table
table = [];
for (let y = 0; y < tableSize; y++) {
let row = [];
for (let x = 0; x < tableSize; x++) {
let cell = new Cell(y, x, false);
row.push(cell);
}
table.push(row);
}
//add living cells for patterns
//Glider
table[1][1].isAlive = true;
table[2][2].isAlive = true;
table[2][3].isAlive = true;
table[3][2].isAlive = true;
table[3][1].isAlive = true;
render();
}
//Build board and render initial state
pattern();
render();
html {
background: slategray;
}
.game {
background: #ddc;
border-radius: 2px;
padding-left: 0;
padding-right: 0;
margin-left: auto;
margin-right: auto;
margin-top: 10%;
display: block;
}
h1 {
color: white;
text-align: center;
font-family: sans-serif;
}
button {
text-align: center;
padding: 12px;
border-radius: 2px;
font-size: 1.2em;
margin-left: auto;
margin-right: auto;
margin-top: 12px;
display: block;
}
.controls {
display: flex;
width: 300px;
margin: auto;
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Conway's Game of Life</title>
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<h1>Conway's Game of Life</h1>
<canvas id="canvas" class="game" width="319px" height="319px"></canvas>
<div class="controls">
<button onclick='start()'>Start</button>
<button onclick='stop()'>Stop</button>
<button onclick='reset()'>Reset</button>
<button onclick='pattern()'>Pattern</button>
</div>
<script src="js/game.js"></script>
</body>
</html>