doc 4 of 5

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 dragging parsnip.tres into 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 @export array, inspector-editable) and an index (a Dictionary, O(1) by id), built in _ready from the whole roster — and you can say why _ready is the first safe moment (the scene's exports are assigned before it)
  • A bad id produces a push_warning that 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 .tres or 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 _ready ran: the catalogue is assigned in code after _ready (a _ready on 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_name two crops share, part 02's id/display split reached for the wrong field). The key is the id; 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, not catalogue.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 match on 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.