Motivation
I've recently read a post about an interviewer who likes to ask of their candidates to implement Conway's Game of Life. Naturally I started thinking how I would do it. Since I'm intrigued by Blazor (because C#) and I use React at work (because it's better), here we are about to see how you can build the Game of Life, first with React and in a later post with Blazor.
I plan to group these posts into a series, so that each stays digestible and you can read the one that interests you.
Let me know in the comments if you're interested in seeing implementations in Xamarin.Forms/MAUI, WPF or Flutter.
Here's the code: https://github.com/mariusmuntean/GameOfLife
Create the React project
Create a new React project with npx
, give it a name and choose Typescript
npx create-react-app gol.react --template typescript
Business Logic
In the src
directory, create a new one for the new types that you're going to add. I named mine models
. Add a file for an enum that represents the state of a single cell
export enum CellState {
Dead = "Dead",
Alive = "Alive",
}
The game consists of a 2D grid where each slot is taken up by a cell. A cell can be either dead or alive. Now add the Cell class, ideally in another file
import { CellState } from "./CellState";
export class Cell {
public CurrentState: CellState = CellState.Dead;
public NextState: CellState = CellState.Dead;
constructor(currenCellState?: CellState) {
if (currenCellState) {
this.CurrentState = currenCellState;
}
}
public tick = () => {
this.CurrentState = this.NextState;
this.NextState = CellState.Dead;
};
public toggle = () => {
this.CurrentState = this.CurrentState === CellState.Alive ? CellState.Dead : CellState.Alive;
};
}
The CurrentState
of a Cell tells us how the cell is currently doing. Later we'll have to compute the new state of each Cell based on the state of its neighbors. To make the code simpler, I decided to store the next state of the Cell in the NextState
property.
When the game is ready to transition each Cell into its next state, it can call tick()
on the Cell instance and the NextState
becomes the CurrentState
.
The method toggle()
will allow us to click somewhere on the 2D grid and kill or revive a Cell.
Let's talk about life. At the risk of sounding too reductionist, it's just a bunch of interacting cells. So we'll create one too
import { Cell } from "./Cell";
import { CellState } from "./CellState";
import { EmptyCellsType } from "./EmptyCellsType";
import { InitialCellsType } from "./InitialCellsType";
export class Life {
readonly columns: number;
readonly rows: number;
readonly onNewGeneration?: (newCells: ReadonlyArray<ReadonlyArray<Cell>>) => void;
private _cells: Cell[][];
public get cells(): Cell[][] {
return this._cells;
}
constructor(input: InitialCellsType | EmptyCellsType, onNewGeneration?: (newCells: ReadonlyArray<ReadonlyArray<Cell>>) => void) {
if (input instanceof InitialCellsType) {
this._cells = input.initialCells;
this.rows = this._cells.length;
this.columns = this._cells[0].length;
} else {
this.columns = input.columns;
this.rows = input.rows;
if (this.columns <= 0 || this.rows <= 0) {
throw new Error("Width and height must be greater than 0");
}
this._cells = [];
for (let row: number = 0; row < this.rows; row++) {
for (let col: number = 0; col < this.columns; col++) {
this._cells[row] = this._cells[row] ?? [];
this._cells[row][col] = new Cell(CellState.Dead);
}
}
}
this.onNewGeneration = onNewGeneration;
}
}
Let's break down what we just created. Life is a class that keeps track of a bunch of cells. For that we're using _cells:Cell[][]
which is just a 2D array of our simple Cell
class. Having a 2D array allows us to know exactly where each cell is and who its neighbors are.
Traversing the 2D array can be cumbersome so I keep track of its dimensions with the properties Rows
and Columns
.
There are two ways in which I want to be able to create a new Life
- From scratch - meaning I jest tell it how many rows and columns of
Cell
s I want and theLife
just initializes its 2D_cells
array withCell
s in theDead
state.
For that, you need to add this new type
export class EmptyCellsType {
public columns: number = 0;
public rows: number = 0;
}
It just holds a pair of numbers corresponding to the desired amount of Cell
rows and columns.
- From a file - think of a saved game state. We'll later save the state of the game into a file and then load it up. When loading the saved game state, we need to tell the
Life
instance what each of itsCell
's state should be. For now, just create this new class
import { Cell } from "./Cell";
export class InitialCellsType {
public initialCells: Cell[][] = [];
}
At this point we can create a new Life
, where all the cells are either dead or in a state that we received from 'outside'.
Our Life
needs a bit more functionality and then it is complete. The very first time we load up the game, all the cells will be dead. So it would be nice to be able to just breathe some life into the dead cells.
For that, Life
needs a method that takes the location of a Cell
and toggles its state to the opposite value.
public toggle = (row: number, col: number) => {
if (row < 0 || row >= this.rows) {
throw new Error("Row is out of range");
}
if (col < 0 || col >= this.rows) {
throw new Error("Col is out of range");
}
const cellToToggle = this.cells[row][col];
cellToToggle.toggle();
};
The Life
instance just makes sure that the specified location of the Cell
makes sense and then tells that Cell to toggle its state. If you remember, the Cell
class can toggle its state, if told to do so.
The last and most interesting method of Life
implements the 3 rules of the Game of Life.
- Any live cell with two or three live neighbours survives.
- Any dead cell with three live neighbours becomes a live cell.
- All other live cells die in the next generation. Similarly, all other dead cells stay dead.
public tick = () => {
// Compute the next state for each cell
for (let row: number = 0; row < this.rows; row++) {
for (let col: number = 0; col < this.columns; col++) {
const currentCell = this._cells[row][col];
const cellNeighbors = this.getNeighbors(row, col);
const liveNeighbors = cellNeighbors.filter((neighbor) => neighbor.CurrentState === CellState.Alive).length;
// Rule source - https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life#Rules
if (currentCell.CurrentState === CellState.Alive && (liveNeighbors === 2 || liveNeighbors === 3)) {
currentCell.NextState = CellState.Alive;
} else if (currentCell.CurrentState === CellState.Dead && liveNeighbors === 3) {
currentCell.NextState = CellState.Alive;
} else {
currentCell.NextState = CellState.Dead;
}
}
}
// Switch each cell to its next state
for (let row: number = 0; row < this.rows; row++) {
for (let col: number = 0; col < this.columns; col++) {
const currentCell = this._cells[row][col];
currentCell.tick();
}
}
this.onNewGeneration?.(this.cells);
};
private getNeighbors = (row: number, col: number): Cell[] => {
const neighbors: Cell[] = [];
for (let colOffset: number = -1; colOffset <= 1; colOffset++) {
for (let rowOffset: number = -1; rowOffset <= 1; rowOffset++) {
if (colOffset === 0 && rowOffset === 0) {
// skip self
continue;
}
const neighborRow = row + rowOffset;
const neighborCol = col + colOffset;
if (neighborRow >= 0 && neighborRow < this.rows) {
if (neighborCol >= 0 && neighborCol < this.columns) {
neighbors.push(this._cells[neighborRow][neighborCol]);
}
}
}
}
return neighbors;
};
Let me quickly walk you through the code. I'm traversing the 2D array of Cell
s, making use of the rows and columns. For each cell I'm looking at its neighbors and based on the 3 game rules I'm computing the next state of the Cell
.
When I'm done with that, I'm traversing the 2D grid again (I know, not very efficient of me, but I went for readable code) and telling each Cell
to switch to its next state.
You might be wondering what this onNewGeneration()
function is good for. Well, at this point in time I had no idea how the UI will function and I imagined that it would be nice to have a callback that lets me know when all the Cell
s were updated to their new state. It just so happens that we don't need that callback after all.
We're done with the business logic. It's time for the UI.
UI
In the src
directory, create a new directory called SimpleLifeComponent
. Inside this new directory create an index.ts
file with this content
export { SimpleLife } from "./simple-life.component";
Immediately after that, add a new file called simple-life.component.tsx
next to the index.ts
(this way VS Code will stop yelling at you that it can't find the referenced file).
KonvaJs
After some decent (10 minutes, but with my noise-cancelling headphones on) research (googled '2D drawing in React') of my own, I decided to go with KonvaJs.
It has excellent support for React. Take a look at this snippet from their docs and you'll be ready to draw in no time
import { Stage, Layer, Rect, Circle } from 'react-konva';
export const App = () => {
return (
// Stage - is a div wrapper
// Layer - is an actual 2d canvas element, so you can have several layers inside the stage
// Rect and Circle are not DOM elements. They are 2d shapes on canvas
<Stage width={window.innerWidth} height={window.innerHeight}>
<Layer>
<Rect width={50} height={50} fill="red" />
<Circle x={200} y={200} stroke="black" radius={50} />
</Layer>
</Stage>
);
}
So, all you have to do is install it like so
npm install react-konva konva
SimpleLife
This is going to be the component that takes care of rendering the game and it will allow us to interact with the game. As usual, it is possible to break up a React component in multiple smaller ones, but my intention was for YOU to see as much code as possible, at a glance.
Start by adding these imports
import React, { FC, useCallback } from "react";
import { useState } from "react";
import { Layer, Stage, Rect } from "react-konva";
import { Cell } from "../models/Cell";
import { CellState } from "../models/CellState";
import { Life } from "../models/Life";
import { InitialCellsType } from "../models/InitialCellsType";
import { EmptyCellsType } from "../models/EmptyCellsType";
Nothing fancy here, just the normal React imports, Konva and our own types.
Next step is to add the props type
interface Props {
width: number;
height: number;
rows: number;
columns: number;
}
The component will receive the number of rows and columns that define how many cells there's going to be. It also takes a width and a height, in pixels. The pixel dimensions tell our component how much space it has for its cells and it will 'fit' the cells in the available space. Don't overthink it, I didn't 😁.
We will need an instance of Life
when the component lights up the very first time. For that, add this next function just below the Props
interface
function getInitialLife(columns: number, rows: number, onNewGeneration: (newCells: ReadonlyArray<ReadonlyArray<Cell>>) => void): Life | (() => Life) {
return () => {
const initialLife = new Life({ columns, rows } as EmptyCellsType, onNewGeneration);
// Glider
initialLife.toggle(2, 2);
initialLife.toggle(3, 2);
initialLife.toggle(4, 2);
initialLife.toggle(4, 1);
initialLife.toggle(3, 0);
return initialLife;
};
}
The function doesn't do much, but it's honest work. It takes the number of rows and columns (and that unused callback I mentioned above) and returns a function that returns a Life
with the specified amount of rows and columns. It also toggles some of the Cell
s to the Alive
state. The shaped those live cells make is a canonical shape and is called a 'Glider' because, as you will see, they will glide through the 2D space.
Add the SimpleLife
component, below the previous function.
export const SimpleLife: FC<Props> = ({ width, height, rows, columns }) => {
const onNewGeneration = (newCells: ReadonlyArray<ReadonlyArray<Cell>>) => {
// console.dir(newCells);
};
const [life, setLife] = useState<Life>(getInitialLife(columns, rows, onNewGeneration));
const [, updateState] = useState({});
const forceUpdate = useCallback(() => updateState({}), []);
const onCellClicked = (row: number, column: number) => {
life.toggle(row, column);
forceUpdate();
};
const cellEdgeAndSpacingLength = Math.min(width / columns, (height - 30) / rows);
const cellEdgeLength = 0.9 * cellEdgeAndSpacingLength;
const canvasWidth = cellEdgeAndSpacingLength * columns;
const canvasHeight = cellEdgeAndSpacingLength * rows;
return (
<>
<Stage width={canvasWidth} height={canvasHeight}>
<Layer>
{life &&
life.cells.map((cellRow, rowIndex) => {
return cellRow.map((cell, columnIndex) => {
return (
<Rect
key={(rowIndex + 1) * (columnIndex + 1)}
x={columnIndex * cellEdgeAndSpacingLength}
y={rowIndex * cellEdgeAndSpacingLength}
width={cellEdgeLength}
height={cellEdgeLength}
fill={cell.CurrentState === CellState.Alive ? "red" : "black"}
onClick={(e) => onCellClicked(rowIndex, columnIndex)}
></Rect>
);
});
})}
</Layer>
</Stage>
</>
);
};
Let's break it down.
The component has a Life
instance, which is its internal state. It is created with the getInitialLife()
function that you added jus above the component.
The forceUpdate()
is just a little trick that allows us to force re-rendering.
Next up is the 4 lines with the computation. Their goal is to obtain the optimal cell edge length and canvas size, given the amount of rows and columns and the available space for our component.
Finally some TSX. Inside a <Stage>
, which is a wrapper <div>
for the canvas, I'm adding a <Layer>
(Konva renders this as an HTML canvas) that contains many rectangles, one rectangle for each of our Cell
s.
Remember that life.cells
is an array of arrays of Cell
. So there I'm using two nested calls to map()
that allow me to traverse the whole data structure and emit a new Konva <Rect>
for each Cell
.
x
and y
are the <Rect>
's pixel coordinates on the final canvas and with
and height
are the <Rect>
's pixel dimensions. A <Rect>
will be ⬛️ when the Cell
is dead and 🟥 when the Cell
is alive. I've also wired up the <Rect>
's onClick
handler to call our onCellClicked()
function, which tells the Life
instance to toggle the appropriate Cell
's state.
To actually see something on the screen, use the <SimpleLife>
component in the App.tsx
file. Something like this should work
import React from "react";
import { SimpleLife } from "./SimpleLifeComponent";
function App() {
return <SimpleLife width={window.innerWidth}
height={window.innerHeight}
rows={35}
columns={35}></SimpleLife>;
}
export default App;
At this point you should be able to see the game and click cells to toggle their state.
It's alive!
Let's add a button that tells the Life
instance to progress to the next generation of Cell
states.
Back in the SimpleLife
component, bellow onCellClicked()
, add this function
const onTick = () => {
life.tick();
forceUpdate();
};
And in the TSX, below the closing Stage
tag (</Stage>
) add this line
<button onClick={() => onTick()}>Tick</button>
Now open the link with canonical shapes in the Game of Life in a new browser window and create a few shapes by clicking in your game. By clicking the new button that you added, you should see how your shapes are doing in the Game of Life.
Oh my!
Let's add a new button to clean up the mess you made :D
First add this new function below onTick()
const onClear = () => {
setLife(getInitialLife(columns, rows, onNewGeneration));
};
and this line of TSX below the previous button
<button onClick={() => onClear()}>Clear</button>
Now you're able to clear the board and get the Glider back.
I'll save you, my little creatures, 4 ever!
"Wouldn't it be nice to be able to save the game state and reload it later?" I hear you ask. Excellent question and yes, that would be nice!
Let's start by preparing some infrastructure code. In your src
directory, add a new one and call it utils
. Inside utils create a file called download.ts
and add this function
export const download = (filename: string, text: string) => {
var element = document.createElement("a");
element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(text));
element.setAttribute("download", filename);
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
};
The function takes a file name and some text, and tells your browser that it wants to save that text as a file with the specified name.
Back in the SimpleLife
component, add this import
import { download } from "./../utils/download";
Then add this function below onClear()
const onSave = () => {
download(`game state ${new Date().toTimeString()}.json`, JSON.stringify(life.cells));
};
And finally, add this button to the TSX, just below the other buttons
<button onClick={() => onSave()}>Save</button>
Now, whenever you have an assortment of creatures that you're particularly fond of, you can save them as a JSON file.
"But how can I get them back?" Go back to download.ts
and add this function
export const pickFile = (onLoadedSuccessfully: (fileContent: string) => void) => {
const filePickerInput = document.createElement("input");
filePickerInput.type = "file";
filePickerInput.id = "file";
filePickerInput.className = "file-input";
filePickerInput.accept = ".json";
filePickerInput.style.display = "none";
filePickerInput.onchange = (e) => {
const filereader = new FileReader();
filereader.onloadend = (ee) => {
if (!ee) {
return;
}
onLoadedSuccessfully(filereader.result as string);
};
filereader.readAsText((e.target as any)?.files?.[0]);
};
document.body.appendChild(filePickerInput);
filePickerInput.click();
document.body.removeChild(filePickerInput);
};
When invoked, it opens the browser's file picker dialog and lets your callback know whenever you pick a JSON file.
Back in SimpleLife
, adjust the previous import to look like this
import { download, pickFile } from "./../utils/download";
Now add this nasty little function, below onSave()
const onLoad = () => {
pickFile((fileContent) => {
const reloadedCellData = JSON.parse(fileContent);
if (!reloadedCellData) {
return;
}
const reloadedCellsMissingPrototypeChain = reloadedCellData as Array<Array<Cell>>;
if (!reloadedCellsMissingPrototypeChain) {
return;
}
const reconstructed: Cell[][] = [];
const rows = reloadedCellsMissingPrototypeChain.length;
const cols = reloadedCellsMissingPrototypeChain[0]?.length;
for (let row: number = 0; row < rows; row++) {
reconstructed[row] = reconstructed[row] ?? [];
for (let col: number = 0; col < cols; col++) {
reconstructed[row][col] = new Cell(reloadedCellsMissingPrototypeChain[row][col].CurrentState);
}
}
const initialCell: InitialCellsType = new InitialCellsType();
initialCell.initialCells = reconstructed;
setLife(new Life(initialCell));
});
};
It triggers the file picker and when the right file is selected it will deserialize it into an instance of Cell[][]
. Unfortunately, the deserialized object is lacking type information, which Typescript needs. So I'm just looping over the data and creating a proper Cell[][]
instance.
finally, add yet another button to the TSX
<button onClick={() => onLoad()}>Load</button>
And now you can load previous game states that you saved.
Conclusion
I had fun building this little game and I hope you had too. KonvaJs turned out to be an excellent little library and now I can't stop thinking about my next drawing adventure in React.
Keep your eyes open for new posts in this series. Blazor should be next!
Top comments (0)