Part 03 — Catalogues and tables
You will build: the roster + index — the @export array humans edit and the Dictionary
the game hits — with the bad-id warning and the duplicate-id check that make a catalogue
honest instead of just convenient.
You'll learn: why two structures hold one list · the id as the join key · scene variants
as data · the granularity question (global catalogue vs per-owner list).
Why this exists#
Part 02 made a kind a file. This part makes a set of kinds findable. A single CropData
is content; twenty of them, looked up by id at runtime, is a catalogue — and a catalogue
has a shape that's easy to get wrong in both directions: one structure that's editable but
slow to hit, or fast to hit but uneditable. The 3D farm's crop_catalogue + _by_id (stage
05) is the correct shape, and this part is the general form plus the two checks that keep it
telling the truth.
Build it#
A — The roster and the index, and why both#
The 3D farm's Farm carries two structures that hold the same crops (stage 05):
@export var crop_catalogue: Array[CropData] = [] # the roster — humans edit this
var _by_id: Dictionary[StringName, CropData] = {} # the index — the game hits this
func _ready() -> void:
for data in crop_catalogue:
_by_id[data.id] = data
They're not redundant, and the reason is that they're edited and used by different people at different times:
- The roster (
Array[CropData],@export) is the human surface. It shows in the inspector as an ordered list of crop resources — a designer adds the parsnip by draggingparsnip.tresinto slot 3, and the order is visible (which is a design fact: the inventory lists crops in roster order, part 02's acceptance test depends on it). An array is what the inspector renders well, and what "a list a human maintains" means. - The index (
Dictionary[StringName, CropData]) is the game's surface.plant(cell, &"turnip")needs the turnip's definition by id, and a dictionary hits it in O(1) where the roster would scan. The game never iterates the roster at runtime; it iterates the index's keys when it needs "all crops" (the save, part 02's stage 07) and hits it by id when it needs "this crop."
Building the index in _ready is the load order: the roster is set by the scene (the
inspector's exports are assigned before any _ready runs), so _ready is the first moment
the roster is complete and the index can be built from it. Build the index in a state's
_enter or a signal and you've indexed a partial roster — the catalogue missing whatever
the scene hadn't assigned yet. The 3D farm's state machine defers its first _enter_state behind
await owner.ready for the same reason: the index, like the machine's registration, is built
when its input is whole.
B — The bad id, named#
The roster/index split creates a failure mode neither structure has alone: an id the game
asks for that the roster doesn't have. A typo in selected_crop_id (&"turnip" →
&"trunip") or a crop removed from the roster but still referenced. The 3D farm's stage 05
handles it, and the handling is the pattern:
var data: CropData = _by_id.get(crop_id, null)
if data == null:
push_warning("Farm: no crop with id '%s' in the catalogue" % crop_id)
return false
push_warning — not push_error, not a silent null — because a bad id is recoverable
(the plant is refused, the game continues) but not intended (someone referenced a crop
that isn't there). The warning names the id, so the typo is findable in the output in one
grep. The alternative failures are both worse: a silent null (the field never plants and
the bug is "why doesn't the turnip work" three sessions from now) and a push_error/assert
(the game stops over a data typo, which is the error's weight on the warning's problem).
A catalogue that can't name its own misses is a catalogue you're debugging by elimination.
C — The duplicate id, checked#
The bad-id check catches a missing definition. The duplicate check catches a colliding
one — two .tres files with the same id, where the roster holds both and the index holds
one (the last, silently):
func _ready() -> void:
for data in crop_catalogue:
if _by_id.has(data.id):
push_warning("Farm: duplicate crop id '%s' — the earlier entry is shadowed" % data.id)
_by_id[data.id] = data
Without the check, the duplicate is the most insidious catalogue bug: the roster shows two
turnips (the designer sees both, thinks both are live), the index has one (the game plants
the last), and the two turnips can have different days_per_stage — so the game grows a
crop at a tempo the designer didn't pick, and the inspector (showing both) can't reveal which
won. The warning names the id and says "the earlier entry is shadowed," which is the
consequence the designer needs to see. (Part 02's stretch — the derived stage_count — is
the same class of bug one level down: a second source of truth that can disagree with the
first. The duplicate id is a second source of truth that looks like two.)
D — Scene variants as data#
The survivor's stage 03 stretch made a Brute as a scene variant of Golem — same scene,
different EnemyStats exports, different skin. A variant is the catalogue pattern applied to
scenes instead of resources: the variant's inspector is the per-kind data, and the
controller reads it without knowing the variant exists. The test is stage 03's: "if you have
to write any code to make it — beyond the variant — find where the species assumption hid,
and move it to EnemyStats."
The variant is where "add a kind" stops being a .tres and becomes a .tscn, and the line
between the two is does the kind have a scene presence? A crop is data (a .tres — its
"scene" is the one shared Crop view, part 02's triple). An enemy is data plus a scene
(a variant — its EnemyStats are the data, its collider and skin are the scene). A weapon
upgrade is data only (a .tres). The container follows the presence, the same rule as part
02's three containers, one level up: the kind is a file; the file's type is set by whether
the kind is shown.
E — Granularity: global catalogue vs per-owner list#
The 3D farm's crop_catalogue is global — one roster on the Farm, every cell can plant
any crop in it. The survivor's weapon upgrade_list is per-owner — each weapon carries
its own roster of upgrades (stage 07), and the level-up rolls from that weapon's list.
Both are correct, and the choice is a design fact, not a style one:
- Global when the set is shared — the farm's crops are the same whether planted by you or (later) an NPC; one roster, one index, one place to add the parsnip.
- Per-owner when the set is owned — a weapon's upgrades are that weapon's (the
crossbow doesn't offer the sword's
faster_swing); each owner's roster is its design surface, and the level-up's roll is scoped to the owner by construction.
The granularity is the scope of the id: a global catalogue's ids are unique across the
game (two crops can't share an id, part B's check), a per-owner list's ids are unique
within the owner (two weapons can each have a damage upgrade — the id is the kind, the
owner is the scope). When a per-owner list grows to need cross-owner lookups ("which
weapons offer knockback?"), that's the moment a global index of owners earns its keep —
and the moment to build it, not before. The event bus shelf
is the general form of "a lookup that outgrew its scope"; this is the data version.
F — Commit#
git add . && git commit -m "data 03: roster + index, bad-id and duplicate checks, variants"
Checkpoint — definition of done#
- Your catalogue has a roster (an
@exportarray, inspector-editable) and an index (aDictionary, O(1) by id), built in_readyfrom the whole roster — and you can say why_readyis the first safe moment (the scene's exports are assigned before it) - A bad id produces a
push_warningthat names it — type the typo, read the warning, fix the typo. The warning is the check; its absence is the bug - A duplicate id produces a warning that says which entry is shadowed — and you've seen the consequence (the game plants the last, the inspector shows both)
- You can state the granularity of your catalogue (global or per-owner) and the design fact that decided it (shared set vs owned set)
- A new kind added as a file (a
.tresor a scene variant) with zero code — the acceptance test, part 02's, run on the catalogue not just the definition - Zero warnings (other than the ones you provoked); committed
Stretch (no instructions)#
A catalogue that validates itself at load: in _ready, after the index is built, check
every definition's internal consistency — the 3D farm's CropData with an empty
stage_meshes (part 02's show_stage warning, caught up front instead of at first
plant), a days_per_stage of 0 (the division-by-zero the 3D farm's stage 06 growth would hit
on the first advance_day). The catalogue is the one place that sees all the definitions
at once, which is the one place a cross-definition check can live. A warning per bad
definition, named, at load — the game starts knowing its content is sound.
If you get stuck#
- The index is empty at runtime (every lookup warns) → the roster wasn't set when
_readyran: the catalogue is assigned in code after_ready(a_readyon the owner that fills the array), or the scene's export was never set (the array is empty in the inspector). The index is built from the roster as it is at_ready; fill the roster before then, or rebuild the index after you fill it. - Two crops with different ids plant the same definition → the index is keyed by something
that's not unique (a
display_nametwo crops share, part 02's id/display split reached for the wrong field). The key is theid; a key that collides is a key that wasn't the contract. - The per-owner list "leaks" across owners (weapon A offers weapon B's upgrade) → the roll
reads a global roster instead of the owner's list (part E's granularity, built as global
when the design is per-owner). The scope is the owner; the roll is
owner.upgrade_list, notcatalogue.all_upgrades. - Adding a kind needs a code line, and the line is in the index build → the new kind's
definition has a field the index build special-cases (a
matchon the id, part 02's behaviour-selector leak). The index is built from the roster's shape, not its contents; a line that names a specific kind is the catalogue knowing too much.