Signals are Godot's calmest decoupling tool — until your project grows and you start asking which node owns what. This is the same question every team hits, and the answer is rarely "one pattern fits everything." Here are the three signal patterns we reach for at ArcadeOn, and when each one earns its keep.
Pattern 1 — direct connection
The default. A node holds a reference to another node and wires its signal in _ready:
func _ready():
$Player.health_changed.connect(_on_player_health_changed)
This is the right call when the listener already owns or instantiates the emitter — a HUD wired to its parent player, a door wired to its trigger area, a save slot button wired to its data model. The dependency is local, the wiring lives next to the nodes it concerns, and Godot's editor inspector can show you the connection without grepping the codebase.
Where it falls down: when the listener and emitter are in different scenes, instantiated at different times, or one outlives the other. Hold a reference to a freed node and the editor will let you, right up until connect() throws at runtime.
Pattern 2 — the event bus autoload
A single autoload script (Events.gd) that declares every cross-cutting signal in the game. Anything that fires it does Events.player_died.emit(); anything that listens does Events.player_died.connect(_on_player_died). No direct references, no lifetime worries.
This is the right call for events that several systems care about — a death event that the UI, audio, achievement and analytics systems all hook. It's also the only sane approach when scenes load and unload independently of each other (level transitions, save/load, in-game pause menus).
The cost is discoverability. A signal in Events.gd doesn't appear in the editor's Node inspector, and you can't right-click "go to definition" on the listener. We mitigate this with a strict convention: every signal in the bus has a doc-comment line above it describing exactly when it fires, and the bus file is the only place those signals are declared. If you can't find it in Events.gd, it doesn't exist as a bus event.
Pattern 3 — group broadcast
Underrated. get_tree().call_group("enemies", "alert_player_seen", player_position) sends a message to every node in a group without anyone holding a reference. The receiving nodes don't need to be the same class — anything in the group with that method will get the call.
This is the right call for fire-and-forget broadcasts to many anonymous recipients — alert all guards, freeze all projectiles, despawn all interactables. It sidesteps both the lifetime issues of direct connection and the discoverability cost of the bus. The trade-off is that the call is fully untyped: call_group won't tell you at compile time that some of the receivers don't actually implement alert_player_seen.
How we choose, in two questions
Before adding a signal connection, we ask:
- Is there exactly one listener, and is that listener guaranteed to live as long as the emitter? Use direct connection.
- Do multiple unrelated systems care about this event? Use the bus.
Group broadcast is the answer when neither question applies cleanly — when the listeners are many, similar, and the emitter doesn't care who's home.
The shape of a Godot codebase falls out of these three patterns more naturally than people expect. The bus stays small (a handful of signals — death, level transition, save/load, settings changed); direct connections handle 80% of the rest; group broadcasts pick up the occasional "tell everyone in this category" call.
One thing not to do
Don't bus everything. We've seen Godot projects with two hundred lines in their Events autoload, every minor state change shouted into the void. The bus stops being a useful navigation aid when it becomes a dumping ground — at that point you've reinvented a global event queue without any of the tooling that makes those usable.
If you're not sure whether something belongs in the bus, the test is: will more than two unrelated scenes ever care about this? If no, keep it local.
Further reading
The official GDScript signals docs are short and worth re-reading once a year — Godot 4 added typed signal parameters in 4.2 which catches a class of bugs you used to find at runtime. The Godot Engine site also keeps a clean changelog for every minor release; signal behaviour got a small but useful upgrade in each of the last three.