Godot Signals: Object Scope and Event Bus
đ Signal scopes in Godot, implementing a global event bus, and the pitfalls that come with it.
- Intro
- Problem: Subscriber needs a direct access to publisher
- Solution: Signal scopes
- About Global Scope
- Connecting signals in UI
- Random commentary
I discussed how Event-Driven Architecture (EDA) can be applied to Godot signals here.
Intro
Godot signals are a powerful tool for decoupling systems in your project.
How official docs describe them:
Signals are a delegation mechanism built into Godot that allows one game object to react to a change in another without them referencing one another.
However, this zero-reference promise isnât fully realized in examples.
In this post I discuss:
- A reference problem and how it can be solved via event bus.
- New dangers that appear with a new approach.
- Further EDA comparisons and some other technical commentary.
đľ Door SFX example
Simple direct call
Imagine we have a Door class that represents an interactive object in game, and a DoorSFXSystem class whose responsibility is playing different door sounds. If a player opens the door, we want to play a creaking sound.
This is a basic object relationship:
1
2
3
4
class_name DoorSFXSystem
func play_sound():
# playing sound
1
2
3
4
5
6
7
class_name Door
var sfx_system: DoorSFXSystem
func open_door():
# some opening logic
sfx_system.play_sound()
Decoupling using Godotâs signals
Now Door has a signal as its attribute, and DoorSFXSystem connects to this signal. The door would emit it instead of the direct call to sfx system.
1
2
3
4
5
6
7
class_name Door
signal door_opened
func open_door():
# some opening logic
door_opened.emit()
1
2
3
4
5
6
7
8
9
class_name DoorSFXSystem
var door: Door
func _ready():
door.door_opened.connect(_on_door_opened)
func _on_door_opened():
# play sound
In EDA terms, the system that emits an event is called Publisher, and the system listening to events - Subscriber. I will be using these terms.
Problem: Subscriber needs a direct access to publisher
EDA typically handles interactions between fully decoupled systems. But in our example a subscriber still holds a direct reference to the publisher:
1
2
3
4
5
6
class_name SFXSystem
var door: Door # <-- here!
func _ready():
door.door_opened.connect(_on_door_opened)
If the door and its dependent systems are in the same scene, this tight coupling is fine.
But imagine another scenario:
An achievements service tracks how many doors the player opens during playthrough. Naturally, this service doesnât have references to every door in the game, and it probably doesnât even know that the Door class exists.
This situation is closer to typical EDA: the achievements service is independent of the door class, but they still need to communicate. How do we establish this connection?
Solution: Signal scopes
To solve this, letâs distinguish between two structural approaches which I call âsignal scopesâ: object scope and global scope. The door example falls into the object scope, so weâll break that pattern down first.
đŞ Object scope
Itâs the approach shown in the official Godot tutorial and used by built-in nodes.
Object-scoped signals are attributes of a specific object. This means that a specific class (Door) has a signal (door_opened) which might be emitted on its state change.
This scope naturally represents one-to-one/one-to-many relationship between the publisher and subscriber:
- Multiple
Doorinstances each have their own independentdoor_openedsignals. - The number of subscribers can vary: A
DoorSFXSystemmight subscribe to play a sound, while aVFXSystemsubscribes to trigger a dust effect, etc.
This scope is used when the subscriber has a relation to the publisher which can be described in your code or tree hierarchy (subscriber will be accessing the publisherâs signal in order to connect to it).
Button example
Consider a UI where pressing an âOptionsâ button opens a submenu. The buttonâs identity matters (itâs not the âExitâ or âNew Gameâ button). The subscriber (e.g., OptionSubmenuLoader) needs to connect to that exact buttonâs signal in order to open the sub-menu. They are both probably a part of the same UI options menu.
đ Global scope
This scope comes into play when you donât have direct access between the publisher and subscriber (e.g. they are not a part of the same scene).
Instead of belonging to a specific instance, global-scoped signals are declared in a separate class that is independent from publisher or subscriber.
These signals typically represent many-to-one/many-to-many relationships, since multiple different publishers can emit the same event.
Button example variation
Pressing any button triggers a generic click sound. The sound player doesnât care which specific button was pressed (Options, New Game, or Exit) or which menu it belongs to; it might not even be tied to the UI logic at all.
Achievements example
Every door simply emits a global door_opened signal. Achievements service subscribes to only this one event. It doesnât care which door has been opened (and doesnât know that Door class exists).
About Global Scope
Implementation via event bus
Godot features Autoloads, which act as the engineâs version of the Singleton pattern: it is initialized once and remains globally accessible from anywhere in your code.
We can create an Autoload named GlobalSignals that holds our global-scoped signals:
1
2
signal door_opened
signal button_pressed
Returning to our achievements example, the doorâs code will look like this:
1
2
3
4
5
6
7
8
class_name Door
signal door_opened
func open_door():
# ...
GlobalSignals.door_opened.emit() # <- global-scoped signal
door_opened.emit() # <- object-scoped signal
This can be seen as an implementation of the event bus pattern
Notice that
DoorSFXSystemstill needs the object-scoped signal: in case of subscribing to the globalGlobalSignal.door_opened, one opened door would trigger a sound on every existing door in the level
The same idea is described in the official tutorial comment by samuelfine. Given the number of reactions it got, I think there is demand for such an approach.
All the dangers
I use global-scoped signals and global signals interchangeably. I also sometimes refer to object-scoped signals as local signals. These are just terms I came up with, but I think the semantics is intuitive.
Global-scoped signals come with caveats that require careful handling.
Ruins architecture
The biggest trap is that they are too easy to use. Developer might be tempted not to design the components relationships (e.g, abstraction layers, function interfaces, SOLID patterns) and just throw a global signal at the problem. Using only four lines of code:
- GlobalSignals:
signal another_signal(any_data)
- SystemBob:
GlobalSignal.another_signal.connect(_on_another_signal)func _on_another_signal(any_data): pass
- SystemAlice:
GlobalSignal.another_signal.emit(["hi", "bob"])
In contrast, object-scoped signals still inherently require some structural relationship.
One-to-many pitfall
Consider a scenario where enemies stop attacking when the player dies. Emitting a global player_died signal would be fast and effective.
The problem arises if you ever add split-screen or multiplayer: a single playerâs death will freeze every enemy on the map.
Of course, adding a second main character involves a massive game redesign anyway, so this was and extreme example. But the idea is that while using the global scope is fine for a singleton publisher, you must be certain that the publisher is a true singleton by nature, and a second instance wonât be required later.
Broadcasting pitfall
On emitting signal, you can pass additional data. I call it payload, while itâs not quite the same.
With global signals, it is common to include a payload since the publisher and subscriber are independent.
Imagine an analytics system that tracks which menu buttons a player presses most frequently. Every button could emit a global button_pressed signal containing its unique button ID as a payload.
This is fine, but a tricky problem can emerge later: developers might start relying on this global signal for what should be object-scoped scenarios. Remember the earlier example where an âOptionsâ button opens a submenu? The OptionSubmenuLoader connected directly to that specific button. Now, a developer might just use the global button_pressed signal instead, filtering the events to open the menu only if button_id == "OptionsButton".
This doesnât scale well. Every button press across the game will trigger the OptionSubmenuLoader: the subscriber will be constantly filtering irrelevant data, leading to wasted performance and a convoluted signal topology.
Essentially, we give up on the natural âEvent routingâ signal ability and force a broadcast model: every subscriber receives every signal and manually filters the information. This is not necessarily a bad design, if you are aware of the pros and cons.
Comparison to Event-Driven Architecture
Event bus
In EDA it is typical that components donât know about each other but have a âmiddle manâ to speak to (comes in many shapes: event bus, event queue, event broker, etc). Global-scoped signals resemble this model: publisher and subscriber use a GlobalSignals autoload to communicate.
Event Routing
Common middleman feature is called Event Routing. Our bus has zero logic, yet signals provide this by design. I discussed it here.
Pulling after receiving event
In EDA it is common for a subscriber to make a direct request to publisher after receiving an event in order to better understand the context of it. This is a known trade-off of the âthinâ events that donât contain much data.
In global-scoped signals there is no analogy for that. It probably hints that the payload must be designed more carefully upfront.
Ironically, object-scoped relationship can support this: since subscriber has a direct access to publisher, it can use some publisherâs getters after receiving the signal.
DDD comment
Object-scoped đŞ signals - operate inside a single Bounded Context. Because the subscriber has direct access to the publisher, this usually represents communication within an Aggregate.
Global-scoped đ signals - used to communicate between different Bounded Contexts. The systems are fully independent and unaware of each otherâs logic.
Connecting signals in UI
Godot has a feature of connecting the signals via UI, which means that connect api is not called in the code. Official tutorial describes it here.
This is likely why the docs state that signals help components interact âwithout referencing one anotherâ.
It is a valid âUI programming levelâ solution, but it has several problems:
- UI connection makes it implicit: you donât know how code works outside the engine. This means that in external IDE or repo storage (like github) such function handlers would appear unused. I wrote about it here.
- Commits would not reflect the difference in code (some other resources will be changed, as this connection data should be stored somewhere).
- Code refactoring may silently break the connection. With explicit connection, actions moving the handler will result in a compilation error.
- Doesnât work if you create or instantiate nodes during runtime:
necessary when you create nodes or instantiate scenes inside of a script (link).
My opinion is:
- It is useful for a quick prototyping and learning, but once the things âare settledâ, itâs better to make the connection in code (using appropriate scope we discussed).
- In a large project with multiple developers and heavy VCS usage, UI connections become a major risk.
Random commentary
Some related thoughts that I didnât know where to fit.
DIP comment
Iâve discussed the dependency reversion that signals provide here: link, link
In our canonical example, the SFX system depends on the door. However, scenarios where the Door depends on the DoorSFXSystem are also common and valid.
Consider a generic SFXSystem: it doesnât know about doors, but is good at playing sounds from a library (e.g. it maps a sound type to audio stream file using a predefined global map).
You can maintain this decoupling by combining signals with dependency injection:
- The
Doorinjects its signal while initializingSFXSystem. - The door still does not call
SFXSystem.play_sounddirectly and ignores the sfx system entirely after initialization. - Such sfx system can be attached to any other interactive object, provided that object initializes it with the appropriate signal.
Local Event Bus
Imagine the door has many signals (door_closed, door_locked etc) and multiple systems depend on them. Then a DoorSignalContainer can be created. Dependent systems, like the SFXSystem, would use this injected container to handle their logic.
It is similar to the global scope we discussed but this time the bus is local, scoped strictly to a specific item (the door).
đ¤ˇââď¸ Why use signals at all
It may seem that the signal approach is not necessary when the two components have direct access to each other (object scope). While this is true on a small scale, we still have all the advantages of the decoupled system. I discussed it in details here.
Another argument might be that a game app is a big monolith (at least an indie game without multiplayer features): You donât have network boundaries or independent components running on different machines. Every part can be accessed directly using global tree structure, singletons, Godotâs Groups or low level API. But the absence of these boundaries only makes deliberate decoupling more important, not less.







