Architecture brief — what problems they solve and how they work together
In Godot, the scene tree is your UI hierarchy. But when a deeply-nested button needs to talk to a screen three levels up, or when a service needs to be shared across sibling scenes, you have two bad default options:
Put everything in a global singleton. Every node can reach it. But now everything is coupled to one god-object, nothing is scoped, and testing individual scenes in isolation becomes impossible.
Thread service references through constructor arguments or @export vars from parent to child to grandchild. This creates tight coupling and fragile chains that break when you rearrange the tree.
What you actually want is something like React Context: a way for a parent to provide something, and for any descendant to query for it — without every node in between needing to know about it.
That's exactly what ContextNode does. And UISignalBus is a lightweight event bus that sits inside the context system, giving scoped UI components a way to communicate events without direct references to each other.
game/scripts/context/context_node.gd
A ContextNode is a scene tree node that maintains a local registry of services and data. Think of it as a React Context Provider — it makes things available to all its descendants without passing references explicitly.
provide_instance(my_service) to register a service in its local registry, keyed by script type.ContextNode.get_service(self, MyService) to look it up.RootContextNode autoload (the global scope).UISignalBus) can exist at multiple levels. A child always gets the nearest one. This is scoping — just like CSS cascading or React Context nesting.
provide_instance(instance)get_service(requester, type_script)requester through parent ContextNodes, return the first match. Falls back to RootContextNode.
get_data(requester, type_script)get_service() — semantically for data/Resource objects.
game/ui/events/ui_signal_bus.gd
Now that ContextNode gives us scoped service lookup, UISignalBus is simply a service that gets provided at each scope. It's a tiny Node with one signal:
class_name UISignalBus
extends Node
signal ui_event_emitted(ui_event: UIEvent)
Because it's provided via ContextNode, each screen scope gets its own isolated bus. Events emitted on one bus don't leak into sibling scopes.
Here's the full lifecycle of a UI event, step by step:
Add as a child of ContextContainer in a .tscn file. Auto-discovered and registered during _enter_tree(), so it's available before any _ready() runs.
# In .tscn:
[node name="UISignalBus"
parent="ContextContainer"]
script = ui_signal_bus.gd
Created in code and provided to the local context. Used by nested screens like ControlSwapScreen and TabBarContainer.
var bus := UISignalBus.new()
bus.name = "UISignalBus"
provide_instance(bus)
Events don't automatically propagate between scopes. Each screen handler must explicitly forward unhandled events to the parent scope's bus:
func _on_ui_event_emitted(ui_event: AIBGUIEvent) -> void:
match ui_event.event_type:
AIBGUIEvent.Type.HIDE_TAB_BAR:
home_nav_bar.hide()
return # handled locally — stop
# Not handled here — forward to parent scope's bus
GameUtils.emit_ui_event_in_parent(self, ui_event)
emit_ui_event_in_parent uses node.get_parent() as the requester for the context lookup. This skips the current node's own bus and finds the next one up — the parent scope's bus.
_enter_tree() (before _ready()). But if you call provide_instance() in _ready(), child nodes doing get_service() in their own _ready() may not find it yet — Godot runs _ready() depth-first (children before parents). Prefer scene-based setup or use _enter_tree().
get_service(self, UISignalBus) walks up the tree and returns the first match. If you need the top-level bus, you must explicitly bubble events up through intermediate handlers.