Free Ebook cover Godot 4 for Beginners: Build a Small 2D Game from Scratch

Godot 4 for Beginners: Build a Small 2D Game from Scratch

New course

10 pages

Signals and Events: Clean Communication Between Nodes

Capítulo 5

Estimated reading time: 9 minutes

+ Exercise

Why Signals Matter (and What Problem They Solve)

In a small prototype, it is tempting to make nodes talk to each other by grabbing direct references (for example, $UI/ScoreLabel or get_parent().get_node("GameManager")) and calling methods. This works until you reorganize your scene tree, reuse a scene in a different level, or add a second UI layout. Suddenly, many scripts break because they depended on a specific hierarchy.

Signals are Godot’s built-in event system. A node can emit a signal when something happens, and any other node can listen and react. The emitting node does not need to know who is listening. This is the main decoupling tool in Godot: it keeps scenes reusable and reduces “spaghetti references.”

Signal vocabulary

  • Emitter: the node that emits a signal (e.g., a coin).
  • Listener: the node that connects to the signal (e.g., GameManager, UI).
  • Payload: optional data passed with the signal (e.g., coin value, new health).

Direct References vs Signal-Based Messaging

ApproachExampleProsCons
Direct reference$UI.update_score(score)Fast to write, explicitBreaks when hierarchy changes; hard to reuse scenes; can create circular dependencies
Signalsscore_changed.emit(score)Decoupled; multiple listeners; reusable scenes; easier refactorsRequires setup (connect); flow is less “linear” unless you name signals well

A good rule: if a node is a reusable “thing” (coin, player, door, hazard), prefer signals to announce events. Keep direct references for truly owned relationships (e.g., a node controlling its own child nodes).

Creating Custom Signals (Godot 4)

In Godot 4, you declare custom signals at the top of a script using signal. You can define parameters to send data to listeners.

signal health_changed(current: int, max: int)

Then you emit it when the event happens:

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

health_changed.emit(current_health, max_health)

Step-by-Step: Player Health Change Signal

1) Add a signal to the Player script

In your Player scene script (commonly on the CharacterBody2D), add a health model and a signal. Keep the health logic inside the Player; let others react via signals.

extends CharacterBody2D

signal health_changed(current: int, max: int)
signal died

@export var max_health: int = 5
var current_health: int

func _ready() -> void:
	current_health = max_health
	health_changed.emit(current_health, max_health)

func apply_damage(amount: int) -> void:
	current_health = max(current_health - amount, 0)
	health_changed.emit(current_health, max_health)
	if current_health == 0:
		died.emit()

func heal(amount: int) -> void:
	current_health = min(current_health + amount, max_health)
	health_changed.emit(current_health, max_health)

2) UI listens without knowing Player internals

Create a UI script (for example, on a CanvasLayer or a Control) that exposes methods to update visuals. The UI should not call Player methods like heal() or apply_damage(); it should only display.

extends CanvasLayer

@onready var health_label: Label = $HUD/HealthLabel

func _on_player_health_changed(current: int, max: int) -> void:
	health_label.text = "HP: %d/%d" % [current, max]

func _on_player_died() -> void:
	health_label.text = "HP: 0/0"

The missing piece is the connection: you can connect in the editor or via code.

Connecting Signals in the Editor

Editor connections are great when the relationship is stable within a scene (for example, a UI scene that always listens to the Player in the same level scene).

Step-by-step (Editor)

  • Select the Player node in the Scene tree.
  • Go to the Node dock (next to Inspector).
  • Find your custom signal (e.g., health_changed).
  • Click Connect….
  • Choose the target node (e.g., your UI root node).
  • Select or create the receiver method (Godot will offer to generate _on_player_health_changed).
  • Repeat for died if needed.

Godot will store this connection in the scene, so it survives play sessions and is visible to teammates.

Connecting Signals via Code (Runtime Wiring)

Code connections are useful when nodes are spawned dynamically, when you want to keep scenes reusable across different levels, or when you want a central place to wire your game flow (often a GameManager).

Example: Level script wires Player to UI

extends Node2D

@onready var player: CharacterBody2D = $Player
@onready var ui: CanvasLayer = $UI

func _ready() -> void:
	player.health_changed.connect(ui._on_player_health_changed)
	player.died.connect(ui._on_player_died)

Tip: If you rename methods, code connections will error at runtime (easy to catch). Editor connections can silently break if you delete the method; watch the output for warnings.

Building an Interaction Pipeline: Area2D → GameManager → UI

Now let’s build the maintainable pipeline described below:

  • A collectible Area2D emits collected.
  • GameManager listens, updates score, and emits score_changed.
  • UI listens to score_changed and updates labels.

This keeps the coin reusable (it doesn’t know about score), and the UI independent (it doesn’t know about coins).

1) Coin scene: Area2D emits a collected signal

Create a Coin scene with an Area2D root, a CollisionShape2D, and a Sprite2D. Attach a script to the Area2D:

extends Area2D

signal collected(value: int)

@export var value: int = 1

func _ready() -> void:
	body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node) -> void:
	if body.is_in_group("player"):
		collected.emit(value)
		queue_free()

Notes:

  • This coin only checks for a group called player. That’s a light dependency and is usually acceptable for gameplay interactions.
  • The coin does not update score, does not talk to UI, and does not need to know a GameManager exists.

2) GameManager: listens to coins, updates score, emits score_changed

Create a GameManager node (commonly a plain Node) in your level scene. Attach:

extends Node

signal score_changed(new_score: int)

var score: int = 0

func add_score(amount: int) -> void:
	score += amount
	score_changed.emit(score)

Now connect all coins to the GameManager. If coins are placed in the level ahead of time, you can connect them in _ready() by scanning a group.

3) Put coins in a group and auto-wire them

Select each Coin instance in the editor and add it to a group, for example coins (Node dock → Groups). Then in your level script (or in GameManager), connect them:

extends Node2D

@onready var game_manager: Node = $GameManager

func _ready() -> void:
	for coin in get_tree().get_nodes_in_group("coins"):
		coin.collected.connect(_on_coin_collected)

func _on_coin_collected(value: int) -> void:
	game_manager.add_score(value)

This wiring script can live in the level root, or you can move it into GameManager if you prefer GameManager to own the wiring. The key is: coins emit, something central listens.

4) UI listens to GameManager score updates

UI script:

extends CanvasLayer

@onready var score_label: Label = $HUD/ScoreLabel

func _on_score_changed(new_score: int) -> void:
	score_label.text = "Score: %d" % new_score

Connect GameManager to UI (editor or code). Code example in the level root:

extends Node2D

@onready var game_manager: Node = $GameManager
@onready var ui: CanvasLayer = $UI

func _ready() -> void:
	game_manager.score_changed.connect(ui._on_score_changed)

Level Completion Signal (Win Condition as an Event)

Level completion is another event that benefits from signals: the level can announce “completed,” while UI and GameManager decide what to do (show a panel, stop input, load next level, save stats).

Option A: Exit Area2D emits level_completed

Create an Exit scene (Area2D) similar to the coin:

extends Area2D

signal level_completed

func _ready() -> void:
	body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node) -> void:
	if body.is_in_group("player"):
		level_completed.emit()

GameManager listens and broadcasts a higher-level event

Let GameManager be the place that turns raw events into game state changes.

extends Node

signal score_changed(new_score: int)
signal level_finished

var score: int = 0
var finished: bool = false

func add_score(amount: int) -> void:
	if finished:
		return
	score += amount
	score_changed.emit(score)

func finish_level() -> void:
	if finished:
		return
	finished = true
	level_finished.emit()

Wire the exit to GameManager (level root script):

extends Node2D

@onready var exit: Area2D = $Exit
@onready var game_manager: Node = $GameManager

func _ready() -> void:
	exit.level_completed.connect(game_manager.finish_level)

UI listens for level_finished

extends CanvasLayer

@onready var panel: Control = $WinPanel

func _ready() -> void:
	panel.visible = false

func _on_level_finished() -> void:
	panel.visible = true

Connect:

game_manager.level_finished.connect(ui._on_level_finished)

Maintainability Patterns (Practical Guidelines)

Name signals after events, not actions

  • Good: collected, health_changed, level_finished
  • Avoid: update_ui, add_score_now (these sound like commands and imply who should react)

Prefer “data out” signals

Emit the information listeners need (new score, current/max health). This prevents listeners from reaching back into the emitter to query state, which reintroduces coupling.

Keep emitters ignorant of the scene tree

A coin should not do get_tree().get_first_node_in_group("ui") to update labels. Emit collected and let something else decide what that means.

Use a GameManager as an event hub (but don’t turn it into a god object)

GameManager is a good place to aggregate gameplay state (score, finished flag) and broadcast changes. Keep it focused on game state and event routing; avoid stuffing unrelated logic into it.

When direct references are still fine

  • A UI node updating its own child label: $HUD/ScoreLabel.text = ...
  • A Player controlling its own weapon child node
  • A scene that is intentionally not reusable and has a fixed structure

Quick Troubleshooting Checklist

  • Signal never fires: confirm you call emit() and the condition is met (e.g., player is in the player group).
  • Connected but handler not called: verify the method signature matches the signal parameters.
  • Multiple calls unexpectedly: you may be connecting multiple times (for example, connecting in _ready() of a node that is re-entered). Consider guarding with is_connected or ensuring wiring happens once.
  • Scene refactor broke things: prefer code wiring using groups or exported NodePaths, or keep editor connections inside the same scene where possible.

Now answer the exercise about the content:

In a reusable Coin scene, what is the main advantage of emitting a collected signal instead of directly calling a UI or GameManager method to update the score?

You are right! Congratulations, now go to the next page

You missed! Try again.

Signals let a coin announce an event without knowing who listens. This avoids hard-coded node paths and keeps the coin reusable even if the scene tree or UI changes; listeners (e.g., GameManager/UI) decide how to react.

Next chapter

Animation and Visual Feedback in a 2D Godot 4 Game

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.