Skip to content

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 @export and its variants.
  • Use @onready to 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. @export is the seam that hands tuning to whoever is balancing, without touching code.
  • A plain var x := $Child crashes at startup. Member initializers run at object-creation time, before children exist, so the child reference is null. @onready defers 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:

var sprite := $Sprite          # runs at creation: $Sprite is null → error

@onready defers that one variable's initialization until _ready, when the child is in the tree:

@onready var sprite := $Sprite         # runs at ready-time: $Sprite is valid

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.

  1. Declare a member var hits := 0. Write func bump(): hits += 1. Call bump() three times in _ready, print hits (3). Now move var hits := 0 inside bump, repeat, and watch it print 1 every time — the local-versus-member bug, on purpose. Restore the member.
  2. Add a property with a setter that clamps: var energy: int = 0: with set(value): energy = clampi(value, 0, 100). Assign energy = 500 and energy = -10, printing after each; confirm 100 and 0.
  3. 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.
  4. Add a child node named Marker to your scene. Try var m := $Marker as a plain member and run (crash/null). Change it to @onready var m := $Marker and 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.