Coding Quests
The Scroll Library
Tutorials

Godot 4 Signals Tutorial: The Complete Guide

February 25, 2026Updated June 11, 20267 min read

Signals are Godot's built-in event system, and they're the first thing that made the engine click for me. Nodes talk to each other without knowing about each other. A health bar updates when the player takes damage. A door opens when a key gets collected. An enemy dies and the score ticks up. All signals.

If you've used events in JavaScript, delegates in C#, or the observer pattern in any language, this is the same idea with Godot syntax. If you haven't, even better. You get to learn it the clean way.

What Is a Signal?

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

Without signals, you 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 layout, the UI, the audio system, and the game manager. Rename one node, move the health bar into a different container, and the game crashes. I've shipped that bug. It's not fun to track down.

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, the audio, the camera, and the game manager each connect on their own and react on their own terms.

Built-in Signals

Every Godot node ships with 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 (the Node tab, Signals section) or in code. Editor connections are visible right there in the editor and survive scene tree changes. Code connections give you more flexibility for anything dynamic. I default to code for gameplay logic, mostly because it's easier to search for later.

Custom Signals

Declare signals at the top of your script, then 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's more than one way to connect, and each has a job:

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

When a connected node gets freed, Godot cleans up the connection automatically. Sometimes you still need to disconnect by hand:

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)

The common case is a menu that opens and closes. Connect every time it opens without ever disconnecting, and you stack a fresh handler on each open:

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, systems need to talk across the whole scene tree. The player dies, and the UI, the music, the camera, and the game state all care. Wiring those connections one by one turns into a web of dependencies.

The fix is a global signal bus: one autoloaded script holding signals that 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

That's one autoloaded script, and no node knows about any other node. Want a new system that reacts to player death? One connect call. Zero changes to existing code. The same decoupling idea drives the UI in my Godot 4 inventory tutorial, where a single inventory_changed signal keeps the data and the display in sync.

Signals vs Direct Calls

So when do you reach for a signal instead of just calling the method?

Use signals when the sender shouldn't know who's listening, when multiple systems react to one event, or when a listener might not exist at all. Use direct calls when you need a return value, when there's exactly one receiver that always exists, or when a parent is just driving its own child. Signals also have a small overhead per emit, so a hot inner loop is better off with a direct call.

My rule of thumb: signals go up and out (a child notifies a parent, a component notifies a system). Direct calls go down and in (a parent configures a child, a manager controls a 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 without callback chains. The death sequence alone sells it: play, await, show the game over screen. Three lines that read top to bottom.

Common Signal Mistakes

Connecting the same signal twice is the classic. Call connect() every time a menu opens and the handler fires multiple times per event. Check is_connected() first, or connect once in _ready() and leave it alone.

Emitting in _ready() is sneakier. Other nodes might not be ready yet, so the signal fires into the void and nobody hears it. If you need to emit on startup, defer it:

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)

Then there's using signals for everything. Not every method call needs to be a signal. A parent configuring its own child should just call the method. Signals are for decoupling, not a religion.

Two more quick ones. If your signal emits two parameters but your handler only accepts one, Godot errors at runtime, so match the parameter count and types. And while Godot cleans up when you've connected to a node that gets freed, connecting a freed node's method to someone else's signal will throw errors. If the signal source outlives the listener, disconnect in _exit_tree().

Going Further

Signals sit under every system in Godot. Once the basics and the bus pattern feel natural, the next step is building systems where signals drive everything: dialogue where finishing a quest opens new conversation options, combat where damage events trigger stagger states, inventory where item changes update the UI on their own.

The free Inventory System quest is the gentlest place to see that in practice, since the whole UI runs off one signal. And the Dialogue and Quest System quest is where it scales up: signals and an event bus connecting dialogue, quests, and game state into one decoupled architecture.

Build the event bus this week. It's one script, maybe ten minutes of work, and it changes how you structure everything that comes after.

FAQ

What's the difference between emit_signal and .emit() in Godot 4?

They do the same thing, but .emit() on the signal itself is the Godot 4 way. Signals are first-class objects now, so health_changed.emit(health) gets you autocomplete and an error the moment you typo the name. emit_signal("health_changed", health) takes a string and only fails at runtime, which is exactly when you don't want to find out.

When should I use signals instead of direct function calls?

Use a signal when the sender shouldn't know who's listening, or when several systems react to the same event. Use a direct call when you need a return value or when there's one receiver that always exists, like a parent driving its own child. The up-and-out versus down-and-in rule covers most cases.

Can a signal return a value?

No. Emitting a signal gives you nothing back from the listeners, and that's by design, since the sender isn't supposed to know they exist. If you need an answer back, that's a direct method call.

godottutorialsignals

Stop reading. Start building.

Beat the demo boss by writing real Godot code, then build this for real in the Inventory System Quest.

Free, and no card needed. Built by a real person, with new quests every month.

Get the next Godot build in your inbox

New quests, project breakdowns, and game-dev tips. Free, no spam, unsubscribe anytime.

Written by Coding Quests

We teach Godot 4 by making you build complete systems: inventories, save systems, action roguelike controllers, enemy AI. The scrolls are free. The quests are where it sticks.