UISignalBus & ContextNode

Architecture brief — what problems they solve and how they work together


The Problem

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:

Option A: Global Autoloads

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.

Option B: Pass References Down

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.

Without ContextNode Screen Panel Panel Widget Button pass ref pass ref pass ref pass ref Every node must explicitly pass services down the chain With ContextNode Screen (provides) Registry UISignalBus GameService Panel Panel Widget Button get_service() get_service() Any descendant queries upward. Middle nodes don't need to know.

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.

Part 1: ContextNode — Scoped Dependency Injection

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.

How It Works

  1. A parent ContextNode calls provide_instance(my_service) to register a service in its local registry, keyed by script type.
  2. Any descendant calls ContextNode.get_service(self, MyService) to look it up.
  3. The lookup walks up the tree through each parent ContextNode's registry until it finds a match.
  4. If nothing is found in any parent, it falls back to the RootContextNode autoload (the global scope).
ContextNode Lookup: Walks Up the Tree RootContextNode (Autoload) AuthManager, ConfigManager, LogManager... ScreenMainScene (ControlContextNode) provides: UISignalBus, GameSceneStateData, NetworkService... MatchmakingScreen (ControlContextNode) provides: UISignalBus, MatchmakingContext CreatureCreationFlow provides: UISignalBus MatchReadyButton get_service(self, UISignalBus) Found in MatchmakingScreen! would continue up if not found var bus = ContextNode.get_service( self, UISignalBus) # returns nearest bus UP the tree
Key insight: Because each ContextNode has its own registry, the same type (like UISignalBus) can exist at multiple levels. A child always gets the nearest one. This is scoping — just like CSS cascading or React Context nesting.

API

Part 2: UISignalBus — Scoped UI Events

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.

Each Scope Has Its Own UISignalBus ScreenMainScene scope UISignalBus #1 MatchmakingScreen scope UISignalBus #2 CreatureCreation scope UISignalBus #3 ReadyButton PlayerList SettingsBtn emit listen GenButton PreviewPanel X Events don't cross scope boundaries Event Bubbling (manual) 1. Button emits on bus #2 2. MatchmakingScreen handles 3. If unhandled, forwards to bus #1 4. ScreenMainScene handles bubble up

Part 3: How They Work Together

Here's the full lifecycle of a UI event, step by step:

Full Event Lifecycle 1 SETUP: Screen provides UISignalBus into its context func _ready () -> void: var bus := UISignalBus.new(); provide_instance(bus) # registered in local context 2 EMIT: Child component fires event via helper # Inside a deeply-nested button's click handler: var event := AIBGUIEvent.new(AIBGUIEvent.Type.NAVIGATE_TO_HOME) GameUtils.emit_ui_event_in_parent(self, event) 3 RESOLVE: Helper uses ContextNode to find the nearest bus # Inside GameUtils.emit_ui_event_in_parent(): var bus = ContextNode.get_service(node.get_parent(), UISignalBus) # walks UP tree 4 HANDLE: Screen receives event, handles or bubbles up func _on_ui_event_emitted (ui_event) -> void: match ui_event.event_type: AIBGUIEvent.Type.HIDE_TAB_BAR: ... # handle locally, or forward upward

Part 4: Creating a UISignalBus

Scene-based (preferred)

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

Programmatic

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)

Part 5: Event Bubbling

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)
Note: 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.

Part 6: Common Pitfalls

Different instances: If component A emits on one UISignalBus instance but component B listens on a different one (because they're in different ContextNode branches), the event won't arrive. This is by design — each scope has its own bus. Cross-scope communication must go through the bubbling chain.
Timing: Scene-based context items register in _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().
Nearest, not global: 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.

TL;DR

ContextNode = React Context for Godot Parents provide() services Children get_service() to find them Lookup walks UP the tree Nearest match wins (scoping) RootContextNode = global fallback + UISignalBus = scoped event channel One signal: ui_event_emitted Lives inside a ContextNode scope Found via get_service() Events stay within scope Manual bubbling for cross-scope