Game Event System with Swift

johansteen

Johan Steen

Posted on July 25, 2022

Game Event System with Swift

A game can usually be broken down into different systems, which are the building blocks of the game's architecture.

One system could manage the on screen HUD, another the player, a third the enemies, and so on, and all these systems need to communicate with each other in one way or another. It is very easy to end up with a dependency nightmare between the different game systems.

Dependency Nightmare

We begin with an example of a problematic architecture, one that we are going to try to avoid by using game events for a more modular approach.

Let's say that we have an enemy entity that we want to test in isolation to save some time. There's really no need to startup the entire game world to see the animations, how the enemy responds to certain interactions, or similar things. So we are going to use a barebone scene where we can drop in an enemy entity and to be able to do some quick tests in a live environment.

We setup the barebone scene and add some initialization code to instantiate our enemy. But it won't compile; the enemy entity depends on the existence of the EnemyManager.

So we also instantiate the EnemyManager in our test scene and give it another go. Won't compile again, the EnemyManager can't find the Player, a dependency that is used to let enemies make their AI decisions.

Okay, so we are adding the player to the test scene too. And let's run it.

Ouch, the Player needs the InventoryManager. Okay, let add that one as well. And go! Nooooooo! The player also needs the HUDManager.

Eventually we've pretty much rebuilt the entire game architecture inside what was supposed to be our lean and mean debug scene.

Game Events

Which brings us to events. By using events to communicate between the game's different systems, we get a much more decoupled architecture. When a system raises an event, it doesn't care who listens to it, and a system that listens for an event doesn't care who raised it.

This makes it very easy to take out any object from the game and drop it in an isolated environment or scene and it will pretty much work right away. And we can decorate our test scene with event triggers so we can trigger any event we want on demand and observe the behavior, without actually having to add the system that would trigger the event in the game.

This architecture helps us get rid of hard dependencies between our game's systems.

And of course, when it comes to the actual gameplay scenes, the more decoupled and isolated our systems are, the more reusable the systems will become for future projects, and as a bonus, way easier to debug and test.

Observers vs Broadcasters

When it comes to handling events, there are primarily two design patterns that are highly popular, either using observers or broadcasters with listeners.

I've implemented and used both approaches in different projects, and while I like many things about the observer pattern, it relies on dependencies between objects in one way or another. Which is what we are trying to avoid.

I've used solutions similar to this.

class HealthComponent: GKComponent, Observable {
  private(set) var health: Int {
    didSet { notifyObservers(with: .entityHealthChanged) }
  }
}
Enter fullscreen mode Exit fullscreen mode

And then, the HUD can observe the health component to instantly update the health bar whenever the player's health changes. But this requires that the HUD is aware of the player object, to register with it, which gives us a hard-wired dependency.

So I personally rarely use this pattern anymore, and instead I most of the time rely on broadcasting events and having listeners, which provides a much more decoupled architecture.

The drawback can be that it can be harder to read and follow the event flow in code when there are no hard connections between the systems. Someone new to the code base would have to spend some time looking up and finding out what is listening to what events, as the compiler can't help with that.

I now handle that by logging events when running the game in debug mode, so I can at any time request the log and see what events that were triggered, and which objects that responded to each event. That more or less take care of that drawback.

Implementation

That was a lot of background and theory, but now when we have decided that we want to broadcast events between systems, let's look at how we can implement that functionality in Swift. We are going to make our own implementation as we want maximum performance. We don't want to rely on NotficationCenter or other built-in solutions that are less performant1 than what we can build ourselves.

Event Channels

We are going to use event channels that we broadcast on, and pass along any relevant data. Interested parties can listen to channels of interest and react to events.

So we are going to setup an Event class that each event channel that we create will instantiate and be available for systems to broadcast on or listen to.

public class Event<T> {
}
Enter fullscreen mode Exit fullscreen mode

We are going to use Swift generics for this class, when we define an event channel we also determine with the generic what kind of data we will pass along with the event.

let enemyDestroyedEvent = Event<Enemy>()
Enter fullscreen mode Exit fullscreen mode

Here we create an enemyDestroyedEvent channel where we will pass along an instance of an Enemy object. When an enemy is destroyed the enemy will broadcast and pass along itself on this channel just before it removes itself from the game.

A number of other systems might listen to this event; the audio system could take the Enemy object and determine which sound effect to play; the UI system shows a score floating for a short period of time where the enemy was destroyed. The VFX system instantiates an explosion animation on screen at the enemy's last position.

Our Event class is going to need to store a collection of listeners for each channel so the channel knows which objects that should be notified when the event is raised.

public typealias EventAction = (_ subject: T) -> Void

/// Listener wrapper to be able to use a weak reference to the listener.
private struct Listener {
  /// Weak reference to the listener.
  weak var listener: AnyObject?

  /// Action closure provided by the listener.
  var action: EventAction
}

private var listeners: [ObjectIdentifier: Listener] = [:]
Enter fullscreen mode Exit fullscreen mode

We don't want to risk that the event channel keeps the object alive if the object is removed from the game, so we use a Listener struct as a wrapper around the object that listens, so we can have a weak reference to the listener. We are using ObjectIdentifier as the key for the listener, so it's easy to find the listener in the collection if we need to remove it.

The EventAction typealias gives us some sugared syntax.

Now we have a place to store the listeners for an event channel, so let's add some methods so objects can let the channel know that they want to listen for events.

/// Register a new listener for the event.
public func addListener(_ listener: AnyObject, action: @escaping EventAction) {
  let id = ObjectIdentifier(listener)

  listeners[id] = Listener(listener: listener, action: action)
}

// Unregister a listener for the event.
public func removeListener(_ listener: AnyObject) {
  let id = ObjectIdentifier(listener)

  listeners.removeValue(forKey: id)
}
Enter fullscreen mode Exit fullscreen mode

Thanks to the solid structure where we store the listeners, the addListener(), and removeListener() methods become very straightforward. We basically just have to get the ObjectIdentifier for the listener and add/remove it from the collection.

And finally, we are going to need a method to broadcast events to listeners.

/// Raise the event to notify all registered listeners.
public func notify(_ subject: T) {
  for (id, listener) in listeners {
    // If the listening object is no longer in memory,
    // we can clean up the listener for its ID.
    if listener.listener == nil {
      listeners.removeValue(forKey: id)
      continue
    }

    listener.action(subject)
  }
}
Enter fullscreen mode Exit fullscreen mode

Here we raise the event to iterate through the collection of listeners to notify them. As we used a weak reference to the listener to not keep them alive if they are removed from the game, we also check here so the listener is still around. If it's not, we take the opportunity to clean up the collection by removing it.

And that's it; this class gives us everything we need to broadcast and listen to events.

Using Event Channels

That leaves us with how to use the event channels when we need to communicate between the game's different systems. There are many approaches to choose from. I prefer to use a GameEvent singleton as a communication central where I define all channels that the game uses.

class GameEvent {
  static let shared = GameEvent()
  private init() {}

  // Event Channels.
  let scoreChangedEvent = Event<GameData>()
  let livesChangedEvent = Event<GameData>()
  let enemyDestroyedEvent = Event<Enemy>()
  let playerDamagedEvent = Event<HealthComponent>()
}
Enter fullscreen mode Exit fullscreen mode

This is the place where I'm collecting and organizing all event channels that the game will need, and by exposing it as a singleton, I can simply use it from anywhere in the game code.

For an object to register itself as a listener, we would use the addListener() method in the event channel.

class AudioComponent: Component {
  init() {
      GameEvent.shared.playerDamagedEvent.addListener(self) { [weak self] _ in
        self?.onPlayerDamaged()
      }
  }

  func onPlayerDamaged() {
    // Play the sound effect when player is taking damage.
  }
}
Enter fullscreen mode Exit fullscreen mode

We have a component that handles playing sound effects. By having it listen to the playerDamageEvent it can play the appropriate sound effect every time the player is damaged. Also, the health bar in the HUD would listen to this event and maybe also an animation component that would play a damage animation, and possibly emit some particles.

To be useful, events must also be raised.

class HealthComponent: Component {
  func takeDamage() {
      // Do some other damage related things...

      GameEvent.shared.playerDamagedEvent.notify(self)
  }
}
Enter fullscreen mode Exit fullscreen mode

Which is more or less self-explanatory. This would be the health component for the player that raises the event every time the player takes damage, so other systems can react to it. The component passes along itself with the event, so listeners that relies on data in the health component can do so. The HUD most likely is going to check the health percentage for the component when the visuals on screen are updated.

Conclusion

This is my preferred way to pass data between game systems and I find it highly powerful and flexible. The complete decoupling between objects provides possibilities for very interesting solutions. As an object is not aware, care, or even interested in what listens to its events, you can wire up pretty much anything with anything.

With that in mind, each game component can be kept very clean and only focus on one thing. The health component only needs to track health; everything else in the game that depends on health is handled by other systems, more relevant for the separate purpose, and events are the delivery mechanism between them.

The UI is driven by events from the health component; a destroy component is driven by the same events. An audio component can also listen to the event in question. Anything that makes sense can listen to events, without having to introduce a dependency between objects.

But don't forget... With great power comes great responsibility.


  1. Performance Results. Test results of Notification Center performance. 

💖 💪 🙅 🚩
johansteen
Johan Steen

Posted on July 25, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related