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
Lightscript into a global, scene-independent singleton — accessible from any script by its registered name without any node path or@onreadylookup. - 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
_processdoes 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.valueevery frame in_processruns 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_999in 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),
Lightremains 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
connecttoLight.value_changedfrom 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 toLight.
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 script — Light.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:
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:
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)))
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.
- 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. - In Path, click the folder icon and navigate to
res://scripts/light.gd. The Node Name field auto-populates withLight(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 openscripts/light.gdand delete itsclass_name Lightline (leaveextends Node). A script registered as an autoload must not carry aclass_namematching the autoload name, or Godot 4.6 raises "Class 'Light' hides an autoload singleton." In M2.2 theclass_name Lightwas correct —Lightwas a scene node you referenced as a type (@onready var light: Light) — but now thatLightis an autoload, the registered autoload name is the global accessor (Light.value), and theclass_namebecomes a conflicting duplicate. (You will remove the now-redundant typed reference itself in step 5; theBuildingData/UpgradeDataResources in M4–M5 keep theirclass_namebecause they are never autoloaded.) - Close the dialog. Open
project.godotin a text editor (outside Godot — File Explorer → right-click → Open With → Notepad/VS Code). Find the new[autoload]section. The lineLight="*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. - Back in the Godot editor, open
scenes/main.tscn. In the Scene dock, you still have theLightNode child you added in M2.2 — directly underCanvasLayer, sibling toMainLayout. Right-click it → Delete Node. The scene's tree returns to the M1.4 shape (CanvasLayer → MainLayout → {TopBar, ContentArea, StatusBar}). The autoload provides/root/Lightindependently; the scene-treeLightis now redundant. - Open
scripts/top_bar.gd. Three changes: - Delete the
@onready var light: Light = $"../Light"line. Autoload makes the local reference unnecessary. - In
_on_train_pressed(), the body becomesLight.value += 1.0(nolight.prefix — the autoload's global name isLightitself). - Add a new child reference:
@onready var counter_label: Label = $CounterLabel. The Label does not exist yet; you will add it in step 7. - The illustrative fragment for the connection in
_ready(): Two connections, both named-method form. The first is unchanged from M2.1; the second is new. - Add the handler method below
_on_train_pressed: The%dformat spec wants an integer — the castint(new_value)truncates thefloatbefore substitution. Light is currently always a whole number per click, so the truncation is a no-op; M3 will change that. - Save the script. Switch to the scene editor with
main.tscnopen. In the Scene dock, right-clickTopBar→ Add Child Node → searchLabel→ create. Rename the new Label toCounterLabel. With it selected, in the Inspector, setTexttoLight: 0(the placeholder shown before the first signal arrives). - Press
F5. The top bar shows two children: theTrainbutton and theCounterLabelreadingLight: 0. Click the button. The counter label updates toLight: 1immediately. Click again:Light: 2. Click rapidly: the counter increments per click with no visible lag, and no missed clicks. Close the window. - Confirm the signal-driven flow by writing the value directly. With the game still running, open the Godot editor's Remote tab. Find
/root/Lightin the remote tree (autoloaded singletons appear here at the top level). Select it; the Inspector shows_value. Edit the field to42and press Enter. The counter label in the running game window snaps toLight: 42— the setter ran (clamping is a no-op for42), 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 callLight.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.valueincrements (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
Callableto receive a signal's emissions. ReturnsOKon 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): bodysyntax. 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-rowconnect(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
/rootat game start, defined by the order of their entries inproject.godot's[autoload]section. Earlier autoloads are guaranteed to exist when later ones run their_ready(). Reorder via Project Settings → Globals → Autoload → ↑/↓ buttons.