Learn how to build the classic zero-player game Conway’s Game of Life.
In this series, I take a classic game and rebuild it using Unity’s Entity Component System (ECS).
Play it in WebGL here. For full source code of this project, checkout my github
Table of Contents
Overview
Created in 1970 by British mathematician John Horton Conway, Conway’s Game of Life is a zero-player game (one that requires no sentient players) developed to simulate cellular automaton.
The game is comprised of a 2 dimensional grid of square cells. Each cell in the grid is initially either marked as live or dead and after each step (generation) of the simulation, the state of the cell can change based on the following 3 rules:
- Any live cell with 2 or 3 neighbors survives
- Any dead cell with 3 live neighbors becomes alive
- All other live cells die and all other dead cell remain dead
Using these 3 rules to modify the cell state, we can construct our systems and data structures to build our game.
Cell Data
In order to apply the aforementioned rules, we need to know the current status of the cell. We’ll store that like this:
public struct LifeStatus : IComponentData
{
// 0 is dead, 1 is alive
public byte isAlive;
}
When we apply the rules to change the state of the cell, we don’t want to change the cell immediately, because other cells will be checking its state on the same frame and we want to only apply rules based on the data of this cycle, not next the next cycle. In order to do this we preserve the state of the cell in LifeStatusNextCycle that we will transfer over to LifeStatus at the end of the frame, after all the rules have been applied. This technique is called double buffering.
public struct LifeStatusNextCycle : IComponentData
{
public byte isAlive;
}
You may be wondering why I don’t just attach 2 LifeStatus components to the cells. Unity ECS doesn’t allow you to use 2 of the same type of IComponentData with a single entity. You may also be wondering why I don’t just simply stick this byte of data into the LifeStatus structure. I don’t do this because some systems only need the current life status, not the next frame, so it is an optimization to separate them.
Lastly, each cell needs to be able to check the LifeStatus of its neighboring cells. There’s a variety of ways we could do this, including using dynamic buffers, but I chose to set the neighbors manually for each cell when it is created, using this data structure.
/// <summary>
/// Every cell can have up to 8 neighbors in different directions (n = north, s = south, w = west, e = east)
/// </summary>
public struct Neighbors : IComponentData
{
public Entity nw;
public Entity n;
public Entity ne;
public Entity w;
public Entity e;
public Entity sw;
public Entity s;
public Entity se;
}
Cell Life Systems
Now that we have our data, we can setup systems to operate on it. First, we’ll make a query that gets all of cells’ current LifeStatuses.
ComponentDataFromEntity<LifeStatus> lifeStatusLookup = GetComponentDataFromEntity<LifeStatus>(true);
We have to loop through all of our cells and update their LifeStatusNextCycle using their neighbors’ current LifeStatus.
JobHandle jobHandle = Entities
.WithReadOnly(lifeStatusLookup)
.ForEach((Entity e, int entityInQueryIndex, ref LifeStatusNextCycle next, in Neighbors cell) => {
The rule that we apply is dependent on
- The number of live neighbors that the current cell has
- The LifeStatus of the current cell
byte numLiveNeighbors = 0;
// check current life status of all neighbors
if (cell.nw != Entity.Null) numLiveNeighbors += lifeStatusLookup[cell.nw].isAlive;
if (cell.n != Entity.Null) numLiveNeighbors += lifeStatusLookup[cell.n].isAlive;
if (cell.ne != Entity.Null) numLiveNeighbors += lifeStatusLookup[cell.ne].isAlive;
if (cell.w != Entity.Null) numLiveNeighbors += lifeStatusLookup[cell.w].isAlive;
if (cell.e != Entity.Null) numLiveNeighbors += lifeStatusLookup[cell.e].isAlive;
if (cell.sw != Entity.Null) numLiveNeighbors += lifeStatusLookup[cell.sw].isAlive;
if (cell.s != Entity.Null) numLiveNeighbors += lifeStatusLookup[cell.s].isAlive;
if (cell.se != Entity.Null) numLiveNeighbors += lifeStatusLookup[cell.se].isAlive;
if (lifeStatusLookup[e].isAlive == 1) // the cell currently alive
{
next.isAlive = (byte)math.select(1, 0, numLiveNeighbors < 2 || numLiveNeighbors > 3);
/*if (numLiveNeighbors < 2 || numLiveNeighbors > 3)
{
// die from under population or over population
next.isAlive = 0;
}
else
{
next.isAlive = 1;
}*/
}
else // the cell is currently dead
{
next.isAlive = (byte)math.select(0, 1, numLiveNeighbors == 3);
/*if (numLiveNeighbors == 3)
{
// become alive from reproduction
next.isAlive = 1;
}
else
{
next.isAlive = 0;
}*/
}
With the LifeStatusNextCycle updated, we can copy it over to the LifeStatus
// update the current life status of all the cells with this job
// this job requires the neighbor counter job to finish first
jobHandle = Entities
.WithReadOnly(scaleConsts)
.WithDeallocateOnJobCompletion(scaleConsts)
.ForEach((Entity entity, int entityInQueryIndex, ref Scale scale, ref LifeStatus status, in LifeStatusNextCycle nextStatus) =>
{
status.isAlive = nextStatus.isAlive;
// dead cells are invisible (scale 0)
scale.Value = scaleConsts[status.isAlive];
}).Schedule(jobHandle);
To put it all together, here is the entire system file
using Unity.Collections;
using Unity.Transforms;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
namespace GameLife
{
/// <summary>
/// Responsible for the game logic of Conway's Game of Life
/// https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life
/// </summary>
public class LifeVerificationSystem : JobComponentSystem
{
/// <summary>
/// keeps track of the seconds that have passed
/// </summary>
float timePassed = 0;
/// <summary>
/// The interval (in seconds) before moving to the next generation
/// </summary>
const float UpdateInterval = 0.5f;
public bool forceJob;
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
if (timePassed <= UpdateInterval && !forceJob)
{
// not enough time has passed, get out
timePassed += World.Time.DeltaTime;
return inputDeps;
}
// the job was forced or it's time to update to the next cell generation
timePassed = 0;
forceJob = false;
return PerformJob(inputDeps);
}
public JobHandle PerformJob(JobHandle inputDeps)
{
ComponentDataFromEntity<LifeStatus> lifeStatusLookup = GetComponentDataFromEntity<LifeStatus>(true);
JobHandle jobHandle = Entities
.WithReadOnly(lifeStatusLookup)
.ForEach((Entity cell, ref LifeStatusNextCycle next, in Neighbors neighbors) =>
{
byte numLiveNeighbors = 0;
// check current life status of all neighbors
if (neighbors.nw != Entity.Null) numLiveNeighbors += lifeStatusLookup[neighbors.nw].isAlive;
if (neighbors.n != Entity.Null) numLiveNeighbors += lifeStatusLookup[neighbors.n].isAlive;
if (neighbors.ne != Entity.Null) numLiveNeighbors += lifeStatusLookup[neighbors.ne].isAlive;
if (neighbors.w != Entity.Null) numLiveNeighbors += lifeStatusLookup[neighbors.w].isAlive;
if (neighbors.e != Entity.Null) numLiveNeighbors += lifeStatusLookup[neighbors.e].isAlive;
if (neighbors.sw != Entity.Null) numLiveNeighbors += lifeStatusLookup[neighbors.sw].isAlive;
if (neighbors.s != Entity.Null) numLiveNeighbors += lifeStatusLookup[neighbors.s].isAlive;
if (neighbors.se != Entity.Null) numLiveNeighbors += lifeStatusLookup[neighbors.se].isAlive;
if (lifeStatusLookup[cell].isAlive == 1) // the cell currently alive
{
next.isAlive = (byte)math.select(1, 0, numLiveNeighbors < 2 || numLiveNeighbors > 3);
/*if (numLiveNeighbors < 2 || numLiveNeighbors > 3)
{
// die from under population or over population
next.isAlive = 0;
}
else
{
next.isAlive = 1;
}*/
}
else // the cell is currently dead
{
next.isAlive = (byte)math.select(0, 1, numLiveNeighbors == 3);
/*if (numLiveNeighbors == 3)
{
// become alive from reproduction
next.isAlive = 1;
}
else
{
next.isAlive = 0;
}*/
}
}).Schedule(inputDeps);
// get the scaling constants and save them for our job later. This way, we can do a look up rather than a conditional
EntityQuery scaleConstQuery = EntityManager.CreateEntityQuery(typeof(ScaleConst), typeof(Scale));
NativeArray<Scale> consts = scaleConstQuery.ToComponentDataArray<Scale>(Allocator.TempJob);
NativeArray<float> scaleConsts = new NativeArray<float>(consts.Length, Allocator.TempJob);
for (int i = 0; i < consts.Length; i++)
{
scaleConsts[i] = consts[i].Value;
}
consts.Dispose();
// update the current life status of all the cells with this job
// this job requires the neighbor counter job to finish first
jobHandle = Entities
.WithReadOnly(scaleConsts)
.WithDeallocateOnJobCompletion(scaleConsts)
.ForEach((Entity entity, int entityInQueryIndex, ref Scale scale, ref LifeStatus status, in LifeStatusNextCycle nextStatus) =>
{
status.isAlive = nextStatus.isAlive;
// dead cells are invisible (scale 0)
scale.Value = scaleConsts[status.isAlive];
}).Schedule(jobHandle);
return jobHandle;
}
}
}
Creating the Grid
With the data and the system setup, we can now build our grid. We’ll use the entity manager to create the cell entities and their component data.
Entity CreateCell(int row, int col, float scale)
{
Entity cell = entityManager.CreateEntity(
typeof(Translation),
typeof(LocalToWorld),
typeof(RenderMesh),
typeof(Scale),
typeof(LifeStatus),
typeof(LifeStatusNextCycle),
typeof(Neighbors),
typeof(BoundingBox),
typeof(ClickStatus)
);
SetCellComponentData(cell, row, col, scale);
return cell;
}
Using the row and the col data, we can place our cells in the proper positions
void SetCellComponentData(Entity cell, int row, int col, float scale)
{
#if UNITY_EDITOR
entityManager.SetName(cell, string.Format("[{0},{1}]", row, col));
#endif
entityManager.SetSharedComponentData<RenderMesh>(cell, new RenderMesh { mesh = cellMesh, material = liveCellMaterial });
float halfScale = scale * 0.5f;
Vector3 minPos = gridMin.transform.position;
float3 position = new Vector3(minPos.x + halfScale + (col * scale), minPos.y + halfScale + (row * scale), 0);
entityManager.SetComponentData<Translation>(cell,
new Translation
{
Value = position
});
entityManager.SetComponentData<Scale>(cell,
new Scale
{
Value = 0// invisible by default
});
Bounds box = new Bounds();
box.center = position;
box.extents = new float3(halfScale, halfScale, 1);
entityManager.SetComponentData<BoundingBox>(cell, new BoundingBox { box = box });
entityManager.SetComponentData<Neighbors>(cell, new Neighbors());
entityManager.AddComponent<WorldRenderBounds>(cell);
entityManager.AddComponent<RenderBounds>(cell);
entityManager.AddComponent<ChunkWorldRenderBounds>(cell);
}
Now all we have to do is create all the cells based on the width and height. You can hard code these values of allow the player to set them.
using UnityEngine;
using Unity.Entities;
using Unity.Rendering;
using Unity.Transforms;
using Unity.Mathematics;
using Unity.Collections;
using UnityEngine.SceneManagement;
namespace GameLife
{
/// <summary>
/// Create cell entities based on the settings chosen in the UI. Application entry point
/// </summary>
public class GameHandler : MonoBehaviour
{
public const int MaxSize = 1000;
[SerializeField] int gridSize = 100;
[SerializeField] Mesh cellMesh = null;
[SerializeField] Material liveCellMaterial = null;
[SerializeField] Transform gridMin = null;
[SerializeField] Transform gridMax = null;
EntityManager entityManager;
public int GridSize { get { return gridSize; } }
// Start is called before the first frame update
void Start()
{
entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
}
public void SetGridSize(int width)
{
gridSize = Mathf.Min(width, MaxSize);
}
public void StartGame()
{
InitSystems();
}
private void InitSystems()
{
World.DefaultGameObjectInjectionWorld.GetOrCreateSystem<PointInCellAABSystem>().Enabled = false;
World.DefaultGameObjectInjectionWorld.GetOrCreateSystem<LifeVerificationSystem>().Enabled = true;
}
public void RestartGame()
{
// destroy all entities
NativeArray<Entity> allEntities = entityManager.GetAllEntities(Allocator.Temp);
entityManager.DestroyEntity(allEntities);
allEntities.Dispose();
World.DefaultGameObjectInjectionWorld.GetExistingSystem<PointInCellAABSystem>().Enabled = true;
World.DefaultGameObjectInjectionWorld.GetExistingSystem<LifeVerificationSystem>().Enabled = false;
Time.timeScale = 1;
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}
public void NextCycle()
{
World.DefaultGameObjectInjectionWorld.GetExistingSystem<LifeVerificationSystem>().forceJob = true;
}
public void CreateGrid()
{
World.DefaultGameObjectInjectionWorld.GetOrCreateSystem<LifeVerificationSystem>().Enabled = false;
int gridWidth = gridSize;
int gridHeight = gridSize;
float scaleX = Mathf.Abs((gridMax.position.x - gridMin.position.x) / gridWidth);
float scaleY = Mathf.Abs((gridMax.position.y - gridMin.position.y) / gridHeight);
float scale = Mathf.Abs(Mathf.Min(scaleX, scaleY));
int minIndex = int.MaxValue;
// create all the cell entities based on the grid size
Entity[,] grid = new Entity[gridHeight, gridWidth];
for (int i = 0; i < gridWidth; i++)
{
for (int j = 0; j < gridHeight; j++)
{
grid[j,i] = CreateCell(i, j, scale);
if (minIndex > grid[j,i].Index)
{
minIndex = grid[j,i].Index;
}
}
}
// connect cell entities as neighbors
for (int i = 0; i < gridWidth; i++)
{
for (int j = 0; j < gridHeight; j++)
{
Neighbors neighbors = entityManager.GetComponentData<Neighbors>(grid[i, j]);
if (i > 0)
{
neighbors.w = grid[i - 1, j];
}
if (j > 0)
{
neighbors.s = grid[i, j - 1];
}
if (i < gridWidth - 1)
{
neighbors.e = grid[i + 1, j];
}
if (j < gridHeight - 1)
{
neighbors.n = grid[i, j + 1];
}
if (neighbors.w != Entity.Null && neighbors.n != Entity.Null)
{
neighbors.nw = grid[i - 1, j + 1];
}
if (neighbors.w != Entity.Null && neighbors.s != Entity.Null)
{
neighbors.sw = grid[i - 1, j - 1];
}
if (neighbors.e != Entity.Null && neighbors.n != Entity.Null)
{
neighbors.ne = grid[i + 1, j + 1];
}
if (neighbors.e != Entity.Null && neighbors.s != Entity.Null)
{
neighbors.se = grid[i + 1, j - 1];
}
entityManager.SetComponentData<Neighbors>(grid[i, j], neighbors);
}
}
CreateScaleConstants(scale);
}
void CreateScaleConstants(float scale)
{
Entity invis = entityManager.CreateEntity(
typeof (Scale),
typeof (ScaleConst)
);
entityManager.SetComponentData<Scale>(invis, new Scale { Value = 0 });
Entity vis = entityManager.CreateEntity(
typeof(Scale),
typeof(ScaleConst)
);
entityManager.SetComponentData<Scale>(vis, new Scale { Value = scale });
}
Entity CreateCell(int row, int col, float scale)
{
Entity cell = entityManager.CreateEntity(
typeof(Translation),
typeof(LocalToWorld),
typeof(RenderMesh),
typeof(Scale),
typeof(LifeStatus),
typeof(LifeStatusNextCycle),
typeof(Neighbors),
typeof(BoundingBox),
typeof(ClickStatus)
);
SetCellComponentData(cell, row, col, scale);
return cell;
}
void SetCellComponentData(Entity cell, int row, int col, float scale)
{
#if UNITY_EDITOR
entityManager.SetName(cell, string.Format("[{0},{1}]", row, col));
#endif
entityManager.SetSharedComponentData<RenderMesh>(cell, new RenderMesh { mesh = cellMesh, material = liveCellMaterial });
float halfScale = scale * 0.5f;
Vector3 minPos = gridMin.transform.position;
float3 position = new Vector3(minPos.x + halfScale + (col * scale), minPos.y + halfScale + (row * scale), 0);
entityManager.SetComponentData<Translation>(cell,
new Translation
{
Value = position
});
entityManager.SetComponentData<Scale>(cell,
new Scale
{
Value = 0// invisible by default
});
Bounds box = new Bounds();
box.center = position;
box.extents = new float3(halfScale, halfScale, 1);
entityManager.SetComponentData<BoundingBox>(cell, new BoundingBox { box = box });
entityManager.SetComponentData<Neighbors>(cell, new Neighbors());
entityManager.AddComponent<WorldRenderBounds>(cell);
entityManager.AddComponent<RenderBounds>(cell);
entityManager.AddComponent<ChunkWorldRenderBounds>(cell);
}
}
}