L2.2 — Members, Properties & Annotations¶
What you'll learn
- Separate member variables (persist on the object) from locals (live for one function call).
- Give a variable a custom setter/getter to make one choke point for validation and change signals.
- Export a variable to the Inspector with
@exportand its variants. - Use
@onreadyto safely grab child references, and understand why a plain member would crash.
How it applies
- A local mistaken for a member resets every call. Declaring a counter inside a function, then expecting it to remember its value between calls, produces the "it keeps resetting to zero" bug. Knowing where state lives — on the object versus in the call — is the fix.
- No setter means every call site must remember the rule. If clamping health to
[0, max]lives at each place that changes it, one forgotten clamp ships a negative health bar. A setter enforces the rule in one place that cannot be bypassed — the single-choke-point discipline. - Un-exported tuning needs a programmer for every tweak. A speed or cost baked into code instead
of
@exported cannot be adjusted in the Inspector, so every balance change is a code edit.@exportis the seam that hands tuning to whoever is balancing, without touching code. - A plain
var x := $Childcrashes at startup. Member initializers run at object-creation time, before children exist, so the child reference is null.@onreadydefers that one line to ready-time, when the child is present. This is the most common first-week Godot crash.
Concepts¶
Members versus locals¶
A variable declared at the top level of the script is a member: it belongs to the object and persists for the object's lifetime. A variable declared inside a function is a local: it is created when the function runs and gone when it returns.
extends Node
var hits := 0 # member — persists across calls
func register_hit() -> void:
var bonus := 5 # local — fresh each call, gone at return
hits += 1 # the member accumulates
If hits were declared inside register_hit, it would reset to 0 on every call and never count
anything. You know this distinction from other languages; the GDScript specifics are that members go
at the top of the file (not inside a class { } block — the file is the class) and that member
access does not need a self. prefix (though self.hits is legal).
Properties: a setter as the single choke point¶
Any member can have a custom setter and getter — code that runs on every write and read:
var health: int = 10:
set(value):
health = clampi(value, 0, max_health) # clamp on every assignment
health_changed.emit(health) # and announce the change once
get:
return health
Now health = 999 stores max_health, and health = -5 stores 0 — automatically, everywhere,
because the clamp lives in the setter. Assigning to health inside its own setter writes the
backing store directly and does not re-trigger the setter, so there is no infinite recursion. Some
code prefers an explicit backing variable named _health to make the storage obvious; both styles are
common. The point is the same: one place that every write must pass through. The game books use this
to clamp resources and emit a single change signal per change (the M3.2 Health component is exactly
this shape).
Example
Without a setter, the clamp is everyone's responsibility — and someone forgets:
# scattered: each call site must remember to clamp
health = health - damage # site A — forgot to clamp, can go negative
health = clampi(health - heal_amt, 0, max_health) # site B — remembered
With a setter, the clamp is unforgettable, because it is not at the call sites at all — it is in
the property. Every health = ... anywhere is clamped. This is the same reason a tester prefers one
validated entry point over many: fewer places for the rule to be missed.
@export — surfacing a variable to the Inspector¶
@export makes a member editable in the Inspector and saved into the scene/resource file:
@export var speed: float = 150.0
@export_range(0, 100) var armor: int = 0 # a slider with bounds
@export var actor_name: String = ""
The variant annotations refine the editor widget: @export_range(min, max, step) gives a bounded
slider, @export_enum("Easy", "Hard") a dropdown, @export_group("Combat") a collapsible header
grouping the exports below it. An exported value set in the Inspector is stored in the node's .tscn
(or a resource's .tres), so it travels with the scene and can differ per instance. This is the
data/code seam: code declares what is tunable, the Inspector decides the values.
@onready — deferring initialization to ready-time¶
A plain member initializer runs when the object is created — before children exist (L2.1). So this crashes:
@onready defers that one variable's initialization until _ready, when the child is in the tree:
This is the canonical way to hold a reference to a child node, and you will write it constantly in
Part B and the game books. Read it as "initialize this the moment the node is ready, not the moment it
is created." (@tool, mentioned only here, is a different annotation that makes a script also run in
the editor; you will not need it for the game books.)
Walkthrough¶
Use a fresh script on a Node scene, run with F6.
- Declare a member
var hits := 0. Writefunc bump(): hits += 1. Callbump()three times in_ready, printhits(3). Now movevar hits := 0insidebump, repeat, and watch it print1every time — the local-versus-member bug, on purpose. Restore the member. - Add a property with a setter that clamps:
var energy: int = 0:withset(value): energy = clampi(value, 0, 100). Assignenergy = 500andenergy = -10, printing after each; confirm100and0. - Add
@export var label_text: String = "hi"to a script attached to a node, select the node, and find the field in the Inspector. Change it there, run, and print it — the Inspector value, not the code default, is what appears. - Add a child node named
Markerto your scene. Tryvar m := $Markeras a plain member and run (crash/null). Change it to@onready var m := $Markerand confirm it now resolves.
Optional sanity check
Put a print inside your property's setter. Then assign the property a few times and watch the
setter fire on each assignment in the Output — including the clamp. Try assigning the property from
inside the setter (it already does, to store the value) and confirm there is no infinite loop:
the in-setter assignment writes storage directly without re-entering the setter.
Self-check quiz¶
Q1 — A counter declared var n := 0 inside a function, incremented and printed each call, always prints 1. Why?
A. The function is only called once.
B. n is a local: it is created fresh on every call and destroyed at return, so it never accumulates.
C. := resets the variable.
D. You must use @onready for counters.
Reveal answer
B. A variable declared inside a function is a local, recreated each call; to persist across
calls it must be a member (declared at the top of the script). A contradicts the premise
(it prints, so it is called). C misattributes the reset to := (the issue is scope, not the
operator). D is unrelated — @onready is about timing of child references, not counters.
Q2 — What is the advantage of clamping health in a property setter rather than at each assignment?
A. It runs faster.
B. The clamp is enforced at one choke point every write passes through, so no call site can forget it.
C. It makes health exported automatically.
D. It prevents health from being read.
Reveal answer
B. A setter centralizes the rule: every assignment to the property is clamped, so a forgotten
clamp at some call site cannot happen. A is not the point (it is about correctness, not speed).
C conflates setters with @export (separate features). D is false — a getter still allows reads.
Q3 — var sprite := $Sprite (plain member) errors at startup; @onready var sprite := $Sprite works. Why?
A. @onready creates the Sprite node.
B. Plain member initializers run at object creation, before children exist; @onready defers the line to _ready, when $Sprite is in the tree.
C. $ only works with @onready.
D. @onready makes the variable static.
Reveal answer
B. Member initializers evaluate at creation time, when child nodes are not yet present, so
$Sprite is null and the assignment fails; @onready postpones that initialization to
ready-time, when the child exists. A is wrong — @onready references an existing child, it does
not create one. C is false ($ works anywhere; it just resolves to null too early without
@onready). D invents an unrelated effect.
Integration question¶
Q4 — open
A Door script has @export var locked: bool = true, a plain member var key_icon := $Icon, and
a func unlock(): locked = false with no setter on locked. Three things are notable: the game
crashes on load, designers can toggle the door's locked state per-instance, and a separate
"play a clunk sound when the door locks/unlocks" feature has to be remembered at every place that
writes locked. Connect each observation to a chapter concept, and propose the fix for the crash
and for the scattered sound.
Reveal expected answer
The crash comes from var key_icon := $Icon as a plain member: the initializer runs at
creation, before $Icon exists, so it is null and errors. Fix: @onready var key_icon := $Icon
to defer it to ready-time. Designers can tune per-instance because locked is @exported —
that is the data/code seam working as intended; the value lives in each door's .tscn, not in
code. The scattered sound is the symptom of locked having no setter: every site that writes
locked must separately remember to play the clunk, and one will forget. Fix: give locked a
setter that plays the sound (and emits a change signal) on every write, so the behavior is a
single choke point rather than a convention. The chapter's three ideas — @onready for child
references, @export for the tuning seam, and setters for the single choke point — each map to
one of the three observations.