Game Of Life in 3D

Conway’s Game of Life (see Wikipedia entry) is a classic problem for computer science students, and provides an early example of cellular automata as well as an opportunity to practice nested loops and multi-dimensional arrays. (Javascript example from Shodor.org) . For larger grids, it can also be an opportunity for students to think about domain decomposition and parallelism. (HPC version with parallel code) .

The Game of Life can also be an interesting activity to study in 3 dimensions. (See papers by Carter Bays from 1987 and 2006) .

This post will walk through creating a Unity 3D model of the Game of Life in 3D. Cells will be visualized as on (visible) or off(not visible), with color and transparency used to indicate the stability of each cell.

3D Life System

To begin, start with a new 3D scene in Unity. Add an empty game object in the Hierarchy, and call it “Model.” Attach a script component to it, also called Model.

In the Model script, we are going to create an array to store our Game of Life grid, and some information about the Game of Life rules. Additionally, we will create a routine for counting neighbors, a key step in the Game of Life, which includes the ability to count in 2 or 3D (to compare to the classic 2D problem.) We will use the Update routine to step forward, so that we do not lose any steps in the visualization.

The member variables we will need include arrays for both the grid itself as well as a count of neighbors at each grid position. We need to store all of the counts before updating each cell, as changing the cells during counting will affect our count. We also need a list of GameObjects for each cell to show in the screen, and variables to determine the size of the cell. A boolean will be used to turn off counting in the up-down (y) direction, and the Game of Life rules will be stored in two arrays giving allowed values for the next iteration if the cell is in an on or off state. Timer variables will be used to speed up or slow down the simulation, and we will need to provide some information about material and color for our objects.

	int [,,] theGrid; // length each cell has been on (0 for off)
	int [,,] neighbors; // storage for count of neighbors
	GameObject [,,] theObjects; // array of objects to show cells on screen
	int nx=30; // grid size
	int ny=1;
	int nz=30;
	bool useY = false; // should we count in y direction (for 2D comparison)
	int [] onRule = new int[]{2,3}; // On rule, if on how many neighbors to stay on
	int [] offRule = new int[]{3}; // Off rule. if off how many neighbors to turn on
	int onMax = 100; // maximum stored "age" of cell (to avoid overflow)
	float delay = 0.01f; // preferred delay between frames
	float time = 0.0f; // time counter for delaying frames
	public Material theMaterial;
	public Color youngColor = Color.green;
	public Color oldColor = Color.blue;

To set initial values, we will just use a random number call.

	void Reset() {
		float threshhold = 0.5f; // random threshhold for creating on cell
		for (int i = 0; i < nx; i++) // for every cell
			for (int j = 0; j < ny; j++)
				for (int k = 0; k < nz; k++)
					// set cell at random
					if (Random.Range (0.0f, 1.0f) < threshhold)
						theGrid [i, j, k] = 1;
					else
						theGrid [i, j, k] = 0;
				
	}

We will use the age of each cell to determine how to draw it. Cells with zero age (turned off) will not be shown, cells that are on will be drawn with an interpolated color based on how long they have been on, up to some “old age”.

	void SetVisibility() {
		for (int i = 0; i < nx; i++) { // for every cell
			for (int j = 0; j < ny; j++) {
				for (int k = 0; k < nz; k++) {
					if (theGrid [i, j, k] > 0) { // if cell is on, show it
						theObjects [i, j, k].SetActive (true);
						Color cellColor = Color.Lerp (youngColor,oldColor, (float)theGrid [i, j, k] / onMax);

						theObjects [i, j, k].GetComponent<Renderer> ().material.color = cellColor;
					} else {
						theObjects [i, j, k].SetActive (false);
					}
				}
			}
		}
	}

Our initialization will allocate each of the arrays, and set up the game objects. Note that we will need three public variables set for this to work, a Material and two colors. The Material is so that we can add transparency, the default material in Unity is opaque. In your unity scene, create a new material, and change it from standard to sprite->diffuse (which is both faster than standard and allows transparency). Once your model script is set up and compiling, you can set it’s public variable to the material you just created.

// Use this for initialization
	void Start () {
		// allocate grid space
		theGrid = new int[nx, ny, nz];

		// allocate neighbor count space
		neighbors = new int[nx, ny, nz];

		// set initial grid to random
		Reset ();

		// allocate and place game objects for each cell
		theObjects = new GameObject[nx, ny, nz];
		for (int i = 0; i < nx; i++) { // for every cell
			for (int j = 0; j < ny; j++) {
				for (int k = 0; k < nz; k++) {
					// start with a cube
					theObjects[i,j,k] = GameObject.CreatePrimitive (PrimitiveType.Cube);
					// parent the cube to the Model object
					theObjects [i, j, k].transform.parent = transform;
					// use the largest spacing to set the scale
					float scale = Mathf.Min (1.0f / nx, Mathf.Min (1.0f / ny, 1.0f / nz));
					theObjects [i, j, k].transform.localScale = scale * Vector3.one;
					// place the cells, centering in x and z
					float xpos = i * scale - 0.5f;
					float ypos = j * scale;
					float zpos = k * scale - 0.5f;
					theObjects [i, j, k].transform.position = new Vector3 (xpos, ypos, zpos);
					theObjects [i, j, k].GetComponent<Renderer> ().material = theMaterial;
				}
			}
		}

		// turn objects on or off based on initial values
		SetVisibility ();

	}

Our algorithm has two key routines, counting, and checking the rules. For the count, we will loop over every cell, and use modular arithmetic to “wrap” the edges. Additionally, we will put in a check to see whether or not to use the y direction in our count. This makes it easy to compare to 2D models by not counting in y and setting ny to 1.

	void Count() {
		for (int i = 0; i < nx; i++) { // for every cell
			for (int j = 0; j < ny; j++) {
				for (int k = 0; k < nz; k++) {
					neighbors [i, j, k] = 0; // start count at zero
					for (int l = -1; l < 2; l++) { // nearby in x
						int wi = ((i + l) + nx) % nx; // wrapped neighbor index
						int start = -1;
						int stop = 2;
						if (!useY) { // adjust loop for y if ignoring in count
							start = 0;
							stop = 1;
						}
						for (int m = start; m < stop; m++) { // nearby in y
							int wj = ((j + m) + ny) % ny; // wrapped neighbor index
							for (int n = -1; n < 2; n++) { // nearby in z
								int wk = ((k + n) + nz) % nz; // wrapped neighbor index
								if (l != 0 || m != 0 || n != 0) { // don't count self
									// if nearby cell isn't zero add one to count
									neighbors [i, j, k] += 
										System.Math.Min (theGrid [wi, wj, wk], 1);
								}
							}
						}
					}
				}
			}
		}
	}

To use the rules, for every cell we will compare the neighbor count to the allowed values in our member variables.

	void Step() {
		for (int i = 0; i < nx; i++) { // for every cell
			for (int j = 0; j < ny; j++) {
				for (int k = 0; k < nz; k++) {
					if (theGrid [i, j, k] > 0) { // if cell is on
						bool stayOn = false; // assume off til proven otherwise
						for (int l = 0; l < onRule.Length; l++) { // check allowed values
							if (neighbors [i, j, k] == onRule [l]) {
								stayOn = true;
							}
						}
						// adjust cell value
						if (stayOn) { 
							theGrid [i, j, k] = System.Math.Min (onMax, theGrid [i, j, k] + 1);
						} else {
							theGrid [i, j, k] = 0;
						}
					} else { // if cell is off
						bool turnOn = false; // assume off til proven otherwise
						for (int l = 0; l < offRule.Length; l++) { // check allowed values
							if (neighbors [i, j, k] == offRule [l]) {
								turnOn = true;
							}
						}
						// adjust cell value
						if (turnOn) {
							theGrid [i, j, k] = 1;
						} 
					}
				}
			}
		}
	}

Finally, our update will set up each iteration.

	// Update is called once per frame
	void Update () {
		// update timer by frame rate
		time += Time.deltaTime;
		if(time>delay) { // if its time to take a step, do so
			Count (); // count neighbors first
			Step (); // update values based on neighbors
			SetVisibility (); // update view

			time = 0.0f; // reset timer
		}
	}

When you put it all together it will look like the following

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Model : MonoBehaviour {

	int [,,] theGrid; // length each cell has been on (0 for off)
	int [,,] neighbors; // storage for count of neighbors
	GameObject [,,] theObjects; // array of objects to show cells on screen
	int nx=30; // grid size
	int ny=1;
	int nz=30;
	bool useY = false; // should we count in y direction (for 2D comparison)
	int [] onRule = new int[]{2,3}; // On rule, if on how many neighbors to stay on
	int [] offRule = new int[]{3}; // Off rule. if off how many neighbors to turn on
	int onMax = 100; // maximum stored "age" of cell (to avoid overflow)
	float delay = 0.01f; // preferred delay between frames
	float time = 0.0f; // time counter for delaying frames
	public Material theMaterial;
	public Color youngColor = Color.green;
	public Color oldColor = Color.blue;

	// Use this for initialization
	void Start () {
		// allocate grid space
		theGrid = new int[nx, ny, nz];

		// allocate neighbor count space
		neighbors = new int[nx, ny, nz];

		// set initial grid to random
		Reset ();

		// allocate and place game objects for each cell
		theObjects = new GameObject[nx, ny, nz];
		for (int i = 0; i < nx; i++) { // for every cell
			for (int j = 0; j < ny; j++) {
				for (int k = 0; k < nz; k++) {
					// start with a cube
					theObjects[i,j,k] = GameObject.CreatePrimitive (PrimitiveType.Cube);
					// parent the cube to the Model object
					theObjects [i, j, k].transform.parent = transform;
					// use the largest spacing to set the scale
					float scale = Mathf.Min (1.0f / nx, Mathf.Min (1.0f / ny, 1.0f / nz));
					theObjects [i, j, k].transform.localScale = scale * Vector3.one;
					// place the cells, centering in x and z
					float xpos = i * scale - 0.5f;
					float ypos = j * scale;
					float zpos = k * scale - 0.5f;
					theObjects [i, j, k].transform.position = new Vector3 (xpos, ypos, zpos);
					theObjects [i, j, k].GetComponent<Renderer> ().material = theMaterial;
				}
			}
		}

		// turn objects on or off based on initial values
		SetVisibility ();

	}

	void Count() {
		for (int i = 0; i < nx; i++) { // for every cell
			for (int j = 0; j < ny; j++) {
				for (int k = 0; k < nz; k++) {
					neighbors [i, j, k] = 0; // start count at zero
					for (int l = -1; l < 2; l++) { // nearby in x
						int wi = ((i + l) + nx) % nx; // wrapped neighbor index
						int start = -1;
						int stop = 2;
						if (!useY) { // adjust loop for y if ignoring in count
							start = 0;
							stop = 1;
						}
						for (int m = start; m < stop; m++) { // nearby in y
							int wj = ((j + m) + ny) % ny; // wrapped neighbor index
							for (int n = -1; n < 2; n++) { // nearby in z
								int wk = ((k + n) + nz) % nz; // wrapped neighbor index
								if (l != 0 || m != 0 || n != 0) { // don't count self
									// if nearby cell isn't zero add one to count
									neighbors [i, j, k] += 
										System.Math.Min (theGrid [wi, wj, wk], 1);
								}
							}
						}
					}
				}
			}
		}
	}

	void Step() {
		for (int i = 0; i < nx; i++) { // for every cell
			for (int j = 0; j < ny; j++) {
				for (int k = 0; k < nz; k++) {
					if (theGrid [i, j, k] > 0) { // if cell is on
						bool stayOn = false; // assume off til proven otherwise
						for (int l = 0; l < onRule.Length; l++) { // check allowed values
							if (neighbors [i, j, k] == onRule [l]) {
								stayOn = true;
							}
						}
						// adjust cell value
						if (stayOn) { 
							theGrid [i, j, k] = System.Math.Min (onMax, theGrid [i, j, k] + 1);
						} else {
							theGrid [i, j, k] = 0;
						}
					} else { // if cell is off
						bool turnOn = false; // assume off til proven otherwise
						for (int l = 0; l < offRule.Length; l++) { // check allowed values
							if (neighbors [i, j, k] == offRule [l]) {
								turnOn = true;
							}
						}
						// adjust cell value
						if (turnOn) {
							theGrid [i, j, k] = 1;
						} 
					}
				}
			}
		}
	}

	void Reset() {
		float threshhold = 0.5f; // random threshhold for creating on cell
		for (int i = 0; i < nx; i++) // for every cell
			for (int j = 0; j < ny; j++)
				for (int k = 0; k < nz; k++)
					// set cell at random
					if (Random.Range (0.0f, 1.0f) < threshhold)
						theGrid [i, j, k] = 1;
					else
						theGrid [i, j, k] = 0;
				
	}

	void SetVisibility() {
		for (int i = 0; i < nx; i++) { // for every cell
			for (int j = 0; j < ny; j++) {
				for (int k = 0; k < nz; k++) {
					if (theGrid [i, j, k] > 0) { // if cell is on, show it
						theObjects [i, j, k].SetActive (true);
						Color cellColor = Color.Lerp (youngColor,oldColor, (float)theGrid [i, j, k] / onMax);

						theObjects [i, j, k].GetComponent<Renderer> ().material.color = cellColor;
					} else {
						theObjects [i, j, k].SetActive (false);
					}
				}
			}
		}
	}


	// Update is called once per frame
	void Update () {
		// update timer by frame rate
		time += Time.deltaTime;
		if(time>delay) { // if its time to take a step, do so
			Count (); // count neighbors first
			Step (); // update values based on neighbors
			SetVisibility (); // update view

			time = 0.0f; // reset timer
		}
	}
}

Make these changes to Model, and save and compile your code. Once it compiles correctly, you should be able to set your public variables in the scene. Change the transparency of your “young” color to something low, and set your young and old colors to whatever you like. Set the material public variable to a material you create that you have changed the shader to sprite->diffuse. Run and view the model. You may need to move your camera to a more useful spot, or you may want to add in a camera rig from one of our previous posts.

Some interesting rules to check are the classic 2-D (as shown above), or the 5,6,7/6 rule in 3D (count in Y, set nx, ny, nz > 1, onRule = {5,6,7} offRule = {6}). You may need to also change the “threshhold” variable in Reset() to make the initial field more dense or sparse as you try different rules.

What other interesting 3D rules can you find?

Click here for the final Unity project (note some configuration variables have been made public in this version)

Comments on Twitter

Written on January 10, 2019