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:
- Any live cell with fewer than two live neighbours dies, as if by underpopulation.
- Any live cell with two or three live neighbours lives on to the next generation.
- Any live cell with more than three live neighbours dies, as if by overpopulation.
- 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}
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}
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}
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}
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}
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}
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.