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.

Game_of_life_Demo.png

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 :

  1. A 2-dimensional plane that allows the lives to reproduce, survive and die based on the rules we mentioned.
  2. A Reset Game button that clears everything on the plane
  3. 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.

live_preview.png

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.

live-preview-options.png

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:

  1. unitLength: The width and height of a box.
  2. boxColor: The color of the box.
  3. strokeColor: The color of the stroke of the box.
  4. columns: Number of columns in our game of life. It is determined by the width of the container and unitLength.
  5. rows: Number of rows in our game of life. It is determined by the height of the container and unitLength.
  6. currentBoard: The states of the board of the current generation.
  7. 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 and windowHeight are the width and height of the viewport.
  • width and height 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.

Game_of_Life-Wrap.png

How wrapping actually works here ?

Game_of_life_wrapping_detail.png

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.

End of Content
© 2022 Tecky Academy Limited