Development Blog (semi)random.thoughts()

Bye bye callbacks! Combining Combine and SpriteKit

Consider a simulation game. The game probably has some entities, wether it's cities, cells, soldiers or reindeer. Now consider it is a turn based game, where the simulation advances on a "tick" basis. Before and after the tick, the state of the simulation will have changed, so for instance your cells might have a different color and your reindeer might have moved. Your visualization should update accordingly.

Due to the complexity usually involved with simulation models, your modal is probably a self contained unit, that perhaps knows nothing about UI stuff. Or it shouldn't anyway, because why would it care? Can we use Combine with SpriteKit to simulate the "automagical updating" of SwiftUI. Yes we can!


The classic approach: callbacks


But first, let's look at the classical approach. The classical approach would be to use callbacks. Consider for example a unit struct with a callback:

struct Unit {
var position: (x: Int, y: Int)

static var onUnitChanged: ((Unit) -> Void)?

mutating func update() {
// do something to determine new position
position = newPosition

onUnitChanged?(self)
return self
}
}

You might register a callback somewhere else that responds to the callback:

Unit.onUnitChanged = { unit in 
// find sprite for unit
if let sprite = unitToSpriteMap[unit] {
sprite.position = CGPoint(x: unit.position.x, y: unit.positing.y)
}
}


This works pretty well, but can become complex:
  1. when you need more than one thing reacting to a change;
  2. there are multiple places where the unit can be changed;
  3. there are a lot of events you want to react to.

Some time ago when I was working on HexEngine, I started using SwiftUI for the UI layer. And I was surprised with how easy it is to keep game state and UI in sync. SwiftUI takes care of that in the background. On the other hand, I was adding more and more callbacks to the SpriteKit part of the game to keep the visuals in sync with the UI. I had callbacks calling callbacks. Unused callbacks. Callbacks working against eachother. It was a mess and very difficult to debug.

Fortunately you can actually reuse the SwiftUI way of keeping UI in sync with state using Combine.

Combining Combine and SpriteKit


When you already use SwiftUI in a project, you probably already have ObservableObjects and properties marked @published. In HexEngine, I just reused most of the properties that were already exposed using @published.

The entire simulation state in HexEngine is contained in a World object. This object conforms to ObservableObject and exposes (among others) an @published units: [UUID: Unit] dictionary. I have a UnitSpriteController that takes care of creating and updating sprites representing units in the game scene. Previously, I was using the onUnitCreate(_) and onUnitChanged(_) callback method. Now I use Combine:

func subscribeToUnitsIn(world: World) {
world.$units.sink(receiveCompletion: { completion in
print("Print UnitController received completion \(completion) from world.units")
}, receiveValue: { [weak self] units in
// there are three cases

for unitID in units.keys {

// case 1: unit is known to both UnitController and World:
if self?.unitSpriteMap[unitID] != nil, let unit = units[unitID]{
self?.updateUnitSprite(unit: unit)
}

// case 2: unit is known to world, but not yet to UnitController
if self?.unitSpriteMap[unitID] == nil, let unit = units[unitID] {
self?.createUnitSprite(unit: unit)
}
}

// case 3: the final case is where a unit is known to the UnitController, but not the world
// i.e. when the unit is removed
for unitID in (self?.unitSpriteMap ?? [UUID: UnitSprite]()).keys {
if units[unitID] == nil {
self?.removeUnitSprite(unitID: unitID)
}
}
}).store(in: &cancellables)
}


Same for Cities. 😎

An example from scratch


HexEngine is pretty big, so I created a very small example program. You can find it here.

reindeer

The example is mostly boilerplate code. The simulation itself is little more than a stub.

The simulation is contained in World.swift in the World class:
class World: ObservableObject {
@Published var units: [UUID: Unit]

let width: Double
let height: Double

func step() {
if let randomUnitID = units.keys.randomElement() {
units[randomUnitID] = units[randomUnitID]!.update()
}
}

init(numberOfUnits: Int, width: Double, height: Double) {
units = [UUID: Unit]()
self.width = width
self.height = height

for _ in 0 ..< numberOfUnits {
let newUnit = Unit(movementRangeX: 0...width, movementRangeY: 0...height)
units[newUnit.id] = newUnit
}
}
}


World class conforms to ObservableObject - this will be familiar if you used SwiftUI in the past. The World class contains a list of units. This list is marked @published, so we can use it from SwiftUI as well as other Combine subscribers. Note you need to import Combine or import SwiftUI to use @published in World.swift.

And implements a step() function that updates the simulation (i.e. advances a turn).

World.swift also contains a Unit struct:
struct Unit {
let id: UUID
var posX: Double
var posY: Double

var movementRangeX: ClosedRange<Double>
var movementRangeY: ClosedRange<Double>

init(movementRangeX: ClosedRange<Double>, movementRangeY: ClosedRange<Double>) {
id = UUID()
self.movementRangeX = movementRangeX
self.movementRangeY = movementRangeY
posX = Double.random(in: movementRangeX)
posY = Double.random(in: movementRangeY)
}

func update() -> Unit {
var changedUnit = self
changedUnit.posX = Double.random(in: movementRangeX)
changedUnit.posY = Double.random(in: movementRangeY)

return changedUnit
}
}


Units only have an id (so we can reference them later) and a position (that can change). They also have an update() function that gives them a new position.

There is only one subscriber in the game, in the GameScene class:
Note: GameScene.swift requires import Combine to get access to the required functions and types.
In didmove(to:) we first generate a new world and then subscribe to changes of the units dictionary in World:
override func didMove(to view: SKView) {
world = World(numberOfUnits: 2500, width: Double(self.size.width), height: Double(self.size.height))

world.$units.sink(receiveValue: { [weak self] units in
guard let gc = self else {
print("How is this being called if self is no longer available?")
return
}

for unitID in units.keys {
if let unit = units[unitID] {
if gc.unitSpriteMap[unitID] != nil {
gc.updateSpriteForUnit(unit)
} else {
gc.createSpriteForUnit(unit)
}
}
}
}).store(in: &cancellables)
}


This is where the Combine magic comes in. The syntax might look strange, but it's rather simple. First consider world.$units. This means we want the units publisher and not the actual units dictionary. We then call sink(receiveValue:) on the publisher. 'sink' is a subscriber that accepts the values emitted by the publisher and does something with them. The receiveValue parameter takes a closure that operates on the received value. I'll get into that in a bit.

The last part is the .store(in:) call. This one makes sure the subscriber sticks around after the method ends by putting the subscriber in a cancellables property (of type Set<AnyCancellable>.

The closure itself works are follows:
You can ignore the [weak self] and guard statement for now if you want. More importantly, the closure receives a units value. Note the type of this value: it is of type [UUID: Unit], not [Unit] or a single unit.

We then loop through every key in the dictionary and find the associated unit. There are now two cases:
  1. The unit is already known in both the units dictionary and the unitSpriteMap. This means that a sprite already exists for the unit. We only need to update the sprite. In this case we call updateSpriteForUnit(_).
  2. The unit is known in the units dictionary, but not (yet) in the unitSpriteMap. This means, we don't yet have a sprite for this unit. In this case we call createSpriteForUnit(_) for this unit.

There are also two functions that modify the games visuals as per the changes in units: func createSpriteForUnit(_) and updateSpriteForUnit(_). These are both trivial.

The update() function just calls world.step() every frame to stress the game world somewhat and get an idea of the performance.

Discussion


The method I've shown so far is a pretty naive implementation. Note that the value that is published in $units is of type [UUID: Unit]. This means that even if one unit changes, the entire dictionary is sent to all subscribers. And the subscriber loops through all units. This is highly inefficient if only one unit or a very small number of units changes. We're talking O2 complexity here. The code is simple though.

On the other hand, I did some testing with various numbers of units and various numbers of changed units per tick. I found that, with ~100 units, there is absolutely no problem updating every one every turn. Even though this triggers 100 publishes where all 100 units are evaluated. So we're evaluating say 10.000 units, even if only one changed. On my 2016 base model MacBook Pro, this still nets 60fps in the example program. Similarly, updating one unit every turn allows for 2500 units on screen updated 60fps.

All units updating in the same turn is highly unlikely, because:
  • Units are updated on a per player basis;
  • Most likely not all units need to change;
  • 2500 units is actually quite a lot: in a large map Civ V game, getting more than 50 units per player is a challenge.
So for now, I'll take simplicity over optimization and stick with the naive implementation.

I wrote this after working through the first four chapters of the book "Practical Combine" by Donny Wals. I'm impressed with how easy it was to include Combine in my code and how well Donny was able to teach me.

We value your privacy. This site collects only information required to provide you this Service. For more information, read our privacy policy.