Dev Blog5 min read

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 = 0

The 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_fuel

Three 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" % day

Adding 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_position

The 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, so Events.gd doubles 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

Dev Blog3 min read

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
Read post
Genre3 min read

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
Read post

← Back to all posts