doc 6 of 10

Stage 04 — The farm grid: the cell you face, and the truth that isn't a node

You will build: the Farm — a 1-metre cell grid with world_to_cell / cell_to_world math, a plots dictionary that is the only authority on what's planted, and the UseItemState that lands a seed on the cell in front of you. You'll learn: floori and the origin trap · data before visuals, proven by deletion · design decision 3.

Why this exists#

The 2D farming game asked the same question — where does a plot's truth live? — and answered it with a dictionary beside the tilemap. This stage re-asks it in three dimensions, where the temptation is stronger: a 3D world is made of nodes, so "one node per cell, the tree is the map" feels native. It also meets the one arithmetic trap 2D rarely shows you, because a 2D tile grid painted from the origin never crosses a negative axis. This field does.

Build it#

A — The Farm, and the math with a sharp edge#

New node Farm (Node3D) under World, script:

class_name Farm
extends Node3D

const CELL_SIZE := 1.0

var _plots: Dictionary[Vector2i, Plot] = {}

func world_to_cell(world_position: Vector3) -> Vector2i:
	return Vector2i(
		floori(world_position.x / CELL_SIZE),
		floori(world_position.z / CELL_SIZE)
	)

func cell_to_world(cell: Vector2i) -> Vector3:
	return Vector3(
		(cell.x + 0.5) * CELL_SIZE,
		0.0,
		(cell.y + 0.5) * CELL_SIZE
	)

Two functions, one grid, and a trap sitting in the first one:

Predict before you run: the player stands at x = −0.5, z = 0.3. Which cell are they in? Now do the same calculation with int() instead of floori() — what does the west half of the farm believe?

int() truncates toward zero: int(-0.5) = 0. floori() floors: floori(-0.5) = −1. The cell at (−1, 0) spans x ∈ [−1, 0), z ∈ [0, 1) — so the player at x = −0.5 is in cell (−1, 0), and only floori says so. With int(), every position between −1 and 0 reports cell 0: the west half of the farm plants into cells that don't contain the player, and the bug is invisible on the east side where truncation and flooring agree. Grids that cross the origin make this distinction load-bearing; grids painted from (0,0) outward hide it forever. You now know which kind you have.

cell_to_world adds 0.5 — a cell's center, because a crop planted in cell (3, 4) should stand in the middle of that metre, not on its corner. The pair is an involution in the way that matters: cell_to_world(world_to_cell(p)) is the center of the cell containing p.

B — Decision 3, and the plot's shape#

Design decision 3 — make the call now, record it in the architecture doc:

  • (A) The dictionary. _plots: Dictionary[Vector2i, Plot] on the Farm is the authority; a Plot is a RefCounted holding {crop, stage, days_grown}; the scene tree gets a view node per planted cell that can be deleted and rebuilt. Saving (stage 07) is a walk of the dictionary. This is the 2D game's answer, transferred.
  • (B) One node per cell. Each cell is a Node3D in the tree carrying its own state; the tree is the map. Fewer structures to invent — and saving means walking the tree, which means trusting the tree, which means the moment a node is orphaned or re-parented by accident, the map has a hole no one declared.

One-line trade-off: A is the 2D game's answer and it transfers; B is the temptation. A is the recommended path and what the shipped project has.

# plot.gd — a RefCounted, deliberately not a Node
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

(CropData is stage 05's; for this stage a Plot can hold a StringName crop id — stage 05 upgrades the field. Or build stage 05's CropData first and skip the swap; the grid doesn't care, and neither should you.)

Why RefCounted and not Node: a plot has no scene, no process callback, no transform. It's data with a lifetime, and RefCounted gives it exactly one lifetime rule — freed when the last reference drops, which for a plot is "removed from _plots." No queue_free, no node bookkeeping, no way for a plot to exist in the tree and disagree with the dictionary. If you find yourself wanting a plot to do something (tick itself, emit signals), that want is the central-tick vs per-entity decision from the 2D game resurfacing — and the 2D game's answer (the farm ticks the plots, stage 06) is the one that keeps saving trivial.

C — Planting: the cell in front, the seed in the data#

The player already has the pieces from stage 03: facing, and now one more:

# Player
@export var farm: Farm
@export var selected_crop_id: StringName = &"turnip"

func target_position() -> Vector3:
	return global_position + facing * Farm.CELL_SIZE

target_position is the whole interaction model in one line: where you're going to act is where you're facing, one cell out. Wire farm in the scene (the @export makes it a NodePath in the inspector — the 2D game's "wire by export, not by get_node" rule).

And UseItemState stops being grey:

# use_item_state.gd
class_name UseItemState
extends PlayerState

func _enter_state() -> void:
	var cell := player.farm.world_to_cell(player.target_position())
	player.farm.plant(cell, player.selected_crop_id)

func _physics_update(delta: float) -> void:
	player.move(Vector3.ZERO, 0.0, player.deceleration, delta)
	finished.emit(&"IdleState")

Two design facts wearing state clothes:

  • The action happens in _enter_state, not _physics_update. Planting is an event — it happens once, on the transition, at the cell you were facing when you pressed E. Putting it in the tick would re-plant every frame the state is current, and the state would need a "did I already?" boolean — the flag the state machine exists to avoid.
  • The state completes, so it announces. finished.emit(&"IdleState") on the first tick (after the player has decelerated one frame — the planting commit, visible as a half-beat of stopping) is the finished signal from stage 02 doing its one real job: a state with a job to finish doesn't wait for the machine to notice, it reports in.

Farm.plant, the first version:

# Farm
const PLACEHOLDER: PackedScene = preload("res://entities/crop/crop.tscn")

var _views: Dictionary[Vector2i, Node3D] = {}

func plant(cell: Vector2i, _crop_id: StringName) -> bool:
	if _plots.has(cell):
		return false

	var plot := Plot.new()
	_plots[cell] = plot

	var view: Node3D = PLACEHOLDER.instantiate()
	view.position = cell_to_world(cell)
	add_child(view)
	_views[cell] = view
	return true

The placeholder Crop scene for this stage is a Node3D with a small MeshInstance3D (a 0.2-radius sphere) — a marker that a plot exists. Stage 05 replaces the marker with the real stage-mesh crop; the _views dictionary doesn't change shape, only the scene it instantiates.

Prove the split, the almanac way: plant three cells, then in the debugger run farm._views.values().each(func(v): v.queue_free()) — delete every view. Walk the field. The plants are gone. Now print farm._plots — three entries, all intact. Deleting every visual node lost nothing, which is the architecture doc's honesty test, met in stage 04 instead of stage 07. Rebuild the views from _plots (a loop of plant-minus-the-plot) and watch the field repopulate from data alone.

The plot's truth and its view, ended by two different events. 4 values in one containment tree, against 3 events that end lives: clear all, save load, scene change. Farm holds _views, _plots. _plots holds Plot. Lifetimes — destroyed at clear all: _views; destroyed at save load: _plots, Plot; destroyed at scene change: Farm. Under discussion: _plots. Reading these after they die is the bug: _views. The lifeline axis is ordinal, not temporal: the events are in order, but the spacing between them carries no duration.Read the lifelines, not the boxes. _views (the Crop nodes) dies on clear-all — the F2 key, or a harvest, cell by cell — while _plots (the dictionary) runs on to save-load, and Farm outlives both. That gap is the whole design: the truth is what saving walks, and the views are rebuilt from it without loss. The trap label on _views is the reminder that a mesh is not a plot.

D — Commit#

git add . && git commit -m "stage 04: farm grid, floori cells, plots dictionary, planting by facing"

Checkpoint — definition of done#

  • Plant on the east side of the origin: the marker lands in the cell you're facing, centred in it (± half a metre)
  • Plant on the west side (x < 0): still the cell you're facing. Then temporarily swap floori for int in world_to_cell, plant at x = −0.5, and watch the marker land a full cell east of where you stood. Swap it back. The origin trap, met and released.
  • A second press of E on an occupied cell does nothing (plant returned false) — no double-marker, no error
  • The view-deletion proof ran, and _plots survived it; you rebuilt the views from the dictionary
  • Decision 3 recorded with your reason
  • Zero warnings; committed

Stretch (no instructions)#

A fence ring: a second Farm method, in_bounds(cell) -> bool, that refuses planting outside the 15×15 ground (the ground's half-extent is 7.5 — in cell terms, cells −7..7). The ground box already stops the player; the farm should stop the plot. One comparison, and the field's edge is a rule instead of a wall.

If you get stuck#

  • The marker lands on the corner between four cells instead of a cell's center → you used cell * CELL_SIZE in cell_to_world and dropped the + 0.5. A cell's origin is its corner; its center is where things stand.
  • Planting works but always on the cell you're in, not the one you face → target_position is returning global_position (the facing * CELL_SIZE term is missing, or facing is Vector3.ZERO because you never set it — stage 03's data, read by stage 04's math).
  • E does nothing at all → the plant_action input exists (stage 01) but IdleState and WalkState don't check it yet: add if Input.is_action_just_pressed(&"plant_action"): machine.travel(&"UseItemState") to both — the stage-02 diagram drew these edges in grey.
  • The rebuilt views (the proof) are at the wrong spots → you rebuilt from _plots but positioned the views by the player's cell instead of the plot's cell. The dictionary's keys are the positions; the values are only the state.