Signal Bus Architecture in Godot 4: How We Decoupled Fire, Player, and UI
A practical guide to the signal bus (event bus) pattern in Godot 4 — how a single autoload of signals let us decouple the fire, the player, and the UI in Stick Picker Simulator, and what the pattern actually costs.
For Godot developers
- godot
- godot-signal-bus
- game-architecture
- gamedev
- stick-picker-simulator
If you've built anything non-trivial in Godot 4, you've hit the coupling problem: the player needs to tell the fire something, the fire needs to update the UI, the UI needs to know about the day/night clock, and suddenly every node has a hard reference to every other node. The signal bus pattern — sometimes called an event bus — fixes this with a single autoload full of signals that everything talks through. Here's how we used it in Stick Picker Simulator to make sure the fire never references the UI, the UI never references the player, and nothing breaks when we move a node.
The problem: everyone holds everyone
The naive version looks fine at first. The player picks up a stick and feeds the fire, so the player grabs the fire:
# player.gd — the version that will hurt you later
@onready var fire := get_node("../Fire")
@onready var hud := get_node("../UI/HUD")
func _feed() -> void:
fire.add_fuel(carried)
hud.flash_feed_prompt()Two get_node paths and the player now knows the entire scene tree layout. Move the fire under a different parent and the player breaks. Want a second thing that listens when fuel is added? You're editing the player. The fire, the HUD, the day/night system, the save system — in a real game they all end up reaching into each other, and the dependency graph turns into a hairball.
The fix: one autoload, lots of signals
A signal bus is just an autoload singleton whose only job is to declare signals. Nobody owns it; everybody emits to it and listens on it.
# Events.gd — registered as an autoload named "Events"
extends Node
# Player intent → systems
signal feed_requested(amount: int)
# World clock → everything
signal day_started(day: int)
signal night_started(day: int)
# Fire state → UI and anyone else who cares
signal fire_fuel_changed(current: float, max_fuel: float)
signal fire_died
# Save system → every state holder
signal session_snapshot_requested(snapshot: Dictionary)Register it once in Project Settings → Autoload as Events, and now any script in the project can reference Events.feed_requested without a single get_node.
Example 1: the player asks, the fire answers
The player no longer knows the fire exists. It just announces intent:
# player.gd
func _feed() -> void:
Events.feed_requested.emit(carried)
carried = 0The fire listens for that intent and decides what to do with it:
# fire.gd
func _ready() -> void:
Events.feed_requested.connect(_on_feed_requested)
func _on_feed_requested(amount: int) -> void:
_fuel = min(_fuel + amount, MAX_FUEL)
Events.fire_fuel_changed.emit(_fuel, MAX_FUEL)And the HUD listens for the result, never touching the fire or the player:
# hud.gd
func _ready() -> void:
Events.fire_fuel_changed.connect(_on_fuel_changed)
func _on_fuel_changed(current: float, max_fuel: float) -> void:
fuel_bar.value = current / max_fuelThree scripts, zero direct references between them. You could delete the HUD entirely and the fire wouldn't notice.
Example 2: one event, many listeners
This is where the pattern earns its keep. When the world clock ticks over to a new day, a dozen systems need to react — and the day/night node doesn't want to know about any of them.
# daynight.gd
func _advance_day() -> void:
_day += 1
Events.day_started.emit(_day)# fire.gd — drain ramps up each day
func _on_day_started(day: int) -> void:
_daily_drain = Balance.BASE_DRAIN + Balance.DAILY_INCREMENT * (day - 1)
# spawner.gd — regenerate the deterministic stick field
func _on_day_started(day: int) -> void:
_reseed(GameState.run_seed + day)
# ui.gd — show the day counter
func _on_day_started(day: int) -> void:
day_label.text = "Day %d" % dayAdding a new system that reacts to dawn means writing one connect line in that system. You never touch daynight.gd again. That's the whole pitch: new listeners are additive, not invasive.
Example 3: pulling state without coupling (the save system)
Saving is the trickiest case, because the save system needs data from everyone. Instead of importing every state holder, it broadcasts a mutable dictionary and lets each system fill in its own slice:
# game_state.gd (save system)
func snapshot_session() -> Dictionary:
var snapshot := {}
Events.session_snapshot_requested.emit(snapshot)
return snapshot# fire.gd
func _on_session_snapshot_requested(snapshot: Dictionary) -> void:
snapshot["fire_fuel"] = _fuel
# player.gd
func _on_session_snapshot_requested(snapshot: Dictionary) -> void:
snapshot["player_pos"] = global_positionThe save system never imports the fire or the player. It asks the room "everyone, write down your state," and the dictionary comes back full. (Signal callbacks run synchronously in emit order, so by the time emit returns, the dictionary is complete — which is exactly what you want here.)
What it costs (the honest part)
The signal bus is not free, and anyone who tells you otherwise hasn't shipped with one. The real trade-offs:
- Discoverability drops. "Who actually handles
feed_requested?" has no answer from the call site — you have to grep. We mitigate this by keeping every signal in one file with a comment grouping, soEvents.gddoubles as a map of the whole game's communication. - No compile-time safety on connections. Misspell a handler or change a signal's arguments and you find out at runtime. Static typing on the signal parameters helps; tests on the critical buses help more.
- Ordering can bite you. If two systems must react to the same signal in a specific order, the bus won't guarantee it. When order matters, that's a smell — usually it means one of those reactions should be a fact emitted by the other, not a second listener on the same intent.
- It's easy to over-bus. Not everything deserves a global signal. A node talking to its own child should just call the child. We reserve the bus for cross-system communication.
Why it was worth it
The payoff shows up when you refactor. We've moved the fire around the scene tree, rebuilt the HUD twice, and bolted on systems the original design never anticipated — and none of it required touching the systems those changes talked to, because they were never talking directly in the first place. The fire emits that its fuel changed; whoever cares, cares. That decoupling is the same design instinct behind the whole game: systems that reinforce one idea without stepping on each other.
If you're building anything with more than a couple of interacting systems in Godot 4, a signal bus is the cheapest architecture decision you can make early and the most expensive one to retrofit late. Start the autoload now.
Related posts
No Combat in a Roguelike: How We Made Cold and Dark the Only Enemies
Stick Picker Simulator is a no-combat roguelike — no swords, no monsters. The cold, the dark, and a dying fire turned out to be more oppressive than any enemy, and here is how the design holds together.
- no-combat-roguelike
- roguelike-design
- game-design
- stick-picker-simulator
Why Our Game Is Called Stick Picker Simulator (and Why That Was the Right Call)
Stick Picker Simulator sounds like a joke. That is sort of the point — and naming it that way turned out to be the best decision we made.
- indie-marketing
- game-naming
- stick-picker-simulator
- indie-dev
The Best Roguelikes With No Combat (2026)
Looking for a no-combat roguelike? Here are the best roguelikes with no fighting in 2026 — from poker and slot machines to a survival game where the only enemy is a dying fire.
- no-combat-roguelike
- roguelike-design
- best-of
- stick-picker-simulator