WEF008
Game of Life Code-Along
Game of Life Code-Along
We are going to build our game of life step by step today. Here is the resulting product that we are going to build within this section.
As you can see , we have a large 2-dimensional plane for our lives
to thrive on. And we have a button called Reset Game at the bottom left corner.
Here are the list of features that we want to have in this game :
- A 2-dimensional plane that allows the lives to reproduce, survive and die based on the rules we mentioned.
- A Reset Game button that clears everything on the plane
- Allowing user to click and drag to add new lives manually on the lifeless box.
Live Preview Extension
Before you proceed, we would like you to install an VSCode extension called Live Preview
.
With this extension, you should be able to serve HTML using the option in the right click menu directly.
It makes working with HTML
,CSS
and JavaScript
files much more easier.
State of the Game
Before we start writing anything, we need to consider what would be the state of the game. A state is all of the data that is necessary to represent the current game. We need to get the state of the game right because we would then need to update our user interface using the state
of the game.
Here is the state of the game of life that we are going to implement.
🛠Warning:
Not all codes are require to copy from following sections. Some code sections are only for explains used. Please take care and watch carefully.
const unitLength = 20; const boxColor = 150; const strokeColor = 50; let columns; /* To be determined by window width */ let rows; /* To be determined by window height */ let currentBoard; let nextBoard;
Here are the descriptions of all of the states:
- unitLength: The width and height of a box.
- boxColor: The color of the box.
- strokeColor: The color of the stroke of the box.
- columns: Number of columns in our game of life. It is determined by the width of the container and
unitLength
. - rows: Number of rows in our game of life. It is determined by the height of the container and
unitLength
. - currentBoard: The states of the board of the current generation.
- nextBoard: The states of the board of the next generation. It is determined by the current generation.
Setup Function
Let's write the setup
function for the initialization.
function setup(){ /* Set the canvas to be under the element #canvas*/ const canvas = createCanvas(windowWidth, windowHeight - 100); canvas.parent(document.querySelector('#canvas')); /*Calculate the number of columns and rows */ columns = floor(width / unitLength); rows = floor(height / unitLength); /*Making both currentBoard and nextBoard 2-dimensional matrix that has (columns * rows) boxes. */ currentBoard = []; nextBoard = []; for (let i = 0; i < columns; i++) { currentBoard[i] = []; nextBoard[i] = [] } // Now both currentBoard and nextBoard are array of array of undefined values. init(); // Set the initial values of the currentBoard and nextBoard }
Let's look at some of the magic variables
here. The magic variables
include windowWidth
, windowHeight
, width
and height
. They are all provided by p5.js to make our life easier.
windowWidth
andwindowHeight
are the width and height of the viewport.width
andheight
are the width and height of the canvas element.
const canvas = createCanvas(windowWidth, windowHeight - 100); canvas.parent(document.querySelector('#canvas'));
We are calling createCanvas()
with windowWidth
and windowHeight - 100
to make a canvas that is as wide as the screen but 100 px
shorter than the height. We then use .parent()
to insert our canvas element to the element with id canvas
.
/*Calculate the number of columns and rows */ columns = floor(width / unitLength); rows = floor(height / unitLength);
We can then calculate the columns and rows using the width
,height
and the unitLength
. We need to use the floor
function because there is no guarantee that the quotients would be an integer.
After that , we can simply initialize currentBoard
and nextBoard
to be an array of array. Then we run another function called init
to initialize all the boxes' value to 0.
currentBoard = []; nextBoard = []; for (let i = 0; i < columns; i++) { currentBoard[i] = []; nextBoard[i] = [] } // Now both currentBoard and nextBoard are array of array of undefined values. init(); // Set the initial values of the currentBoard and nextBoard
Init function
Let's finish the init()
function , the function is simple. We just need loop over both currentBoard
and nextBoard
to set all of the boxes' value to 0
.
/** * Initialize/reset the board state */ function init() { for (let i = 0; i < columns; i++) { for (let j = 0; j < rows; j++) { currentBoard[i][j] = 0; nextBoard[i][j] = 0; } } }
Upon loading the page, every box in the board are 0
now.
We can also use random input, for example we can use the random function to randomize initial state of currentBoard
.
// let someVariables = <condictions> : <when_true> : <when_false>; currentBoard[i][j] = random() > 0.8 ? 1 : 0; // one line if nextBoard[i][j] = 0;
Draw Function
As mentioned before, the draw()
function is being run for every single frame. Therefore, we need to draw the state of the current generation to the canvas inside draw()
function.
function draw() { background(255); generate(); for (let i = 0; i < columns; i++) { for (let j = 0; j < rows; j++) { if (currentBoard[i][j] == 1){ fill(boxColor); } else { fill(255); } stroke(strokeColor); rect(i * unitLength, j * unitLength, unitLength, unitLength); } } }
background(255); generate();
In the first line, we set the background to white ( (255,255,255) is the RGB code of white) with the function background()
.
Then we call the function generate()
which calculates the next generation with current generation.
Within the nested for-loop, you can see we are checking if the currentBoard[i][j] == 1
. It means that we are checking if the box has life. If true
, then we set the filling color to the boxColor
, else we set it to white
.
The stroke is set to strokeColor
. Then we can call the rect
function which conveniently use the configuration we just set (filling color is boxColor
and stroke color is strokeColor
) to make a rect
. The parameters i * unitLength, j * unitLength
sets the position of the top left corner of the rectangle and the parameters unitLength, unitLength
set the size of the rectangle.
Generate function
Generate function contains the core business logic of game of life. It basically calculates the next generation solely with the information of the current generation.
Let's look at the implementation of the generate()
function first.
function generate() { //Loop over every single box on the board for (let x = 0; x < columns; x++) { for (let y = 0; y < rows; y++) { // Count all living members in the Moore neighborhood(8 boxes surrounding) let neighbors = 0; for (let i of [-1, 0, 1]) { for (let j of [-1, 0, 1]) { if( i == 0 && j == 0 ){ // the cell itself is not its own neighbor continue; } // The modulo operator is crucial for wrapping on the edge neighbors += currentBoard[(x + i + columns) % columns][(y + j + rows) % rows]; } } // Rules of Life if (currentBoard[x][y] == 1 && neighbors < 2) { // Die of Loneliness nextBoard[x][y] = 0; } else if (currentBoard[x][y] == 1 && neighbors > 3) { // Die of Overpopulation nextBoard[x][y] = 0; } else if (currentBoard[x][y] == 0 && neighbors == 3) { // New life due to Reproduction nextBoard[x][y] = 1; } else { // Stasis nextBoard[x][y] = currentBoard[x][y]; } } } // Swap the nextBoard to be the current Board [currentBoard, nextBoard] = [nextBoard, currentBoard]; }
Again , we are going to loop over the every single box in the board. Inside the for-loop , we need to first count the neighbors of each box.
// Count all living members in the Moore neighborhood(8 boxes surrounding) let neighbors = 0; for (let i of [-1, 0, 1]) { for (let j of [-1, 0, 1]) { if (i == 0 && j == 0) { // the cell itself is not its own neighbor continue; } // The modulo operator is crucial for wrapping on the edge neighbors += currentBoard[(x + i + columns) % columns][(y + j + rows) % rows]; } }
As you can we loop over all of the Moore neighborhoods. Except when i == 0 && j == 0
(which is basically the box itself). We add the number of the neighbors' value. Since 0
represent lifeless, we will add 1
to neighbors
every box with life. Note that we are using (x + i + columns) % columns
and similar code in rows case. Because we don't want our lives hit the edge of our board, we would like them to wrap to the other side of the board.
The following diagram shows you how wrapping works in our board.
How wrapping actually works here ?
As you can see , calculating the neighbors of a cell involves counting all of the neighbors around the current cell. That is what the nested for-loop here is doing.
for (let i of [-1, 0, 1]) { for (let j of [-1, 0, 1]) { if (i == 0 && j == 0) { // the cell itself is not its own neighbor continue; } // rest of the code. } }
The continue
here is for skipping the 0,0
since it is essentially the element itself.
It becomes a problem since we are having a trouble at the edge.We may have our array out of bound.
There are two cases we need to cater:
- cell at the corners
- cell at the '0-sided' edge
To cater for the cells at the corners, we need to use the module operator %
to limit our index between 0
(inclusive) and columns/rows
(exclusive).
To cater for the cells at the 0-sided, we need to use add columns/rows
to the index to make them positive before using the modulo operator%
.
📃 Hints:
We can add columns/rows
to the index as we wish because "-1 % 7" is the same as "6 % 7" which is also the same as "13 % 7".
Implementing the rules of game of life
The remaining code is basically the rules of the Game of Life
.
// Rules of Life if (currentBoard[x][y] == 1 && neighbors < 2) { // Die of Loneliness nextBoard[x][y] = 0; } else if (currentBoard[x][y] == 1 && neighbors > 3) { // Die of Overpopulation nextBoard[x][y] = 0; } else if (currentBoard[x][y] == 0 && neighbors == 3) { // New life due to Reproduction nextBoard[x][y] = 1; } else { // Stasis nextBoard[x][y] = currentBoard[x][y]; }
At the end, we need to swap currentBoard
and nextBoard
. Making the calculated next generation to be the current generation.
// Swap the nextBoard to be the current Board [currentBoard, nextBoard] = [nextBoard, currentBoard];
This is it. You should have a working Game of Life
with random lifes appearing everything you load the page.
Mouse Interaction
It would not feel complete if the users cannot interact with our board.
Let's add some mouse events handler. Luckily, p5.js
already provides useful function like mouseDragged()
, mousePressed()
and mouseReleased()
for us to implement event handler.
As their name suggested, they are invoked when the mouse is dragged , pressed and released.
Let's look at how we implement it.
/** * When mouse is dragged */ function mouseDragged() { /** * If the mouse coordinate is outside the board */ if (mouseX > unitLength * columns || mouseY > unitLength * rows) { return; } const x = Math.floor(mouseX / unitLength); const y = Math.floor(mouseY / unitLength); currentBoard[x][y] = 1; fill(boxColor); stroke(strokeColor); rect(x * unitLength, y * unitLength, unitLength, unitLength); } /** * When mouse is pressed */ function mousePressed() { noLoop(); mouseDragged(); } /** * When mouse is released */ function mouseReleased() { loop(); }
As you can see, we run noLoop()
for mousePressed()
. It means that we want p5.js
to stop running draw()
whenever our mouse is pressed. We also resume the loop of running draw()
when the mouse is released. So the game pauses when the user pressed on the canvas and resume when the mouse is released. We also reuse mouseDragged
in mousePressed
function.
function mouseDragged() { /** * If the mouse coordinate is outside the board */ if (mouseX > unitLength * columns || mouseY > unitLength * rows) { return; } const x = Math.floor(mouseX / unitLength); const y = Math.floor(mouseY / unitLength); currentBoard[x][y] = 1; fill(boxColor); stroke(strokeColor); rect(x * unitLength, y * unitLength, unitLength, unitLength); }
In our mouseDragged
function , we first check if the coordinate of the cursor (mouseX
and mouseY
) is out of the board(Remember the floor
function?). Then we calculate which box our cursor is currently above and we set the box to have a life. Then we paint the box directly because we are now pausing the draw()
function.
Now you can add new life by pressing and dragging on the canvas!
Reset Button
How about the reset button at the bottom left corner. We can easily make the reset
behavior with a simple event handler and a call to init()
.
document.querySelector('#reset-game') .addEventListener('click', function() { init(); });
🛠Warning:
You cannot use mouseClicked or mousePressed here. Because the reset button is outside the canvas. So p5.js does not manage this button.