Part 01 — A number in the wild
You will build: nothing new in the scene — you'll take three numbers that are living in
your code and move each one to the home the test says it belongs in.
You'll learn: the @export family · the per-kind / per-instance / per-run / never test ·
why the home of a number is a design decision, not a style one.
Why this exists#
Every data-driven system starts as a literal. The survivor's first enemy had
var move_speed := 35.0 in the controller (stage 02) — a number, doing its job, and
correct, for one species. The day a second species existed, the number was in the wrong
place, and the fix was not "make it configurable" — it was "decide what this number is."
This part is the decision, with a test that makes it mechanical.
Build it#
A — The @export family, in the order you'll meet them#
@export is the one keyword that moves a value from the code into the inspector — where it
can be seen, changed, and (in a scene) set per-instance without a code edit. The family:
@export var move_speed := 35.0 # any value, editable
@export_range(1.0, 100.0, 0.5) var radius := 20.0 # bounded — the inspector enforces the range
@export_enum("damage", "attack_speed") var kind # closed — the inspector offers the list
@export var crops: Array[CropData] = [] # typed array — the inspector offers CropData resources
@export var farm: Farm # a node — the inspector offers a scene path
Each modifier is a fence: @export_range says the value can't leave the band,
@export_enum says it can't leave the list, the typed array says its members can't leave the
type. The fence is the whole point — an unbounded @export var is a value with a home but no
walls, and part 04's "can a non-programmer break this" test fails on the wall-less ones.
(The programming shelf covers the typing rules
these exports stand on; the class_name that makes Array[CropData] resolvable is there.)
B — The test: what is this number?#
Take a literal out of your code — move_speed, a damage value, a spawn interval, a radius —
and ask the four questions, in order. The first one that's yes is the home:
- Does it never change, even across instances? →
const. Aconst CELL_SIZE := 1.0(the 3D farm's grid, stage 04) is a law of the world, not a setting. Putting a constant in the inspector is a number that can be changed into a different game by accident — the fence that should be a wall is a door. - Does it change per kind (per species, per crop, per weapon)? →
@exporton the scene that is the kind. TheGolemscene'sEnemyStats.move_speedis per-golem-species: theBrutevariant sets 22 in its inspector, theGolemkeeps 35, and the controller readsenemy_stats.move_speedwithout knowing which. The survivor's stage 03 stretch is this exact move — "if you have to write any code to make the Brute, find where the species assumption hid." - Does it change per instance (this one, right now)? → runtime state, on a stats
node, written by the game. The enemy's
move_speedafter stage 03's variance (randf_range(base * 0.75, base)) is per-instance: the base is per-kind (question 2's home), the rolled value is per-instance (question 3's). Two homes, one number's life — the base is data, the roll is state, and the controller reads the state while the inspector shows the base. - Does it change per run (and survive it)? → a save. The 3D farm's
days_grown(question 3, but persistent) and the survivor'slevelare per-run values the save & load shelf serializes. A per-run number that isn't saved resets on reload, and the reset is the bug the player reports as "my progress is gone."
The test is mechanical because the homes are mechanical: const in the script, @export
on the kind's scene, a plain var on the stats node, a field in the save. A number that
fails all four (it changes per kind and per instance and per run — a weapon's damage,
upgraded, saved) has multiple homes, one per stage of its life, and the definition/state
split is the part that tells you which home is which.
C — Run it on three of your numbers#
Pick three literals from a project you've built (the survivor's MAX_ENEMY_COUNT, the 3D
farm's CELL_SIZE, the farming game's MOVE_SPEED — or your own). For each:
- Ask the four questions, in order, and write down the first yes.
- Move the number to that home.
- Say out loud what the old home would have broken:
MAX_ENEMY_COUNTas a per-instance@export(every enemy could set its own cap — the cap is a law, question 1);CELL_SIZEas a per-run save (the grid's size is a world fact, question 1, and saving it would let a future run disagree with the farm's geometry);MOVE_SPEEDas aconst(the second species can't walk a different speed, question 2).
The third column is the whole part: a number in the wrong home doesn't error, it constrains — it removes a degree of freedom the design didn't ask to remove, and the cost shows up as "why can't I make the Brute slower" three species from now.
D — The first mistake, named#
The mistake this part exists to catch is per-instance state on a per-kind home — a value
that should be rolled per enemy stored on the species' @export, so every instance shares
it. The survivor's stage 05 met it as the double-source-of-truth bug (the weapon's damage
export and the hitbox's damage member, both "the damage," the upgrade landing on the one
nobody reads). The data version: move_speed on EnemyStats as the rolled value instead
of the base — every golem in the scene gets the same speed, the variance is gone, and the
inspector shows a number that's a roll wearing a base's clothes. The test catches it:
a rolled speed is question 3 (per-instance), and question 3's home is not the inspector.
E — Commit#
git add . && git commit -m "data 01: three numbers moved to their tested homes"
Checkpoint — definition of done#
- Three numbers moved, each with the four questions answered in writing (a comment at the new home, or the commit message — the answer is the record)
- Each move removed a constraint you can name: the old home would have broken a specific, stated design ("the Brute," "the second crop," "the save")
- At least one of the three was a
constyou demoted or an@exportyou promoted — the test works both directions, and a number that's already home is the one you didn't move - You can state the test's four questions and the four homes without notes
- Zero warnings; committed
Stretch (no instructions)#
A number that looks like question 2 (per-kind) but is question 1 (a law): the survivor's
RING_LENGTH (stage 02's 1728.0). It's per-ring, and there's one ring, and the ring's
size is a consequence of its radius (2 * PI * 275), not a setting. The honest home is a
const derived from the radius — or the radius as the const and the length computed.
Find the form where changing the ring's size is one edit, and say why the "setting" version
would have let the radius and the length disagree.
If you get stuck#
- The
@exportdoesn't show in the inspector → the variable's type isn't exportable (a bareDictionary, aCallable, a custom class without aclass_name), or the@exportis on aconst(it can't be — constants have no per-instance value). The type is the fence's material; part 04's wall-less exports are the ones that fail here. - Moving a number to
@exportmade all instances share the scene's value → you exported the instance's value on a scene that's instanced many times (the golem scene), and the inspector's value is the default, not the per-instance roll. Question 3's home is a plainvarset in_ready(the survivor's stage 03 variance), not an@export— the export is the base (question 2), the roll is the state. Two variables, two homes, one number's life. - A
constyou moved to@exportnow differs between two scenes that should be identical → the "law" wasn't a law; it was a shared default you'd made constant by accident. The two scenes disagreeing is the test telling you the number was question 2 all along — keep the@export, and the disagreement is now a setting, not a bug. - You can't tell question 2 from question 3 → ask "does the designer set this, or does the
game?" Question 2 is set in the inspector (a design decision, per kind); question 3 is
set in
_readyor a signal (a runtime fact, per instance). The designer's numbers are data; the game's numbers are state; the line between them is the overview's rule.