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 offloori()— 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 theFarmis the authority; aPlotis aRefCountedholding{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
Node3Din 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 thefinishedsignal 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 printfarm._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 ofplant-minus-the-plot) and watch the field repopulate from data alone.
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
flooriforintinworld_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 (
plantreturned false) — no double-marker, no error - The view-deletion proof ran, and
_plotssurvived 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_SIZEincell_to_worldand 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_positionis returningglobal_position(thefacing * CELL_SIZEterm is missing, orfacingisVector3.ZERObecause you never set it — stage 03's data, read by stage 04's math). - E does nothing at all → the
plant_actioninput exists (stage 01) butIdleStateandWalkStatedon't check it yet: addif 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
_plotsbut 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.