(Note: This post uses Swift 5.2)
Conway’s Game of Life is a classic programming concept that I was tasked with implementing as part of the computer science curriculum at Lambda School recently. This is the sort of task I really enjoy; not a ton of practical use, but a lot of fun!
The Rules
The game takes places on a rectangular, integer-based grid and operates on the following rules:
- Any cell on the grid can be either dead or alive
- If a cell is alive in one generation and has either 2 or 3 immediate neighbors (horizontally, vertically, or diagonally), it will live on to the next generation
- If a cell has less than 2 neighbors, it dies.
- If a cell has more than 3 neighbors, it dies.
- If a dead cell has exactly 2 neighbors, it springs to life.
These rules can be demonstrated simply with the following function:
func cell(_ cell: Cell, willLivegiven neighborCount: Int) -> Bool {
if cell.isAlive {
return neighborCount == 2 || neighborCount == 3
} else {
return neighborCount == 2
}
}
We could remove some redundancy and simplify this even more to:
func cell(_ cell: Cell, willLivegiven neighborCount: Int) -> Bool {
return neighborCount == 2 || (cell.isAlive && neighborCount == 3)
}
The question now is how to get neighbors, whether a cell has a position or a position contains a cell, and other implementation details.
The Data Structures
I went through a few different iterations, but my data structures ended up looking like this when all was said and done:
struct Tilemap {
private var tiles = [Point: Tile]()
private(set) var width: Int = 1
private(set) var height: Int = 1
private(set) var population: Int = 0
var gridWraps = true
init(width: Int = 1, height: Int = 1) {
self.width = width
self.height = height
}
}
enum Tile: UInt8, CaseIterable {
case dead
case alive
init<I: BinaryInteger>(safe rawValue: I) {
if let tile = Tile(rawValue: UInt8(rawValue)) {
self = tile
} else {
self = Tile(safe: abs(Int(rawValue)) % Self.allCases.count)
}
}
}
typealias Point = Vector
struct Vector {
var x: Int = 0
var y: Int = 0
}
(I chose Tilemap
because I had recently been going through Nick Lockwood’s excellent Retro Rampage game engine tutorial series, which I took a lot of cues from. Check out objc.io’s excellent video series on the first few steps of the tutorial as well.)
In Swift, structs often end up being more performant than classes in many circumstances, and it came in handy for a couple of specific reasons here, which we’ll see later.
My first version of the Tilemap
used an array of Tile
objects and a width
property, with height
and population
being computed based on those properties. Although height
wasn’t terribly expensive computationally (tiles.count / width
), population
needed to loop through the entire array every time it was calculated. Making it a stored property and incrementing it every time a tile is changed makes it so values are only recalculated as they need to be, and reducing the need for additional loops.
The dictionary mains makes resizing the map more efficient; for most purposes, an array would probably work just as well (if not better). Overall this is likely a little more complicated than it needs to be, but as I was working through various implementations, this one ended up being the most performant and useful. I also have a tendency at times to get caught up in details before I get something working, so I leaned a little further towards “just get something working.” I could likely improve it further with some adjustments, but this gets the job done for now. With more time I’d love to do more detailed testing.
Tile
could probably just have been a Bool
, but when I started, I had planned to possibly make other types of tiles (“walls” that couldn’t become alive, for example, or coloring tiles differently if they were about to become alive or had just died).
Point
s and Vector
s are essentially the same thing for our purposes, in that they consist of an x
and y
coordinate, so it makes sense to use a single type to represent them both, making arithmetic with them easier (which helps with finding neighbors).
extension Vector {
static var zero: Vector { Vector(x: 0, y: 0) }
static var up: Vector { Vector(x: 0, y: -1) }
static var down: Vector { Vector(x: 0, y: 1) }
static var left: Vector { Vector(x: -1, y: 0) }
static var right: Vector { Vector(x: 1, y: 0) }
static var upLeft: Vector { Vector(x: -1, y: -1) }
static var upRight: Vector { Vector(x: 1, y: -1) }
static var downLeft: Vector { Vector(x: -1, y: 1) }
static var downRight: Vector { Vector(x: 1, y: 1) }
static var neighborVectors: Set<Vector> {
[.up, .upRight, .right, .downRight, .down, .downLeft, .left, .upLeft]
}
var neighbors: Set<Vector> {
Set(Self.neighborVectors.map { self + $0 })
}
}
With that, I can now call point.neighbors
to get the set of all points adjacent to that point.
Updating the Map
The requirements for this assignment required that I use a buffer map on which to map changes. So although my initial instinct was to write an update
method that updates the map in place, the buffer method ended up having some benefits.
First, we need to generate the new changes to apply to the buffer:
extension Tilemap {
//...
func newGenerationChanges() -> Set<Point> {
self.compactMapToSet { point in
let tileIsAlive = self[point].isAlive
var count = 0
var tileWillLive: Bool = false
for neighbor in point.neighbors {
if self[neighbor].isAlive == true {
count += 1
} else { continue }
if count == 3 || (tileIsAlive && count == 2) {
tileWillLive = true
} else if count >= 4 || (tileIsAlive && count >= 3) {
tileWillLive = false
break
}
}
return tileIsAlive != tileWillLive ? point : nil
}
}
//...
}
(Check out the source code to get a look at some of the helper methods used here and elsewhere.)
Essentially, for every point on the map, we’ll check whether its status should change, and if it does, we’ll add that point to the Set
of changes. When we’re done, we’ll have a subset of the entire map that contains only what will change rather than an entirely new map.
Notice that in the for-loop in newGenerationChanges
, I didn’t end up using the function we wrote earlier; using that, I needed to go through every neighbor every time, but doing it this way, we can skip the remaining neighbors if we hit our “overpopulated” threshold, saving some time.
After this, the changes are then applied to the buffer map by the GameEngine
object (which we’ll look at later).
mutating func apply(_ changes: Set<Point>) {
changes.forEach { point in
population += self[point].isDead ? 1 : -1
tiles[point].toggle()
}
}
For each point that needs to be changed:
- The
population
is incremented or decremented based on whether the tile is about to becomealive
ordead
. - The tile is toggled.
The Engine
The GameEngine
takes a lot of credit for keeping things relatively efficient, using Grand Central Dispatch to run as many operations as possible on background threads, letting the main thread do the work of updating the UI.
class GameEngine: ObservableObject {
@Published var tilemap: Tilemap {
didSet { bufferMap = tilemap }
}
private var bufferMap: Tilemap
private let updateThread = DispatchQueue.global()
private var lastUpdateTime = CFAbsoluteTimeGetCurrent()
init(
tilemap: Tilemap = .init(width: Tilemap.defaultSize,
height: Tilemap.defaultSize)
) {
self.tilemap = tilemap
self.bufferMap = tilemap
self.gridWraps = tilemap.gridWraps
}
}
(Note: I’m leaving out some stored properties in the interest of keeping this post shorter.)
Because I used SwiftUI for the UI, the class conforms to ObservableObject
and contains several @Published
properties to properly keep the UI up-to-date. More on that later.
The real magic happens in the main
method.
extension GameEngine {
func toggleRunning() {
isRunning ? stop() : start()
}
func start() {
guard !isRunning else { return }
isRunning = true
main()
}
private func main() {
updateThread.async { [weak self] in
while self?.isRunning == true {
guard let self = self else { return }
let currentTime = CFAbsoluteTimeGetCurrent()
let deltaTime = currentTime - self.lastUpdateTime
if deltaTime < self.frameFrequency {
continue
}
self.lastUpdateTime = currentTime
let computedFramerate = 1 / deltaTime
DispatchQueue.main.async {
self.actualFrameRate = computedFramerate
}
DispatchQueue.global().sync {
self.update()
}
}
}
}
private func update() {
let changes = self.bufferMap.newGenerationChanges()
DispatchQueue.main.sync {
self.tilemap.apply(changes)
self.generation += 1
}
}
func stop() {
isRunning = false
}
}
As soon as start
is called, the engine begins running through the main run loop repeatedly until isRunning
is switched to false
(i.e., until either stop
is called or the object is deallocated).1 Every run through the loop, we check whether enough time has passed for our requested framerate; if not, we wait (continue
).
If we’re ready, we calculate the current framerate in case it’s slower than we’d like, so we can display that in the UI (this is why setting the actualFrameRate
happens on the main queue; since it’s a @Published
property, it directly updates the UI as a result, so that all needs to be dealt with on the main thread). Then, we run the update
method on a new queue. Calling it on this same background thread seemed to, strangely, cause some UI issues. I believe this has to do with the way the framerate/delta time is calculated, but again, I ran out of time to do much thorough testing.
The update
method then generates the new changes on the new background thread, and applies them on the main thread (again, because this directly affects the UI2). Then, back in main
, the loop continues for every generation until we stop.
The UI
Most of the UI was implemented using SwiftUI. I first attempted to implement the TilemapView
using a nested HStack
and VStack
, but this ended up being incredibly inefficient, so I ended up using custom path-drawing. Again, I wanted to use SwiftUI, but their touch APIs don’t yet allow one to get the position of a single touch, so using a UIKit UIView
subclass (wrapped in a UIViewRepresentable
) ended up being simpler.
Here’s the draw
method of that view, which is called whenever the view needs to be redrawn:
override func draw(_ rect: CGRect) {
let liveColor = UIColor { traits in
switch traits.userInterfaceStyle {
case .dark: return .white
default: return .black
}
}
let deadColor = UIColor { traits in
switch traits.userInterfaceStyle {
case .dark: return .black
default: return .white
}
}
let tileSize = getTileSize(tilemapSize: tilemapSize, rect: rect)
deadColor.set()
UIRectFill(rect)
if showGrid {
gridColor.setStroke()
}
for column in 0..<tilemap.width {
for row in 0..<tilemap.height {
let point = Point(x: column, y: row)
guard let tile = tilemap.tile(at: point) else { continue }
let origin = getTileOrigin(point: point, tileSize: tileSize)
let tileColor = tile.isAlive ? liveColor : deadColor
if showGrid {
tileColor.setFill()
} else {
tileColor.set()
}
let tileRect = CGRect(origin: origin, size: tileSize)
UIRectFill(tileRect)
UIRectFrame(tileRect)
}
}
}
As you might expect, it loops through every possible tile and colors it on the screen based on whether it is dead or alive (and on whether dark mode is on!). It may also draw a visual grid if that option is enabled (this would probably better be factored out into a separate view that could be drawn on top of this one).
The rest of the views are fairly straightforward SwiftUI; having not worked much with it, I quite enjoyed learning it more thoroughly and making an adaptive, relatively-pretty interface.
In the end, for a 25x25 grid (the minimum required by the assignment), the simulation easily runs at 20 generations per second, which is already too fast to really keep track of. It starts to lag a bit around 50x50, but it’s still quite usable even at 100x100.
I’m certain that there are many, many ways I could improve upon this (I wanted to implement the HashLife algorithm, but that’s going to take a bit more research than I have time for). I’m happy with how it turned out, though, as it gave me an excuse to practice working with these sorts of custom structures and algorithms and game-dev-like concepts in a very encapsulated way. I’m excited to take these lessons into future projects.
-
Because external references to the engine could theoretically be
nil
‘d while the loop is still running, we capture[weak self]
to hold a weak reference to theGameEngine
within themain
loop. This way, if that does happen, we won’t get a reference cycle where the loop continues running and we don’t have access to it to do anything about it. Instead, the reference count will go to zero, the object will be deallocated, and the loop will cease running. ↩ -
I experimented with using
ObservableObject
’sobjectWillChange
publisher directly, but because the UI will modify the tilemap directly at times, this didn’t work. ↩