DEV Community

Cover image for Game Event System with Swift
Johan Steen
Johan Steen

Posted on • Originally published at blog.bitbebop.com

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. 

Top comments (3)

Collapse
 
chiefgui profile image
Guilherme Oderdenge

Hey @johansteen - thank you so much for sharing this article!

I've been into game development since 2018 through Unreal Engine and always pursued a great way to deal with communication across different game subsystems and coincidentally had the same conclusion you had: agnostic events using listeners!

Since you and I have this same perspective on the subject, I'd like to ask your take on one thing that is still wandering around my thoughts:

When one takes damage is that we have to subtract health; or when we subtract health is that we notify that one took damage?

I know the answer may sound obvious for this specific case (health and damage), it may not be for other scenarios though. To name you another example:

When one hits the jump input is that we have to broadcast they're jumping, thus making the character jump; or when one leaves the ground is that we broadcast the jumping event?

You know, is jumping a consequence of leaving the ground; or is leaving the ground a consequence of jumping?

I always have these dualities and sometimes bad conclusions are leading to poor architecture choices that damage my project, specially because I am not always aware that said decisions are forking into different conventions within the same codebase.

Collapse
 
johansteen profile image
Johan Steen

@chiefgui

I like to think that the event describes the action that occurred (or will occur). I organize events loosely into categories, like Gameplay, Lifecycle, Input and some others depending on project.

If we take the jump example.

When one hits the jump input is that we have to broadcast they're jumping, thus making the character jump; or when one leaves the ground is that we broadcast the jumping event?

You know, is jumping a consequence of leaving the ground; or is leaving the ground a consequence of jumping?

I'd separate that into two different events.

When the player hits the jump input, I'd say that is an Input action event, and I'd broadcast that as a JumpAction event, that the player entity can listen for, and execute the actual logic to make the jump happen.

When the input system broadcasts the JumpAction input event, the actual jump might or might not occur. There might be reasons preventing the player entity to perform a jump in the current situation, the input system should probably not need to know if the player is blocked from jumping. So the JumpAction is always broadcasted, which keeps it decoupled from the jump logic.

If the player entity responds to the input event and actually can perform the jump, ie leaving the ground, then I'd say that is a Gameplay event, and I'd broadcast a gameplay JumpEvent from the player entity, that other interested parties can listen to.

If there's a reason that there might be a need for other systems to perform logic before and after some code executes, I'd prefix the event with something like "Will" and "Did".
PlayerWillJump and PlayerDidJump.

With the Damage example, I'd most likely do a Damage event that includes the number of damage that is dealt. The Damage event knows about the damage it deals ,but it does not know about state of the entity that receives the damage. Then the entity receiving the damage can perform any logic when reducing the health. The receiving entity might have boost modifiers that reduces the amount of damage taken. And then when the health is adjusted, a HealthChanged event can be broadcasted.

Of course, depending on how the project is structured and the architecture of different systems, there might be better choices how to organize the event handling between systems. But hopefully you can pick some useful info from these ideas.

Collapse
 
bibakhan profile image
bibakhan

Hi this is an amazing post. Keep posting such am amazing contents. mymoddedapk.com/