Field Stage Lifecycle

This page documents the runtime behavior of a field session — what happens, in what order, and which module owns it. The Field Stages viewer shows the per-stage geometry (floor mesh, grid, waypoints); this is the contract for the logic that drives the player through them. The key words MUST, MUST NOT, and MAY are used as in RFC 2119.

Historically all of this lived in one 3,900-line valley_field_controller.gd. It is being split into focused modules (tracked in docs/valley-field-split.md); the module map below records where each behavior lives.

The cell model

A field is a graph of cells grouped into sections. The autopilot log identifies a cell as sec:row,col (e.g. 0:1,2); the human label pairs a section letter with grid coords (e.g. A 1,2). Each cell loads one stage (a floor.glb + visual _m.glb) and has up to four cardinal exits.

A cell MAY be authored with a rotation (0/90/180/270). Rotation is a label swap only: it relabels which gate is north/east/south/west. The floor mesh, waypoints, and objectives stay in stage-local space — the runtime MUST NOT apply a rotation matrix to geometry. Direction relabeling goes through StageRotation.rotate_dir.

Cell load: restore vs. fresh

On entering a cell the controller checks _cell_states (keyed by cell position):

Each cell carries a plan: a label, an ordered do[] action list, and an exit direction. After objects are present, the plan's actions run, then the player walks to the exit.

Objects a cell can contain

All cell objects are created by CellObjectSpawner:

ObjectBehaviorPersisted state
boxbreakable container; may drop meseta / item / materialintactbroken
enemycombat target; killing all in a room can unlock gates / drop a keyalivedead
fence / switcha step-switch toggles its linked fence(s)link state
wallblocker; destructible walls can be clearedintact / destroyed
messagereadable pack; reading MAY advance an objective or trigger a reactionavailableread
needle_trap / bear_trapdamage / immobilize on contactarmed / sprung
dialog_triggerfires dialog on enter (or a condition)ready → fired
field_npcplaced NPC with dialog / animation
warp_point / area_warpmoves the player to another section / cell
quest_itempickup that counts toward an objectiveavailable → collected

Each object type has its own reference page — a live 3D model viewer, config schema, persisted state, and interactions: see Cell Objects.

Plan actions (do[])

Observed in the autopilot as action i/n: <name>. A cell's plan MUST complete its do[] list before the walk-to-exit begins:

ActionMeaning
kill_alldefeat every enemy in the room
flip_switchstep on the switch (toggles linked fences)
open_gateopen a now-unlocked gate (after key / clear)
pickup_keycollect the key dropped for a locked gate
dismiss_dialogadvance past a triggered dialog
wait_quest_completehold until objectives flip the quest complete (final cell)

Persistence across re-entry

save_cell_state snapshots each cell as { objects: [{ type, state, pos }], drops: [...] } into _cell_states, alongside controller-level _keys_collected, _gates_opened, and _visited_cells. The invariant: walking back through a cleared cell MUST NOT undo progress.

Flush on exit

The "killed enemies MUST NOT respawn" invariant above is only upheld if the snapshot is taken after the kill. The timing is therefore normative, not incidental:

This strengthens — it does not contradict — the "Killed enemies MUST NOT respawn" bullet: that bullet names the invariant, this clause pins the ordering that guarantees it. The companion guarantee is #426 (input precedence — world interaction consumes the palette button), which makes the warp-button press a genuine same-frame race; tracked as the #423 regression this clause closes.

Exception: the expedition-end return to city (_return_to_city) intentionally discards section state and MUST NOT flush — the run is over and there is no cell to return to.

Test + probe enforcement

Every persistence bullet above is pinned at both test layers (the two-layer rule):

Gates

A gate blocks a cell exit until its condition is met. There are three kinds:

Gate direction is resolved through StageRotation so a rotated cell still opens the correct physical exit.

Fences & switches

Fences are distinct from gates. A fence is toggled by its own dedicated step-switch: stepping on the switch (flip_switch) toggles the fence(s) linked to it. Unlike a key — which is section-wide — a switch is bound to a specific fence link, so a given switch only ever affects its own fence(s).

Exit & end of stage

Walking onto an exit trigger transitions to the connected cell (or section, via an area gate). When the cell's exit is empty and objectives are met, the final cell's wait_quest_complete resolves and the controller spawns the end-cell exit, which telepipes the player back to the city for the quest report.

That is the success exit. A field session also has a failure exit: if the player's HP reaches 0 the run ends in Defeat — a red overlay + "You were defeated" prompt that returns the player to the city (full-HP revive, a 50% carried-meseta penalty, session ended). Defeat and the end-cell telepipe are the only two ways a field session ends from inside the field.

HUD across an area transition

An area transition is a full scene reload (SceneManager.goto_scenechange_scene_to_file). The HUD splits into two tiers with different lifecycles (#444, superseding the #430-era "planned end-state" note that used to live here):

The transition is still covered by the SceneManager fade-to-black. The fade rect MUST sit on a canvas layer above the HUD layers (and the start menu at layer = 150) so the whole frame transitions uniformly under black, and MUST cover the full viewport — a ColorRect whose anchors are set via anchors_preset alone stays at size (0,0): black, full-alpha, correctly layered, yet zero-area, so it masks nothing. The rect MUST be sized with set_anchors_and_offsets_preset(PRESET_FULL_RECT) (anchors and offsets). Pinned in test_scene_manager_fade_rect_full_size.

With the stats panel persistent, the fade's coverage of the HUD region is belt-and-suspenders, not the mechanism: the #430 masking hid the per-scene rebuild gap under black, whereas now there is no stats-panel gap to hide — the fade only needs to mask the world reload and the per-scene HUD tier. The two guarantees MUST NOT be conflated: persistence is the stats-panel contract; the fade contract stands on its own for everything else.

Test + probe enforcement (two-layer rule)

Quests vs. free roam

Quests and free (explorable) areas share the same stage JSON format. The only difference is that a free area has no guild quest counter and no associated quest dialog — so it has no hard clear condition. The player roams it without a "report back" objective; there is no wait_quest_complete / end-cell telepipe gating exploration.

Planned — implicit quests in free roam: hidden objectives the player can discover, trigger, and complete by exploring, rather than the guild's explicit "go here, do this" structure. Because free areas already use the same format, this would layer optional, discoverable goals onto a free area without changing the format — the player picks them up by exploring, not by accepting them at a counter.

Where each behavior lives

The split is in progress; ✅ = extracted, ⏳ = still on the controller (target module named):

BehaviorModule
Direction / rotation mathStageRotation
Cell object spawning + save/restoreCellObjectSpawner
Player spawn + orbit camera⏳ controller
Floor / map collision⏳ controller → MapCollisionBuilder
Portals, gate triggers + labels⏳ controller → PortalGateManager
Weather, sky, lights, stage effects⏳ controller → WeatherController
Telepipe, companion, combat waves⏳ controller

Observable contract ([sanity] log)

The autopilot prints the lifecycle as it runs; the regression matrix asserts on these lines, so they are the behavioral contract:

LineMeans
checkpoint: <name>milestone reached (title, city_office, valley_field entered, …)
cell-load sec:r,c … plan label='…' do=[…] exit='…'a cell loaded; its plan
action i/n: <name>executing a do[] action
walk to exit '<dir>' via N waypoint(s) / waypoint i/n reachednavigating to the exit
stuck-walk diagnostic … / FAIL: walk stuck …pathfinding failed — the stage needs authored waypoints
checkpoint: hud-stats-held id=… scene=…the persistent HP/PP/Lv panel survived a scene transition as the same instance (#444; FAIL: if it was rebuilt or freed)
checkpoint: defeat-screen-shownHP hit 0 → the defeat prompt is up
checkpoint: defeat-return-to-city meseta <before> -> <after>chose "Yes" → revived, penalty applied, session ended
DONE okquest cleared end-to-end (success oracle)