One of the most elegant features in Godot is its signal system — a first-class implementation of the Observer design pattern baked directly into the engine. If you've wrestled with tangled node references, fragile dependencies between scenes, or code that breaks every time you rename something, signals are the answer.
In this article we'll unpack why the Observer pattern matters, how Godot's signal system maps onto it, and when you should reach for it versus other communication methods.
The Observer Pattern in Plain English
The Observer pattern is a behavioral design pattern that defines a one-to-many dependency: when one object changes state, all its dependents are notified automatically. There are two roles:
- Subject (Publisher) — the object that emits events.
- Observer (Subscriber) — objects that react to those events.
The key insight is that the Subject has no knowledge of who is observing it. It just announces: "something happened". Observers decide what to do with that information. This inversion of dependency is what makes the pattern so powerful for keeping code decoupled.
Don't call us, we'll call you. The Subject fires an event into the void; whoever cares will listen.
Godot's Take: Signals as Built-in Observers
Godot doesn't make you implement the Observer pattern from scratch. Signals are the Observer pattern, natively supported by every Object subclass in the engine. Every Node you'll ever use can both define signals and connect to signals from other nodes.
In Godot, emitting a signal is equivalent to calling all currently connected callables in sequence. Under the hood it's a registered list of callbacks — exactly the observer list from the classic pattern.
Declaring a Signal
You declare a signal at the top of a script with the signal keyword. You can emit it with no arguments, or define a typed signature for the data it carries.
# player.gd
extends CharacterBody2D
# Simple signal — no payload
signal died
# Signal with typed parameters
signal health_changed(new_health: int, max_health: int)
signal item_collected(item_name: String)
var health: int = 100
var max_health: int = 100
func take_damage(amount: int) -> void:
health = clamp(health - amount, 0, max_health)
health_changed.emit(health, max_health)
if health == 0:
died.emit()
Notice that the Player has no reference to a HUD, a GameManager, or anything else. It simply announces state changes. This is the core philosophy.
Connecting a Signal
There are two primary ways to connect signals: in the editor via the Node panel, or in code using .connect().
# hud.gd — the observer
extends CanvasLayer
@onready var health_bar: ProgressBar = $HealthBar
@onready var player: Player = $"/root/Game/Player"
func _ready() -> void:
# Connect in code — clean and explicit
player.health_changed.connect(_on_health_changed)
player.died.connect(_on_player_died)
func _on_health_changed(new_health: int, max_health: int) -> void:
health_bar.value = float(new_health) / max_health * 100.0
func _on_player_died() -> void:
show_game_over_screen()
The HUD listens for events and responds independently. The Player and HUD can both change their internals without ever breaking each other, as long as the signal contract (name + parameters) stays the same.
Player emits one signal → multiple observers react independently
Lambdas & Callables
In Godot 4, signals connect to Callables — a first-class type representing any function reference, including lambdas. This unlocks concise inline handlers:
# Connect with a lambda — no need for a named method
player.died.connect(func():
get_tree().create_timer(2.0).timeout.connect(
func(): get_tree().reload_current_scene()
)
)
# Bind extra arguments with .bind()
var enemy_id: int = 42
enemy.died.connect(_on_enemy_died.bind(enemy_id))
func _on_enemy_died(id: int) -> void:
print("Enemy %d eliminated" % id)
The .bind() method is especially handy when you're connecting many nodes of the same type (e.g., a grid of buttons) and need to know which one triggered the signal without creating a unique handler for each.
Connection Flags
connect() accepts an optional flags parameter that controls connection behavior:
# One-shot: automatically disconnects after firing once
some_signal.connect(my_callback, CONNECT_ONE_SHOT)
# Deferred: callback runs at the end of the current frame
# Useful to avoid modifying the scene tree during physics
some_signal.connect(my_callback, CONNECT_DEFERRED)
# Reference-counted: won't keep the target alive
some_signal.connect(my_callback, CONNECT_REFERENCE_COUNTED)
Calling connect() on a signal that's already connected to the same callable will cause the handler to fire twice per emit. Always check is_connected() first, or use CONNECT_ONE_SHOT / editor connections to avoid duplicates.
Built-in Signals vs. Custom Signals
Godot's built-in nodes ship with many pre-declared signals you're probably already using — Button.pressed, Timer.timeout, Area2D.body_entered. Custom signals simply extend this model to your own game logic.
| Type | Example | Best For |
|---|---|---|
| Built-in | Timer.timeout, Button.pressed |
Engine lifecycle events; UI interactions |
| Custom (no args) | signal died |
Simple state change notifications |
| Custom (typed args) | signal health_changed(new: int, max: int) |
Carrying data along with the event |
| Autoload signal bus | EventBus.level_completed |
Global cross-scene communication |
The Signal Bus Pattern
For large projects, directly connecting nodes can still create implicit dependencies — the HUD needs a reference to the Player, the AudioManager needs the Enemy, and so on. The Signal Bus (or Event Bus) pattern solves this with a singleton autoload that owns all global signals.
# event_bus.gd — add as an Autoload named "EventBus"
extends Node
signal player_died
signal player_health_changed(new_health: int, max_health: int)
signal level_completed(level_index: int, time_taken: float)
signal item_picked_up(item: ItemData)
# player.gd — emit through the bus
func take_damage(amount: int) -> void:
health -= amount
EventBus.player_health_changed.emit(health, max_health)
if health <= 0:
EventBus.player_died.emit()
# hud.gd — subscribe without knowing about Player at all
func _ready() -> void:
EventBus.player_health_changed.connect(_on_health_changed)
EventBus.player_died.connect(_on_player_died)
Now HUD, AudioManager, SaveSystem, and AchievementSystem can all react to player events without any of them holding a reference to the Player node. Scenes become self-contained units.
The Signal Bus is best for truly global events — level transitions, game-state changes, analytics hooks. For local parent-child communication, a direct signal connection or even a direct method call is often cleaner.
Signals vs. Direct Calls vs. Groups
Signals are not always the right tool. Here's a quick heuristic for choosing your communication method:
| Method | Use When | Watch Out For |
|---|---|---|
Direct call$Child.do_thing() |
Parent controlling a child it owns | Creates tight coupling upward |
| Signal | Child notifying parent/siblings of events | Can obscure data flow if overused |
Groupscall_group() |
Broadcasting to many unrelated nodes | No type safety, harder to trace |
| Autoload / Service | Shared global state or utilities | Can become a god object |
A good rule of thumb: data flows down (parent calls children directly), events flow up (children signal parents). Signals across unrelated branches benefit from an Event Bus.
C# Equivalent
If you're using C# in Godot 4, signals map onto C# events with the [Signal] attribute. The semantics are identical, just with C# syntax.
// Player.cs
using Godot;
public partial class Player : CharacterBody2D
{
[Signal]
public delegate void DiedEventHandler();
[Signal]
public delegate void HealthChangedEventHandler(int newHealth, int maxHealth);
private int _health = 100;
public void TakeDamage(int amount)
{
_health = Mathf.Clamp(_health - amount, 0, 100);
EmitSignal(SignalName.HealthChanged, _health, 100);
if (_health == 0) EmitSignal(SignalName.Died);
}
}
Cleaning Up Connections
Godot automatically cleans up signal connections when a node is freed — so in most cases you don't need to manually disconnect. However, there are edge cases: lambdas referencing freed objects can cause crashes if CONNECT_DEFERRED fires after the target is gone. Use is_instance_valid() as a guard, or use disconnect() explicitly in _exit_tree().
func _exit_tree() -> void:
if player.is_connected("health_changed", _on_health_changed):
player.health_changed.disconnect(_on_health_changed)