Stage 05 — Crops as data: the third crop takes zero code
You will build: CropData as a custom Resource · the turnip and carrot .tres files
with mesh stages · the real Crop scene that renders a plot's stage · and the catalogue
lookup that makes "add a crop" a file operation.
You'll learn: the definition/state split, the 2D game's core pattern, re-earned in 3D ·
why Plot holds a CropData and never a Crop · the never-mutate-a-.tres rule.
Why this exists#
Stage 04's placeholder marker says something is planted. This stage says what — and the
"what" is a data file, not a script. The reference project's history is the proof: the
carrot was added as carrot.tres with zero code changes, and that sentence is the whole
design's acceptance test. If you can't add a crop without touching a .gd file, the split
didn't take.
Build it#
A — CropData: the kind#
# crop_data.gd
class_name CropData
extends Resource
@export var id: StringName = &""
@export var display_name: String = "New Crop"
@export var days_per_stage: int = 1
@export var stage_meshes: Array[Mesh] = []
Four fields, each with a job:
id— the machine name (&"turnip"), aStringNamebecause it's compared (and keyed) constantly and never displayed. The catalogue is aDictionary[StringName, CropData]; StringNames hash by identity, so the lookup is as cheap as it gets.display_name— the human name, for the inventory's UI (stage 07) and for nothing else. Code never branches on it.days_per_stage— the growth tempo: how many day-ticks per mesh stage. The number the 2D game's turnip carried, now on a 3D crop.stage_meshes— the look, as an array of meshes, one per growth stage. Stage 0 rendersstage_meshes[0]; maturity is the last one. An array of Meshes, not of scenes: theCropnode is the only scene in the system, and it swaps a property.
Make the turnip: FileSystem dock → right-click data/crops/ → New Resource → CropData.
Fill it: id &"turnip", name "Turnip", days_per_stage 1, and three stage_meshes —
placeholder primitives are the project's art pipeline and a legitimate choice at this stage:
a small sphere (stage 0, the sprout), a medium one (stage 1), a larger one (stage 2,
mature). Each is a PrimitiveMesh created in the array's inspector (the 3D modelling shelf's
low-poly asset path is where these become
models; until then, geometry is data and data is geometry). Save it as
data/crops/turnip.tres.
B — The split, upgraded#
Stage 04's Plot held a StringName; it now holds the definition:
# plot.gd — as stage 04 left it, with the field upgraded
class_name Plot
extends RefCounted
var crop: CropData
var stage: int = 0
var days_grown: int = 0
func _init(crop_data: CropData) -> void:
crop = crop_data
And Farm.plant resolves the id against the catalogue:
# Farm — the new parts
@export var crop_catalogue: Array[CropData] = []
var _by_id: Dictionary[StringName, CropData] = {}
func _ready() -> void:
for data in crop_catalogue:
_by_id[data.id] = data
func plant(cell: Vector2i, crop_id: StringName) -> bool:
if _plots.has(cell):
return false
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
var plot := Plot.new(data)
_plots[cell] = plot
_add_view(cell, plot)
return true
func _add_view(cell: Vector2i, plot: Plot) -> void:
var crop := CROP_SCENE.instantiate() as Crop
add_child(crop)
crop.position = cell_to_world(cell)
crop.show_stage(plot.crop, plot.stage)
_views[cell] = crop
Walk the division of labour, because it's the pattern's whole surface:
crop_catalogueis the@exportarray on the Farm — the roster, edited in the scene's inspector (addturnip.tres, latercarrot.tres). It's anArray[CropData], so the inspector only offers CropData resources and the type is enforced at the door._by_idis the index, built once in_readyfrom the roster. The roster is for humans (order, visibility); the index is for lookups (O(1) by id). Keeping both is the catalogue pattern the data-driven shelf's third part generalizes: a typed array you can see, a dictionary you can hit.plantrefuses twice — occupied cell, unknown id — and the unknown-id pathpush_warnings instead of failing silently. A typo inselected_crop_idis now a named warning in the output, not a field that quietly never plants._add_viewis the projection step. It instantiates the one crop scene, positions it by the cell's center, and callsshow_stagewith the plot's current state. The view is built from the data; it never feeds back.
C — Crop: the view that shows a stage#
# crop.gd — on a Node3D with a MeshInstance3D child named Mesh
class_name Crop
extends Node3D
@onready var mesh_instance: MeshInstance3D = $Mesh
func show_stage(data: CropData, stage: int) -> void:
if data.stage_meshes.is_empty():
push_warning("CropData '%s' has no stage meshes" % data.id)
return
mesh_instance.mesh = data.stage_meshes[clampi(stage, 0, data.stage_meshes.size() - 1)]
The scene is one Node3D and one MeshInstance3D — nothing else, because a view that
carries state is a view that will lie. show_stage takes the definition and the stage and
renders exactly that: no member variables, no signals, no memory of the last stage.
clampi is the guard against a plot whose stage outlives its definition (a crop data file
edited down to two stages while a three-stage plot exists in the field) — it degrades to the
last mesh instead of indexing off the array, and the push_warning names the empty
definition for the two-stage file that forgot its meshes.
The 2D game's rule transfers verbatim: never mutate a loaded .tres at runtime. Ten
turnips reference one turnip.tres; it is shared, and Godot shares loaded resources by
reference — change days_per_stage on the loaded resource and every turnip in the field
changes tempo at once. The per-plant fact is Plot.days_grown; the per-kind fact is
CropData.days_per_stage, and the line between them is the definition/state split drawn on a farm.
D — The zero-code proof#
Make data/crops/carrot.tres: id &"carrot", name "Carrot", days_per_stage 2 (slower
than the turnip — the tempo is data and the data says so), three meshes (a prism reads as
a carrot at placeholder resolution). Add it to the Farm's crop_catalogue in the scene.
Change the player's selected_crop_id to &"carrot".
Plant. Grow it (stage 06's key, or the debugger's farm.advance_day() twice). Watch: the
carrot takes two day-ticks per stage on its own, because Plot reads crop.days_per_stage
and Crop reads crop.stage_meshes — no line of code names "carrot". That's the
acceptance test from the top of the stage, and the reference project's actual history.
Predict before you run: plant a turnip and a carrot in adjacent cells, then load
turnip.tresand change itsstage_meshes[1]to a cube. Save. What does the running game show — and what will the next run show?
The running game shows nothing changed (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. That's the shared-reference fact, demonstrated in both tenses: the file is not the runtime, and the runtime is not private.
E — Commit#
git add . && git commit -m "stage 05: CropData, catalogue, stage-mesh crop view, zero-code carrot"
Checkpoint — definition of done#
- Turnip and carrot plant side by side and read as different (different meshes, and stage 06 will make the tempo visible)
- Adding a third crop — any primitive, any id — takes one
.tres, one catalogue entry, and zero.gdedits; do it and commit it as its own change -
push_warningfires on a badselected_crop_id(type&"parsnip"for ten seconds) and names the missing id - The mutation prediction ran and both tenses came out the way you said they would
- You can state the rule — if two plants of the same kind could ever disagree about it,
it belongs on Plot, not CropData — and name a field that would break if it lived on the
wrong side (
days_grownonCropDatawould make every turnip the same age) - Zero warnings (other than the ones you provoked on purpose); committed
Stretch (no instructions)#
A crop that shrinks as it grows: stage 0 the big mesh, stage 2 the small one (a harvest-ready sign — "the bigger it looks, the younger it is" is a design choice, not a bug, if the data says so). The stage array is just an array; the meaning is yours.
If you get stuck#
- The crop renders at the origin (0,0,0) instead of its cell →
_add_viewset the scene's position but you're looking at aCropwhose root isn't the node you positioned (an extra wrapper in the scene).Crop's root is the positioned node — check the scene's top level. - All crops look the same →
stage_mesheson one of the.tresfiles is empty (thepush_warningnames it), or both files reference the same primitive sub-resource (dragging one resource into both arrays shares it — the mutation prediction, small edition). - The carrot grows at turnip speed →
days_per_stageis read from the Farm (stage 04's leftover@export var days_per_stageonFarm, if you kept it) instead of fromplot.crop. The tempo is per-kind; the farm doesn't have one. Delete the farm's field and let the data carry it. plantreturns true but nothing appears →_add_viewran,CROP_SCENEloaded, and theCrop'sMeshInstance3Dhas no mesh becauseshow_stagegot an empty array (the warning is in your output — the never-mutate rule's twin: read the output; warnings are the system talking).