godot-signal-architecture
SKILL.md
Signal Architecture
Signal Up/Call Down pattern, typed signals, and event buses define decoupled, maintainable architectures.
Available Scripts
global_event_bus.gd
Expert AutoLoad event bus with typed signals and connection management.
signal_debugger.gd
Runtime signal connection analyzer. Shows all connections in scene hierarchy.
signal_spy.gd
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.
NEVER Do in Signal Architecture
- NEVER create circular signal dependencies — A signals to B, B signals back to A? Infinite loops + stack overflow. Use mediator (parent OR AutoLoad) to break cycle.
- NEVER skip signal typing —
signal movedwithout types? No autocomplete OR type safety. Usesignal moved(direction: Vector2)for editor support. - NEVER forget to disconnect signals — Node freed but signal still connected? "Attempt to call on null instance" error. Disconnect in
_exit_tree()OR useCONNECT_REFERENCE_COUNTED. - NEVER connect signals in _ready() for dynamic nodes — Enemy spawned after level load? Signals not connected. Connect when instantiating OR use groups +
awaitpattern. - NEVER use signals for parent→child — Parent signaling to child breaks encapsulation. CALL DOWN directly:
child.method(). Reserve signals for child→parent communication. - NEVER emit signals with side effects —
died.emit()callsqueue_free()inside? Listeners can't respond before node freed. Emit FIRST, then cleanup. - NEVER use string-based signal names —
connect("heath_chnaged", ...)typo = silent failure. Use direct reference:player.health_changed.connect(...).
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()beforeemit()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 useCONNECT_REFERENCE_COUNTED
Reference
Related
- Master Skill: godot-master
Weekly Installs
56
Repository
thedivergentai/…c-skillsGitHub Stars
35
First Seen
Feb 10, 2026
Security Audits
Installed on
gemini-cli55
codex54
opencode52
kimi-cli51
amp51
github-copilot51