Skip to content

Counter Label Updated by Signal

A milestone

This chapter closes the click loop. By the end of M2.3 — when you press F5 and click the Train button — the counter Label will update from Light: 0 to Light: 1 to Light: 2, in real time, driven entirely by signals. That moment is the first time the textbook produces something a player would recognize as an idle game. Every later module adds to this loop: M3 adds passive income, M4 adds upgrades that multiply each click's effect, M5 adds buildings that tick on their own, M6 adds prestige. M2.3 is the seed; everything else is growth.

What you'll learn

  • How the autoload mechanism turns the Light script into a global, scene-independent singleton — accessible from any script by its registered name without any node path or @onready lookup.
  • How to connect a custom signal to a handler in code with Light.value_changed.connect(_on_light_changed), and the type-checking the engine does at connect time.
  • The closed loop of click → setter → signal → listener → UI update, with each step performing one job and one job only.
  • Why a Label that updates from a signal handler scales (one connection per Label, no per-frame polling) where a Label that polls in _process does not.
  • The handler-method versus lambda choice for connection callables, and when each is the right pick.

How it applies

  • Frame-budget cost of polling. A Label that reads Light.value every frame in _process runs sixty times per second whether the value changed or not. With twenty UI Labels in a finished idle game, that is twelve hundred no-op frame-budget consumers per second. Signal-driven update runs only when the value changed — for an idle game where most ticks change zero or one resource, the savings round to "no UI cost between events."
  • Source-of-truth discipline. The Label has no internal counter, no cached value, no idea what Light is supposed to be. It receives the new value as a signal argument and renders it. The bug class "Label drift" — where the displayed number disagrees with the underlying state — cannot exist when the Label has no state of its own.
  • QA reproducibility. A QA tester can write Light.value = 999_999 in the Remote tab and watch the Label snap to "Light: 999999" instantly. The Label updates because the signal fired, not because the tester clicked anything. This is the same code path that production clicks use, so the test is a true exercise of the production path.
  • Cross-scene survival. Autoload state survives scene changes. When M6 introduces the prestige reset (which swaps the main scene), Light remains in memory; whatever lifetime totals it tracks for prestige currency are not lost in the swap. A node-attached Light would be destroyed with its scene.
  • Scriptable across systems. The achievement system, the audio system, the analytics system, and the save system can all connect to Light.value_changed from wherever they live in the project. Each subsystem subscribes once in its own _ready(); none has to know who else is listening or in what order. Adding a new subsystem is a one-line change in that subsystem's setup, with zero edits to Light.

Concepts

Autoload registration

An autoload is a script the engine instantiates exactly once at game launch and adds to the scene tree at /root/<RegisteredName>. The instance survives every scene change for the life of the program. After registration, the registered name (e.g., Light) becomes a global identifier in every other scriptLight.value, Light.value_changed.connect(...) — without any @onready var, get_node, or preload.

Autoload registration is a project-level setting, stored in project.godot under the [autoload] section. The line looks like:

[autoload]
Light="*res://scripts/light.gd"

The leading asterisk in *res://... is engine syntax for "the script is the autoloaded thing" (as opposed to a packed scene). The order autoloads appear in project.godot is the order they are added to the tree at launch — earlier autoloads are guaranteed to exist when later ones run their _ready().

Example

You autoload Light and a hypothetical Save script, with Save ordered above Light. Save._ready() runs first; if it tries to read Light.value, it crashes — Light does not yet exist in the tree. The fix is to reorder so Light autoloads first. The autoload list is not just registration; it is also a dependency-order declaration. Idle-game projects typically autoload data-holders first (Light, GameState), systems next (Tick, SaveSystem), UI helpers last.

Global access without node paths

After registration, Light works like a built-in class name. From top_bar.gd:

Light.value += 1.0                      # write
Light.value_changed.connect(_on_change) # connect

No @onready var light: Light = $"../Light". No get_node("/root/Light"). The autoload name is resolved at parse time by the GDScript compiler, so the editor's autocomplete, type-checking, and "go to definition" all work against the autoloaded class as if you had imported it.

The previous chapter's path-based reference (@onready var light: Light = $"../Light") is therefore replaced by no reference at all. Code reads cleaner and breaks less when the scene tree is reorganized; in exchange, Light becomes a hard project-wide dependency — every scene assumes it exists. For singletons (one Light per game), that is exactly the right trade.

Example

You move MainLayout to a different parent during a scene refactor. With path-based access, $"../Light" either still resolves (if Light happens to be a sibling at the new location) or fails silently (if not). With autoload access, the move does not affect Light at all — its tree position never changes; only the scene around it does.

Connecting a custom signal

signal.connect(callable) registers a Callable as a listener. The same syntax works for engine-emitted signals (Button.pressed from M2.1) and custom signals (Light.value_changed from M2.2): publisher.signal_name.connect(callable).

The engine type-checks at connect time. value_changed was declared signal value_changed(new_value: float) in M2.2; a connection to a method whose first parameter is anything but float (or untyped) emits a runtime warning when the project loads. Catch type mismatches at script-load time, not at first emit.

A connection persists until disconnect() is called or one of the two parties (publisher or listener-bearing object) is freed. For autoload-to-autoload connections (both alive forever), connect once in _ready() and forget.

Example

You connect twice: once in _ready() of top_bar.gd and once in _ready() of a child script that also reaches Light. Each click now updates the Label twice — once per connection. Calling connect again on the same (signal, callable) pair is not idempotent; it adds a second registration. The fix is to not connect twice; if you must, pass CONNECT_ONE_SHOT (one-time) or check is_connected() first.

Handler method vs lambda

There are two shapes for the connected Callable.

Named method. A method on a script_on_light_changed(new_value: float) -> void:. Connect with Light.value_changed.connect(_on_light_changed). The bare method name resolves to a Callable bound to self.

Lambda (anonymous function). Inline at the connection site:

Light.value_changed.connect(func(new_value: float) -> void:
    counter_label.text = "Light: %d" % int(new_value))

Lambdas are useful for one-off, simple updates whose body is shorter than the method-declaration boilerplate. They are awkward for handlers that need to refer to multiple captured variables or that you'll want to debug — a stack trace through a lambda gives you <anonymous@...>, not a method name.

The textbook convention: named methods for production handlers (debuggable, reusable, testable in isolation); lambdas for the one-line "set the label text" type of trivia. M2.3 will use a named method to make the connection visible at module boundary.

Example

You connect with a lambda that references a local variable inside _ready():

var prefix: String = "Light: "
Light.value_changed.connect(func(v: float): counter_label.text = prefix + str(int(v)))
The lambda captures prefix — copies the reference at connect time. After _ready() returns, the local goes out of scope but the lambda's captured reference keeps it alive for the rest of the game. Captured-by-reference closures are convenient and perfectly safe for immutable values; convenient and dangerous for mutable ones (a captured Array mutated later by some other path will surprise the listener). Named methods do not capture, so this confusion does not arise.

Walkthrough

You will perform these in your own Godot editor. Coming in, the click handler from M2.2 increments Light.value (visible in the Remote tab) but nothing on screen updates.

  1. Open Project → Project Settings…. Click the Globals tab at the top of the dialog (the Autoload sub-tab is selected by default in 4.6). The right pane shows two fields: Path and Node Name, plus an Add button.
  2. In Path, click the folder icon and navigate to res://scripts/light.gd. The Node Name field auto-populates with Light (Pascal-cased from the file name). Confirm the Enabled checkbox is ticked. Click Add. A new row appears in the autoload list: Light res://scripts/light.gd Enabled. Now open scripts/light.gd and delete its class_name Light line (leave extends Node). A script registered as an autoload must not carry a class_name matching the autoload name, or Godot 4.6 raises "Class 'Light' hides an autoload singleton." In M2.2 the class_name Light was correct — Light was a scene node you referenced as a type (@onready var light: Light) — but now that Light is an autoload, the registered autoload name is the global accessor (Light.value), and the class_name becomes a conflicting duplicate. (You will remove the now-redundant typed reference itself in step 5; the BuildingData/UpgradeData Resources in M4–M5 keep their class_name because they are never autoloaded.)
  3. Close the dialog. Open project.godot in a text editor (outside Godot — File Explorer → right-click → Open With → Notepad/VS Code). Find the new [autoload] section. The line Light="*res://scripts/light.gd" should be present. This is the same line the dialog wrote, in the source-control-friendly format. Close the file without changes.
  4. Back in the Godot editor, open scenes/main.tscn. In the Scene dock, you still have the Light Node child you added in M2.2 — directly under CanvasLayer, sibling to MainLayout. Right-click it → Delete Node. The scene's tree returns to the M1.4 shape (CanvasLayer → MainLayout → {TopBar, ContentArea, StatusBar}). The autoload provides /root/Light independently; the scene-tree Light is now redundant.
  5. Open scripts/top_bar.gd. Three changes:
  6. Delete the @onready var light: Light = $"../Light" line. Autoload makes the local reference unnecessary.
  7. In _on_train_pressed(), the body becomes Light.value += 1.0 (no light. prefix — the autoload's global name is Light itself).
  8. Add a new child reference: @onready var counter_label: Label = $CounterLabel. The Label does not exist yet; you will add it in step 7.
  9. The illustrative fragment for the connection in _ready():
    func _ready() -> void:
        train_button.pressed.connect(_on_train_pressed)
        Light.value_changed.connect(_on_light_changed)
    
    Two connections, both named-method form. The first is unchanged from M2.1; the second is new.
  10. Add the handler method below _on_train_pressed:
    func _on_light_changed(new_value: float) -> void:
        counter_label.text = "Light: %d" % int(new_value)
    
    The %d format spec wants an integer — the cast int(new_value) truncates the float before substitution. Light is currently always a whole number per click, so the truncation is a no-op; M3 will change that.
  11. Save the script. Switch to the scene editor with main.tscn open. In the Scene dock, right-click TopBarAdd Child Node → search Label → create. Rename the new Label to CounterLabel. With it selected, in the Inspector, set Text to Light: 0 (the placeholder shown before the first signal arrives).
  12. Press F5. The top bar shows two children: the Train button and the CounterLabel reading Light: 0. Click the button. The counter label updates to Light: 1 immediately. Click again: Light: 2. Click rapidly: the counter increments per click with no visible lag, and no missed clicks. Close the window.
  13. Confirm the signal-driven flow by writing the value directly. With the game still running, open the Godot editor's Remote tab. Find /root/Light in the remote tree (autoloaded singletons appear here at the top level). Select it; the Inspector shows _value. Edit the field to 42 and press Enter. The counter label in the running game window snaps to Light: 42 — the setter ran (clamping is a no-op for 42), the signal fired, the listener ran, the label re-rendered. No click was needed; the path clicked is the one a tester or a save-loader would use.

Optional sanity check. Disconnect the signal at runtime: in the Remote tab, find /root/CanvasLayer/MainLayout/TopBar, select it, and call Light.value_changed.disconnect(_on_light_changed) from the integrated REPL (View → Editor Layout → enable Debugger panel; in the running session, the Stack Variables sub-panel exposes a console). Click the train button. Light.value increments (Remote tab confirms) but the label does not change. Reconnect: Light.value_changed.connect(_on_light_changed). The next click resumes updating the label. This makes the signal-driven dependency observable as a live wire.

Self-check quiz

Q1 — You autoload Light after a Save autoload that calls Light.value = saved_total in its _ready(). The game crashes on launch with 'Identifier not declared in the current scope: Light.' Why?

A. Light is misspelled; autoload names are case-sensitive. B. Save._ready() runs before Light is autoloaded; the global name does not yet exist when Save references it. C. Autoload-to-autoload references require await on the second autoload's ready signal. D. _ready() cannot reference autoloads — only _process can.

Reveal answer

B — autoload load order matters. Autoloads are added to /root in the order they appear in project.godot's [autoload] section. With Save listed first, Save._ready() runs before Light exists; the GDScript runtime cannot resolve the global name Light because nothing has been registered under it yet. The fix is to drag Light above Save in the Autoload list (Project Settings → Globals → Autoload → ↑ button). A is wrong: autoload names are case-sensitive but the error message would point to the misspelling, not "not declared." C fabricates an await requirement; autoloads are synchronous. D is wrong: _ready() references autoloads constantly — but only autoloads that have already loaded.

Q2 — In top_bar.gd's _ready() you have Light.value_changed.connect(_on_light_changed). The same line is also present in another autoloaded script's _ready(). What is the visible behavior on a single click?

A. The Label updates once; the second connect() returns ERR_INVALID_PARAMETER because the engine refuses to register duplicate (signal, callable) pairs. B. The Label updates twice — the handler runs once per connection, in connection order. C. The engine raises "duplicate connection" and refuses to register the second connect. D. The first connection is silently overwritten by the second.

Reveal answer

B — every connect() adds an independent registration. The engine does not check whether the same (signal, callable) pair is already connected. Two connects make two registrations; one emit fires both. The handler _on_light_changed runs twice per emit, in the order the connections were registered. The defensive pattern is if not Light.value_changed.is_connected(_on_light_changed): Light.value_changed.connect(_on_light_changed), or pass CONNECT_REFERENCE_COUNTED if you genuinely want pair-counted connections that disconnect on matching disconnect. A invokes a real Godot error code (ERR_INVALID_PARAMETER) applied to a behavior the engine does not have — duplicate (signal, callable) connections are permitted, not rejected. C is wrong: the engine permits duplicate connections silently. D is wrong: connections accumulate, they do not overwrite.

Q3 — You replace the named handler with a lambda inside _ready():

var fmt: String = "Light: %d"
Light.value_changed.connect(func(v: float): counter_label.text = fmt % int(v))

You then change fmt to "Devotion: %d" after the connect() call, still inside _ready(). The next emit shows "Devotion: 1". Why?

A. The lambda re-evaluates fmt at every call (closure-by-reference); the post-connect change is seen by the running lambda. B. The connection re-binds whenever the captured local changes. C. GDScript lambdas inline-substitute their captures at connect time — the change is seen because the lambda has not yet run. D. The behavior is undefined; lambdas should not capture local variables.

Reveal answer

A — closure capture is by reference. The lambda holds a reference to fmt's storage slot, not a snapshot of its value at connect time. Reassigning fmt later in _ready() is visible to the lambda because both the assignment and the lambda's read go through the same reference. This is convenient for setup-then-fire patterns and dangerous for state that changes during gameplay — a captured fmt mutated by other code surprises the lambda's reads. C is wrong: GDScript does not inline captures. B fabricates re-binding. D is wrong: capture is well-defined and useful, not undefined; you just have to think about lifetime and mutability.

Integration question

Q4 — open

The full flow you have built across M2.1, M2.2, and M2.3 is: button press → handler increments Light.value → setter clamps and emits value_changed → listener handler updates counter_label.text. Each link in the chain has a single responsibility. Suppose someone adds a second click source — a keyboard hotkey that also increments Light. How many of the three scripts (top_bar.gd, light.gd, the Label's update path) need to change to make the keyboard hotkey work, and why?

Reveal expected answer

Exactly one script changes: the script that registers the new input source. The new code is one line — if Input.is_action_just_pressed("train_hotkey"): Light.value += 1.0 — typically inside an _input() or _unhandled_input() callback on whichever script owns input handling (often the top-level UI controller, sometimes a dedicated InputManager autoload). light.gd is unchanged: the setter does not care who is writing. The Label's update path is unchanged: the listener does not care who emitted. The decoupling pays off the moment a second source exists. The deeper architectural property is that "what" (Light went up by 1) is separated from "why" (a click, a hotkey, an offline-earnings tick, a debug command) and from "what happens next" (the Label updates, a sound plays, an achievement counter advances). Each of those four "what happens next" listeners is one connect call in its own script; none of them touch the click handler or the setter.

Glossary

Glossary

signal.connect(callable)
The method that registers a Callable to receive a signal's emissions. Returns OK on success, an error code if the callable's signature does not match the signal's declared parameters. Each (signal, callable) pair can be connected multiple times without deduplication.
lambda (anonymous function)
A function defined inline at its use site, using func(args): body syntax. Captures the local variables it uses by value, at the moment the lambda is created — not by reference — so a lambda built inside a loop keeps that iteration's values (this is exactly what makes the per-row connect(func(): GameState.purchase_upgrade(upgrade)) pattern in M4.3 work). Useful for short one-off handlers; awkward for production code that needs debuggable stack traces or testable methods.
autoload load order
The order in which autoloaded scripts are added to /root at game start, defined by the order of their entries in project.godot's [autoload] section. Earlier autoloads are guaranteed to exist when later ones run their _ready(). Reorder via Project Settings → Globals → Autoload → ↑/↓ buttons.
### Ask Claude (side-channel) Try one of these in a Claude Code session if a piece did not land: - `Walk me through the call stack on a single click — train_button.pressed → handler → Light.value setter → emit → listener → counter_label.text. Show me what's on the stack at each step.` - `Explain CONNECT_DEFERRED vs the default connection mode — when would I want a signal handler to run on the next frame instead of inline?` - `Re-explain autoload by analogy to a Python module-level singleton or a JavaScript module's default export — what's similar, what's different.` - `Open the Godot 4.6 class reference for Signal. Find the methods that distinguish "connect a callable once" from "connect with reference counting" — CONNECT_ONE_SHOT, CONNECT_REFERENCE_COUNTED, CONNECT_DEFERRED. When would you reach for each?`