doc 7 of 10

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"), a StringName because it's compared (and keyed) constantly and never displayed. The catalogue is a Dictionary[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 renders stage_meshes[0]; maturity is the last one. An array of Meshes, not of scenes: the Crop node is the only scene in the system, and it swaps a property.

Make the turnip: FileSystem dock → right-click data/crops/New ResourceCropData. 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_catalogue is the @export array on the Farm — the roster, edited in the scene's inspector (add turnip.tres, later carrot.tres). It's an Array[CropData], so the inspector only offers CropData resources and the type is enforced at the door.
  • _by_id is the index, built once in _ready from 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.
  • plant refuses twice — occupied cell, unknown id — and the unknown-id path push_warnings instead of failing silently. A typo in selected_crop_id is now a named warning in the output, not a field that quietly never plants.
  • _add_view is the projection step. It instantiates the one crop scene, positions it by the cell's center, and calls show_stage with 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 MeshInstance3Dnothing 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_meshesno 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.tres and change its 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 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 .gd edits; do it and commit it as its own change
  • push_warning fires on a bad selected_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_grown on CropData would 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_view set the scene's position but you're looking at a Crop whose 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_meshes on one of the .tres files is empty (the push_warning names 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_stage is read from the Farm (stage 04's leftover @export var days_per_stage on Farm, if you kept it) instead of from plot.crop. The tempo is per-kind; the farm doesn't have one. Delete the farm's field and let the data carry it.
  • plant returns true but nothing appears → _add_view ran, CROP_SCENE loaded, and the Crop's MeshInstance3D has no mesh because show_stage got an empty array (the warning is in your output — the never-mutate rule's twin: read the output; warnings are the system talking).