All Scrolls

Godot 4 Signals Tutorial: The Complete Guide

Coding Quests/February 25, 2026/Tutorials

Godot 4 Signals Tutorial: The Complete Guide

Signals are Godot's built-in event system. They let nodes communicate without knowing about each other. A health bar updates when the player takes damage. A door opens when a key is collected. An enemy dies and the score increases. All of these use signals.

If you've used events in JavaScript, delegates in C#, or the observer pattern in any language, signals are the same concept with Godot-specific syntax.

What Is a Signal?

A signal is a message that a node broadcasts. Other nodes can listen for that message and react to it. The node sending the signal doesn't know or care who's listening. This decoupling is the entire point.

Without signals, you'd write code like this:

GDScript
# Player.gd - tightly coupled
func take_damage(amount: int) -> void:
health -= amount
# Player has to know about ALL these systems
$"../UI/HealthBar".value = health
$"../AudioManager".play("hit_sound")
$"../CameraShake".shake(0.3)
if health <= 0:
$"../GameManager".player_died()
$"../UI/DeathScreen".show()

The player script now depends on the exact scene tree structure, the UI layout, the audio system, and the game manager. Move any of those nodes and the game crashes.

With signals:

GDScript
# Player.gd - decoupled
signal health_changed(new_health: int)
signal died
func take_damage(amount: int) -> void:
health -= amount
health_changed.emit(health)
if health <= 0:
died.emit()

The player just announces what happened. The health bar, audio manager, camera, and game manager each independently connect to those signals and react on their own terms.

Built-in Signals

Every Godot node comes with built-in signals. You'll use these constantly:

GDScript
# Timer
$Timer.timeout.connect(_on_timer_finished)
# Area3D / Area2D
$DetectionZone.body_entered.connect(_on_body_entered)
$DetectionZone.body_exited.connect(_on_body_exited)
# Button (UI)
$Button.pressed.connect(_on_button_pressed)
# AnimationPlayer
$AnimationPlayer.animation_finished.connect(_on_animation_done)
# CharacterBody collision
# (not a signal - use move_and_slide() return + get_last_slide_collision())
# Node lifecycle
tree_entered.connect(_on_added_to_tree)
tree_exited.connect(_on_removed_from_tree)
ready.connect(_on_ready)

You can connect these in the Editor (Node tab, Signals section) or in code. Editor connections are visible in the Inspector and survive scene tree changes. Code connections give you more flexibility for dynamic behavior.

Custom Signals

Define signals at the top of your script, emit them when something happens:

GDScript
# inventory.gd
signal item_added(item: Item)
signal item_removed(item: Item)
signal inventory_full
func add_item(item: Item) -> bool:
if items.size() >= max_slots:
inventory_full.emit()
return false
items.append(item)
item_added.emit(item)
return true

Signals can carry any number of parameters:

GDScript
# No parameters
signal door_opened
# One parameter
signal health_changed(new_health: int)
# Multiple parameters
signal damage_dealt(target: Node, amount: int, type: String)

Connecting Signals in Code

There are several ways to connect signals. Each has its use case:

GDScript
# Method reference (most common)
func _ready() -> void:
$Enemy.died.connect(_on_enemy_died)
func _on_enemy_died() -> void:
score += 100
# Lambda (for simple one-liners)
$Button.pressed.connect(func(): get_tree().quit())
# With extra arguments using bind()
$Enemy.died.connect(_on_enemy_died.bind(enemy_name, reward_xp))
func _on_enemy_died(name: String, xp: int) -> void:
print(name + " defeated! +" + str(xp) + " XP")
# One-shot (auto-disconnects after firing once)
$Timer.timeout.connect(_on_timeout, CONNECT_ONE_SHOT)
# Deferred (fires at end of frame, after physics)
$Area.body_entered.connect(_on_entered, CONNECT_DEFERRED)

Disconnecting Signals

If a node that's connected to a signal gets freed, Godot cleans up the connection automatically. But sometimes you need to disconnect manually:

GDScript
# Disconnect by method reference
$Enemy.died.disconnect(_on_enemy_died)
# Check before disconnecting
if $Enemy.died.is_connected(_on_enemy_died):
$Enemy.died.disconnect(_on_enemy_died)

Common case: you connect a signal in a menu that opens and closes. Without disconnecting, you'd connect a new handler every time the menu opens.

GDScript
func open_menu() -> void:
visible = true
# Only connect if not already connected
if not $ConfirmButton.pressed.is_connected(_on_confirm):
$ConfirmButton.pressed.connect(_on_confirm)
func close_menu() -> void:
visible = false
if $ConfirmButton.pressed.is_connected(_on_confirm):
$ConfirmButton.pressed.disconnect(_on_confirm)

The Signal Bus Pattern

As your game grows, you'll have systems that need to communicate across the entire scene tree. The player dies and the UI, music, camera, and game state all need to react. Connecting all of these individually creates a web of dependencies.

The solution: a global signal bus. An autoloaded script that holds signals anyone can emit or connect to.

GDScript
# event_bus.gd (add as Autoload named "Events")
extends Node
# Player events
signal player_damaged(amount: int, new_health: int)
signal player_died
signal player_healed(amount: int)
# Game events
signal enemy_killed(enemy_type: String)
signal quest_completed(quest_id: String)
signal item_collected(item: Item)
signal level_completed
# UI events
signal show_dialogue(text: String, speaker: String)
signal notification(message: String)

Any script can emit:

GDScript
# player.gd
func take_damage(amount: int) -> void:
health -= amount
Events.player_damaged.emit(amount, health)
if health <= 0:
Events.player_died.emit()

Any script can listen:

GDScript
# health_bar.gd
func _ready() -> void:
Events.player_damaged.connect(_on_player_damaged)
func _on_player_damaged(_amount: int, new_health: int) -> void:
value = new_health
GDScript
# music_manager.gd
func _ready() -> void:
Events.player_died.connect(_on_player_died)
func _on_player_died() -> void:
play_death_music()
GDScript
# score_manager.gd
func _ready() -> void:
Events.enemy_killed.connect(_on_enemy_killed)
func _on_enemy_killed(enemy_type: String) -> void:
match enemy_type:
"goblin": score += 50
"dragon": score += 500

The signal bus is just one autoloaded script. No node needs to know about any other node. Adding a new system that reacts to player death means adding one connect call. No existing code changes.

Signals vs Direct Calls

When should you use a signal instead of calling a method directly?

Use signals when:

  • The sender doesn't need to know who's listening
  • Multiple systems need to react to one event
  • The connection might not exist (optional listeners)
  • You want decoupled, testable code

Use direct calls when:

  • You need a return value
  • There's exactly one receiver and it always exists
  • The call is internal to a component (parent calling child)
  • Performance is critical (signals have a small overhead per emit)

A good rule of thumb: signals go up and out (child notifies parent, component notifies system). Direct calls go down and in (parent configures child, manager controls component).

GDScript
# Signal UP: child tells parent something happened
# (child doesn't know what the parent is)
signal hit_detected(damage: int)
# Direct call DOWN: parent tells child what to do
# (parent knows exactly what the child is)
$HealthComponent.take_damage(25)

Awaiting Signals

You can pause a function until a signal fires using await:

GDScript
func play_death_sequence() -> void:
$AnimationPlayer.play("death")
await $AnimationPlayer.animation_finished
# Code here runs AFTER the animation finishes
show_game_over_screen()
func spawn_with_delay() -> void:
await get_tree().create_timer(2.0).timeout
spawn_enemy()
func fade_and_remove() -> void:
var tween := create_tween()
tween.tween_property(self, "modulate:a", 0.0, 0.5)
await tween.finished
queue_free()

await turns synchronous-looking code into asynchronous sequences. It's clean, readable, and avoids callback chains.

Common Mistakes

Connecting the same signal twice. If you call connect() every time a menu opens, you'll fire the handler multiple times per event. Always check is_connected() first, or connect in _ready() instead.

Emitting signals in _ready(). Other nodes might not be ready yet. If you need to emit on startup, use call_deferred():

GDScript
func _ready() -> void:
# Other nodes might not be connected yet
call_deferred("_emit_initial_state")
func _emit_initial_state() -> void:
health_changed.emit(health)

Using signals for everything. Not every method call needs to be a signal. If a parent is configuring its own child, a direct call is simpler and faster. Signals are for decoupling, not for every interaction.

Forgetting signal parameters. If your signal emits (amount: int, new_health: int) but your handler only accepts (amount: int), Godot will error at runtime. Match the parameter count and types.

Connecting to freed nodes. If you connect to a signal on a node that gets freed, Godot handles cleanup. But if you connect a freed node's method to someone else's signal, you'll get errors. Disconnect in _exit_tree() if the signal source outlives the listener.

Going Further

Signals are foundational to every system in Godot. Once you're comfortable with basic signals and the signal bus pattern, the next level is building systems where signals drive everything: dialogue systems where quest completion triggers new conversation options, combat systems where damage events trigger stagger states, and inventory systems where item changes update UI automatically.

We teach this exact architecture in our Dialogue & Quest System course, where signals and the event bus pattern connect dialogue, quests, and game state into a clean, decoupled system. If you want to see signals at scale in a real project, that's where to start.

godottutorialsignals