Skip to content

G2.6 — Signals: Node Communication

What you'll learn

  • Connect a listener to a signal in code (.connect) and in the editor (the Node dock).
  • Match a handler's signature to the signal's parameters, and connect at the right time (_ready).
  • Use built-in signals: Button.pressed, Area2D.body_entered, Timer.timeout, and friends.
  • Apply the "respond, don't reach in" discipline, and see why it keeps nodes testable and reusable.

How it applies

  • Reaching across the tree welds nodes together. A button that directly updates a label, plays a sound, and bumps a counter cannot be reused or tested without all three of those systems present. Emitting a signal and letting them respond keeps the button ignorant of its listeners — the seam that makes each piece independent.
  • A signature mismatch breaks the connection. A handler whose parameters do not match the signal's is rejected at connect time or fed the wrong data. Knowing the payload is a contract (L2.3) tells you what the handler must accept.
  • Connecting too early or twice is a silent defect. Connect in _init and the child is not there yet; connect both in the editor and in code and the handler runs twice per emit. Both are avoidable once you know the timing and the duplication trap.
  • Signals are test seams. Because a signal is a public announcement, a test can connect a spy to it and assert it fired with the right values — observability you do not get from a node that quietly reaches into others.

Try it first

A button press must do three unrelated things: update a score label, play a click sound, and increment a save-counter — three systems the button should not have to know about. Before reading on: how do you make the button trigger all three without the button holding references to the label, the audio player, or the counter? Name the mechanism and who connects to what.

Concepts

Connecting in code

L2.3 covered declaring and emitting a signal; this chapter covers the other half — connecting a listener. In code, you call .connect on the signal, passing a Callable (L1.4) — the handler to run when it fires:

func _ready() -> void:
    button.pressed.connect(_on_button_pressed)   # _on_button_pressed is a Callable (no parentheses)

func _on_button_pressed() -> void:
    print("clicked")

Two rules: connect in _ready (the source node must exist — G2.1), and the handler's signature must match the signal's parameters. A signal declared signal hit(amount: int) needs a handler func _on_hit(amount: int). Mismatched parameters are rejected or misfed.

Connecting in the editor

You can also wire signals without code: select the emitting node, open the Node dock (G1.1), find the signal under the Signals tab, double-click it, and choose a target node and method. Godot generates a handler stub and records the connection in the .tscn. Editor connections suit fixed, scene-local wiring (a specific button to its scene's script); code connections suit dynamic wiring (connecting to nodes spawned at runtime). Do not do both for the same pair — connecting in the editor and in code makes the handler fire twice per emit.

Built-in signals

Most nodes emit useful signals out of the box. The ones you meet constantly:

  • Button.pressed — the button was clicked.
  • Area2D.body_entered(body) / area_entered(area) — something overlapped the area (G2.7); the payload is what entered.
  • Timer.timeout — a Timer finished.
  • AnimatedSprite2D.animation_finished — a non-looping animation completed (G2.3).

You connect to these exactly like custom signals; the only difference is the engine declares and emits them for you.

func _ready() -> void:
    $Timer.timeout.connect(_on_timer_timeout)
    $Hurtbox.area_entered.connect(_on_hurtbox_area_entered)

func _on_hurtbox_area_entered(area: Area2D) -> void:
    # the payload tells you what entered
    ...

"Respond, don't reach in"

The discipline behind signals is directional: a node emits when something happens and lets others respond, rather than reaching across the tree to call into them. The emitter does not know — and must not need to know — who is listening. This is the loose coupling from G1.4/§1.5, realized: the button from the "Try it first" prompt emits pressed, and the label, the audio player, and the counter each connect their own handler. The button references none of them. Add a fourth listener later and the button does not change.

This is also why signals are test seams: because the announcement is public, a test can connect its own spy handler and assert the signal fired with the expected payload — the coupling-and-mockability bridge from the QA table. A node that instead reached directly into three others would have to be tested with all three present.

Example

The "Try it first" answer, wired by the listeners — the button stays ignorant:

# in the scene's script, in _ready — each system connects ITSELF to the button:
func _ready() -> void:
    $Button.pressed.connect(_on_press)

func _on_press() -> void:
    $ScoreLabel.text = str(score)     # the label updates
    $ClickSound.play()                 # the sound plays
    Game.clicks += 1                   # the counter increments (an autoload, G1.5)

The Button node's own script contains nothing about labels, sounds, or counters — it just emits pressed. The wiring lives outside it. Swap the button for a different one and the listeners do not care; remove the sound and the button does not care. That independence is the whole point, and it is the architecture the game books' SignalBus elaborates (the pattern is theirs; the connect/emit mechanic is this chapter's).

Connection management

signal.disconnect(callable) removes a connection; signal.is_connected(callable) tests for one. For a one-time response, connect with the one-shot flag so it auto-disconnects after firing once:

anim.animation_finished.connect(_on_finished, CONNECT_ONE_SHOT)

One-shot is the right tool for "do this once when the animation ends," avoiding a connection that lingers and re-fires on later animations.

Walkthrough

  1. Make a scene with a Button (under a CanvasLayer, G2.4) and a Label. In the scene script's _ready, connect $Button.pressed to a handler that sets $Label.text. Run, click, confirm.
  2. Now wire a second effect from the same press — increment a counter and print it — by adding to the same handler. Note the button script knows nothing; the scene script does the connecting.
  3. Editor connection: select the Button, open the Node dock → Signals, and connect pressed to a new method via the dialog. Run — observe it fires. Then also connect the same signal in code and watch the handler fire twice; remove one to fix the double-fire.
  4. Built-in payload: add a Timer (set Autostart, a short wait time) and connect its timeout to a handler that prints. Confirm it fires on the interval.

Optional sanity check

Connect a signal to a handler whose parameters do not match (e.g. connect a no-arg signal to a handler that requires an int), run, and read the error. Then correct the signature. That mismatch is the "payload is a contract" rule (L2.3) enforced at connect time.

Self-check quiz

Q1 — A button must update a label, play a sound, and bump a counter without referencing any of them. How?

A. The button script holds references to all three and calls them. B. The button emits pressed; the label, sound, and counter (or a coordinating script) each connect a handler — the button knows none of them. C. Put all four nodes in one script. D. Use absolute node paths from the button to each system.

Reveal answer

B. The button emits; listeners connect and respond, so the button references nothing and new listeners can be added without touching it — loose coupling via signals. A and D are the reach-in approach that welds the button to the other systems. C collapses the separation entirely, defeating reuse and testability.

Q2 — A handler runs twice for every button click. Likely cause?

A. The signal is emitted twice by the engine. B. The signal was connected both in the editor (Node dock) and again in code, so two connections fire. C. _ready runs twice. D. The button is pressed twice.

Reveal answer

B. Connecting the same signal-to-handler pair in both the editor and code creates two connections, so each emit calls the handler twice. A is not how the engine behaves. C is false (_ready runs once). D blames the user for a wiring bug. Remove one of the two connections.

Q3 — Why are signals described as \"test seams\"?

A. They make the game run faster under test. B. Because a signal is a public announcement, a test can connect a spy handler and assert it fired with the expected payload — observable behavior a reach-in design hides. C. Signals disable themselves during testing. D. They are unrelated to testing.

Reveal answer

B. Emitting is observable: a test can listen on the signal and verify it fired with the right values, and listeners can be substituted with doubles — the mockability/coupling bridge. A is irrelevant. C invents behavior. D contradicts the point.

Integration question

Q4 — open

An enemy's death should: remove the enemy, add score, drop loot, and update a kill-counter UI. A teammate writes the enemy's die() to directly call get_node(\"/root/Main/UI/Score\").add(10), LootSpawner.drop(...), and so on. It works, but the enemy scene now crashes when tested alone, and breaks whenever the UI tree is rearranged. Using this chapter's connect/emit mechanic and the respond-don't-reach-in discipline (and G1.4/G1.5), rewrite the approach and explain why it is more testable.

Reveal expected answer

The enemy should emit a signal on death — e.g. signal died(position, score_value) (declared per L2.3) — and die() should just queue_free() itself after emitting, knowing nothing about score, loot, or UI. The interested systems connect to that signal: a score system, a loot spawner, and the kill-counter UI each register a handler (or they all subscribe through a central event-bus autoload, G1.5, which is the SignalBus pattern the game books build on this mechanic). This is "respond, don't reach in": the enemy announces, the listeners react. Why it is more testable and robust: the enemy scene no longer references /root/Main/UI/Score or any other node, so it can be instanced and tested in isolation — a test connects a spy to died and asserts it fired with the right payload, no UI or loot system required. It also stops breaking when the UI tree is rearranged, because the enemy never addressed the UI by a fragile absolute path (G1.4) in the first place — the wiring lives in the listeners, not in the emitter. The mechanic (declare, emit, connect) plus the discipline (emit and let others respond) replaces tight, path-based coupling with a public seam that is both decoupled and observable.