Godot 4 Signals Deep Dive: Event-Driven Architecture

Master Godot's signal system for clean, decoupled code. Learn custom signals, signal buses, async/await patterns, and when to use signals vs direct calls.

Godot signals flowing between nodes visualization

Signals are one of Godot's most powerful features. They enable loose coupling, making your code more modular and easier to maintain. If you're coming from Unity events or C# delegates, you'll find signals familiar but even more integrated into the engine.

Built-in Signals

Most Godot nodes come with built-in signals. Buttons have 'pressed', Area2D has 'body_entered', Timer has 'timeout'. You can connect these in the editor or via code.

gdscript
# Connecting signals in code
func _ready():
    # Method 1: Using connect()
    $Button.pressed.connect(_on_button_pressed)

    # Method 2: Using callable with bind() for extra arguments
    $Button.pressed.connect(_on_button_pressed.bind("extra_data"))

    # Method 3: One-shot signal (disconnects after first emit)
    $Timer.timeout.connect(_on_timeout, CONNECT_ONE_SHOT)

func _on_button_pressed(extra = null):
    print("Button pressed! Extra: ", extra)

func _on_timeout():
    print("Timer finished!")
Unity Events
// Define event
public event Action<int> OnDamage;

// Subscribe
player.OnDamage += HandleDamage;

// Invoke
OnDamage?.Invoke(50);
Godot Signals
# Define signal
signal damage_taken(amount)

# Connect
player.damage_taken.connect(handle_damage)

# Emit
damage_taken.emit(50)

Custom Signals

Define your own signals to broadcast game events. Signals can have any number of parameters.

gdscript
# player.gd
extends CharacterBody2D

# Define signals
signal health_changed(new_health, max_health)
signal died
signal coin_collected(amount)

var health: int = 100
var max_health: int = 100

func take_damage(amount: int):
    health -= amount
    health_changed.emit(health, max_health)

    if health <= 0:
        died.emit()

func collect_coin(value: int):
    coin_collected.emit(value)
gdscript
# health_bar.gd - Connects to player signals
extends ProgressBar

@onready var player = get_parent().get_node("Player")

func _ready():
    player.health_changed.connect(_on_health_changed)
    player.died.connect(_on_player_died)

func _on_health_changed(new_health: int, max_health: int):
    max_value = max_health
    value = new_health

func _on_player_died():
    # Show game over screen
    get_tree().change_scene_to_file("res://game_over.tscn")

Signal Bus Pattern (Global Events)

For game-wide events (player died, level completed, settings changed), use a signal bus autoload. This prevents tight coupling between distant nodes.

gdscript
# event_bus.gd - Add as Autoload named "Events"
extends Node

# Player events
signal player_died
signal player_respawned
signal health_changed(current, maximum)

# Game events
signal level_completed(level_id)
signal game_paused
signal game_resumed

# UI events
signal show_dialog(text)
signal achievement_unlocked(achievement_id)
gdscript
# Any script can emit global signals
func die():
    Events.player_died.emit()

# Any script can listen to global signals
func _ready():
    Events.player_died.connect(_on_player_died)
    Events.level_completed.connect(_on_level_completed)

func _on_player_died():
    play_death_sound()
    shake_camera()

func _on_level_completed(level_id):
    save_progress()

Async/Await with Signals

Godot 4 lets you await signals, making sequential async operations clean and readable.

gdscript
# Wait for animation to finish
func attack():
    $AnimationPlayer.play("attack")
    await $AnimationPlayer.animation_finished
    print("Attack animation done!")

# Wait for timer
func delayed_spawn():
    await get_tree().create_timer(2.0).timeout
    spawn_enemy()

# Wait for custom signal
func dialog_sequence():
    show_dialog("Hello!")
    await Events.dialog_closed
    show_dialog("How are you?")
    await Events.dialog_closed
    print("Dialog sequence complete")

Signals vs Direct Calls

Use signals when the sender shouldn't know (or care) who receives the message. Use direct method calls when you have a specific target.

  • Use Signals: Player health changed (UI, sound, achievements all care)
  • Use Signals: Enemy died (spawn system, score, particles)
  • Use Direct Call: Player tells weapon to fire (1-to-1 relationship)
  • Use Direct Call: Getting a value from another node

Best Practices

  • Name signals in past tense (health_changed, died, collected)
  • Disconnect signals when nodes are freed to prevent errors
  • Use signal buses sparingly—not everything needs to be global
  • Document signal parameters with comments or typed hints
  • Prefer signals over checking conditions every frame

Next Steps

Signals are the foundation of decoupled architecture in Godot. Practice by refactoring existing code to use signals where appropriate. Try our interactive Signal Lab in the Learning Hub to see signals flow in real-time!

👤
Godot Learning Team Helping developers transition to Godot with practical tutorials and comparisons.