Signals are most useful when they make ownership easier to see. A node announces that something it owns has changed, and another node reacts without the emitter reaching into that listener. The difficult part is not the signal keyword. It is choosing the correct owner, connector, and listener so the event flow stays understandable six months later.
Quick Answer: Let the State Owner Emit
The practical rule is simple: the node that owns a value or event emits the signal. If a Health node owns the current health value, it emits health_changed. A HUD listens and updates presentation. The HUD does not reduce health, and Health does not search for a progress bar. That separation is the reason to use a signal.
Connect the signal from a node that can legitimately see both sides. In a small Player scene, the Player root or a nearby coordinator can connect Health to HUD. This makes the dependency visible in one place. Read the node ownership guide if deciding where that connector belongs still feels arbitrary.
Use Direct Signals by Default
See the event flow, break it, then repair it
Trace ownership first, then diagnose the broken connection.
Owns the health value and emits health_changed after a real change.
Choose a diagnosis to continue.
Direct signal access is the clearest default in Godot 4. It provides editor completion, validates names earlier than string-based connection code, and keeps the relationship close to the nodes involved. The emitter still does not need a reference to its listeners. It only exposes an event with a meaningful payload.
# health.gd
class_name Health
extends Node
signal health_changed(current: int, maximum: int)
signal depleted
@export var maximum := 100
var current := 100
func take_damage(amount: int) -> void:
if amount <= 0 or current == 0:
return
current = maxi(current - amount, 0)
health_changed.emit(current, maximum)
if current == 0:
depleted.emit()
func heal(amount: int) -> void:
if amount <= 0 or current == maximum:
return
current = mini(current + amount, maximum)
health_changed.emit(current, maximum)The signal payload contains the information a listener needs, but it does not expose Health's internal fields for mutation. Emitting only after a real value change also prevents duplicate UI work. This becomes important when damage, healing, save restoration, and respawn all share the same state owner.
Connect Through the Scene That Knows Both Sides
A reusable Health scene should not know whether its listener is a HUD, a floating bar, an audio controller, or an analytics tool. The parent scene that assembles those nodes can connect them in _ready(). That parent already owns the composition, so the connection is not a hidden service-locator dependency.
# player.gd
extends CharacterBody2D
@onready var health: Health = %Health
@onready var health_bar: ProgressBar = %HealthBar
func _ready() -> void:
health.health_changed.connect(_on_health_changed)
health.depleted.connect(_on_health_depleted)
_on_health_changed(health.current, health.maximum)
func _on_health_changed(current: int, maximum: int) -> void:
health_bar.max_value = maximum
health_bar.value = current
func _on_health_depleted() -> void:
set_physics_process(false)The initial manual call synchronizes the HUD with current state without pretending an event occurred. This is different from emitting health_changed during setup solely to initialize a listener. Events describe changes; direct reads can establish the initial snapshot. Keeping those jobs separate makes debugging more predictable.
Connections made in the editor are also valid when they make ownership clearer for the team. Code connections are often easier to review and refactor for reusable scenes, while editor connections can be convenient for authored level interactions. Pick one intentionally and document unusual cross-scene wiring.
When a Global Signal Bus Is Justified
A global bus is useful when an event is genuinely broader than one scene composition. A run ending, language changing, achievement unlocking, or save profile switching may have listeners in systems that do not share a stable parent. In that case, an Autoload can expose a small set of project-level signals.
The bus becomes harmful when it is the first answer to every connection. If Player movement, Health, HUD, pickups, and enemies all publish vaguely named events globally, the scene tree stops explaining the game. Listeners appear from anywhere, event order becomes difficult to trace, and removing one feature can leave invisible subscriptions behind. The Autoload guide covers the same boundary from the global-state side.
- Use a direct signal when emitter and listener share a clear scene owner.
- Use a parent coordinator when several siblings react to one local event.
- Use an Autoload bus only for a deliberately small set of project-wide events.
- Name bus signals after completed domain events, such as run_finished, not vague commands such as update_ui.
Debug Signals in This Order
First confirm the runtime node that actually owns the signal. A common failure connects to a visual child or wrapper because its name looks correct in the scene tree. Print the node path or inspect the Remote scene tree, then verify that the script defining the signal is attached to that exact instance.
Second confirm connection timing. A child fetched with @onready is available after the scene enters the tree, but a dynamically instanced node may not exist when another system tries to connect. Let the owner create the instance and connection together, or expose an explicit setup method with typed dependencies.
Third compare the emitted arguments with the callback signature. If health_changed emits current and maximum, the receiver must accept those values unless you intentionally bind additional arguments. Finally, guard against duplicate connections when setup can run more than once by checking is_connected() or fixing the lifecycle that repeats setup.
Build the Player, Health, and HUD Flow
Create a Player scene with a CharacterBody2D root, a Health child, and a small Control-based HUD. Apply damage from one test input, heal from another, and print each callback once. The scene is complete when Health can be reused without HUD code and the HUD can be replaced without changing Health.
Then add one second listener, such as a hurt sound or screen flash, from the same parent coordinator. This proves that the event is reusable without turning Health into a manager. If the scene hierarchy becomes unclear, rebuild the composition in the Scene Builder and label each node by responsibility.
- Health owns current, maximum, damage, healing, and depletion.
- Player owns composition and connects local responsibilities.
- HUD owns presentation and never writes health directly.
- A project-level bus remains absent until a real cross-scene event requires it.
Practice and Sources
Complete the event-flow lab above, then implement the same ownership in a real scene. Save the proof only after you can explain why Health emits, why Player connects, and why HUD listens. The source links below document signal syntax and scene organization; the article adds the production boundary and debugging order that connect those APIs into a maintainable workflow.
Frequently asked questions
- Who should emit a signal in Godot?
- The node that owns the state or event should normally emit the signal. A Health node should emit health_changed because it owns health; the HUD should only listen and present the value.
- Should every Godot project use a global signal bus?
- No. Prefer direct signal connections inside a scene or between nodes with a clear common owner. Use a global bus only when an event genuinely crosses scene boundaries and no stable local coordinator exists.
- Why does Godot say a signal does not exist?
- The connection is usually targeting the wrong node, using an old string name, or running before the expected node is ready. Confirm the signal owner and use typed signal access where possible.
Sources and revision context
Rewritten around typed Godot 4 signal syntax, visible scene ownership, and a complete Health-to-HUD example. The guidance separates local direct connections from genuinely cross-scene events.
