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):
- First visit →
spawn_fresh_cell_objectsreads the stage config and instantiates every authored object. - Re-entry →
restore_cell_objectsrebuilds from the saved snapshot, so prior progress is preserved (see persistence).
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:
| Object | Behavior | Persisted state |
|---|---|---|
box | breakable container; may drop meseta / item / material | intact → broken |
enemy | combat target; killing all in a room can unlock gates / drop a key | alive → dead |
fence / switch | a step-switch toggles its linked fence(s) | link state |
wall | blocker; destructible walls can be cleared | intact / destroyed |
message | readable pack; reading MAY advance an objective or trigger a reaction | available → read |
needle_trap / bear_trap | damage / immobilize on contact | armed / sprung |
dialog_trigger | fires dialog on enter (or a condition) | ready → fired |
field_npc | placed NPC with dialog / animation | — |
warp_point / area_warp | moves the player to another section / cell | — |
quest_item | pickup that counts toward an objective | available → 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:
| Action | Meaning |
|---|---|
kill_all | defeat every enemy in the room |
flip_switch | step on the switch (toggles linked fences) |
open_gate | open a now-unlocked gate (after key / clear) |
pickup_key | collect the key dropped for a locked gate |
dismiss_dialog | advance past a triggered dialog |
wait_quest_complete | hold 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.
- Killed enemies MUST NOT respawn.
- Broken boxes MUST NOT reappear (and MUST NOT re-drop loot).
- Opened gates MUST stay open; collected keys stay collected.
- Read messages stay read.
- Picked-up items (quest items, box drops) MUST NOT reappear. Items still on the ground MUST keep the same position and the same state on re-entry — they MUST NOT move, re-roll, or change state.
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:
- When the player leaves a cell via any warp or exit —
cell transition,
area_warp, telepipe travel, StartWarp, section advance, or final exit — the controller MUST snapshot the cell's live object state viasave_cell_statebefore the scene transition (goto_scene). - That snapshot MUST reflect combat resolved in the
same frame as the warp. An enemy whose
is_aliveflippedfalsein the same frame the warp button was pressed (e.g. a kill bound to the same button that triggers the exit) MUST be persisted asdeadand MUST NOT respawn on return. - The capture builds its alive-set from each live enemy's
is_aliveflag. BecauseEnemyBase._die()flipsis_alivesynchronously and onlyqueue_free()s the node ~1.5 s later, a same-frame kill is still a valid node readingis_alive == falseat flush time — so the flush records itdead. Any refactor of the exit path MUST preserve this ordering (flush after same-frame combat, before transition).
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):
- Seeded unit tests (
test_runner.gd) drive the realCellObjectSpawner._save_cell_statecapture for each category — enemies (test_kill_state_survives_warp_flush), boxes, drops, messages / walls / quest items, and controller-level keys / gates — then round-trip through a city suspend/resume and assert the state survives.test_field_state_full_contract_roundtripbounces a mixed cell to the city three times in a row. - Live autopilot oracle. Under
PSZ_AUTOPILOT, each exit-flush emits[sanity] checkpoint: cell-flush cell=<sec:pos> dead=<n> boxes_destroyed=<n> drops_pending=<n> msgs_read=<n> items_collected=<n>and hands the tally toAutopilot.observe_cell_flush. The key is section-qualified — the samerow,collabel exists as unrelated cells in different sections (e.g. A 3,1 vs B 3,1), so a cell is only ever compared against its own prior flush. On a re-visit the oracle requires the accumulating fields (dead / boxes_destroyed / msgs_read / items_collected) to be non-decreasing against that cell's high-water mark; a decrease means a re-entry resurrected something, and prints[sanity] FAIL:, which fails the autopilot run.drops_pendingis reported but not asserted: ground loot is generated by combat that can post-date an early pass-through flush, so the count is legitimately non-monotonic — drop identity persistence (position + amount preserved, collected drops gone) is pinned by the seeded unit test instead.
Gates
A gate blocks a cell exit until its condition is met. There are three kinds:
- Normal gate — unlocks once every enemy in the room is
cleared (the cell's
kill_allaction). - Key gate — opened with a key. Keys are section-scoped:
any key found anywhere in a section opens any
key gate in that same section (they are not paired one-to-one). A sibling cell
typically drops the key on clear; the player does
pickup_keythenopen_gate. - Area gate — currently the
area_warpobject (a clearer name would be area gate). Not a barrier within a section but a connection to a different section; passing through transitions the player across sections.
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_scene → change_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 HP/PP/Lv stats panel is persistent. It lives on the
HudStatsautoload (aCanvasLayeratlayer = 200, pattern:PsoStartMenu). It MUST persist across an area transition: it MUST NOT be freed or rebuilt by the per-scene controller (same node instance before and after the warp), and it MUST remain rendered — holding its last values — while the world fades and reloads underneath it, with no blank frame. - The stats panel MUST read live values from the
GameState/CharacterManagerautoloads (HP/PP via the*_changedsignals, level vialevel_up), so it shows correct values continuously. - The per-scene HUD (
FieldHud: minimap, action palette, quick-weapon menu, action log, FPS) is scene-specific and MAY continue to be freed/rebuilt per scene byvalley_field_controller._ready/city_area_base; only the stats panel is promoted. - Visibility: the panel is rendered in gameplay scenes (city and field),
MUST stay rendered under the PSO start menu
(
layer 200 > 150, the oldkeep_statsrule), is hidden under full-screenSceneManageroverlays (shops, storage, guild), and is hidden on non-gameplay scenes (title / character select / create). - The panel's backdrop texture is a pack asset; the autoload
MUST NOT
preload()it (autoloads must compile in repo-only CI) — it lazy-load()s and retries until the pack mounts.
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)
- Seeded unit test
test_hud_stats_persistent_paneldrives a simulated transition (_transitioning+scene_changed, the same signals a realgoto_scenefires) and pins: same instance id across the change, panel in-tree and rendered on every step, values live fromGameState/CharacterManager, and the overlay-visibility rules. - Live autopilot oracle. Under
PSZ_AUTOPILOT, everyscene_changedmakesHudStatsreport its panel toAutopilot.observe_hud_stats, which prints[sanity] checkpoint: hud-stats-held id=<instance> scene=<file>and prints[sanity] FAIL:if the instance id ever changes (panel rebuilt) or the panel leaves the tree (panel freed) — failing the run via the pass-oracle'sFAIL:grep.
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):
| Behavior | Module |
|---|---|
| Direction / rotation math | ✅ StageRotation |
| Cell object spawning + save/restore | ✅ CellObjectSpawner |
| 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:
| Line | Means |
|---|---|
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 reached | navigating 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-shown | HP hit 0 → the defeat prompt is up |
checkpoint: defeat-return-to-city meseta <before> -> <after> | chose "Yes" → revived, penalty applied, session ended |
DONE ok | quest cleared end-to-end (success oracle) |