
Godot Signal Architecture
- 267 installs
- 454 repo stars
- Updated July 28, 2026
- thedivergentai/gd-agentic-skills
Use godot-signal-architecture for development tasks
About
godot-signal-architecture: A skill for development. This provides functionality for development workflows.
- godot-signal-architecture
Godot Signal Architecture by the numbers
- 267 all-time installs (skills.sh)
- +26 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #1,453 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/thedivergentai/gd-agentic-skills --skill godot-signal-architectureAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 267 |
|---|---|
| repo stars | ★ 454 |
| Last updated | July 28, 2026 |
| Repository | thedivergentai/gd-agentic-skills ↗ |
What it does
Use godot-signal-architecture for development tasks
Files
Signal Architecture
Signal Up/Call Down pattern, typed signals, and event buses define decoupled, maintainable architectures.
Available Scripts
signal_up_call_down_pattern.gd
Clean implementation of decoupled hierarchy communication: children signal up, parents call down.
global_signal_bus_router.gd
Expert AutoLoad event bus for system-level event routing (Achievements, UI, Saving).
callable_bind_context.gd
Injecting extra static context into signal callbacks using Callable.bind().
unbind_unwanted_args.gd
Cleaning up function signatures by discarding unneeded signal arguments with unbind().
await_signal_sequencing.gd
Replacing messy timers and state flags with linear, readable await signal sequences.
safe_dynamic_connections.gd
Verifying connection state using is_connected() to prevent runtime multi-connection errors.
disconnect_ghost_signals.gd
Crucial memory management pattern for disconnecting signals when switching tracking targets.
one_shot_deferred_connections.gd
Using CONNECT_ONE_SHOT and CONNECT_DEFERRED for self-cleaning and physics-safe callbacks.
track_signal_emitter_source.gd
Identifying which node fired a shared signal using CONNECT_APPEND_SOURCE_OBJECT.
complex_signal_sequencer.gd
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_PARAMETERat runtime unless using theObject.CONNECT_REFERENCE_COUNTEDflag to stack connections [3, 4]. - 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 movedwithout types lacks editor support. Always usesignal moved(dir: Vector2)[27]. - NEVER forget to disconnect dynamic signals — Ghost connections cause "call on null instance" errors. Disconnect in
_exit_tree()or usebind_node()[28]. - NEVER emit signals with immediate side effects on the emitter — If
died.emit()callsqueue_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 diedPattern 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 signalPattern 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 = truePattern 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 removedPattern 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
Godot automatically disconnects signals when a node or object is freed [6]. However, there is a CRITICAL EXCEPTION:
- Capturing Lambdas: If a lambda captures a local variable (e.g.,
func(): print(x)), Godot cannot automatically disconnect it. You MUST manually disconnect it in_exit_tree()or a cleanup method to prevent crashes [8, 9].
var my_lambda: Callable
func _ready() -> void:
var x = 10
my_lambda = func(): print(x) # Capturing lambda
player.died.connect(my_lambda)
func _exit_tree() -> void: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
# await_signal_sequencing.gd
# Replacing timers and flag-based logic with awaits
extends Node
func start_boss_intro():
print("Boss Roar!")
# Create and await an inline timer
await get_tree().create_timer(2.0).timeout
$Boss/AnimationPlayer.play("camera_pan")
# Wait for animation to finish signal
await $Boss/AnimationPlayer.animation_finished
print("Battle Start!")
$UI/BossBar.show()
# callable_bind_context.gd
# Passing extra static data to signal callbacks
extends Node
func _ready():
# Signal emits: (hit_by, level)
# We bind: (weapon, damage)
# Resulting callback receives: (hit_by, level, weapon, damage)
$Player.hit.connect(_on_hit.bind("Legendary Sword", 50))
func _on_hit(hit_by: String, level: int, weapon: String, damage: int):
print("Hit by %s (Lvl %d) with %s for %d" % [hit_by, level, weapon, damage])
# complex_signal_sequencer.gd
# Managing multi-step async sequences using signals
extends Node
func run_intro():
# Chain of awaits for a clean linear flow
await $Fade.fade_out()
await get_tree().process_frame # Ensure UI is updated
$LevelLoader.load_map("Level1")
await $LevelLoader.finished
await $Fade.fade_in()
$Player.enable()
# disconnect_ghost_signals.gd
# Memory management when switching tracking targets
extends Node
var current_target: Node
func track_new_entity(entity: Node):
# Cleanup old connection to prevent memory/logic leaks
if current_target and current_target.died.is_connected(_on_target_died):
current_target.died.disconnect(_on_target_died)
current_target = entity
current_target.died.connect(_on_target_died)
func _on_target_died():
print("Current target lost.")
# skills/signal-architecture/code/global_event_bus.gd
extends Node
## Signal Architecture Expert Pattern
## Implements a Namespaced Global Event Bus.
# 1. Namespaced Signal Groups
# Professional pattern: Group signals by domain (UI, Game, System).
class UI_Signals:
signal menu_opened(menu_name: String)
signal button_clicked(id: String)
signal alert_triggered(text: String)
class Game_Signals:
signal entity_spawned(entity: Node)
signal player_died(position: Vector3)
signal world_reset_started
# Instantiate the groups
var UI = UI_Signals.new()
var Game = Game_Signals.new()
# 2. Cross-Frame Stability (CONNECT_DEFERRED)
# Expert logic: Use deferred connections for dangerous scene changes.
func trigger_safe_world_reset() -> void:
# Logic: If this signal triggers a scene load, deferred ensures
# it happens after the current frame processing completes.
Game.world_reset_started.emit()
func connect_heavy_listener(target: Object, method: String) -> void:
# Pattern for connecting transient or complex listeners safely.
Game.entity_spawned.connect(Callable(target, method), CONNECT_DEFERRED)
## EXPERT NOTE:
## Use 'Signal Up, Call Down': Nodes should NEVER 'get_parent()'.
## They emit signals that parents (or the Event Bus) listen to.
## For 'signal-architecture', use 'Typed Signal Parameters':
## 'signal damaged(amount: int, source: Node)' to eliminate
## runtime type bugs and improve IDE autocomplete.
## Use 'Ref-Counted Connections': Transient objects should
## disconnect in their '_exit_tree()' to prevent memory leaks
## and 'Object is freed' errors.
## NEVER allow UI nodes to modify Game State directly; they
## should emit a signal, and a Controller should handle the logic.
# global_signal_bus_router.gd
# Centralized event routing without shared gameplay state
extends Node
# EXPERT NOTE: Use a dedicated Autoload (EventBus) for broad events
# like achievements or global UI updates. Avoid for local scene logic.
# global_events.gd (Autoload)
signal player_leveled_up(new_level: int)
signal achievement_unlocked(id: String)
signal game_saved()
func notify_level_up(level: int):
player_leveled_up.emit(level)
# one_shot_deferred_connections.gd
# Specialized connection flags for physics and cleanup
extends Node
# EXPERT NOTE:
# CONNECT_ONE_SHOT: Disconnects automatically after firing.
# CONNECT_DEFERRED: Runs at the end of the frame (safe for physics changes).
func _ready():
# Executes once, then cleans up. Deferred ensures physics space is unlocked.
$DeathTrigger.body_entered.connect(_on_death, CONNECT_ONE_SHOT | CONNECT_DEFERRED)
func _on_death(_body):
get_tree().reload_current_scene()
# safe_dynamic_connections.gd
# Verifying connection state to prevent runtime errors
extends Node
func connect_sensor(sensor: Node):
var callback = _on_sensor_triggered
# Connecting a connected signal is a runtime error.
if not sensor.triggered.is_connected(callback):
sensor.triggered.connect(callback)
func _on_sensor_triggered():
print("Sensor activity detected.")
# skills/signal-architecture/scripts/signal_debugger.gd
@tool
extends EditorScript
## Signal Debugger Expert Pattern
## Runtime signal connection analyzer and debugger.
func _run() -> void:
print("=== Signal Connection Analyzer ===")
var root := EditorInterface.get_edited_scene_root()
if not root:
printerr("No scene loaded in editor")
return
_analyze_node_signals(root, 0)
func _analyze_node_signals(node: Node, depth: int) -> void:
var indent := " ".repeat(depth)
var signals_list := node.get_signal_list()
if signals_list.size() > 0:
print("\n%s📡 %s (%s)" % [indent, node.name, node.get_class()])
for sig_info in signals_list:
var sig_name: String = sig_info["name"]
var connections := node.get_signal_connection_list(sig_name)
if connections.size() > 0:
print("%s ✓ %s → %d connection(s)" % [indent, sig_name, connections.size()])
for conn_info in connections:
var target: Object = conn_info["callable"].get_object()
var method: String = conn_info["callable"].get_method()
var flags: int = conn_info["flags"]
var flag_names: Array[String] = []
if flags & CONNECT_ONE_SHOT:
flag_names.append("ONE_SHOT")
if flags & CONNECT_REFERENCE_COUNTED:
flag_names.append("REF_COUNTED")
if flags & CONNECT_DEFERRED:
flag_names.append("DEFERRED")
var flag_str := " [%s]" % ",".join(flag_names) if flag_names.size() > 0 else ""
print("%s → %s.%s()%s" % [indent, target if target else "?", method, flag_str])
else:
# Unused signal
print("%s ○ %s (not connected)" % [indent, sig_name])
for child in node.get_children():
_analyze_node_signals(child, depth + 1)
## EXPERT NOTE:
## This shows ALL signal connections in a scene at edit time.
## For runtime debugging, use signal_spy observer pattern.
## CRITICAL: Unused signals = code smell, remove or document why not connected.
# skills/signal-architecture/scripts/signal_spy.gd
extends Node
## Signal Spy Expert Pattern
## Runtime signal observer for debugging and testing.
class_name SignalSpy
var _observed_signals: Dictionary = {}
func observe(target: Object, signal_name: String) -> void:
if not target.has_signal(signal_name):
push_error("Signal '%s' not found on %s" % [signal_name, target])
return
var key := "%s::%s" % [target.get_instance_id(), signal_name]
if key in _observed_signals:
return # Already observing
_observed_signals[key] = {
"target": target,
"signal_name": signal_name,
"emit_count": 0,
"last_args": [],
"history": []
}
target.get(signal_name).connect(
func(args = []): _on_signal_emitted(key, args),
CONNECT_REFERENCE_COUNTED
)
print("[Spy] Now observing: %s.%s" % [target.name if target is Node else target, signal_name])
func _on_signal_emitted(key: String, args) -> void:
var data: Dictionary = _observed_signals[key]
data["emit_count"] += 1
data["last_args"] = args if args is Array else [args]
data["history"].append({
"time": Time.get_ticks_msec(),
"args": data["last_args"]
})
print("[Spy] Signal emitted: %s.%s (count: %d, args: %s)" % [
data["target"].name if data["target"] is Node else data["target"],
data["signal_name"],
data["emit_count"],
str(data["last_args"])
])
func get_emit_count(target: Object, signal_name: String) -> int:
var key := "%s::%s" % [target.get_instance_id(), signal_name]
return _observed_signals.get(key, {}).get("emit_count", 0)
func get_last_args(target: Object, signal_name: String) -> Array:
var key := "%s::%s" % [target.get_instance_id(), signal_name]
return _observed_signals.get(key, {}).get("last_args", [])
func was_emitted(target: Object, signal_name: String) -> bool:
return get_emit_count(target, signal_name) > 0
func reset(target: Object, signal_name: String) -> void:
var key := "%s::%s" % [target.get_instance_id(), signal_name]
if key in _observed_signals:
_observed_signals[key]["emit_count"] = 0
_observed_signals[key]["last_args"] = []
_observed_signals[key]["history"] = []
func print_report() -> void:
print("\n=== Signal Spy Report ===")
for key in _observed_signals:
var data: Dictionary = _observed_signals[key]
print("%s.%s: %d emissions" % [
data["target"].name if data["target"] is Node else "?",
data["signal_name"],
data["emit_count"]
])
## EXPERT NOTE:
## Use this for testing signals without mocking frameworks.
## Example:
## var spy = SignalSpy.new()
## spy.observe(player, "died")
## player.health = 0
## assert(spy.was_emitted(player, "died"))
# signal_up_call_down_pattern.gd
# Decoupling logic by restricting how nodes interact
extends Node
# EXPERT NOTE: "Signal Up" means children emit events.
# "Call Down" means parents call child methods.
# NEVER have a child call get_parent().method().
# --- CHILD SCRIPT Example (Player.gd) ---
# signal action_completed
# --- PARENT SCRIPT (Level.gd) ---
@onready var player = $Player
func _ready():
# Parent calls down to initialize
player.initialize_stats(100)
# Parent listens to child events
player.action_completed.connect(_on_player_action_completed)
func _on_player_action_completed():
print("Player finished task. Level progressing.")
# track_signal_emitter_source.gd
# Identifying which node fired a shared signal
extends Node
func _ready():
# Connect multiple buttons to the same handler
for btn in $Menu/Buttons.get_children():
# Append the emitter itself to the callback arguments
btn.pressed.connect(_on_button_pressed, CONNECT_APPEND_SOURCE_OBJECT)
func _on_button_pressed(source: Button):
print("Clicked: ", source.name)
match source.name:
"Quit": get_tree().quit()
"Start": _start_game()
# unbind_unwanted_args.gd
# Cleaning up function signatures by discarding signal data
extends Node
func _ready():
# area_entered emits 1 argument (the area).
# unbind(1) drops it so we can use a simpler function.
$Area2D.area_entered.connect(_play_chime.unbind(1))
func _play_chime():
# This function doesn't need to know WHICH area entered.
$AudioStreamPlayer.play()