Code kata exercise – Game of life
Before I begin, I want to express my gratitude to Swiftly for its excellent article on the Game of Life. His insights and guidance have inspired me to approach this code kata in a fresh and exciting way.
In this article, I present my own perspective on the Game of Life, hoping to extend and contribute to the knowledge he has shared.
You can check the complete code on my GitHub repo.
Kata in software development
“Kata” is a term that originates from Japanese martial arts, specifically from disciplines such as karate, judo, and aikido. In this context, a kata refers to a sequence of movements performed in a specific order. It is a form of practice that allows practitioners to train and perfect their techniques, movements, and overall martial arts skills.
Outside of martial arts, the term “kata” has been adapted and applied to various fields, including software development. In the context of software development, “kata” refers to structured exercises or practices that help developers improve their coding skills, problem-solving abilities, and understanding of programming concepts, algorithms, design patterns, and best practices.
There are several popular code kata exercises that developers often practice to improve their software development skills such as FizzBuzz, String Reversal, TDD (Test-Driven Development) Katas…
Those are just some examples of common kata code exercises, In this article, we will try to solve a popular code kata exercise called the “Game of Life”. So let’s go 😁
The “Game of Life”
The “Game of Life” is a popular programming kata that involves creating a simulation of an evolving population of cells. This exercise is typically used to practice writing clean, modular code that is easy to read and maintain.
The “Game of Life” is a mathematical game devised by the British mathematician John Horton Conway in 1970. Despite its name, it is not a typical game in the conventional sense with players and rules. Instead, it is a simulation or “game” that models the behavior of cells on a two-dimensional grid, each cell can either be “alive” or “dead”.
The cells change their state over time based on a set of simple rules. these rules indicate whether a cell will be alive or dead in the next generation as follows:
- Any live cell with fewer than two live neighbors dies as if caused by underpopulation.
- Any live cell with two or three live neighbors lives on to the next generation.
- Any live cell with more than three live neighbors dies, as if by overpopulation.
- Any dead cell with exactly three live neighbors becomes a live cell, as if by reproduction.
One important aspect of this Kata is writing code that is modular by breaking the program down into smaller parts, each of which is responsible for a specific aspect of the game.
Another important consideration is testing. In order to ensure that the program is working correctly we will use the TDD approach, ensuring correct functioning by examining various input scenarios. Creating test cases covering all possible scenarios, such as a small set of cells, helps identify bugs and ensures the program’s functionality on a larger scale.
Let’s write some code
Our program will be composed of two separate parts:
The UI:
We will use SwiftUI to create the UI of our game.
The domain:
It contains all the different logic parts of the game such as the calculation of the next generation of cells, how to determine the neighbors of a given cell, and so on. As we said previously we will use the TDD approach so this part will be driven by unit tests.
A cell is represented by a struct called Cell, each cell has two coordinates (x, y) in the grid and a state of type CellState either .alive or .dead.
struct Cell {
let x: Int
let y: Int
var state: CellState = .dead
var position: Position {
(x: x, y: y)
}
}
enum CellState {
case alive
case dead
}
In the Game class, we will create the grid for the game and put the necessary logic for it.
typealias Row = [Cell]
typealias Grid = [Row]
typealias Position = (x: Int, y: Int)
final class Game {
var numberOfRows: Int
var numberOfColums: Int
var grid = Grid()
init(numberOfRows: Int, numberOfColums: Int) {
self.numberOfRows = numberOfRows
self.numberOfColums = numberOfColums
grid = createGrid()
}
}
The creation of the grid is simple, we can use two for loops to create cells and append them to the grid. The reason why we use two for loops is that the grid has two dimensions ie: a matrix.
private func createGrid() -> Grid {
var grid = Grid()
for x in 0..<numberOfRows {
var row = Row()
for y in 0..<numberOfColums {
row.append(Cell(x: x, y: y, state: .dead))
}
grid.append(row)
}
return grid
}
That’s it! Now let’s write our first test.
the rules of the game are based on neighborhood, so we need to create an empty function getNeighborsForCell(at position: Position) -> [Cell] that determines all the neighbors of a given cell.
Cell’s neighbors are all cells around our main cell, see the image below.
We can begin with a test like this
func test_ifTwoCellsAreNeighbors() {
// GIVEN
let cell = Cell(x: 2, y: 2)
// Offset = (x-x', y-y')
let cell1 = Cell(x: 1, y: 1) // Offset between cell et cell1 = (1, 1)
let cell2 = Cell(x: 1, y: 2) // Offset between cell et cell2 = (1, 0)
let cell3 = Cell(x: 1, y: 3) // Offset between cell et cell3 = (1, -1)
let cell4 = Cell(x: 2, y: 1) // Offset between cell et cell4 = (0, 1)
let cell5 = Cell(x: 2, y: 2) // Offset between cell et cell5 = (0, 0)
let cell6 = Cell(x: 2, y: 3) // Offset between cell et cell6 = (0, -1)
let cell7 = Cell(x: 3, y: 1) // Offset between cell et cell7 = (-1, 1)
let cell8 = Cell(x: 3, y: 2) // Offset between cell et cell8 = (-1, 0)
let cell9 = Cell(x: 3, y: 3) // Offset between cell et cell9 = (-1, -1)
let cell10 = Cell(x: 1, y: 4) // Offset between cell et cell10 = (1, -2)
// THEN
XCTAssertTrue(sut.areNeighbors(rhd: cell, lhd: cell1))
XCTAssertTrue(sut.areNeighbors(rhd: cell, lhd: cell2))
XCTAssertTrue(sut.areNeighbors(rhd: cell, lhd: cell3))
XCTAssertTrue(sut.areNeighbors(rhd: cell, lhd: cell4))
XCTAssertTrue(sut.areNeighbors(rhd: cell, lhd: cell6))
XCTAssertTrue(sut.areNeighbors(rhd: cell, lhd: cell7))
XCTAssertTrue(sut.areNeighbors(rhd: cell, lhd: cell8))
XCTAssertTrue(sut.areNeighbors(rhd: cell, lhd: cell9))
XCTAssertFalse(sut.areNeighbors(rhd: cell, lhd: cell5))
XCTAssertFalse(sut.areNeighbors(rhd: cell, lhd: cell10))
}
We see clearly that two cells are neighbors if the absolute offset between the x-axis is 1 or 0 and the offset between the y-axis is 1 or 0. With this logic in mind, we can implement our function like this:
func areNeighbors(rhd: Cell, lhd: Cell) -> Bool {
let range = [0, 1]
let xOffset = abs(rhd.x - lhd.x)
let yOffset = abs(rhd.y - lhd.y)
return range.contains(xOffset) && range.contains(yOffset)
}
We notice that all assertions have passed, except for cell5 because it has the same position as cell.In other words, it’s the cell itself. so we need to add this line of code to our function in the first line.
if rhd == lhd { return false }
Now the test should pass 😁
To determine the next generation of the game we need to get all the living neighbors for a given cell, let’s add a function func getAliveNeighbordForCell(at position: Position)-> [Cell] which returns an empty array.
As usual, we begin by writing the test first:
func test_getLivingNeighborsForCell_ShouldBeZeroInEmptyGameGrid() {
// GIVEN
let position: Position = (x:2, y: 3)
let aliveCells = sut.getAliveNeighbordForCell(at: position)
// THEN
XCTAssertEqual(aliveCells.count, 0)
}
This test should succeed, now what if our cell has two living neighbors, our second test should be like this:
func test_foundTwoLivingNeighbors() {
// GIVEN
sut.changeStateOfCellAt(position: (x:2, y: 1), to: .alive)
sut.changeStateOfCellAt(position: (x:2, y: 3), to: .alive)
let position: Position = (x:2, y: 2)
let aliveCells = sut.getAliveNeighbordForCell(at: position)
// THEN
XCTAssertEqual(aliveCells.count, 2)
}
This test fails because we return an empty table. we need to return cells that are neighbors and have the state of .alive
func getAliveNeighbordForCell(at position: Position) -> [Cell] {
getNeighborsForCell(at: position)
.filter{ $0.state == .alive }
}
And Voilà! test succeed.
Let’s move on to the second part of our Kata. The question at hand is: How can I determine the state of my game for the next generation? We need to consider implementing a function func computeNextGeneration() that allows us to determine the state of each game cell for the next generation. As usual, let’s think about the tests involved.
The idea is to write a test for each rule of the game, let’s begin with the REPRODUCTION rule:
- If a dead cell has exactly three living neighbors, it becomes alive (reproduction).
// REPRODUTION
func test_deadCellWithThreeNeighbors_getsAlive() {
// GIVEN
sut.changeStateOfCellAt(position: (x:1, y: 1), to: .alive)
sut.changeStateOfCellAt(position: (x:2, y: 1), to: .alive)
sut.changeStateOfCellAt(position: (x:3, y: 1), to: .alive)
// WHEN
sut.computeNextGeneration()
let state = sut.getCell(at: (x: 2, y: 2))?.state
// THEN
XCTAssertEqual(state, .alive)
}
According to rule number one, we will first focus on determining the dead cells, find those that have exactly three living neighbors, and then apply a transformation function to obtain a new matrix of cells.
func computeNextGeneration() {
_ = grid
.flatMap { $0 }
.filter { cell in
isReproductionCondition(cell)
}
.map { cell in
// Change the state from .alive to .dead and vice versa
grid[cell.x][cell.y].state.toggle()
}
}
private lazy var isReproductionCondition = { (cell: Cell) -> Bool in
return (cell.state == .dead)
&&
self.getAliveNeighbordForCell(at: cell.position).count == 3
}
Now let’s run our test. And the green color appears ✅
Let us now move on to the second rule OVERPOPULATION:
- If a living cell has more than three living neighbors, it dies (overpopulation).
The test should be something like this:
// OVERPOPULATION
func test_cellWithMoreThanThreeLivedNeighbors_dies() {
// GIVEN
sut.changeStateOfCellAt(position: (x:1, y: 1), to: .alive)
sut.changeStateOfCellAt(position: (x:1, y: 2), to: .alive)
sut.changeStateOfCellAt(position: (x:1, y: 3), to: .alive)
sut.changeStateOfCellAt(position: (x:2, y: 1), to: .alive)
sut.changeStateOfCellAt(position: (x:2, y: 2), to: .alive)
// WHEN
sut.computeNextGeneration()
let cell1 = sut.getCell(at: (x: 1, y: 2))
let cell2 = sut.getCell(at: (x: 2, y: 2))
// THEN
XCTAssertEqual(cell1?.state, .dead)
}
We should update now the computeNextGeneration() function to make this test green without impacting our old tests.
func computeNextGeneration() {
_ = grid
.flatMap { $0 }
.filter { cell in
isReproductionCondition(cell)
||
isOverpupulationCondition(cell)
}
.map { cell in
// Change the state from .alive to .dead and vice versa
grid[cell.x][cell.y].state.toggle()
}
}
private lazy var isOverpupulationCondition = { (cell: Cell) -> Bool in
return (cell.state == .alive)
&&
self.getAliveNeighbordForCell(at: cell.position).count > 3
}
The same logic goes with the third rule UNDERPOPULATION:
- If a living cell has fewer than two living neighbors, it dies (underpopulation).
// UNDERPOPULATION
func test_cellWithLessThanTwoLivedNeighbors_dies() {
// GIVEN
sut.changeStateOfCellAt(position: (x:1, y: 2), to: .alive)
sut.changeStateOfCellAt(position: (x:2, y: 3), to: .alive)
// WHEN
sut.computeNextGeneration()
let cell1 = sut.getCell(at: (x: 1, y: 2))
let cell2 = sut.getCell(at: (x: 2, y: 3))
// THEN
XCTAssertEqual(cell1?.state, .dead)
XCTAssertEqual(cell2?.state, .dead)
}
func computeNextGeneration() {
_ = grid
.flatMap { $0 }
.filter { cell in
isReproductionCondition(cell)
||
isOverpopulationCondition(cell)
||
isUnderpopulationCondition(cell)
}
.map { cell in
// Change the state from .alive to .dead and vice versa
grid[cell.x][cell.y].state.toggle()
}
}
private lazy var isUnderpupulationCondition = { (cell: Cell) -> Bool in
return (cell.state == .alive)
&&
self.getAliveNeighbordForCell(at: cell.position).count < 2
}
We could refactor our code and make only one condition for overpopulation and underpopulation conditions since they share the same condition (cell.state == .alive)
We end up with this code:
func computeNextGeneration() {
_ = grid
.flatMap { $0 }
.filter { cell in
isReproductionCondition(cell)
||
isOverpupulationOrUnderpopulationCondition(cell)
}
.map { cell in
grid[cell.x][cell.y].state.toggle()
}
}
/// If a dead cell has exactly three living neighbors,
/// it becomes alive (reproduction).
private lazy var isReproductionCondition = { (cell: Cell) -> Bool in
return (cell.state == .dead)
&&
self.getAliveNeighbordForCell(at: cell.position).count == 3
}
/// If a living cell has more than three living neighbors, it dies (overpopulation).
/// Or
/// If a living cell has fewer than two living neighbors, it dies (underpopulation).
private lazy var isOverpupulationOrUnderpopulationCondition = { (cell: Cell) -> Bool in
return (cell.state == .alive)
&&
(
self.getAliveNeighbordForCell(at: cell.position).count > 3
||
self.getAliveNeighbordForCell(at: cell.position).count < 2
)
}
The last rule will be applied automatically since our filter treats all three first conditions.
- Any live cell with two or three live neighbors lives on to the next generation.
and our last tests will succeed.
func test_cellWithTwoLivedNeighbors_lives() {
// GIVEN
sut.changeStateOfCellAt(position: (x:0, y: 3), to: .alive)
sut.changeStateOfCellAt(position: (x:0, y: 4), to: .alive)
sut.changeStateOfCellAt(position: (x:1, y: 4), to: .alive)
sut.changeStateOfCellAt(position: (x:2, y: 5), to: .alive)
sut.printGrid()
// WHEN
sut.computeNextGeneration()
let cell = sut.getCell(at: (x: 1, y: 4))
// THEN
XCTAssertEqual(cell?.state, .alive)
sut.printGrid()
}
func test_cellWithThreeLivedNeighbors_lives() {
// GIVEN
sut.changeStateOfCellAt(position: (x:0, y: 3), to: .alive)
sut.changeStateOfCellAt(position: (x:0, y: 4), to: .alive)
sut.changeStateOfCellAt(position: (x:0, y: 5), to: .alive)
sut.changeStateOfCellAt(position: (x:1, y: 4), to: .alive)
sut.printGrid()
// WHEN
sut.computeNextGeneration()
let cell = sut.getCell(at: (x: 1, y: 4))
// THEN
XCTAssertEqual(cell?.state, .alive)
sut.printGrid()
}
We finish building the core logic of the Kata Game of Life and now let’s plug this logic with a graphic interface and see the game in practice, this time we will use SwiftUI to create the interface.
The UI part
The game of life consists of a set of cells that live and die within a grid.
In the ContentView we will create an instance of our Game class to be able to use the grid property.
For this, the Game class needs to conform to ObservableObject and the grid property should be marked by the property wrapper @StateObject , with this SwiftUI will allows us to observe any change on the grid property and it will update the UI accordingly.
final class Game: ObservableObject {
var numberOfRows: Int
var numberOfColums: Int
@Published var grid = Grid()
}
struct ContentView: View {
@StateObject private var game = Game(
numberOfRows: 10,
numberOfColums: 10
)
}
Creating a cell using swiftUI is very simple we can achieve that by using the Rectangle shape like this:
Rectangle()
.fill(.green)
.frame(
width: 50,
height:50
)
.border(.gray.opacity(0.5), width: 1)
For now, the width and the height have a hard-coded value of 50, later we will make this dynamic by calculating the width and the height according to our screen and the number of cells.
To create the grid we will loop over our matrix using the ForEash and use a VStack for the rows and an HStack for the columns in this way:
VStack(spacing: .zero) {
ForEach(game.grid, id: \.self) { row in
HStack(spacing: .zero) {
ForEach(row, id: \.self) { cell in
Rectangle()
.fill(fillColorForCell(state: cell.state))
.frame(
width: 50,
height: 50
)
.border(.gray.opacity(0.5), width: 1)
}
}
}
}
To make the width and the height of a cell dynamic we should embed our VStack in a container view called GeometryReader that provides information about the geometry of its parent view.
We will use this information to create a function calculateCellWidth(in: geometry) and use it for both width and height to obtain a square.
GeometryReader { geometry in
VStack(spacing: .zero) {
ForEach(game.grid, id: \.self) { row in
HStack(spacing: .zero) {
ForEach(row, id: \.self) { cell in
Rectangle()
.fill(.green)
.frame(
width: calculateCellWidth(in: geometry),
height: calculateCellWidth(in: geometry)
)
.border(.gray.opacity(0.5), width: 1)
}
}
}
}
}
// Outside the body view
private func calculateCellWidth(in geometry: GeometryProxy) -> CGFloat {
(geometry.size.width) / CGFloat(game.numberOfColums)
}
For the fill color, we will use blue for the .alive state and clear for .dead, to determine the color we add a helper function private func fillColorForCell(state: CellState) -> Color like this:
Rectangle()
.fill(fillColorForCell(state: cell.state))
.frame(
width: calculateCellWidth(in: geometry),
height: calculateCellWidth(in: geometry)
)
.border(.gray.opacity(0.5), width: 1)
// Outside the body view
private func fillColorForCell(state: CellState) -> Color {
switch state {
case .alive:
return .blue
case .dead:
return .clear
}
}
Now time to lunch the game, first we need to initialize the game randomly. so we add two helper functions getXRandomLocation() -> Int and getYRandomLocation() -> Int and use them in the initializeGame() function, the idea is to randomly populate half of the grid (50 cells) with living cells.:
private func initializeGame() {
// numberOfRows x numberOfColums = 10 x 10 = 100
// Fill the half of the grid 100 / 2 = 50
for _ in 0...50 {
let x = getXRandomLocation()
let y = getYRandomLocation()
game.grid[x][y]
.state
.toggle()
}
}
private func getXRandomLocation() -> Int {
return Int(arc4random()) % game.numberOfRows
}
private func getYRandomLocation() -> Int {
return Int(arc4random()) % game.numberOfColums
}
Make the call of the initializeGame() in the .onAppear modifier.
For now, the grid display only the first generation, To continuously update the grid, we simply need to call the computeNextGeneration() method every ‘n’ seconds, to achieve that we will use the Timer of the Combineframework and compute the next generation every two seconds:
@State private var cancellable: Cancellable?
private func launchTheGame() {
cancellable = Timer.publish(
every: 2,
on: .main,
in: .default
)
.autoconnect()
.subscribe(on: DispatchQueue.main)
.sink { _ in
game.computeNextGeneration()
}
}
To stop the game we need simply to cancel our subscription like this:
private func stopTheGame() {
cancellable?.cancel()
}
Add two button to our view one for lunching the game and the other to stop the game:
Button(action: {
launchTheGame()
}) {
Text("LAUNCH THE GAME")
.frame(maxWidth: .infinity)
.font(.headline)
}
.padding(
.horizontal,
Constant.horizontalInset
)
.tint(.blue)
.buttonStyle(.borderedProminent)
.buttonBorderShape(.automatic)
.controlSize(.large)
Button(action: {
stopTheGame()
}) {
Text("Stop")
.frame(maxWidth: .infinity)
.font(.headline)
}
.padding(
.horizontal,
Constant.horizontalInset
)
.tint(.blue)
.buttonStyle(.bordered)
.buttonBorderShape(.automatic)
.controlSize(.large)
That it! We can watch now the simulation of the Game of Live.
Conclusion
Overall, the Game of Life is a great way to practice programming skills and improve your ability to write clean, maintainable, and testable code.
By breaking the program down into smaller, more manageable parts and testing each section thoroughly, you can ensure that the program is working correctly.
Whether you’re a beginner or an experienced developer, this kata is sure to challenge and inspire you. So why not give it a try and see what you can create?
You can follow the new series about modern iOS development on SwiftWithWalid Youtube Channel