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 Up/Call Down pattern, typed signals, and event buses define decoupled, maintainable architectures.
Expert AutoLoad event bus with typed signals and connection management.
Runtime signal connection analyzer. Shows all connections in scene hierarchy.
Testing utility for observing signal emissions with count tracking and history.
MANDATORY - For Event Bus: Read global_event_bus.gd before implementing cross-scene communication.
signal moved without types? No autocomplete OR type safety. Use signal moved(direction: Vector2) for editor support._exit_tree() OR use CONNECT_REFERENCE_COUNTED.await pattern.child.method(). Reserve signals for child→parent communication.died.emit() calls queue_free() inside? Listeners can't respond before node freed. Emit FIRST, then cleanup.connect("heath_chnaged", ...) typo = silent failure. Use direct reference: player.health_changed.connect(...).Use Signals For:
Use Direct Calls For:
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
# 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
# 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
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)
# 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()
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
# 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
)
# ✅ Good
signal button_pressed()
signal enemy_defeated(enemy_type: String)
signal animation_finished(animation_name: String)
# ❌ Bad
signal pressed()
signal done()
signal finished()
# ❌ 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)
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)
# ✅ 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()
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")
Issue: Signal not firing
print() before emit() to verifyIssue: Signal firing multiple times
CONNECT_ONE_SHOTIssue: "Attempt to call function on a null instance"
_exit_tree() or use CONNECT_REFERENCE_COUNTED