Riding the Event Bus in Godot
Baja the Frog
Posted on February 11, 2024
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...
- Directly calling down to some arbitrary scene
- 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)
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)
# 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)
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)
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)
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)
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
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))
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...
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!
- GDQuest - The Event Bus Singleton
- Tutemic - The good and bad of events (video)
- Refactoring Guru - Observer Pattern
- GMGStudio - Observer Pattern
Thanks for reading and leave a comment if you have any questions or thoughts!
Posted on February 11, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.