Game of Life
import React, { useEffect, useRef, useReducer, useState } from "react";
import { IoIosPlay, IoIosPause } from "react-icons/io";
// Helpers
import getWidth from "../../helpers/getWidth";
// Style
import styled from "styled-components";
import { primary } from "../../styles/fonts";
const StyledCanvas = styled.canvas`
border: 1px solid black;
`;
const ControlBar = styled.div`
height: 24,
display: "flex",
width: "100%",
justifyContent: "space-around",
alignItems: "center"
`;
const StyledPauseIcon = styled(IoIosPause)`
color: black;
cursor: pointer;
font-size: 28px;
&:hover {
color: ${primary};
}
`;
const StyledPlayIcon = styled(IoIosPlay)`
color: black;
cursor: pointer;
font-size: 28px;
&:hover {
color: ${primary};
}
`;
const RATIO = 2;
let width = getWidth();
let height = width / RATIO;
const verticalLines = 150;
let cellWidth = height / verticalLines;
let horizontalLines = Math.floor(width / cellWidth);
let canvasCtx;
const actions = {
PLAYING: "PLAYING",
UPDATE_CELLS: "UPDATE_CELLS"
};
function generateInitialCells() {
let row = [];
let allCells = [];
for (let i = 0; i < verticalLines; i++) {
for (let j = 0; j < horizontalLines; j++) {
if (Math.floor(Math.random() * 100) < 45) {
row.push(1);
} else {
row.push(0);
}
}
allCells.push(row);
row = [];
}
return allCells;
}
function draw(currentCells) {
// NOTE: context and cellWidth are global and should not
// need to be passed down
canvasCtx.fillStyle = "black";
currentCells.forEach((row, rowIndex) => {
row.forEach((cell, cellIndex) => {
if (cell === 1) {
canvasCtx.fillRect(
cellIndex * cellWidth,
rowIndex * cellWidth,
cellWidth,
cellWidth
);
}
});
});
}
function generateNext(rowIndex, cellIndex, numOfCells, currentCells) {
const numOfRows = currentCells.length;
const neighbors = [
{ row: rowIndex - 1, cell: cellIndex - 1 },
{ row: rowIndex - 1, cell: cellIndex },
{ row: rowIndex - 1, cell: cellIndex + 1 },
{ row: rowIndex, cell: cellIndex - 1 },
{ row: rowIndex, cell: cellIndex + 1 },
{ row: rowIndex + 1, cell: cellIndex - 1 },
{ row: rowIndex + 1, cell: cellIndex },
{ row: rowIndex + 1, cell: cellIndex + 1 }
];
let liveNeighborCount = 0;
neighbors.forEach(neighbor => {
// don't access part of array that doesn't exist
if (
neighbor.row > -1 &&
neighbor.row < numOfRows &&
neighbor.cell > -1 &&
neighbor.cell < numOfCells &&
currentCells[neighbor.row][neighbor.cell] === 1
) {
liveNeighborCount++;
}
});
// Calculate next cell state
if (currentCells[rowIndex][cellIndex] === 1 && liveNeighborCount < 2) {
// underpopulation
return 0;
} else if (currentCells[rowIndex][cellIndex] === 1 && liveNeighborCount > 3) {
// overpopulation
return 0;
} else if (
currentCells[rowIndex][cellIndex] === 0 &&
liveNeighborCount === 3
) {
// reproduction
return 1;
} else if (
currentCells[rowIndex][cellIndex] === 1 &&
(liveNeighborCount === 3 || liveNeighborCount === 2)
) {
// stable living conditions
return 1;
} else {
// remain dead
return 0;
}
}
function updateAndDraw(state) {
const { current } = state;
const currentCopy = [...current];
let tempRow = [];
let allCells = [];
if (!currentCopy.length) {
const initCells = generateInitialCells();
draw(initCells);
return initCells;
}
current.forEach((row, rowIndex) => {
row.forEach((cell, cellIndex) => {
tempRow.push(generateNext(rowIndex, cellIndex, row.length, currentCopy));
});
allCells.push(tempRow);
tempRow = [];
});
canvasCtx.clearRect(0, 0, width, height);
draw(allCells);
return allCells;
}
function GameOfLife() {
const [stateWidth, setWidth] = useState(getWidth());
const canvasRef = useRef(null);
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
// canvas setup
canvasCtx = canvasRef.current.getContext("2d");
if (state.playing) {
const id = setInterval(() => {
requestAnimationFrame(() => {
dispatch({ type: actions.UPDATE_CELLS });
});
}, 1000 / 10);
return () => {
clearInterval(id);
};
}
}, [state.playing]);
useEffect(() => {
function handleResize() {
// this is only so the component
// will rerender when the window is resized
setWidth(getWidth());
// need to store this as a ref
width = getWidth();
height = width / RATIO;
cellWidth = height / verticalLines;
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return (
<>
<StyledCanvas
ref={canvasRef}
width={width}
height={width / RATIO}
></StyledCanvas>
<ControlBar>
{state.playing ? (
<StyledPauseIcon
onClick={() => {
dispatch({ type: actions.PLAYING, playing: false });
}}
/>
) : (
<StyledPlayIcon
onClick={() => dispatch({ type: actions.PLAYING, playing: true })}
/>
)}
</ControlBar>
</>
);
}
export default GameOfLife;
const initialState = {
current: [],
playing: true
};
function reducer(state, action) {
if (action.type === actions.UPDATE_CELLS) {
return {
...state,
current: updateAndDraw(state)
};
} else if (action.type === actions.PLAYING) {
return {
...state,
playing: action.playing
};
} else {
return state;
}
}
The Rules
The game of life is a 0-player, 2-dimensional cellular automata with simple rules. Cells with two states, on (live, 1, black) or off (dead, 0, white), are randomly distributed throughout a grid (the universe). With each new discrete step in time, the universe transitions to a new state based on the following logic:
  • A cell with fewer than 2 neighbors will die (Loneliness)
  • A cell with 2 or 3 neighbors will continue it’s life (Stable)
  • A cell with 3 or more neighbors will die (Overpopulation)
  • An empty or dead cell with exactly 3 neighbors will become live (Reproduction)
Implementation
I originally wrote this implementation of the game as a class component, but the version seen above was rewritten as a functional component with hooks. This rewrite ended up being more difficult than I expected. Originally I had used the componentDidMount method on the class as a place to fire off requestAnimationFrame. This worked quite well, componentDidMount fires only a single time after mounting which is exactly what needed to happen. I stored the state of the board as a 2d array in the state variable on the class. The function inside requestAnimationFrame would calculate the next state of the board, draw the updated state, and then use setState to store the updated array so on the next render cycle the function would have access to the current state of the board. Cool.
Problem
Rewriting the component as a functional component meant I lost access to componentDidMount (along with any React.Component class methods). The closest equivalent to componentDidMount is the useEffect hook with an empty dependency array. This is initially what I attempted, along with storing the state of the board in a useState variable. Once I had made the changes I ran npm run start only to find nothing being displayed. After some digging and research I found out this was because of the way useEffect works. The function I placed in requestAnimationFrame would close over the state at the time of the initial render, which at the time was an empty array. Even though I would update the state, the effect was only being called once and the function only had access to the state that existed at that initial call. In order to have access to the updated state I would need to recall requestAnimationFrame any time the state update. To make that change I added the state to the dependency array, meaning anytime a change was made to the state, the logic inside useEffect would be called. This resulted in an infinite loop, and even if it didn’t, constantly recalling requestAnimationFrame is not how the function is intended to be used.
Solution
After some reading on Dan Abramov’s blog post, a complete guide to useEffect, I found the best solution would be to move the update logic to a reducer via the useReducer hook. The resulting function left inside is just the dispatch of an action. This action is then handled by the reducer, which calculates and draws the next state to the screen. This allows the logic and state to be decoupled from the effect.