Riding the Event Bus in Godot

bajathefrog

Baja the Frog

Posted on February 11, 2024

Riding the Event Bus in Godot

One of the most popular pieces of advice when working in Godot is to:  

"Call down and signal up"

It's catchy and helpful and exactly what you should be thinking about when communicating between nodes that live close to one another. Like parent-child nodes or even sibling nodes.

But what if I told you there was a secret(?), third option?

I'm listening

Do you know what Events are? Or the Observer Pattern?

If you do, great! You might not need the rest of this. But you might still want a ready-to-go Event Bus implementation for Godot, which you can find on my Github.

If you don't know or aren't sure you need it in Godot, then read on!


There are a lot of good resources you can find on the topic of an Event Bus or the Observer Pattern. I've linked some at the bottom of the article.

But the short version is: Events follow an observer or publisher/subscriber structure that allow one entity in the game to tell anyone who cares that something just happened.

Something changes with one thing and everyone who wants to know about it is automatically updated.

This pattern is great for reducing brittle, overly-coupled code.

Signals help a lot with this actually at the "local" level, but on their own, they still require directly connecting from one node to another.

This doesn't scale well as lots of things enter the fray or you want information to cross boundaries of systems.

So what's the issue?

As more and more nodes and scenes enter your tree, you are asking for a headache if you are...

  1. Directly calling down to some arbitrary scene
  2. Connecting signals between dynamically added scenes and lots of possible systems

A classic example

You are making a game.
There is a hero.
There is combat.

You need the UI to display a health bar that communicates how much health the hero has left.

Attempt 1: Call down

The health bar UI has to get, or be given, a reference to the hero and then check every frame to see what their health is.



# ui.gd

func _process(delta):
    # how do we get the hero to begin with?
    var latest_health = hero.health.value 
    _update_bar(latest_health)


Enter fullscreen mode Exit fullscreen mode

This is calling _update_bar more frequently than we need AND it makes it harder to build and debug the UI on its own because it requires a hero to work. No good.

Attempt 2: Signal up

The health bar UI has to get, or be given, a reference to the hero and connects itself to a signal about health changes. This is an improvement as we no longer are checking every frame but the fact remains we still have to rely on a hero reference.



# hero.gd

signal health_changed(latest_health)


func take_damage(dmg):
    health.value -= dmg
    emit_signal("health_changed", health.value)


Enter fullscreen mode Exit fullscreen mode


# ui.gd

func _ready():
    # Again, how do we get the hero to begin with?
    hero.connect("health_changed", self, "_on_health_changed")


func _on_health_changed(latest_health):
    _update_bar(latest_health)


Enter fullscreen mode Exit fullscreen mode

And if we flipped the dependencies we would have the same problem where the hero would demand some access to the UI.

So in either case - the UI and hero need a direct reference to one or the other.

Not the end of the world on its own but as things grow and scale you'll suddenly find lots of systems needs lots of direct references to each other and you will question your life (trust me).

Attempt 3: Using simple, signal-based events

You can actually make a huge improvement with very little work by just using an Events autoload (so that it's globally accessible) that has a bunch of signals defined.

This is what I did for my game JOUNCER PX and while it started to feel precarious towards the end, the game was ultimately small enough to make it work.

Here's the script events.gd that we'll make an autoload.



# events.gd
extends Node

signal hero_health_changed(new_value)


Enter fullscreen mode Exit fullscreen mode

Now, we can broadcast this change from the player like so:



# hero.gd

func take_damage(dmg):
    health.value -= dmg
    Events.emit_signal("hero_health_changed", health.value)


Enter fullscreen mode Exit fullscreen mode

And subscribe to that change in the UI:



# ui.gd

func _ready():
    Events.connect("hero_health_changed", self, "_on_hero_health_changed")


func _on_hero_health_changed(new_value):
    _update_bar(new_value)


Enter fullscreen mode Exit fullscreen mode

See that? Now the UI never needs a direct reference to the hero. It just needs to hook up to the Event it cares about and doesn't need to know where it was triggered from.

Attempt 4: Using an EventBus

By using an EventBus, a hero can broadcast an Event much like they would trigger a signal.

But the advantage of an EventBus is you can more easily pass around Events as more complex objects and are not constrained to defining every possible Event in one file (events.gd).

Let's take a look:



# hero.gd

func take_damage(dmg):
    health.value -= dmg
    var health_event = HealthEvent.new(health.value)
    EventBus.service().broadcast(event)


# Defining our custom Hero.HealthEvent
class HealthEvent extends Event
    var new_health: float
    # you could include other information like 
    # the previous health or health as a %

    const ID = "HERO_HEALTH_EVENT_ID"

    # this passes in ID to the base `Event` _init
    func _init(new_value: float).(ID):
        self.new_health = new_value



Enter fullscreen mode Exit fullscreen mode

And then on the UI side of things:



# ui.gd

func _ready():
    EventBus.service().subscribe(Hero.HealthEvent.ID,
            self, 
            "_on_hero_health_event")


func _on_hero_health_event(event: Event):
    var player_health_event = event as Hero.HealthEvent
    if player_health_event:
        print(str(player_health_event.new_value))


Enter fullscreen mode Exit fullscreen mode

This gives us the flexibility to separate the mechanism for passing around events from the content of the events themselves. This scales better and allows you to define events closer to the source (such as in the same file as the event "owner").

In both cases...

A hyper-realistic rendering of how events can pass information without breaking boundaries between systems
Using either events as signals or the via the EventBus removes the need for direct references or complicated dependency injection and opens up the possibility of simulating events through hotkeys and debug tools.

As an example - if you want to test the UI a great way to do it is a hotkey that simulates a player health event rather than requiring your player to go out and take damage!

And it's just as easy for an arbitrary number of other nodes and systems to listen to health events too.

  • Want an enemy that gets stronger when the hero is low on health?
  • A chest that only unlocks when hero health is full?
  • Want an achievement manager that counts how many times the hero dies?

All easily implemented by subscribing without needing to guarantee a hero is ever in the scene, let alone how to access it.

So what's the catch?

Well, don't turn everything into a nail just because you have a hammer!

Like any tool or pattern, you can over-do it.

The main risk with events is creating too many that are overly granular and can spawn off their own events.

This can make debugging really complicated and make it harder to reason about what is happening and why.

So generally, communicate direct interactions between nodes when you can. And if you feel yourself needing to let something waayyy out in the world or UI know about something that has happened, that's usually a good sign you want an event!

Wrapping up!

So let's go back to that sage Godot wisdom:

"Call down, signal up"

Excellent advice and should always be the starting point when dealing with close-proximity node relationships.

But when you are working with a different kind of relationship, one that crosses scenes and system boundaries - events are your friend!

They remove explicit references from the equation and make it easy for lots of different entities and systems to respond to things happening in the game.

Just be intentional about designing your events.

If you want an EventBus system that's ready to be dropped into your Godot project, check out my repo!

More Reading

To read more on the pattern in general, I think the best source is Robert Nystrom's book - Game Programming Patterns - for all sorts of game programming advice, but it has 2 chapters on this topic in particular:

And here are a handful of other pieces I found on the web, but there's lots!

Thanks for reading and leave a comment if you have any questions or thoughts!

💖 💪 🙅 🚩
bajathefrog
Baja the Frog

Posted on February 11, 2024

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

Sign up to receive the latest update from our blog.

Related

Riding the Event Bus in Godot
godot Riding the Event Bus in Godot

February 11, 2024