19
Build the Game of Life with React and TypeScript
In this tutorial, we'll develop the popular Game of Life using React and TypeScript. The Game of Life was created by the late John Conway in 1970. It consists of a grid of cells, each either alive or dead which interacts with its neighbours following a set of rules. It is more of a simulation or cellular automation than a game as it requires no input from the user.
I find it to be a good project for practicing some useful concepts in React. It is relatively easy to build, and only took about 150 lines of code to complete. This project was originally recorded by Ben Awad in this video, however, this tutorial makes several modifications to the codebase.
This tutorial assumes a basic knowledge of React (including Hooks) and TypeScript.
Let's set up our React project with TypeScript by running the following command in the terminal:
npx create-react-app game-of-life --template typescript
You can also grab the starter files for the project here and follow the instructions in the README if you prefer.
A grid is naturally made up of a number of rows and columns. Let's start by creating variables in App.tsx
to keep track of these values, including the grid itself. Store the grid in state so that it can be easily updated. For this we will employ the useState
hook. The useState
hook returns a stateful value, and a function to update it. Destructure those return values into grid
and setGrid
variables as shown below.
// App.tsx
import { FC, useState } from "react";
const numRows = 25;
const numCols = 35;
const App: FC = () => {
const [grid, setGrid] = useState();
};
Note that we're annotating the type of our component as a Functional Component(FC). This makes sure that the signature of our function is correct and it returns valid JSX. Also, all the code in this tutorial will be written in one file, namely App.tsx
.
Next, we want to initialise the grid. useState
accepts one argument which will be returned as the initial state on the first render. Create a function that returns an array of random live and dead cells.
// App.tsx
const randomTiles: = () => {
const rows = [];
for (let i = 0; i < numRows; i++) {
rows.push(Array.from(Array(numCols), () => (Math.random() > 0.7 ? 1 : 0))); // returns a live cell 70% of the time
}
return rows;
}
const App = () => {
const [grid, setGrid] = useState(() => {
return randomTiles();
});
};
The randomTiles
function creates a multidimensional array of randomly placed 0s and 1s. 0 means dead and 1 means alive. The length of the array is the number of rows we declared earlier and each array in it contains numCols
items (in this case, 35). Notice that the type is annotated as an array of zeroes and ones. You can already see below what our grid will look like:
Now, whenever the App component is rendered for the first time, the initial state will be a grid of random cells. Next thing is to display them. Update your App.tsx file as shown below:
// App.tsx
const App = () => {
const [grid, setGrid] = useState(() => {
return randomTiles();
});
return (
<div>
{grid.map((rows, i) =>
rows.map((col, k) => (
<div
style={{
width: 20,
height: 20,
backgroundColor: grid[i][k] ? "#F68E5F" : undefined,
border: "1px solid #595959",
}}
/>
))
)}
</div>
);
};
The code above iterates over the grid, which has been initialised to randomTiles
, and each time generates a 20 x 20 box to represent a cell. The background color of each cell is dependent on whether it is alive or dead.
At the moment, the cells formed are in a straight line as shown above. We need them to be arranged neatly into a grid. To achieve that, let us make the wrapping div
a Grid container and style it as follows:
// App.tsx
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${numCols}, 20px)`,
width: "fit-content",
margin: "0 auto",
}}
>{...}</div>
//I use ... to denote code already established.
Now that we have what we're looking for, you can style the page in any other way you want.
Apart from the randomly generated cell state, we want each cell to be clickable to make it either alive or dead. Add an event handler to the cell div
as follows:
// App.tsx
return (
<div
style={
{
// ...
}
}
>
{grid.map((rows, i) =>
rows.map((col, k) => (
<div
key={`${i}-${k}`}
onClick={() => {
let newGrid = JSON.parse(JSON.stringify(grid));
newGrid[i][k] = grid[i][k] ? 0 : 1;
setGrid(newGrid);
}}
style={
{
// ...
}
}
></div>
))
)}
</div>
);
What the click event handler above does is:
- It clones the
grid
array into anewGrid
, - Finds the clicked cell by it's index and checks if it's alive or dead,
- If the cell is currently alive, it makes it dead and vice versa,
- Finally, it updates the state with the modified
newGrid
.
It is best practice to always add a unique identity to elements in React to help React know when it has changed. Set the key
attribute of each cell to its specific position in the grid.
There's no game of life without the actual interaction between the cells, so let's work on that. Let's start by storing the running status of the simulation in state, same way we did the grid, then initialize it to false
. Let's allow TypeScript infer the type for us here which will be boolean
.
// App.tsx
const App = () => {
const [grid, setGrid] = useState(() => {
return randomTiles();
});
const [running, setRunning] = useState(false);
// ...
};
By default, the simulation is not running. Now, let's create a button to start or stop the simulation:
// App.tsx
<button
onClick={() => {
setRunning(!running);
}}
>
{running ? "Stop" : "Start"}
</button>
Next up, we'll work on implementing the interactions between the cells and their neighbors following the rules of the game which include:
- Any live cell with fewer than two live neighbours dies, as if 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.
Create a positions
array outside the App component. This array represents the eight neighbors surrounding a cell, which we will make use of within the simulation.
// App.tsx
import { useState, useCallback } from "react";
const positions = [
[0, 1],
[0, -1],
[1, -1],
[-1, 1],
[1, 1],
[-1, -1],
[1, 0],
[-1, 0],
];
Within the App component, create a function called runSimulation
using the useCallback
hook and pass the grid as an argument. The reason why useCallback
is being used here is to prevent our function from being created every time the App component is rendered. useCallback
creates a memoized function every time it's dependency array changes, this means that the function will be created only once and then run when necessary. In this case, we'll leave the dependency array empty.
// App.tsx
const App = () => {
// ...
const runningRef = useRef(running);
runningRef.current = running;
const runSimulation = useCallback((grid) => {
if (!runningRef.current) {
return;
}
let gridCopy = JSON.parse(JSON.stringify(grid));
for (let i = 0; i < numRows; i++) {
for (let j = 0; j < numCols; j++) {
let neighbors = 0;
positions.forEach(([x, y]) => {
const newI = i + x;
const newJ = j + y;
if (newI >= 0 && newI < numRows && newJ >= 0 && newJ < numCols) {
neighbors += grid[newI][newJ];
}
});
if (neighbors < 2 || neighbors > 3) {
gridCopy[i][j] = 0;
} else if (grid[i][j] === 0 && neighbors === 3) {
gridCopy[i][j] = 1;
}
}
}
setGrid(gridCopy);
}, []);
// ...
};
We're creating runSimulation
once but we want the current running
value at all times, and the function will not keep updating the value for us. To fix that, let's create a runningRef
variable using the useRef
hook and initialize it to the current value of the running
state. This way, the running status is always up to date within our simulation because it is being stored in a ref. Whenever the .current
property of runningRef
is false, the function will stop, otherwise it will proceed to work with the rules of the game.
Now, runSimulation
clones the grid, loops over every cell in it and computes the live neighbors that each cell has by iterating over the positions
array. It then checks to make sure that we're not going out of bounds and are within the rows and columns in the grid. If that condition is met, it increments the number of live neighbors of the cell in question. The forEach
loop will run 8 times for each cell.
Next, it enforces the rules. If the number of live neighbors
of the cell is less than 2 or greater than 3, the cell dies. Else, if the cell is dead and it has exactly 3 neighbors, the cell lives and proceeds to the next generation. After all the cells are covered, it updates the grid state with the gridCopy
.
To make the simulation continuous, we need a function that runs it after a specified interval. Let's fire the setInterval
method when the Start button is clicked:
// App.tsx
<button
onClick={() => {
setRunning(!running);
if (!running) {
runningRef.current = true;
}
setInterval(() => {
runSimulation(grid);
}, 1000);
}}
>
{running ? "Stop" : "Start"}
</button>
The click event handler updates the running state to its opposite, but in case it is false, it changes the ref to true and calls runSimulation
every second. If you run this in your browser, you'll see that the simulation isn't running as it should. It appears to be stuck in a loop between two or three generations. This is due to the mismatch between the React programming model and setInterval
which you can read more about here.
While researching a solution to this issue, I discovered this custom hook written by Dan Abramov called useInterval
. Create a file called useInterval.tsx
in your project directory and paste the following code into it:
// useInterval.tsx
import { useEffect, useRef } from "react";
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
// Remember the latest callback if it changes.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
useEffect(() => {
// Don't schedule if no delay is specified.
if (delay === null) {
return;
}
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
export default useInterval;
Import the hook into the App component and make use of it as follows:
// App.tsx
import useInterval from "./useInterval";
// Put this right under runSimulation() inside the App function
useInterval(() => {
runSimulation(grid);
}, 150);
The syntax of this hook looks identical to setInterval
, but works a bit differently. It’s more like setInterval
and clearInterval
tied in one, and it's arguments are dynamic. Delete the setInterval
function from the click handler and watch our app run smoothly.
Let's add a function to empty the grid of all live cells. Create a function called generateEmptyGrid
:
// App.tsx
const generateEmptyGrid = (): number[][] => {
const rows = [];
for (let i = 0; i < numRows; i++) {
rows.push(Array.from(Array(numCols), () => 0));
}
return rows;
};
This function looks like randomTiles
except it returns a multidimensional array containing only zeroes. Create a button to update the state with the new array of dead cells:
// App.tsx
<button
onClick={() => {
setGrid(generateEmptyGrid());
}}
>
Clear board
</button>
When you check the browser, you should see an error that looks like this:
This is because of the way TypeScript works. When you initialize a variable, TypeScript infers its type as narrowly as possible if you don't explicitly annotate it. In our case, when we declared the grid
state, we initialized it to randomTiles
. Because we didn't annotate the type of randomTiles
, it's type was inferred as () => (0 | 1)[][]
, that is, a function that returns only zeroes and ones.
Now, generateEmptyGrid
's type is inferred as () => number[][]
which is not assignable to () => (0 | 1)[][]
. That is the reason behind that error above which shows that our code failed to compile. For our app to work, the types have to be compatible. Let's annotate their types so that they are the same:
// App.tsx
const generateEmptyGrid = (): number[][] => {
const rows = [];
for (let i = 0; i < numRows; i++) {
rows.push(Array.from(Array(numCols), () => 0));
}
return rows;
};
const randomTiles = (): number[][] => {
const rows = [];
for (let i = 0; i < numRows; i++) {
rows.push(Array.from(Array(numCols), () => (Math.random() > 0.7 ? 1 : 0)));
}
return rows;
};
Now that they are both multidimensional arrays containing numbers and can be assigned to each other, our Clear button should work as expected. Let's add in another button to randomize the tiles again if the user desires to.
// App.tsx
<button
onClick={() => {
setGrid(randomTiles());
}}
>
Random
</button>
This click handler just updates the state with our previously declared randomTiles
function that returns randomly placed 0s and 1s.
In this tutorial, we have successfully built Conway's Game of Life using React and TypeScript. We covered how to make use of some React hooks including useState
, useCallback
and useRef
. We saw how React and setInterval
don't work too well together and fixed the issue with a custom hook. We also discussed how TypeScript infers types when they are not annotated, how a type mismatch caused our code not to compile and how to solve the problem.
The complete code for this project can be found in this GitHub repository. I hope you have gained some value from this article. Your feedback will be appreciated in the comments.
Thanks for reading!
19