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:
# Player.gd - tightly coupledfunc 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:
# Player.gd - decoupledsignal health_changed(new_health: int)signal diedfunc 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:
# 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 lifecycletree_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:
# inventory.gdsignal item_added(item: Item)signal item_removed(item: Item)signal inventory_fullfunc add_item(item: Item) -> bool: if items.size() >= max_slots: inventory_full.emit() return false items.append(item) item_added.emit(item) return trueSignals can carry any number of parameters:
# No parameterssignal door_opened# One parametersignal health_changed(new_health: int)# Multiple parameterssignal damage_dealt(target: Node, amount: int, type: String)Connecting Signals in Code
There are several ways to connect signals. Each has its use case:
# 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:
# Disconnect by method reference$Enemy.died.disconnect(_on_enemy_died)# Check before disconnectingif $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.
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.
# event_bus.gd (add as Autoload named "Events")extends Node# Player eventssignal player_damaged(amount: int, new_health: int)signal player_diedsignal player_healed(amount: int)# Game eventssignal enemy_killed(enemy_type: String)signal quest_completed(quest_id: String)signal item_collected(item: Item)signal level_completed# UI eventssignal show_dialogue(text: String, speaker: String)signal notification(message: String)Any script can emit:
# player.gdfunc take_damage(amount: int) -> void: health -= amount Events.player_damaged.emit(amount, health) if health <= 0: Events.player_died.emit()Any script can listen:
# health_bar.gdfunc _ready() -> void: Events.player_damaged.connect(_on_player_damaged)func _on_player_damaged(_amount: int, new_health: int) -> void: value = new_health# music_manager.gdfunc _ready() -> void: Events.player_died.connect(_on_player_died)func _on_player_died() -> void: play_death_music()# score_manager.gdfunc _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 += 500The 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).
# 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:
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():
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.