doc 2 of 3

Part 01 — Building the bus

You will build: an Events autoload carrying typed signals, with emitters and listeners wired to it — and the connection hygiene that stops it leaking.

The whole thing#

Create events.gd. It contains signals and nothing else:

extends Node

## Global announcements. Signals only — no state, no logic, no helper methods.
## Everything here is past tense: a fact that already happened.

signal coin_collected(value: int)
signal player_died(at: Vector2)
signal level_completed(level_id: StringName, seconds: float)
signal setting_changed(key: StringName, value: Variant)

Register it at Project → Project Settings → Globals → Autoload, path res://events.gd, node name Events. Godot creates a Node, attaches the script, and adds it above your main scene, so Events is reachable from anywhere.

That's the entire implementation. Everything else on this shelf is about restraint.

Why signals only#

The moment you add var score: int to this file, it stops being a bus and becomes a Global.gd — the grab-bag the engine's own documentation warns about. Score belongs to a score object; the bus only announces that it changed.

The test: if Events were deleted, would any information be lost? The answer must be no. A bus is wiring, not storage. Wiring holds nothing.

Why typed parameters#

signal coin_collected(value: int) gives you autocompletion at the connection site and an error at the emission site when you pass the wrong thing. signal coin_collected(data) gives you a dictionary of unknown shape that every listener has to guess at. The type annotation costs six characters.

Emitting#

# coin.gd
func _on_body_entered(body: Node2D) -> void:
	if body.is_in_group("player"):
		Events.coin_collected.emit(value)
		queue_free()

The coin announces and dies. It doesn't know the HUD exists. That's the property you're buying.

Listening, and the part everyone gets wrong#

# hud.gd
func _ready() -> void:
	Events.coin_collected.connect(_on_coin_collected)


func _on_coin_collected(value: int) -> void:
	score += value
	%ScoreLabel.text = str(score)

Connect in _ready(). Now the leak.

Events outlives your scenes. It's an autoload — it exists for the whole run. When your HUD is freed on a level change, the connection from Events.coin_collected to that HUD's method still exists, pointing at a dead object.

Godot is kind here: connections to a freed Object are cleaned up automatically, so you won't usually crash. But two things still bite:

  • Duplicate connections. Reload a level without freeing the listener and it connects a second time. Now one coin adds ten points. This is the single most common event bus bug, and it always presents as "the numbers are wrong sometimes."
  • Zombie listeners. A node that persists across scenes but should only listen sometimes keeps reacting when it shouldn't.

Two defences. First, disconnect deliberately when a listener's interest ends:

func _exit_tree() -> void:
	if Events.coin_collected.is_connected(_on_coin_collected):
		Events.coin_collected.disconnect(_on_coin_collected)

Second, for anything that should only ever fire once, let the engine handle it:

Events.level_completed.connect(_on_level_completed, CONNECT_ONE_SHOT)

CONNECT_ONE_SHOT disconnects itself after the first emission. Note the spelling — Godot 3 called it CONNECT_ONESHOT, without the second underscore, and the rename is exactly the kind of thing that survives in copied code.

Two flags worth knowing#

connect() takes a flags argument, and two of the five options matter in practice:

CONNECT_ONE_SHOT — as above.

CONNECT_DEFERRED — the callback runs at the end of the frame instead of immediately. Reach for it when a listener wants to free nodes, change scenes, or otherwise restructure the tree in response to an event. Doing that during emission means you're mutating the tree while the engine is mid-traversal, which produces errors that look like they come from somewhere else entirely.

Events.player_died.connect(_restart_level, CONNECT_DEFERRED)

Predict before you run: connect a listener, then reload the scene three times without ever disconnecting, then collect one coin. How many times does the handler run? Write your answer, then add a print and check.

Build it#

  1. Create events.gd with the four signals above and register it as an autoload.
  2. Emit coin_collected from a pickup.
  3. Listen in a HUD that is not an ancestor of the coin — a CanvasLayer sibling.
  4. Reload the scene several times and confirm the count stays correct. If it doesn't, you've just reproduced the duplicate-connection bug on purpose, which is the best way to own it.
  5. Add _exit_tree() disconnection and confirm the fix.

Checkpoint — definition of done#

  • events.gd contains signals and nothing else — no variables, no functions
  • A coin updates the HUD with neither node referencing the other
  • Reloading the level repeatedly does not multiply the score increment
  • You used CONNECT_ONE_SHOT somewhere it genuinely fits
  • You can explain: why can a connection to a freed node be harmless, while a duplicate connection to a live one is a real bug?

Stretch (no instructions)#

Add a setting_changed(key, value) announcement and have two unrelated systems react — say, audio volume and window mode. Then ask the harder question: should the settings values live in the bus? Work out why the answer is no, and where they should live instead.

If you get stuck#

  • Events is not defined → the autoload isn't registered, or the node name differs from what you're typing. The name in the Autoload tab is the global identifier, exactly as spelled.
  • Handler never fires → print at the emission site first. If the emit runs and the handler doesn't, the connection didn't happen — usually a listener whose _ready() runs after the emission, which is a real ordering problem and a sign this event might belong somewhere less global.
  • Handler fires several times per event → duplicate connections. Events.coin_collected.get_connections().size() tells you the truth immediately.
  • Errors about freeing nodes during emission → that's what CONNECT_DEFERRED is for.

Next: Part 02 — keeping it honest, which is about the six-months-later problem.