Skip to content

Conway’s Game of Life in Unity ECS

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 hereFor 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:

  1.  Any live cell with 2 or 3 neighbors survives
  2.  Any dead cell with 3 live neighbors becomes alive
  3. 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

  1. The number of live neighbors that the current cell has
  2. The LifeStatus of the current cell
 
So this loop will count up the number of live neighbors, check the current LifeStatus, then update our LifeStatusNextCycle 
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);
        }
    }
}