doc 3 of 3

Part 02 — Keeping it honest

You will build: a tracer that makes bus traffic visible, plus the naming rules and the review test that decide what's allowed on the bus at all.

The bus from part 01 works. The question this part answers is whether you'll still understand your project after adding forty more signals to it.

The failure mode, described precisely#

Direct connections are traceable. You see %HealthBar.set_value(hp) and you know both ends.

A bus signal has no visible other end. To find who reacts to player_died you search the project for that string and hope every listener spells it the same way. Nothing in the editor draws the arrow. Delete a listener and nothing complains; add a second emitter in a place you forgot about and behaviour changes with no diff that mentions the listener at all.

That's the trade. You bought decoupling and paid in traceability. The rest of this part is about keeping the price low.

Rule 1 — Past tense, always#

GoodBadWhy
coin_collectedadd_scoreThe second one commands. The emitter has decided the outcome
player_diedshow_game_overDitto — and now only one thing can ever respond
level_completedload_next_levelThe emitter shouldn't know what comes next
setting_changedapply_settingsAn announcement; every system decides what it means

The renaming isn't cosmetic. show_game_over has exactly one sensible listener, which means the bus is being used as a long-distance function call — and a direct reference would be clearer, testable, and traceable. When you can't name an event in the past tense, that is the signal telling you it isn't an event.

Rule 2 — If it has one listener, it isn't a bus event#

One emitter, one listener, forever? Then you have two objects that know about each other, pretending not to. Connect them directly and delete the bus signal. You lose nothing and regain the ability to click through to the other end.

The bus is for one-to-many, where the many is genuinely open — you don't know who will care, and you want new listeners to be addable without editing the emitter.

Rule 3 — Group the signals and comment the contract#

At twenty signals, an alphabetical wall becomes unreadable. Section it, and write down what each one means, not what it does:

extends Node

# ─── Gameplay ──────────────────────────────────────────────
## A coin entered the player's collection. Fires once per coin, before the coin is freed.
signal coin_collected(value: int)
## The player's health reached zero. Fires once; respawn logic is NOT implied.
signal player_died(at: Vector2)

# ─── Progression ───────────────────────────────────────────
signal level_completed(level_id: StringName, seconds: float)

# ─── Settings ──────────────────────────────────────────────
signal setting_changed(key: StringName, value: Variant)

"Fires once per coin, before the coin is freed" is the kind of detail that costs an afternoon when it's undocumented and a listener assumes otherwise.

The tracer#

Make the invisible visible. Since every emission goes through this one file, one debug flag buys you a log of the whole system:

@export var trace: bool = false


func _ready() -> void:
	if not OS.is_debug_build():
		return
	# script-declared signals ONLY — get_signal_list() would also return every signal
	# inherited from Node and Object (tree_entered, renamed, child_order_changed, …)
	for s in get_script().get_script_signal_list():
		var signal_name: StringName = s["name"]
		# bind the name so one handler can report every signal
		connect(signal_name, _trace.bind(signal_name))


func _trace(signal_name: StringName) -> void:
	if trace:
		print("[events] %s%d listener(s)" % [
			signal_name, get_signal_connection_list(signal_name).size() - 1
		])

get_script_signal_list() reports the signals your script declares, so this picks up new ones automatically — no maintenance. Reach for it rather than Object.get_signal_list(), which walks the whole inheritance chain and would hand you tree_entered, renamed, child_order_changed and a dozen more engine signals you never declared. Callable.bind() pre-supplies the name, letting one handler serve all of them. Subtract one from the listener count because the tracer is itself a listener.

One honest exception to note: part 01 said events.gd should hold signals and nothing else, and this adds a variable and two methods to it. Debug instrumentation is a fair exception — but it's an exception, so keep it behind OS.is_debug_build() as above and don't let anything else move in beside it.

Flip trace on and play. You now have a readable timeline of everything the bus carried, in order. Two things jump out immediately: events firing far more often than you assumed, and events with zero listeners — dead announcements nobody consumes, which are pure cost.

(The bind trick works cleanly for signals with no parameters. Signals with arguments deliver theirs before the bound one, so a fully general tracer needs Callable.unbind() or a small per-arity helper. Getting that working is a genuinely good exercise.)

The review test#

Every few weeks, read events.gd top to bottom and ask three questions per signal:

  1. Is it past tense? If not, it's a command — find its real home.
  2. How many listeners? Zero means delete it. One means connect directly instead.
  3. Could a normal signal have carried this? If emitter and listener turn out to share an ancestor after all, use it. Tree shapes change; a bus signal added when they were far apart may not be needed any more.

A bus that shrinks during review is a healthy one. A bus that only grows is becoming the global-state problem the engine documentation warned about, one reasonable-looking addition at a time.

Checkpoint — definition of done#

  • Every signal on your bus is past tense
  • No signal has exactly one permanent listener
  • Signals are grouped with a comment stating each one's contract
  • The tracer prints a readable timeline, and you found at least one surprise in it
  • You can explain: what specifically do you give up by moving a direct connection onto the bus?

Stretch (no instructions)#

Extend the tracer to log the emitting object as well as the signal name. CONNECT_APPEND_SOURCE_OBJECT is worth reading about. Then use it to answer a question you can't currently answer: which object emits player_died, and is it always the same one?

If you get stuck#

  • Tracer prints nothing → is it a debug build, and is trace on? Autoload @export values are set in the Autoload tab's inspector, which is easy to miss.
  • Errors connecting in the loop → some signals take parameters and your handler doesn't match. That's the arity problem noted above; start by filtering to zero-argument signals and grow from there.
  • Listener count always reads one too high → you didn't subtract the tracer itself.

Back to the overview, or the shelf this one pairs with: save & load, where "everything changed" notifications are exactly what loading needs to emit.