For this project, my goal was to implement the computer science classic, John Conway's "Game of Life." I had a total of 4 days to work on it: I began the project on Monday and Friday morning I presented it to my team.
The Process
Understanding the problem
I dedicated most of Monday to reading about Game of Life in order to understand the core concepts and principles of this program. To summarize, Game of Life is a simulation of how a given population will change over time based on the following rules.
- Any empty cell with 3 live neighbors becomes a live cell (reproduction)
- Any live cell with fewer than 2 neighbors dies (underpopulation)
- Any live cell with more than 3 neighbors dies (overpopulation)
- Only the live cells that have 2 or 3 live neighbors survive to the next generation
The program is basically a 2D grid where cells come alive or die depending on the state of their adjacent cells (how many of them were alive or dead in the previous generation). Although John Conway originally devised this simulation in 1970, it is still studied today, with new population formations still being discovered as recently as 2018.
Planning MVP
OK, so I had the core concepts down. Next, I needed to narrow down the scope of what I was going to build. Although Game of Life only has a few hard rules, there are many variations and extra features that I could add to my implementation to make it more interesting and engaging.
Another reason I like to have my scope fixed on paper ASAP is because whenever I plan a project, I leave a buffer of some free time between when I need to have it finished and the actual deadline. This way, I have time to refactor and polish my work; or, if I get stuck on something early on, I have time to catch up and still make the deadline.
So far my week was going to look like this:
Monday: research, narrow down the scope, plan MVP
Tuesday and Wednesday: write code
Thursday: buffer (polish or catch up)
Friday morning: presentation
This gave me only 2 days of actual programming time, so it was crucial to decide on what my MVP would look like and stick to it.
Here's what I came up with:
- A grid of cells where the simulation will be displayed
- Simulation function that shows what each generation will look like based on the rules
- A heading that clearly labels which generation the simulation is currently displaying (label the initial population Generation 0, the next one Generation 1, etc)
- Buttons to start/stop the simulation
- The rules of the game
In addition (but still part of MVP):
- A button to generate a random population on the grid
- Cells in the grid have to be clickable so that users can manually set any cell to be alive or dead
- Controls to manually step through the simulation one generation at a time (another button?)
- A way for the user to control the speed of the simulation (three more buttons?)
In terms of appearance and aesthetic, I wanted to achieve a clean and minimalist look with the conservative feel of actual scientific software.
I had an idea for how I wanted the components to fit together visually, so I made a very basic wireframe to guide me.
Tech stack: React to build the interface and handle functionality, and CSS for styling.
Writing code, part 1: Smooth sailing ⛵
Building out the presentational components of this project using React and CSS was exciting and satisfying. I am very much a visual person so making UX decisions while also developing UI was a challenge I really enjoyed.
The grid
The grid itself was surprisingly easy to make! It's a div
styled using CSS Grid, which is a 2-dimensional layout system that comes with CSS. No need to install any extra dependencies! All I had to do was add display: grid
and then use the grid-template-columns
property to control how many columns I wanted to generate and their width. Since these columns are filled with cells (grid items), I then added height, width, and a thin border around each cell and that was it!
.grid-container {
width: 70%;
display: grid;
grid-template-columns: repeat(20, 3.25vw);
}
.cell {
border: 1px solid black;
width: 3.25vw;
height: 3.25vw;
}
I used percentages and view-width (vw) as size units because I wanted the grid to look consistent on any screen size. Toward the end of the project, I had some time to refactor and make the entire interface responsive, but more on that later.
Colors
Since I wanted my interface to look clean and minimalist, similar to real scientific software, I kept color to a minimum.
For maximum attention-grabbing impact, I wanted the live cells to be represented in red (as opposed to purple or some other color), but to give this simulation the conservative look I was going for, I went with a subdued #cd5c5c. From there, I experimented with different greens to find one that would look best together with this red, and ended up going with #4caf50.
Controls
In general, I really like the look of pill-shaped buttons, but for this project I wanted them to have a more business-professional feel. Rectangle-shaped buttons with sharp borders looked too strict and uninviting, so I added a small border radius, as well as a thin black border.
At first I wanted to have two separate buttons to start and stop the simulation. I also needed to build in some way to reset the entire simulation back to generation 0 and an empty grid. I was going to group all three buttons together in the same row, but I didn't like the crowded look of it. That's when I got the idea to refactor the start/stop buttons into one: by default, it reads "start simulation" and on click the text changes to "stop simulation". The functionality of the button (whether it starts or pauses the simulation) also toggles based on a boolean slice of state that the app maintains.
<button onClick={handleSimulation} type="button">
{runningSimulation ? "stop simulation" : "start simulation"}
</button>
Even though my first thought was to have separate buttons to set simulation speed, I decided it was better to consolidate them into one dropdown since users can only choose one speed at a time. One dropdown replacing three buttons also made the interface look less cluttered. The speeds themselves took a bit of experimentation, but I settled on 100ms for fast, 500ms for average, and 1000ms for slow. Since users also have the option to see each generation one at a time (and to look at each frame as long as they want), I did not think it was necessary to support a speed slower than 1000ms.
Writing code, part 2: No pain, no gain 😅
The hardest part for me was the algorithm responsible for building the next generation of cells based on the previous one. A lot of the difficulty stemmed from my own misunderstanding of how it was supposed to work (this is why understanding the problem is THE most important step in the software development process!).
In my mind, as the algorithm traveled across the grid it was supposed to calculate the state of each cell based on the state of its neighbors. This part was correct. Where I went wrong was in my assumption that if the neighbors already had their new values calculated, I was supposed to use those new values. If I hadn't been so excited to start writing code and spent more time understanding the problem I would have realized that, conceptually, calculating generation y based on values from generation x and y makes no sense. This algorithm that I was trying to implement was not only messy, but it resulted in new generations that looked entirely wrong -- cells died off or came alive in ways that didn't conform to the rules of the game, and within a generation or two all cells were dead no matter what the initial population looked like!
I kept thinking the problem was with my code, and I kept trying to debug what was on the screen, when in reality the program I wrote did exactly what I told it to do. It was my mental model of what I was supposed to accomplish that needed debugging.
I was stuck.
Getting unstuck
I was growing discouraged, so I decided to look for help. After years of taking online programming classes and learning on my own, I knew that the best way for me to understand this problem was to find a code-along tutorial. There is something about having someone else go through the process with me, step by step, that really helps solidify new concepts in my mind. Half way into the code-along tutorial (I believe it was this one), it finally clicked -- I needed to be calculating the new generation based on the values from previous generation only.
In my quest to debug this problem, I also discovered an interesting technique for calculating the neighbors of each cell. As the rules of the game state, what happens to a cell from one generation to the next depends on how many live neighbors it has. In a 2D grid, each cell can have up to 8 neighbors. To calculate what happens to each cell, I have to first count how many of its adjacent cells are live cells. The technique is to identify each neighbor cell by its (x, y) coordinates relative to the cell the value of which I'm trying to calculate. In other words, for any cell (0, 0), the neighbors will have the following possible coordinates:
// this helps to calculate neighbors painlessly
const neighborCoordinates = [
[0, 1],
[0, -1],
[1, -1],
[-1, -1],
[1, 1],
[-1, 1],
[1, 0],
[-1, 0],
];
So for each cell, the algorithm would take its actual (x, y) coordinates in the 2D grid, and compute the neighbors by adding the relative coordinates of each neighbor. At this point all I had to do was check if the neighbor was a live (1) or dead (0) cell, count up those values, and set the next generation of (x, y) cell to be either 0 or 1, depending on the number of live neighbors.
Here is the entirety of the runSimulation() algorithm.
const runSimulation = useCallback(() => {
// increment generation
let nextGeneration = generation + 1;
setGeneration(nextGeneration);
// make a new grid
let nextGenGrid = emptyGrid();
let oldGridCopy = [...grid];
// iterate over the current grid
// to calculate new values
for (let i = 0; i < numRows; i++) {
for (let j = 0; j < numCols; j++) {
// count up neighbors
let neighbors = 0;
// calculate neighbor coordinates
neighborCoordinates.forEach(([x, y]) => {
let newX = x + i;
let newY = y + j;
// if the new coordinates are in the grid
// (not below 0 or greater than numRows/numCols limit)
// count them as a neighbor and
// add their value to neighbors
if (newX >= 0 && newX < numRows && newY >= 0 && newY < numCols) {
neighbors += oldGridCopy[newX][newY];
}
});
// change cell state according to game logic
// if there are fewer than 2 or more than 3 neighbors,
// cell dies
if (neighbors < 2 || neighbors > 3) {
nextGenGrid[i][j] = 0;
}
// any cell with exactly 3 neighbors will either
// be born or survive from previous generation
else if (neighbors === 3) {
nextGenGrid[i][j] = 1;
}
// otherwise, the next generation looks the same
else {
nextGenGrid[i][j] = oldGridCopy[i][j];
}
}
}
setGrid(nextGenGrid);
});
React Hooks
Prior to this project, I'd already had experience with React's useState, useEffect, useContext, and useReducer, as well as my own custom hooks.
Since I needed the simulation algorithm to run in the background to calculate what the next generation should look like every x milliseconds (set by the simulation speed), I put the function inside a useEffect hook. This didn't give me the functionality I was going for, because each time the grid was updated, the entire component was re-created -- together with the simulation algorithm.
A little more Google-fu revealed that I can use React's useCallback hook to allow my runSimulation function to persist through component re-renders. I then referenced this useCallback-supercharged function inside my useEffect hook.
React's useEffect hook will run its code whenever there's a change in any of the variables listed in its dependency array. In my case, one of the dependency array items was a boolean slice of state that controlled whether the simulation was running or not. For that reason, inside the useEffect hook, I first checked to see if runningSimulation was set to false, in which case I wanted my program to do nothing and just return. Otherwise, I wanted it to continuously execute the runSimulation function at intervals of x milliseconds, where x is the speed selected by the user (defaults to "slow" or 1000ms if no selection was made).
Finally, whenever setting up counters or timers like this inside of useEffect, it's important to clean them up (otherwise they keep running in the background). The clean up process is initiated by the return keyword, followed by the cleanup function.
useEffect(() => {
if (runningSimulation === false) {
return;
}
const timer = setInterval(() => {
runSimulation();
}, speed);
return () => clearInterval(timer);
}, [runSimulation, runningSimulation]);
Close to the finish line
It was Wednesday night, and I finally had a working MVP. Thursday was my buffer, and I had all day to polish my project and make it look more presentable.
But when I signed off for the day on Wednesday with a working MVP, I wasn't super excited. I'd spent three whole days struggling through this project, trying to understand the requirements, working through tutorials, always with at least 10 StackOverflow tabs open, anxious to finish it on time... I wondered, have I learned anything? If I had to build this again, would I be able to, without any outside help?
When I woke up on Thursday morning, I knew I had to find out. I started a new create-react-app project and began to build the Game of Life simulation from scratch -- the grid, the algorithm, everything! Second time around, I felt like I had a much better understanding of how all the pieces fit together and the code I needed to write to make it all work. This also made me feel more prepared to give the presentation! I finished my second build in just a few hours (!!!) and still had plenty of time left as a buffer. I spent the extra time making the application responsive, and adding an explanation for each item in the Control Panel to improve the UX.
It was this second build that I presented on Friday to my team. It's also the build that I've linked below.
Reflection
I'm very happy with how this project turned out. I was able to implement Conway's Game of Life, I found ways to unblock myself when I got stuck, and (to me, this part is the most important) I was able to see my own measurable growth as a developer when I rebuilt the project that originally took me 3 long, stressful days in only a few hours!
Future direction
If I have time to return to this project in the future, I would like to add some population presets. The classical Game of Life yields some interesting population formations, such as glider guns, spaceships, and pulsars. Users might want to start with one of these special populations on the grid and observe their behavior over time.
Check it out!
I deployed the project on Vercel. You can interact with the live app here or take a look at the code here.
Thank you for reading! If you've built a Game of Life implementation as well, feel free to link to it in the comments! I'd love to take a look😊
Top comments (0)