Conway's Game of life with SwiftUI

I thought it would be fun to look into creating Conway's Game of life in SwiftUI over the holidays. Conway's Game of life is an automatic game consisting of a 2D grid where cells are either alive or dead. The state of each cell in the next iteration is based on the cells around it in the current iteration following some simple rules.

Conway's Game of Life was devised by John Conway in 1970 can be fascinating to watch the different patterns evolve. Here are the 4 simple rules of the game:

  1. Any live cell with fewer than two live neighbours dies, as if by underpopulation.
  2. Any live cell with two or three live neighbours lives on to the next generation.
  3. Any live cell with more than three live neighbours dies, as if by overpopulation.
  4. Any dead cell with exactly three live neighbours becomes a live cell, as if by reproduction.


Grid to display

The implementation is broken into two phases; the UI to display the state of the game and animate as the game changes; and the logic to change the cell status from generation to generation based on the 4 rules of the game. Start with using the Grid container view that arranges views in a 2D grid and use rounded rectangles to represent each cell. A green color to represent a live cell and a gray color to represent a dead cell.

The view sets the color of each cell randomly to represent a live or dead cell.


GridLayoutView

 1struct GridLayoutView: View {
 2    var body: some View {
 3        VStack {
 4            VStack(spacing: 5) {
 5                Text("Use Grid")
 6                    .font(.largeTitle)
 7                    .fontWeight(.bold)
 8                
 9                Grid(alignment: .center, horizontalSpacing: 2, verticalSpacing: 2) {
10                    ForEach(0..<5) { row in
11                        GridRow {
12                            ForEach(0..<5) { _ in
13                                let n = Int.random(in: 1..<10)
14                                
15                                RoundedRectangle(cornerRadius: 5)
16                                    .foregroundStyle(n%3 == 0 ? .green : .gray)
17                            }
18                        }
19                    }
20                }
21                .padding(5)
22                .background(.black)
23                .frame(width:300, height: 300)
24            }
25            
26            Spacer()
27                .frame(height: 20)
28            
29            Divider()
30            
31            VStack(spacing: 5) {
32                Text("Grid 50 * 50")
33                    .font(.largeTitle)
34                    .fontWeight(.bold)
35                
36                Grid(alignment: .center, horizontalSpacing: 0.1, verticalSpacing: 0.1) {
37                    ForEach(0..<50) { row in
38                        GridRow {
39                            ForEach(0..<50) { _ in
40                                let n = Int.random(in: 1..<10)
41                                RoundedRectangle(cornerRadius: 5)
42                                    .foregroundStyle(n%9 == 0 ? .green : .gray)
43                            }
44                        }
45                    }
46                }
47                .padding(5)
48                .background(.black)
49                .frame(width:300, height: 300)
50                
51                Spacer()
52            }
53        }
54    }
55}

Using Grid to layout rectangles to represent cells



Grid to display from 2D array

The view uses a the Grid container view to layout cells and set the color based on values in a 2 dimensional grid of values. The values in the grid are hard-coded with values of 1 or 0 representing live and dead respectively.


GridFromArrayView

 1struct GridFromArrayView: View {    
 2    let gridData = [
 3        [1,1,0,0,0],
 4        [0,1,0,1,0],
 5        [0,0,0,0,1],
 6        [0,1,0,1,0],
 7        [0,0,0,1,0]
 8    ]
 9    var body: some View {
10        VStack {
11            Text("Grid from 2D array")
12                .font(.largeTitle)
13                .fontWeight(.bold)
14            Grid(alignment: .center, horizontalSpacing: 2, verticalSpacing: 2) {
15                ForEach(0..<gridData.count, id:\.self) { r in
16                    GridRow {
17                        ForEach(0..<gridData[r].count,  id:\.self) { c in
18                            Rectangle().fill(gridData[r][c] == 1 ? .green: .gray)
19                        }
20                    }
21                }
22            }
23            .padding(2)
24            .background(.black)
25            .frame(width: 300, height: 300)
26            
27            Spacer()
28                .frame(height: 40)
29            
30            Divider()
31            
32            Text("Values in 2D array")
33                .font(.largeTitle)
34                .fontWeight(.bold)
35            Grid(alignment: .center, horizontalSpacing: 3, verticalSpacing: 3) {
36                ForEach(0..<gridData.count, id:\.self) { r in
37                    GridRow {
38                        ForEach(0..<gridData[r].count,  id:\.self) { c in
39                            Rectangle().fill(.quaternary)
40                                .overlay(
41                                    Text("\(gridData[r][c])")
42                                        .font(.title2)
43                                        .fontWeight(.bold)
44                                )
45                        }
46                    }
47                }
48            }
49            .background(.quaternary)
50            .frame(width: 200, height: 200)
51            
52            Spacer()
53        }
54    }
55}

Using Grid to layout cells based on values in 2D array



Canvas to display

Grid container view may not be the most efficient way to display the cells from the Game of Life, as all the cells will need to change on each iteration of the game. Canvas may be a more efficient way to display all the cells insite one view. In Better performance with canvas in SwiftUI, I found that the use of canvas can be very efficient in rendering and updating the SwiftUI views.

The CanvasLayoutView uses a Canvas to layout a grid of rounded rectangles and sets the color of each cell randomly to represent a live or dead cell.


CanvasLayoutView

 1struct CanvasLayoutView: View {
 2    var body: some View {
 3        VStack {
 4            VStack(spacing: 5) {
 5                Text("Use Canvas")
 6                    .font(.largeTitle)
 7                    .fontWeight(.bold)
 8                Canvas { context, size in
 9                    let w = size.width
10                    let h = size.height
11                    let rows = 5
12                    let cols = 5
13                    let offset = 0.03 * w / CGFloat(cols)
14                    
15                    for r in 0..<rows {
16                        for c in 0..<cols {
17                            let n = Int.random(in: 1..<10)
18
19                            let x = w / CGFloat(cols) * CGFloat(c) + offset
20                            let y = h / CGFloat(rows) * CGFloat(r) + offset
21                            context.fill(
22                                Path(
23                                    roundedRect: CGRect(
24                                        x: x,
25                                        y: y,
26                                        width: 0.94 * w / CGFloat(cols),
27                                        height: 0.94 * h / CGFloat(rows)
28                                    ),
29                                    cornerRadius: w/CGFloat(cols*10)),
30                                with: .color(n%3 == 0 ? .green : .gray))
31                        }
32                    }
33                }
34                .background(.black)
35                .frame(width: 300, height: 300)
36                .border(Color.black)
37            }
38            
39            Spacer()
40                .frame(height: 20)
41            
42            Divider()
43            
44            VStack(spacing: 5) {
45                Text("Canvas 50 * 50")
46                    .font(.largeTitle)
47                    .fontWeight(.bold)
48                Canvas { context, size in
49                    let w = size.width
50                    let h = size.height
51                    let rows = 50
52                    let cols = 50
53                    let offset = 0.03 * w / CGFloat(cols)
54                    
55                    for r in 0..<rows {
56                        for c in 0..<cols {
57                            let n = Int.random(in: 1..<10)
58
59                            let x = w / CGFloat(cols) * CGFloat(c) + offset
60                            let y = h / CGFloat(rows) * CGFloat(r) + offset
61                            context.fill(
62                                Path(
63                                    roundedRect: CGRect(
64                                        x: x,
65                                        y: y,
66                                        width: 0.94 * w / CGFloat(cols),
67                                        height: 0.94 * h / CGFloat(rows)
68                                    ),
69                                    cornerRadius: w/CGFloat(cols*10)),
70                                with: .color(n%9 == 0 ? .green : .gray))
71                        }
72                    }
73                }
74                .background(.black)
75                .frame(width: 300, height: 300)
76                .border(Color.black)
77                
78                Spacer()
79            }
80        }
81    }
82}

Using Canvas to layout rectangles to represent cells



Canvas to display from 2D array

The CanvasFromArrayView uses a Canvas to layout a grid of rounded rectangles and sets the color of each cell corresponding to a 2D array of values for the cells.


CanvasFromArrayView

 1struct CanvasFromArrayView: View {
 2    
 3    let gridData = [
 4        [1,1,0,0,0],
 5        [0,1,0,1,0],
 6        [0,0,0,0,1],
 7        [0,1,0,1,0],
 8        [0,0,0,1,0]
 9    ]
10    
11    var body: some View {
12        VStack {
13            VStack(spacing: 5) {
14                Text("Canvas from 2D array")
15                    .font(.largeTitle)
16                    .fontWeight(.bold)
17                Canvas { context, size in
18                    let w = size.width
19                    let h = size.height
20                    let rows = gridData.count
21                    let cols = gridData[0].count
22                    let offset = 0.03 * w / CGFloat(cols)
23                    
24                    for r in 0..<rows {
25                        for c in 0..<cols {
26                            let x = w / CGFloat(cols) * CGFloat(c) + offset
27                            let y = h / CGFloat(rows) * CGFloat(r) + offset
28                            context.fill(
29                                Path(
30                                    roundedRect: CGRect(
31                                        x: x,
32                                        y: y,
33                                        width: 0.94 * w / CGFloat(cols),
34                                        height: 0.94 * h / CGFloat(rows)
35                                    ),
36                                    cornerRadius: w/CGFloat(cols*10)),
37                                with: .color(gridData[r][c] == 1 ? .green: .gray))
38                        }
39                    }
40                }
41                .background(.black)
42                .frame(width: 300, height: 300)
43                .border(Color.black)
44            }
45            Spacer()
46                .frame(height: 40)
47            
48            Divider()
49            
50            VStack(spacing: 5) {
51                Text("Values in 2D array")
52                    .font(.largeTitle)
53                    .fontWeight(.bold)
54                Grid(alignment: .center, horizontalSpacing: 3, verticalSpacing: 3) {
55                    ForEach(0..<gridData.count, id:\.self) { r in
56                        GridRow {
57                            ForEach(0..<gridData[r].count,  id:\.self) { c in
58                                Rectangle().fill(.quaternary)
59                                    .overlay(
60                                        Text("\(gridData[r][c])")
61                                            .font(.title2)
62                                            .fontWeight(.bold)
63                                    )
64                            }
65                        }
66                    }
67                }
68                .background(.quaternary)
69                .frame(width: 200, height: 200)
70                
71                Spacer()
72            }
73        }
74    }
75}

Using Canvas to layout cells based on values in 2D array



Game Of Life Model and ViewModel

The LifeModel is used to contain and control the essence of the Game of Life. It can be created with a variable number of cells, which form a square grid. Conceptually the grid is setup to warp around, so that the cells on the top row are neighbours to cells on the bottom row and so on.

The LifeViewModel is used to bridge between the model and the views following the MVVM pattern - see MVVM in SwiftUI for more information on MVVM.

The CanvasFromModelView is used to test the display of the data from the LifeModel in a SwiftUI view using the Canvas to layout the cells. A button is added to the view to reset the model and ensure the data is reflected in the view.


LifeModel

  1struct LifeModel {
  2    private(set) var cellCount:Int
  3    var grid:[[Int]]
  4    private(set) var numberOfCycles: Int
  5    
  6    init(cells:Int){
  7        cellCount = cells
  8        grid = Array(repeating: Array(repeating: 0, count: cellCount), count: cellCount)
  9        self.numberOfCycles = 0
 10        self.randomise()
 11    }
 12    
 13    mutating func randomise() {
 14        for r in 0..<cellCount {
 15            for c in 0..<cellCount {
 16                grid[r][c] = (Int.random(in: 1..<100))%3 == 0 ? 1 : 0
 17            }
 18        }
 19    }
 20    
 21    mutating func step() {
 22        let newGrid = nextGrid(currentGrid: self.grid)
 23        if self.grid != newGrid {
 24            self.grid = newGrid
 25            numberOfCycles += 1
 26        }
 27    }
 28    
 29    mutating func performStepDelay() async -> Bool {
 30        var gridIsDifferent = true
 31        let newGrid = nextGrid(currentGrid: self.grid)
 32        if self.grid == newGrid {
 33            gridIsDifferent = false
 34        } else {
 35            self.grid = newGrid
 36            numberOfCycles += 1
 37        }
 38
 39        usleep(300000)
 40        return gridIsDifferent
 41    }
 42        
 43    func nextGrid(currentGrid:[[Int]]) -> [[Int]] {
 44        // initialise the next grid with all zeros
 45        var nextGrid = Array(repeating: Array(repeating: 0, count: cellCount), count: cellCount)
 46
 47        for row in 0..<(currentGrid.count) {
 48            for col in 0..<(currentGrid[row].count) {
 49                // Create a 3x3 grid for cells surrounding the current cell
 50                let surroundingGrid: [[Int]] = [
 51                    [
 52                        currentGrid[(row-1+currentGrid.count)%currentGrid.count][(col-1+currentGrid[row].count)%currentGrid[row].count],
 53                        currentGrid[(row-1+currentGrid.count)%currentGrid.count][col],
 54                        currentGrid[(row-1+currentGrid.count)%currentGrid.count][(col+1+currentGrid[row].count)%currentGrid[row].count]
 55                    ],
 56                    [
 57                        currentGrid[row][(col-1+currentGrid[row].count)%currentGrid[row].count],
 58                        currentGrid[row][col],
 59                        currentGrid[row][(col+1+currentGrid[row].count)%currentGrid[row].count]
 60                    ],
 61                    [
 62                        currentGrid[(row+1+currentGrid.count)%currentGrid.count][(col-1+currentGrid[row].count)%currentGrid[row].count],
 63                        currentGrid[(row+1+currentGrid.count)%currentGrid.count][col],
 64                        currentGrid[(row+1+currentGrid.count)%currentGrid.count][(col+1+currentGrid[row].count)%currentGrid[row].count]
 65                    ]
 66                ]
 67                let cellValue:Int = determineCellValue(threeGrid: surroundingGrid)
 68
 69                nextGrid[row][col] = cellValue
 70            }
 71        }
 72        
 73        return nextGrid
 74    }
 75    
 76    ///  Rules
 77    ///  1.   Any live cell with fewer than two live neighbours dies, as if by underpopulation.
 78    ///  2.   Any live cell with two or three live neighbours lives on to the next generation.
 79    ///  3 .  Any live cell with more than three live neighbours dies, as if by overpopulation.
 80    ///  4.   Any dead cell with exactly three live neighbours becomes a live cell, as if by reproduction.
 81    func determineCellValue(threeGrid:[[Int]]) -> Int {
 82        let currentCellValue = threeGrid[1][1]
 83        let neighbourCount = threeGrid.flatMap { $0 }.reduce(0, +) - currentCellValue
 84        var cellValue = 0
 85        switch neighbourCount {
 86        case 0..<2 :
 87            cellValue = 0
 88        case 2:
 89            cellValue = currentCellValue==1 ? 1 : 0
 90        case 3:
 91            cellValue = 1
 92        case 4..<9 :
 93            cellValue = 0
 94        default:
 95            cellValue = 0
 96        }
 97        return cellValue
 98    }
 99    
100}

LifeViewModel

 1@Observable class LifeViewModel {
 2    var lifeModel = LifeModel(cells: 10)
 3    private(set) var isRunning: Bool
 4
 5    init() {
 6        self.isRunning = false
 7    }
 8
 9    var numberOfCycles:  Int  {
10        return lifeModel.numberOfCycles
11    }
12    
13    func reset(gridSize: Int = 10) {
14        self.isRunning = false
15        self.lifeModel = LifeModel(cells: gridSize)
16    }
17    
18    func step() {
19        lifeModel.step()
20    }
21    
22    func setGridSize(cellNumber:Int) {
23        self.lifeModel = LifeModel(cells: cellNumber)
24    }
25    
26    @MainActor
27    func performNumberOfCycles(number: Int) async {
28        self.isRunning = true
29        for _ in 1...number {
30            let different = await lifeModel.performStepDelay()
31            if !different || !self.isRunning {
32                break
33            }
34        }
35        self.isRunning = false
36    }
37    
38    @MainActor
39    func stop() async {
40        self.isRunning = false
41    }
42}

CanvasFromModelView

 1struct CanvasFromModelView: View {
 2    @Environment(LifeViewModel.self) private var lifeViewModel
 3
 4    var body: some View {
 5        let gridData = lifeViewModel.lifeModel.grid
 6        
 7        VStack {
 8            VStack(spacing: 5) {
 9                Text("Canvas from Model")
10                    .font(.largeTitle)
11                    .fontWeight(.bold)
12                Canvas { context, size in
13                    let w = size.width
14                    let h = size.height
15                    let rows = gridData.count
16                    let cols = gridData[0].count
17                    let offset = 0.03 * w / CGFloat(cols)
18                    
19                    for r in 0..<rows {
20                        for c in 0..<cols {
21                            let x = w / CGFloat(cols) * CGFloat(c) + offset
22                            let y = h / CGFloat(rows) * CGFloat(r) + offset
23                            context.fill(
24                                Path(
25                                    roundedRect: CGRect(
26                                        x: x,
27                                        y: y,
28                                        width: 0.94 * w / CGFloat(cols),
29                                        height: 0.94 * h / CGFloat(rows)
30                                    ),
31                                    cornerRadius: w/CGFloat(cols*10)),
32                                with: .color(gridData[r][c] == 1 ? .green: .gray))
33                        }
34                    }
35                }
36                .background(.black)
37                .frame(width: 300, height: 300)
38                .border(Color.black)
39            }
40            Spacer()
41                .frame(height: 40)
42            
43            Divider()
44            
45            VStack(spacing: 5) {
46                Text("Values in Model")
47                    .font(.largeTitle)
48                    .fontWeight(.bold)
49                
50                Grid(alignment: .center, horizontalSpacing: 2, verticalSpacing: 2) {
51                    ForEach(0..<gridData.count, id:\.self) { r in
52                        GridRow {
53                            ForEach(0..<gridData[r].count,  id:\.self) { c in
54                                Rectangle().fill(.quaternary)
55                                    .overlay(
56                                        Text("\(gridData[r][c])")
57                                            .font(.title3)
58                                            .fontWeight(.bold)
59                                            .foregroundColor(
60                                                gridData[r][c] == 1 ?  .primary : .secondary
61                                            )
62                                    )
63                            }
64                        }
65                    }
66                }
67                .background(.quaternary)
68                .frame(width: 250, height: 250)
69                
70                Spacer()
71            }
72            
73            Button("Randomise") { lifeViewModel.reset() }
74                .buttonStyle(.borderedProminent)
75        }
76    }
77}

Using Canvas to layout cells from ViewModel



Play Game of Life

Now that all the pieces are in place, we can create the Game of Life view. A [picker][] is used to provide different options for the number of cells on a side of the grid. A Reset button is to reset the grid with a random number of cells alive. A step button performs one iteration of the game. The start button starts the game with the grid updating automatically. A delay is incorporated in the model to allow time for the view to update and the update is called asynchronously.


GameOfLifeView

  1struct GameOfLifeView: View {
  2    @Environment(LifeViewModel.self) private var lifeViewModel
  3    @State var gridSize: Int
  4    
  5    let repeatOptions = [5, 10, 20, 50, 100]
  6    
  7    var body: some View {
  8        let gridData = lifeViewModel.lifeModel.grid
  9        
 10        VStack {
 11            VStack(spacing: 5) {
 12                Text("Conway's Game of Life")
 13                    .font(.title)
 14                    .fontWeight(.bold)
 15                Canvas { context, size in
 16                    let w = size.width
 17                    let h = size.height
 18                    let rows = gridData.count
 19                    let cols = gridData[0].count
 20                    let offset = 0.03 * w / CGFloat(cols)
 21                    
 22                    for r in 0..<rows {
 23                        for c in 0..<cols {
 24                            let x = w / CGFloat(cols) * CGFloat(c) + offset
 25                            let y = h / CGFloat(rows) * CGFloat(r) + offset
 26                            context.fill(
 27                                Path(
 28                                    roundedRect: CGRect(
 29                                        x: x,
 30                                        y: y,
 31                                        width: 0.94 * w / CGFloat(cols),
 32                                        height: 0.94 * h / CGFloat(rows)
 33                                    ),
 34                                    cornerRadius: w/CGFloat(cols*10)),
 35                                with: .color(gridData[r][c] == 1 ? .green: .gray))
 36                        }
 37                    }
 38                }
 39                .background(.black)
 40                .frame(width: 350, height: 350)
 41                .border(Color.black)
 42            }
 43            
 44            Divider()
 45                .frame(height: 50)
 46
 47            VStack {
 48                GroupBox {
 49                    Text("Grid Size:")
 50                        .font(.custom("Helvetica Neue", size: 22, relativeTo: .largeTitle))
 51                        .fontWeight(.bold)
 52                    
 53                    
 54                    Picker("", selection: $gridSize) {
 55                        ForEach(repeatOptions, id: \.self) {
 56                            Text("\($0)")
 57                                .fontWeight(.heavy)
 58                        }
 59                    }
 60                    .onChange(of: gridSize) {
 61                        lifeViewModel.setGridSize(cellNumber: gridSize)
 62                    }
 63                    .pickerStyle(.segmented)
 64                    .background(Color(hue: 0.10, saturation: 0.10, brightness: 0.98))
 65                    .disabled(lifeViewModel.isRunning)
 66                }
 67                .groupBoxStyle(YellowGroupBoxStyle())
 68            }
 69            
 70            Divider()
 71            
 72            VStack {
 73                Text("Number of Cycles: \(lifeViewModel.numberOfCycles)")
 74                    .font(.custom("Helvetica Neue", size: 22, relativeTo: .largeTitle))
 75                    .fontWeight(.bold)
 76            }
 77            
 78            HStack {
 79                Button("Reset") { lifeViewModel.reset(gridSize: self.gridSize) }
 80                    .buttonStyle(ActionButtonStyle())
 81                    .disabled(lifeViewModel.isRunning)
 82                
 83                Button("Step") { lifeViewModel.step() }
 84                    .buttonStyle(ActionButtonStyle())
 85                    .disabled(lifeViewModel.isRunning)
 86            }
 87            
 88            HStack {
 89                Button("Start") {
 90                    Task {
 91                        await lifeViewModel.performNumberOfCycles(number: 1000)
 92                    }
 93                }
 94                .buttonStyle(ActionButtonStyle())
 95                .disabled(lifeViewModel.isRunning)
 96                
 97                Button("Stop") {
 98                    Task {
 99                        await lifeViewModel.stop()
100                    }
101                }
102                .buttonStyle(ActionButtonStyle())
103                .disabled(!lifeViewModel.isRunning)
104                
105                
106            }
107            
108            Spacer()
109        }
110    }
111}

GameOfLifeAppApp

 1struct GameOfLifeAppApp: App {
 2    
 3    /// The app's state.
 4    @State private var lifeViewModel = LifeViewModel()
 5
 6    var body: some Scene {
 7        WindowGroup {
 8            ContentView()
 9                .environment(lifeViewModel)
10        }
11    }
12}

Conway's game of life view



Conway's game of life





Conclusion

It is possible to implement Conway's Game of Life in SwiftUI and there are a number of ways to implement it. This article showed how to use a Canvas to represent the grid for the game and to store the state of the game in a 2D array of Ints.

I find watching the game mesmerising. There is lots of room for improvement in this representation of the game, such as; having set starting patterns; being able to pause the game and manually change the state of some of the cells just to see the effect; or a mechanism to set the speed of the game.

The source code for GameOfLifeApp is available on GitHub.