doc 3 of 5

Part 02 — Definitions and instances

You will build: the definition/state split as a reusable shape — a Resource for the kind, a container for the instance, the rule that decides which field goes where — and you'll run the 3D farm's mutation prediction to see what "shared" actually means. You'll learn: the never-mutate-a-.tres rule and why it's a physics law, not a style one · Resource vs RefCounted vs Node as the three containers · the acceptance test that a new kind is a file.

Why this exists#

Part 01 moved numbers to their homes. This part moves kinds — a crop, a weapon upgrade, an enemy species — to a shape where the kind is a file and the instance is a value. The 3D farm's stage 05 is the worked example in full; this part is the general form, because the same triple (definition / state / view) shows up in the farming game's CropData, the survivor's WeaponUpgrade, and any game where "add a thing" should be "add a file."

Build it#

A — The triple, in the 3D farm's words#

The 3D farm has three types doing three jobs (stage 05), and the jobs are the pattern:

TypeContainerJobLifetime
CropDataResource (a .tres)the kind: id, name, days_per_stage, stage_meshesshared, immutable, loaded once
PlotRefCountedthe instance: which kind, current stage, days_grownper planted cell, mutable, freed by reference count
CropNode (a scene)the view: shows stage_meshes[stage]per planted cell, disposable, rebuilt from the Plot

The rule from the overview, applied field by field:

  • days_per_stagecould two turnips disagree? No. Every turnip takes one day per stage. → definition, on CropData.
  • days_growncould two turnips disagree? Yes. Planted a day apart, they're a day apart forever. → state, on Plot.
  • stage_meshes — the look of the kind, per stage. Two turnips show the same meshes. → definition.
  • stagewhich mesh this turnip is showing, derived from its days_grown. → state.

The test is the same as part 01's, one level up: part 01 asked "what is this number," this part asks "what is this field," and the answer is always "could two of the same kind disagree." A field that answers yes is state; no is definition. The 3D farm's stage 05 checkpoint makes it a drill: "name a field that would break if it lived on the wrong side (days_grown on CropData would make every turnip the same age)."

B — The never-mutate rule, as physics#

The rule: never mutate a loaded .tres at runtime. It reads like a style preference until you see why it's a law. Godot shares loaded resources by reference — ten turnips don't hold ten copies of turnip.tres; they hold ten pointers to the one CropData in memory. Mutate days_per_stage on the loaded resource and every turnip in the field changes tempo at once — not because the code is wrong, but because there is one resource and ten owners.

The 3D farm's stage 05 runs the prediction that makes it visible:

Change turnip.tres's stage_meshes[1] to a cube. Save. What does the running game show — and what will the next run show?

The running game shows nothing (the loaded resource is in memory; the file is a different object now). The next run shows every turnip's middle stage as a cube — every one, because they share the definition. The two tenses are the whole rule: the file is not the runtime, and the runtime is not private. A mutation you make at runtime is invisible until the next load (when it's gone — you mutated memory, not the file) or visible to every instance at once (when you've found the one code path that writes the shared resource). Neither tense is the one you want, which is why the rule is "never," not "carefully."

The corollary is the one that catches the bug: if every instance of a kind changes at once, you mutated the definition. The farming game's stage 04 stuck-list has the fingerprint — "all crops share one look after planting two kinds → you mutated the shared definition somewhere." The data version is the same: a per-instance field that behaves per-kind is a definition that got written to.

C — The three containers, and which one a thing is#

The split's fields need homes, and Godot offers three. The choice is a lifetime question, not a feature one:

  • Resource — shared, loadable from a .tres, inspectable, immutable by convention. The definition's container. A Resource is the only container Godot serializes to a file you can edit in the inspector, which is the whole reason the definition is one: the kind is content, and content is a file.
  • RefCounted — private data, freed when the last reference drops, not a scene node. The state's container when the state has no scene presence. A Plot is a RefCounted because it's data with a lifetime and no transform, no process, no reason to be in the tree — the 3D farm's stage 04 argued it: "if you find yourself wanting a plot to do something, that want is the central-tick decision resurfacing." A RefCounted that starts doing things is a Node you didn't mean to build.
  • Node — a scene presence, a lifetime bound to the tree, a process callback. The view's container, and the only container that can be a child of something and die with it. A Crop is a Node because it's shown, and shown things have to be in the tree to be shown.

The mistake is reaching for the wrong one by habit: a Plot as a Node (state with a scene presence it doesn't need — the 3D farm's stage 04 decision 3, option B, "the tree is the map," the temptation the dictionary beats); a CropData as a RefCounted (a definition you can't edit in the inspector — the kind is no longer content, it's code again); a Crop view as a Resource (a thing that's shown with no scene presence — it can't be shown). The container is the lifetime; the lifetime is the job; pick the job first.

D — The id, and the display name#

Every definition needs a machine name and a human name, and they're different fields for a reason:

@export var id: StringName = &""            # the machine name — keyed, compared, saved
@export var display_name: String = "New Crop" # the human name — shown, never branched on

id is a StringName because it's looked up constantly (part 03's catalogue is a Dictionary[StringName, CropData] — StringNames hash by identity, the cheapest key there is) and saved (the 3D farm's stage 07 save holds the crop's id, never its display name — a renamed display doesn't orphan a save; a renamed id would). display_name is a String because it's shown and nothing else — code never branches on it. The bug the split prevents: a game that matches on "Turnip" (the display name) to decide growth, and breaks the day someone localizes the name to "Rüben" or renames it for flavour. The id is the contract; the display name is the label on the box.

E — The acceptance test#

The 3D farm's stage 05 states it as the design's pass/fail: add a third crop with zero code changes. A parsnip.tres (id, name, days_per_stage, meshes), one entry in the farm's crop_catalogue (part 03), and the parsnip plants, grows on its own tempo, and harvests — with no .gd file edited. If the parsnip needs a line of code, the split has a hole, and the hole is a field on the wrong side of the rule (a per-instance fact on the definition, or a per-kind fact hardcoded in a controller). Find the line; the line is the leak.

The survivor's stage 07 runs the same test on upgrades: a third WeaponUpgrade .tres applies through the same apply_upgrade with no new match branch as long as its upgrade_type is one the enum already has — the fence part 04 names. A new kind of upgrade (one the enum doesn't list) is a new enum value and a new branch, which is code, by design: the set of upgrade kinds is closed, and the set of upgrade instances is open.

F — Commit#

git add . && git commit -m "data 02: definition/state split, three containers, the id"

Checkpoint — definition of done#

  • You can name, for your game's central kind, which fields are definition and which are state, and why (the "could two disagree" test, per field)
  • The mutation prediction ran: you changed a definition and saw the two tenses (the running game unchanged, the next run every-instance-changed) — and you can say which code path would have made the running game change (a write to the shared resource)
  • Each of your three types is on the container its job requires, and you can say what the wrong container would have cost (part C's three mistakes, named for your game)
  • The id is a StringName, the display name is a String, and no match/if branches on the display name — grep for it; the absence is the check
  • The acceptance test passed: a new kind added as a file, zero code, and you can name the hole that would have made it need a line
  • Zero warnings; committed

Stretch (no instructions)#

A definition with a derived field: the 3D farm's CropData.stage_count() (the farming game's stage 04 left it as a blank — stage_textures.______). The stage count is derived from stage_meshes.size(), and the question is whether it's a field or a method. Make it a method, and say why a stored stage_count field would have been a second source of truth (the definition's own version of the survivor's stage 05 bug — a number that can disagree with the number it's derived from).

If you get stuck#

  • Every instance of a kind changes at once when one should → the mutation fingerprint (part B). Find the write to the shared Resource; the fix is a copy (crop_data.duplicate()) if the instance legitimately needs its own, or a field moved to the Plot if it was state all along.
  • A Plot is "doing things" (ticking itself, emitting its own day signal) → the container reached for Node behaviour while staying a RefCounted. The 3D farm's stage 04 answer: the farm ticks the plots (the central tick), and the Plot is data again. A state container that schedules itself is the per-entity-listener option the central tick beat.
  • The new kind needs a code line, and the line is a match on the id → the id is being used as a behaviour selector, not a lookup key. A kind that behaves differently by id is a kind that needs a different definition field (a flag, a parameter) — the behaviour is data on the definition, not a branch on the id. The branch is the leak.
  • Two definitions with the same id → part 03's duplicate check. For now, the id is the contract and a duplicate is a silent overwrite in the catalogue (the last one wins); the warning that names both is part 03's, and it's the difference between "a kind I can't find" and "two kinds with the same name."