DEV Community

loading...
Cover image for Build the Game of Life with React

Build the Game of Life with React

Marius M.
Senior Software Engineer @zuehlke_group I’m interested in Clean Code, Azure, IoT, Xamarin, React and Blazor.
・12 min read

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.


Alt Text

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
Enter fullscreen mode Exit fullscreen mode

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",
}
Enter fullscreen mode Exit fullscreen mode

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;
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 Cells I want and the Life just initializes its 2D _cells array with Cells in the Dead state.

For that, you need to add this new type

export class EmptyCellsType {
  public columns: number = 0;
  public rows: number = 0;
}
Enter fullscreen mode Exit fullscreen mode

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 its Cell's state should be. For now, just create this new class
import { Cell } from "./Cell";

export class InitialCellsType {
  public initialCells: Cell[][] = [];
}
Enter fullscreen mode Exit fullscreen mode

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();
  };
Enter fullscreen mode Exit fullscreen mode

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.

  1. Any live cell with two or three live neighbours survives.
  2. Any dead cell with three live neighbours becomes a live cell.
  3. 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;
  };
Enter fullscreen mode Exit fullscreen mode

Let me quickly walk you through the code. I'm traversing the 2D array of Cells, 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 Cells 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";
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

So, all you have to do is install it like so

npm install react-konva konva
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
  };
}
Enter fullscreen mode Exit fullscreen mode

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 Cells 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>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

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 Cells.

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;
Enter fullscreen mode Exit fullscreen mode

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();
  };
Enter fullscreen mode Exit fullscreen mode

And in the TSX, below the closing Stage tag (</Stage>) add this line

<button onClick={() => onTick()}>Tick</button>
Enter fullscreen mode Exit fullscreen mode

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));
  };
Enter fullscreen mode Exit fullscreen mode

and this line of TSX below the previous button

<button onClick={() => onClear()}>Clear</button>
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

Then add this function below onClear()

  const onSave = () => {
    download(`game state ${new Date().toTimeString()}.json`, JSON.stringify(life.cells));
  };
Enter fullscreen mode Exit fullscreen mode

And finally, add this button to the TSX, just below the other buttons

<button onClick={() => onSave()}>Save</button>
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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));
    });
  };
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (0)