| name | godot-signal-architecture |
| description | Expert blueprint for signal-driven architecture using "Signal Up, Call Down" pattern for loose coupling. Covers typed signals, signal chains, one-shot connections, and AutoLoad event buses. Use when implementing event systems OR decoupling nodes. Keywords signal, emit, connect, CONNECT_ONE_SHOT, CONNECT_REFERENCE_COUNTED, event bus, AutoLoad, decoupling. |
Signal Architecture
Signal Up/Call Down pattern, typed signals, and event buses define decoupled, maintainable architectures.
Available Scripts
Clean implementation of decoupled hierarchy communication: children signal up, parents call down.
Expert AutoLoad event bus for system-level event routing (Achievements, UI, Saving).
Injecting extra static context into signal callbacks using Callable.bind().
Cleaning up function signatures by discarding unneeded signal arguments with unbind().
Replacing messy timers and state flags with linear, readable await signal sequences.
Verifying connection state using is_connected() to prevent runtime multi-connection errors.
Crucial memory management pattern for disconnecting signals when switching tracking targets.
Using CONNECT_ONE_SHOT and CONNECT_DEFERRED for self-cleaning and physics-safe callbacks.
Identifying which node fired a shared signal using CONNECT_APPEND_SOURCE_OBJECT.
Managing multi-step asynchronous loading and transitions using sequential signal awaits.
NEVER Do in Signal Architecture
- NEVER use the legacy string-based
Object.connect() — Typos result in silent failures. Always use signal.connect(_callback) for compile-time validation [1].
- NEVER use signals to dictate behavior top-down — Signals are past-tense events (e.g., "died"). Use direct method calls for commands (e.g., "kill") [2].
- NEVER connect a signal twice to the same Callable — This throws an
ERR_INVALID_PARAMETER at runtime unless using CONNECT_REFERENCE_COUNTED [3].
- NEVER use a Global Signal Bus for local data — Pollutes global state and makes debugging harder. Use local connections for scene-specific logic [4].
- NEVER assume callbacks must accept all signal arguments — Use
unbind() to drop unwanted parameters and keep your API clean [5].
- NEVER create circular signal dependencies — A signals B, B signals back to A? Use a mediator (parent or AutoLoad) to break the loop [26].
- NEVER skip signal typing —
signal moved without types lacks editor support. Always use signal moved(dir: Vector2) [27].
- NEVER forget to disconnect dynamic signals — Ghost connections cause "call on null instance" errors. Disconnect in
_exit_tree() or use bind_node() [28].
- NEVER emit signals with immediate side effects on the emitter — If
died.emit() calls queue_free(), listeners might fail to respond. Emit first [31].
- NEVER use signals for high-frequency data streams — Sending 1000+ signals/second (like per-particle updates) is inefficient. Use shared arrays or direct buffers.
Use Signals For:
- UI button presses → game logic
- Player death → game over screen
- Item collected → inventory update
- Enemy killed → score update
- Cross-scene communication via AutoLoad
Use Direct Calls For:
- Parent controlling child behavior
- Accessing child properties
- Simple, local interactions
Implementation Patterns
Pattern 1: Define Typed Signals
extends CharacterBody2D
# ✅ Good - typed signals (Godot 4.x)
signal health_changed(new_health: int, max_health: int)
signal died()
signal item_collected(item_name: String, item_type: int)
# ❌ Bad - untyped signals
signal health_changed
signal died
Pattern 2: Emit Signals on State Changes
# player.gd
extends CharacterBody2D
signal health_changed(current: int, maximum: int)
signal died()
var health: int = 100:
set(value):
health = clamp(value, 0, max_health)
health_changed.emit(health, max_health)
if health <= 0:
died.emit()
var max_health: int = 100
func take_damage(amount: int) -> void:
health -= amount # Triggers setter, which emits signal
Pattern 3: Connect Signals in Parent
# game.gd (parent)
extends Node2D
@onready var player: CharacterBody2D = $Player
@onready var ui: Control = $UI
func _ready() -> void:
# Connect child signals
player.health_changed.connect(_on_player_health_changed)
player.died.connect(_on_player_died)
func _on_player_health_changed(current: int, maximum: int) -> void:
# Call down to UI
ui.update_health_bar(current, maximum)
func _on_player_died() -> void:
# Orchestrate game over
ui.show_game_over()
get_tree().paused = true
Pattern 4: Global Signals via AutoLoad
For cross-scene communication:
# events.gd (AutoLoad)
extends Node
signal level_completed(level_number: int)
signal player_spawned(player: Node2D)
signal boss_defeated(boss_name: String)
# Any script can emit:
Events.level_completed.emit(3)
# Any script can listen:
Events.level_completed.connect(_on_level_completed)
Advanced Patterns
Pattern 5: Signal Chains
# enemy.gd
signal died(score_value: int)
func _on_health_depleted() -> void:
died.emit(100)
queue_free()
# combat_manager.gd
func _ready() -> void:
for enemy in get_tree().get_nodes_in_group("enemies"):
enemy.died.connect(_on_enemy_died)
func _on_enemy_died(score_value: int) -> void:
GameManager.add_score(score_value)
Events.enemy_killed.emit()
Pattern 6: One-Shot Connections
For single-use signal connections:
# Connect with CONNECT_ONE_SHOT flag
timer.timeout.connect(_on_timer_timeout, CONNECT_ONE_SHOT)
func _on_timer_timeout() -> void:
print("This only fires once")
# Connection automatically removed
Pattern 7: Custom Signal Arguments
# item.gd
signal picked_up(item_data: Dictionary)
func _on_player_enter() -> void:
picked_up.emit({
"name": item_name,
"type": item_type,
"value": item_value,
"icon": item_icon
})
# inventory.gd
func _on_item_picked_up(item_data: Dictionary) -> void:
add_item(
item_data.name,
item_data.type,
item_data.value
)
Best Practices
1. Descriptive Signal Names
# ✅ Good
signal button_pressed()
signal enemy_defeated(enemy_type: String)
signal animation_finished(animation_name: String)
# ❌ Bad
signal pressed()
signal done()
signal finished()
2. Avoid Circular Dependencies
# ❌ BAD: A signals to B, B signals back to A
# A.gd
signal data_requested
func _ready():
B.data_ready.connect(_on_data_ready)
data_requested.emit()
# B.gd
signal data_ready
func _ready():
A.data_requested.connect(_on_data_requested)
# ✅ GOOD: Use a mediator (parent or AutoLoad)
# Parent.gd
func _ready():
A.data_requested.connect(_on_A_data_requested)
B.data_ready.connect(_on_B_data_ready)
3. Disconnect Signals When Nodes Are Freed
func _ready() -> void:
player.died.connect(_on_player_died)
func _exit_tree() -> void:
if player and player.died.is_connected(_on_player_died):
player.died.disconnect(_on_player_died)
Or use automatic cleanup:
# Signal auto-disconnects when this node is freed
player.died.connect(_on_player_died, CONNECT_REFERENCE_COUNTED)
4. Group Related Signals
# ✅ Good organization
# Combat signals
signal health_changed(current: int, max: int)
signal died()
signal respawned()
# Movement signals
signal jumped()
signal landed()
signal direction_changed(direction: Vector2)
# Inventory signals
signal item_added(item: Dictionary)
signal item_removed(item: Dictionary)
signal inventory_full()
Testing Signals
func test_health_signal() -> void:
var signal_emitted := false
var received_health := 0
player.health_changed.connect(
func(current: int, _max: int):
signal_emitted = true
received_health = current
)
player.health = 50
assert(signal_emitted, "Signal was not emitted")
assert(received_health == 50, "Health value incorrect")
Common Gotchas
Issue: Signal not firing
- Check: Is the signal spelled correctly when connecting?
- Check: Is the emitting code path actually being executed?
- Check: Use
print() before emit() to verify
Issue: Signal firing multiple times
- Cause: Multiple connections to the same signal
- Solution: Check connections or use
CONNECT_ONE_SHOT
Issue: "Attempt to call function on a null instance"
- Cause: Node was freed but signal still connected
- Solution: Disconnect in
_exit_tree() or use CONNECT_REFERENCE_COUNTED
Reference
Related