Resource Property + Setter Signal¶
What you'll learn
- Why game state — the player's Light total, the click value, the income rate — should not live inside the click handler.
- The
class_namekeyword and how it differs fromextends: one declares what the script is, the other declares what other code can call this thing by. - GDScript's property-with-getter-and-setter syntax, and the backing variable convention that lets a setter clamp, log, or notify on every write.
- How to declare a custom signal with a typed parameter, and how to fire it via
signal_name.emit(args). - The difference between a Node-based state holder (this chapter) and a
Resource-based data file (introduced in M4) — both are valid for different jobs.
How it applies
- Single source of truth. Every part of the game that asks "how much Light does the player have?" reads the same variable. Multiple click sources (button, hotkey, autoclicker addon) flow through one setter, which means one place to enforce floor/ceiling, one place to log, one place to fire the change signal. Bugs around inconsistent counters disappear before they can be written.
- QA scriptability. A setter that clamps the value to non-negative is a single-place invariant. QA can call
Light.value = -100from the Remote tab and watch the value snap to0— the contract is testable in three keystrokes. Scattering increment/decrement logic across handlers leaves no central choke point to test. - Save-system readiness. When M7 introduces save/load, the entire game state is a handful of properties on one Node. Serialize: walk the properties, dump them to JSON. Restore: assign them, and every connected listener receives a
value_changedsignal automatically — UI re-syncs without a single explicit refresh call. - Cheats and modding. A modder or cheat-tool author who wants to manipulate Light writes one line:
Light.value = 1e9. The same setter that clamps your normal gameplay also clamps cheat values into legal ranges, and the same signal updates the UI. The mod surface and the test surface are the same surface. - Memory layout for thousands of clicks. A
floatLight counter on one Node is 8 bytes. A click handler with its ownintcounter on every UI script is 4 bytes per UI script — and four divergent counters that may drift. Centralizing pays for itself almost immediately even ignoring correctness.
A note before you start¶
In M2.1 your click handler did two jobs: it incremented a counter, and it pushed the new count into the status bar. Both jobs lived in the same method. Centralizing the counter into a separate node — the Light autoload — is the right architecture, but it produces a temporary regression: by the end of this chapter, clicking the Train button will not update the status bar. The counter is incrementing correctly inside Light, but nothing is listening.
M2.3 puts the visible feedback back, driven by a signal subscriber instead of a direct property write. The detour is one chapter long. If clicking the button and seeing nothing happen feels wrong while you're working through this chapter, that is the architectural point — you have removed the display coupling, and the next chapter restores display through a cleaner path.
Concepts¶
State should not live in the handler¶
In M2.1 your click handler kept click_count as a local variable on the TopBar script. That worked for the smallest possible case — exactly one click source, exactly one place reading the value. The moment you add a second source (a keyboard shortcut bound to the same logical action, an "auto-train" upgrade firing the same handler) or a second reader (a separate Label, a sound effect that pitches up at higher Light totals) the local variable becomes a problem: each new participant either has to import the TopBar script's variable or maintain its own copy.
The fix is conceptual, not syntactic: lift the state out of the handler and into a node whose only job is to hold it. The click handler then asks that node to increment, and the node decides what that means (apply the click value, clamp, emit a notification, write to a save buffer). Code that wants to read the current value asks the same node.
The textbook's name for this state-holder is Light. It is a Node with one script attached. M2.3 will lift it further into a global autoload so any script in any scene can reach it without a node path.
Example
You add a "Recite Vow" button (a keyboard shortcut, say R) that grants the same Light per press as the click button. With click_count local to TopBar, both handlers must live on TopBar and share the variable — fine for two, painful for ten. With Light.value as a centralized property, both handlers read and write the same property; adding a third source is one line in any script that can reach Light.
class_name versus extends¶
extends declares what the script is. It says: "I am a kind of Node" (or Button, or RefCounted, etc.).
class_name declares what other code can call this thing. It registers a global type name in the project. Without class_name, the script is anonymous outside its file — to use it elsewhere you must preload("res://scripts/light.gd") and reference the loaded script object. With class_name Light, every script in the project can write var l: Light = ... and the editor's "Add Node" dialog lists Light as a creatable node type.
The two are independent: extends Node plus class_name Light makes a Light class that is a kind of Node. Either alone is legal; both together is the conventional pattern when you want a script that other code references by type.
Example
You write extends Node (no class_name) on light.gd. Another script wants to type-check a parameter as Light: func add_to(target: Light) -> void:. The editor shows "Identifier not declared in the current scope: Light." Adding class_name Light to light.gd fixes it project-wide. The script's contents do not change; the registration does.
Property with getter and setter — the backing variable¶
GDScript's property syntax lets a variable read and write through user-defined methods instead of (or alongside) raw memory access. The shape:
The underscored _value is the backing variable — the actual storage. The unprefixed value is the property: its get returns _value, its set overwrites _value. From outside, Light.value looks like a normal field; under the hood every read and write routes through the methods.
The point of routing through methods is that you can do more than store. A setter can clamp, log, fire signals, recompute caches. Other code does not have to remember to call a set_light(x) function — they assign Light.value = x and the setter runs automatically.
The convention of underscoring the backing var is a signal to other readers: "this is internal plumbing; write to value, not _value." GDScript does not enforce privacy, but the convention is universal.
Example
You write a setter that clamps to non-negative:
NowLight.value = -50 sets _value to 0.0, not -50. The clamp lives in one place. Code that assigns Light.value does not have to remember to clamp first; the property does it. Forgetting the clamp at one of (eventually) many call sites is no longer a possible bug.
Declaring a custom signal¶
In M2.1 the pressed signal was built into Button. Your own scripts can declare their own signals with the signal keyword:
The parameter list (new_value: float) is the signal's signature — the types of arguments it carries when emitted. Connections to this signal must accept a float; the engine type-checks at connect time. Signals can declare zero, one, or many parameters; the convention for a "value changed" notification is one parameter carrying the new value.
A signal declaration is a contract: "I will fire this event with this shape." It does not specify when, how often, or under what conditions. Those decisions belong to the code that calls .emit().
Example
A signal declared signal value_changed(new_value: float) and a connection light.value_changed.connect(func(v: int): print(v)) raise a type-check warning at connect time — the listener wants int, the signal carries float. Either change the listener's parameter type or change the signal's declared type. The mismatch is caught at connect time, not at the first emit, which makes the bug surface during the first _ready() instead of during the first click.
emit() from inside the setter¶
To fire the signal, call signal_name.emit(args):
Now every assignment to Light.value clamps, stores, and notifies. Listeners — Labels, sound effects, achievement counters — receive the new value as a parameter. The setter is the canonical single emission point: there is exactly one place in the codebase that emits value_changed, and it is the place that performs the actual write. The two operations are inseparable.
This is the discipline that makes the rest of the system trustworthy. As long as nothing writes to _value directly, every change to Light is announced. UI code can assume "if the Light total has changed, I have already been told." That assumption is what M2.3 will exploit.
Example
You bypass the setter — write Light._value = 999.0 directly from a script. The Light total is now 999, but the UI Label still shows the old value. Nothing emitted; nothing notified. The bug is invisible until the next click triggers a setter call and the Label snaps to whatever value followed 999. This is why "do not write the backing variable directly" is convention rather than mere style — it preserves the signal-emit invariant.
Aside: Resource is a different tool, coming in M4¶
GDScript has another base class called Resource (and the file extension .tres) for objects that are pure data — loaded from disk, edited in the Inspector, no per-frame logic. Upgrade definitions, building definitions, theme presets are all Resources. M4.1 introduces them.
Light is not a Resource. Light is per-game-session state that wants to emit signals when it changes; it lives in the running scene tree (or, after M2.3, in an autoload). Don't confuse the two: Resource for loaded data, Node for live state.
Walkthrough¶
You will perform these in your own Godot editor. The button-click flow from M2.1 should still work: clicking TrainButton increments click_count on TopBar and updates StatusBar.
- In the FileSystem dock, right-click
scripts/→ New → Script. Name itlight.gd. The Attach Node Script dialog defaults toextends Node— leave it. Click Create. - The script editor opens with a generated stub. Replace the body with the four logical pieces in this order: a
class_name Lightdeclaration on the line aboveextends Node, asignal value_changed(new_value: float)declaration, a backing variablevar _value: float = 0.0, and a propertyvar value: floatwithgetreturning_valueandset(new)clamping withmaxfand emittingvalue_changed. The total line count is roughly seven, plus blank lines for readability. - The full code-fragment ceiling for this chapter, in one place:
That is one fragment of nine logical lines — borderline for the textbook's per-chapter line budget, but unsplittable: you cannot demonstrate a setter that emits without showing the setter and the emit together.
class_name Light extends Node signal value_changed(new_value: float) var _value: float = 0.0 var value: float: get: return _value set(new): _value = maxf(new, 0.0) # Emit on every write — no change-gating. Callers that # care about "only emit when value differs" are responsible # for not writing identical values (Q3 explores why). value_changed.emit(_value) - Save the script (
Ctrl+S). The editor type-checksclass_name Lightand registers it project-wide; the next step assumes the registration succeeded. - Open
scenes/main.tscn. In the Scene dock, right-click the rootCanvasLayer→ Add Child Node → searchNode(the plainNodetype, notNode2D) → create. Rename the new child toLight. WithLightselected, right-click in the Scene dock → Attach Script → navigate tores://scripts/light.gd→ click Load. The script is now attached. (Alternative: in the Add Child Node dialog, scroll to findLightlisted under your project's classes —class_nameexposed it there. Either route lands the same node.) - The new tree:
CanvasLayer ├── Light (Node, light.gd attached) └── MainLayout (VBoxContainer) ├── TopBar (HBoxContainer, top_bar.gd attached) ├── ContentArea (HSplitContainer) └── StatusBar (Label)Lightis a sibling ofMainLayout, not a child. M2.3 will lift it out of the scene entirely. - Open
scripts/top_bar.gd(the script from M2.1). Modify it: keep thetrain_buttonandstatus_bar@onreadyreferences, but remove the localclick_countvariable. Add a third@onreadyreference forLight: The path$"../Light"walks up toMainLayout's parent (CanvasLayer) and intoLight. The annotation: Lightonly resolves becauseclass_name Lightwas registered in step 4 — withoutclass_name, the type would have to beNodeand you'd lose the editor's autocomplete onlight.valueandlight.value_changed. - Replace the body of
_on_train_pressed()to assign through the setter. The handler now reads: The+=readslight.value(through the getter), adds1.0, and writes back (through the setter). The setter clamps and emitsvalue_changed. The status-bar update line from M2.1 —status_bar.text = "Trained %d times" % click_count— is gone; M2.3 will put it back, driven by the signal. - Save the script. Press
F5. The button is visible and clickable; the status bar still says "Status: ready" and does not update on click — that is expected. The Light value is incrementing on every click but nothing is listening yet. Open the Remote tab in the running editor (next to the Scene dock; it appears only while the game is running). Find/root/CanvasLayer/Lightin the remote tree, select it, and watch the_valuefield in the Inspector tick up by1.0per click. This confirms the setter is firing and the value is changing — even with no UI feedback yet. - Close the running game. The walkthrough's deliverable for this chapter is invisible to the player — nothing on screen changes from M2.1. M2.3's job is to make the change visible by connecting the signal to a Label.
Optional sanity check. In the Output panel, add
print("Light=", _value)as the line beforevalue_changed.emit(_value)inlight.gd. Save, re-launch, click. The Output printsLight=1.0,Light=2.0, etc. The print is inside the setter, before the emit, so it fires once per assignment, not once per emit-listener. Remove the print when satisfied — leaving prints in shipped code is one of the M8 polish chapter's anti-patterns.
Self-check quiz¶
Q1 — Inside the setter you write _value = new (no clamp). Outside, you call Light.value = -10.0. What is the value of _value afterward, and does value_changed fire?
A. _value is -10.0 (the assignment ran), value_changed fires with -10.0, and the next tick clamps the value back to 0.0 via Godot's autoload-side validation hook.
B. _value is -10.0 and value_changed fires with argument -10.0.
C. _value is 0.0 — float properties without explicit ranges silently clamp negative writes via the engine's type-coercion path, but the unclamped negative does fire value_changed first.
D. The setter raises ERR_PARAMETER_RANGE_OUT_OF_BOUNDS because the property has no explicit range hint and the engine treats any negative write as out-of-range.
Reveal answer
B — _value becomes -10.0 and the signal fires with that value. GDScript's property setter does exactly what you write. The engine does not clamp on your behalf, does not reject negative values, does not coerce types. If your setter just stores, negative values are stored. If your setter emits unconditionally, the emit happens regardless of whether the value made business sense. Clamping is your job (maxf(new, 0.0)); validating is your job; gating the emit on a real change (if new != _value) is also your job. A invokes a fictitious "autoload-side validation hook" — Godot has no post-write validator that fires next tick; if you want clamping, the setter does it. C invokes a fictitious "engine type-coercion path" — float properties without range hints store whatever you assign. D invokes a real Godot error code (ERR_PARAMETER_RANGE_OUT_OF_BOUNDS) but applies it to the wrong API — that code is returned by methods that explicitly validate against @export_range, not from raw property writes. The pedagogical takeaway is that the setter is all the policy you get — it must be deliberate.
Q2 — You add class_name Light to light.gd. Another script writes var l: Light = preload('res://scripts/light.gd').new(). The editor flags this as a warning. What is the cleaner equivalent?
A. var l: Light = Light.new().
B. var l: Light = load("res://scripts/light.gd").
C. var l: Light = $Light — the editor resolves type from the node path.
D. var l: Light = autoload("Light").
Reveal answer
A — Light.new() constructs a fresh instance of the registered class. Once class_name Light is in place, the type name Light is a project-global identifier — you can use it as a constructor (Light.new()), as a parameter type (func add_to(t: Light)), as a return type, etc. Calling preload(...).new() was the pre-class_name workaround and still works, but it is verbose and re-imports the script per use. B is wrong: load() returns the script resource, not an instance — you would still need .new(). C is wrong-ish: $Light returns whatever node has that name in the current scene; the type annotation can be Light only if such a node exists, and the syntax does not "construct" anything. D is fabricated: there is no autoload() function — autoloads are accessed by their registered global name (M2.3).
Q3 — Your setter is set(new): _value = new; value_changed.emit(_value). Code calls Light.value = Light.value (assigning the property to itself, no actual change). What happens?
A. The engine compares new to the existing _value and skips the assignment when they match (similar to C++ assignment operators with self-assignment guards).
B. The assignment runs, the value is unchanged, and value_changed fires anyway with the unchanged value.
C. The setter runs once for Light.value = Light.value because the property syntax has built-in idempotency tracking.
D. The setter runs but emit() is gated by Godot's signal-emit deduplication (a real engine optimisation that suppresses identical successive emits within one frame).
Reveal answer
B — the setter runs unconditionally; the emit fires even though nothing changed. GDScript does not compare old and new values in property assignments — every write executes the setter, every line in the setter runs. If you want change-gating ("emit only when the value differs"), you write it: set(new): if new != _value: _value = new; value_changed.emit(_value). A invokes a C++-style self-assignment guard that GDScript does not provide. C invokes a fictitious "built-in idempotency tracking" — GDScript properties have no such mechanism. D invokes a fictitious "signal-emit deduplication" — the engine fans every emit out to every connected listener regardless of payload. emit() always fans out; emit-on-no-change is a real bug source if listeners do expensive work, and the fix is on you, not the engine.
Integration question¶
Q4 — open
In M2.1 your click handler did click_count += 1 and status_bar.text = "Trained %d times" % click_count in the same method. In M2.2 the handler does light.value += 1.0 and nothing else — the status bar no longer updates. What architectural property of the M2.2 design makes this acceptable, and what new component will need to exist (in M2.3) to make the status bar update again?
Reveal expected answer
The M2.1 design coupled three concerns into one method: holding the count, mutating it, displaying it. The M2.2 design splits them: Light holds and mutates; TopBar's handler only mutates (via light.value += 1.0); display is no one's responsibility yet. This is acceptable as an intermediate state because the invariant is preserved — the value is correct in _value, and value_changed fires every time it changes. What's missing is a subscriber: some code that listens to value_changed and updates the visible UI. That subscriber is what M2.3 introduces, and the pattern (a Label whose text is set in a handler connected to the value-changed signal) generalizes — every UI element in the rest of the textbook follows it. The deeper architectural property is "tell, don't ask": instead of UI code polling Light.value every frame, UI code is told on every change. Polling would also work but burns CPU between clicks; signal-driven update only runs when something actually changed.
Glossary¶
Glossary
class_name- A GDScript directive that registers the script's class under a global name, so other scripts can refer to its type by name and the editor can show it in node-creation dialogs. Without
class_name, the script is anonymous to the rest of the project and must be referenced viapreload(...). - backing variable
- The actual storage variable behind a property with getter/setter. Conventionally prefixed with an underscore (
_value) to signal "private — write through the property, not directly." The property's getter and setter are the only legal access path. - property setter
- A method body associated with a property's
setclause that runs every time code assigns to the property. Receives the incoming value as a parameter. Free to clamp, log, emit signals, or recompute caches before storing into the backing variable. emit()- The method that broadcasts a signal to every connected listener. Synchronous: the call returns only after every listener's handler has run. Listeners run in connection order. Calling
emit()with no listeners is a no-op. - autoload
- A script registered in
Project Settings → Globals → Autoload, instantiated once at game start, reachable from any other script by its registered name. Survives scene changes. Use for cross-scene state and engine-wide systems. (Mentioned here; introduced fully in M2.3.)