doc 8 of 10

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_grown0123
stage012 (mature)2 (mature)

Carrot (days_per_stage = 2):

days_grown012345
stage00112 (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, is for i in 30: advance_day().
  • The view update is conditionalif new_stage != plot.stage means 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.)
  • register is also release-gated — the binding never even exists in an export, so a forgotten Debug.register call 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 driveFarm owns "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 = 0still the sprout. F1 #2: turnip mature (stage 2, clamped); carrot stage 1. F1 #3: turnip holds; carrot 3/2 = 1still 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.stage guard — verify by watching the output for absence, or counting show_stage calls with a print you remove)
  • F2 empties the field and _plots; F1 afterwards is harmless
  • The Debug autoload'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_grown is incrementing twice per day (two callers of advance_day — a leftover direct call and the debug key), or the division reads days_per_stage from the wrong place (the Farm's leftover export, stage 05's bullet). Print plot.days_grown and plot.stage side 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_input isn't processing: the autoload's _ready gate evaluated OS.is_debug_build() as false (you're running a release export in the editor — rare) or the Debug node isn't actually autoloaded (Project Settings → Autoload, the * for singleton). Print Debug.bindings() from the console: an empty list means the registration never ran.
  • The middle mesh shows on day 0 → stage starts at 0 but show_stage is called with days_grown already 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 between Plot.new and the first render. The table says day 0 is stage 0; trust the table.