Stage 06 — A day passes: growth on integer division, debug keys that die in release
You will build: advance_day() — the central growth tick · the days→stage table you
write on paper first · and the Debug autoload, a keycode→action registry that is inert in
an exported build by construction.
You'll learn: integer division as a growth curve · off-by-ones as a rite of passage ·
why debug actions are not input-map actions.
Why this exists#
The 2D game's stage 04 had the same beat — "the days→stage translation is the one genuinely fiddly loop in this stage" — and the paper table was the test. It's fiddly for a reason that doesn't depend on dimension: growth is a step function computed by division, and step functions lie to you at the boundaries. The debug registry is the other half of the stage: a way to drive the day-tick from a key that cannot exist in a player's hands.
Build it#
A — The table, on paper, before the code#
Plot carries days_grown (an int, starts 0) and its definition carries days_per_stage
(an int). The stage is:
stage = days_grown / days_per_stage (integer division)
clamped at the last mesh. Write the two tables before running anything, the way the 2D game's turnip did:
Turnip (days_per_stage = 1):
| days_grown | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| stage | 0 | 1 | 2 (mature) | 2 (mature) |
Carrot (days_per_stage = 2):
| days_grown | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| stage | 0 | 0 | 1 | 1 | 2 (mature) | 2 (mature) |
Now the two boundary questions the tables answer:
Predict before you run: a carrot on day 1 — which mesh? And the turnip on day 3: does it keep growing, keep mesh 2, or error?
Carrot day 1: 1 / 2 = 0 (integer division floors) — still the sprout. The carrot's
first visible change lands on day 2, not day 1, and anyone balancing "why does my carrot
look stalled" is reading this table wrong, not the code. Turnip day 3: 3 / 1 = 3, and the
clamp (mini(..., last_stage)) holds it at mesh 2 — mature is a state, not a speed. A
crop past maturity doesn't grow faster; it waits. (The 2D game's harvest stage gave
maturity a job; this project's does too — stage 07's requirements.)
Off-by-ones in step functions are a rite of passage because the bug is always the same shape:
you test days_grown >= days_per_stage * (stage + 1) somewhere instead of dividing, and the
tables above are the fixture that catches it. The paper is the test; the code is the
transcription.
B — advance_day, the central tick#
# Farm
func advance_day() -> void:
for cell in _plots:
var plot := _plots[cell]
plot.days_grown += 1
var last_stage := plot.crop.stage_meshes.size() - 1
var new_stage := mini(plot.days_grown / plot.crop.days_per_stage, last_stage)
if new_stage != plot.stage:
plot.stage = new_stage
(_views[cell] as Crop).show_stage(plot.crop, plot.stage)
The 2D game's decision 3B, transferred: the farm ticks all plots in one loop rather than each crop listening for a day signal. The reasons don't change with the renderer:
- Save/load is trivial — the day counter and the plots are one walkable structure (stage 07).
- "Skip N days" is a
for— the debug key's future upgrade, and any testing need, isfor i in 30: advance_day(). - The view update is conditional —
if new_stage != plot.stagemeans a day that doesn't cross a boundary touches no mesh at all. Twenty day-ticks on a field of turnips re-swap each crop three times, not twenty. (The 2D game's crops have the same guard; the instinct is the pattern.)
Note what advance_day does not do: it doesn't know who pressed a key, it doesn't emit
a "day" signal (yet — stage 07's TimeSystem is where day_advanced becomes a real wire),
and it doesn't touch _views for plots that didn't change stage. It's a pure data walk with
a conditional projection — the farm doing the only thing the architecture assigned it.
C — The Debug registry: keys that die in release#
The day-tick needs a driver for you, and the player's control scheme (stage 01's Input
Map) is not the place for it. A debug action in the Input Map is a key the player's
scheme knows about, that every is_action_pressed poll can see, that survives into the
export unless you remember to remove it. The reference project's answer is a separate
autoload — Debug — with its own registry:
# debug_actions.gd — autoloaded as "Debug"
extends Node
var _actions: Dictionary[int, Callable] = {}
var _labels: Dictionary[int, String] = {}
func _ready() -> void:
set_process_unhandled_input(OS.is_debug_build())
func register(keycode: Key, label: String, action: Callable) -> void:
if not OS.is_debug_build():
return
if _labels.has(keycode):
push_warning("Debug: %s already bound to '%s', overwriting with '%s'" % [
OS.get_keycode_string(keycode), _labels[keycode], label
])
_actions[keycode] = action
_labels[keycode] = label
func bindings() -> Array[String]:
var lines: Array[String] = []
for keycode in _labels:
lines.append("%s — %s" % [OS.get_keycode_string(keycode), _labels[keycode]])
lines.sort()
return lines
func _unhandled_input(event: InputEvent) -> void:
var key := event as InputEventKey
if key == null or not key.pressed or key.echo:
return
if not _actions.has(key.keycode):
return
_actions[key.keycode].call()
get_viewport().set_input_as_handled()
And the Farm binds its two keys next to the code they drive:
# Farm._ready, after the catalogue build
Debug.register(KEY_F1, "Advance one day", advance_day)
Debug.register(KEY_F2, "Clear all crops", clear_all)
func clear_all() -> void:
for view in _views.values():
view.queue_free()
_views.clear()
_plots.clear()
The design facts, in the order they protect you:
set_process_unhandled_input(OS.is_debug_build())— in an exported build the autoload never processes input. The gate is at the door, not in every handler: a release build doesn't just refuse the keys, it stops listening. (OS.is_debug_build()is true in the editor and in debug exports, false in release — the 2D game's later modules use the same idiom.)registeris also release-gated — the binding never even exists in an export, so a forgottenDebug.registercall is a no-op, not a latent player-facing key. Two gates, belt and suspenders, and both are construction-time facts: the safety doesn't depend on anyone remembering to check at runtime._unhandled_input, not_input— debug keys yield to the game first. If the player's scheme ever binds F1 (it shouldn't — it binds W/A/S/D/E/Shift), the game wins and the debug key loses. Debug machinery sits behind the game, not in front of it.- The overwrite warning — two systems registering the same key is a design collision, and the registry names both claimants instead of letting the last one silently win.
- The bindings are next to the code they drive —
Farmowns "advance a day" because the day is the farm. A central "DebugKeys" script that calls into ten systems is the anti-pattern: the key's home is the system's home.
bindings() (unused so far) is the registry's self-documentation: print it and the game
tells you its own cheat sheet. When stage 07's TimeSystem arrives, it registers its keys
the same way, and the printed list grows by a line.
D — The day, end to end#
Plant a turnip and a carrot in adjacent cells. Press F1.
Predict before you run: one F1 — which crops changed, and what are both of them showing? Two more F1s — what now?
F1 #1: turnip days_grown 1 → stage 1 (the middle mesh); carrot days_grown 1 → 1/2 = 0
→ still the sprout. F1 #2: turnip mature (stage 2, clamped); carrot stage 1. F1 #3:
turnip holds; carrot 3/2 = 1 — still stage 1. F1 #4: carrot mature. The two crops, same
field, same key, different tempos — the table from part A, observed. F2 clears the field and
_plots goes empty; F1 on an empty field is a no-op loop (zero iterations, zero cost) —
the central tick doesn't special-case the empty farm because there's nothing to special-case.
E — Commit#
git add . && git commit -m "stage 06: advance_day, integer-division growth, Debug registry"
Checkpoint — definition of done#
- The paper tables were written before the first F1, and the engine agreed with both
of them — if it disagreed with either, the table or the code is wrong; find which, the
way stage 04 found
floori - Carrot's first visible change is on its second day, and you can say why (the division floors)
- F1 on a mature field changes no meshes (the
new_stage != plot.stageguard — verify by watching the output for absence, or countingshow_stagecalls with a print you remove) - F2 empties the field and
_plots; F1 afterwards is harmless - The
Debugautoload's input processing is off in a release export — you can at least say where the gate is (set_process_unhandled_input(OS.is_debug_build())), even if building an export is stage 07's problem - Zero warnings; committed
Stretch (no instructions)#
A third tempo: a crop with days_per_stage = 3 and four meshes — the stage table gets
a row the two existing crops don't have, and the clamp has more room to work. Write its
table on paper (days 0 through 7) and check the engine against it. A crop whose tempo
doesn't fit the others is the catalogue's proof that it's a catalogue.
If you get stuck#
- Crops jump a stage (turnip: sprout → mature, skipping the middle) →
days_grownis incrementing twice per day (two callers ofadvance_day— a leftover direct call and the debug key), or the division readsdays_per_stagefrom the wrong place (the Farm's leftover export, stage 05's bullet). Printplot.days_grownandplot.stageside by side on each F1 and compare to the table row by row — the 2D game's stuck-list, transferred. - F1 does nothing → the registration ran but
_unhandled_inputisn't processing: the autoload's_readygate evaluatedOS.is_debug_build()as false (you're running a release export in the editor — rare) or theDebugnode isn't actually autoloaded (Project Settings → Autoload, the*for singleton). PrintDebug.bindings()from the console: an empty list means the registration never ran. - The middle mesh shows on day 0 →
stagestarts at 0 butshow_stageis called withdays_grownalready incremented somewhere before the first render — the order in_add_view(stage 05) shows the plot's current stage, which is 0; if it's showing 1, something advanced the plot betweenPlot.newand the first render. The table says day 0 is stage 0; trust the table.