Field Gate Economy
A generated free field is a tree of rooms that locks itself. This page is the normative contract for how those locks are chosen and how the field stays completable. The key words MUST, MUST NOT, SHOULD and MAY are RFC-2119.
Everything below is measured, not designed: psz-re's
sys.field-doorways is confidence: confirmed over
62 field captures, 592 rooms and 1,058 connections, and
level_topology_builder.json carries the parameter values. Where a
number is not measured this page says so.
Status: implemented and tested. The generator and runtime
landed with #595 steps 2–3 (PR #602 and follow-ups); the seeded sweeps in
test_runner are pinned against this page. Divergences from the
measured rule are recorded where they happen below.
Correction: there is no such thing as a spare door
grid_generator.gd currently carries the comment "Extra doors beyond
entry+exit are ALLOWED… a door with nothing behind it is inert geometry".
That is wrong and MUST be corrected. psz-re's node exists
specifically to answer it:
- Room shape comes from the cell's degree and nothing else —
degree 4 →
x, 3 →t, 2 →lori, 1 →s/g/n. - The degree counter is incremented in the same function that links two
neighbours, bumping parent and child together, so
doors == connections by construction. The scene geometry agrees
independently:
room_doorways.jsonreads i 2, l 2, t 3, x 4, and 1 each for n/s/g.
So a generator MUST NOT place a room whose door set exceeds its connections, and MUST NOT implement any sealing pass. What the game closes is a gate, and a gate is always on a live connection — all 417 gated doorways in the capture set still hold their neighbour index.
This also retires an older psz-re line that called
params[8] a pass that "seals spare exits". Nothing is sealed. What
makes a field feel linear is that the graph is a tree.
Relationship to /states/free-field
/states/free-field already carries a "Gates and keys" table listing these same four attribute values, and the working agreement requires a new definition to be reconciled with an existing one rather than left to drift. The split is: this page is normative for the RULE — how a generator decides which doors get gated and where keys go — and free-field's table is the captured Valley layout, a transcribed instance of that rule for one field.
One framing there needs reading in that light. Free-field calls attribute 4 "locked while the room holds live enemies, opens on clear — the common case", which describes what a player experiences in the room once it is gated. It is not a rule for which rooms get gated: that is the per-room roll below, and a room can pass the roll independently of what it happens to hold. The two are consistent, and an implementation MUST take the roll from this page.
The four door kinds
The door attribute lives at cell +0x14+dir and is copied to the room
record at +0x1c..+0x1f — one byte per direction:
| Attr | Kind | Opens when |
|---|---|---|
0 | open | always — walk through |
1 | one-key gate | the player spends 1 key |
2 | two-key gate | the player spends 2 keys |
4 | enemy-defeat gate | the room holds no live enemies |
The way-back door is never gated — 0 of 592 rooms — so a player can always retreat toward the start. An implementation MUST preserve that: it is what makes a key detour possible at all.
A generated gate persists; a door that was never a barrier has no mesh.
An enemy-defeat gate whose room has a fight (the room's generated content
includes enemies) is a real gate: it MUST be built on every visit,
shown closed while enemies are alive and open — frame still
standing, laser and collision off — once the room is cleared. It MUST NOT
disappear on re-entry; a gate you opened is still there when you come back. Whether
a room "has a fight" is read from its generated objects, not the live
enemy count, which goes false the instant the room is cleared. By contrast, a doorway
that was never a barrier — an open door (attr 0), the way-back, or an
enemy-defeat door on a room that never had enemies — MUST render no
gate mesh at all; only its load trigger remains.
The economy
Key-gate budget
budget = (roomCount - 2) * params[5] / 100, with
params[5] = 35 and start and goal excluded from the
count. Integer division, and it is a hard cap: the pass
MUST stop as soon as spend reaches budget.
Eligibility — gates are never adjacent
A candidate room MUST be rejected when any of these holds:
- the room already carries more than one gate;
- any neighbour carries a gate — gated rooms are never adjacent, which is what stops the field turning into a corridor of locks;
- the room's gate/key balance fails the solvability invariant below.
An eligible room with flag 0x20 accepts at
rand(100) < params[4] (50). Other rooms accept
with a probability that scales with remaining budget, so gates
thin out toward the end of the pass rather than stopping at a cliff. An
implementation SHOULD reproduce that taper; a flat probability
that simply stops at the budget will cluster gates near the start.
One key or two
Roll rand(100) against params[6] = 30
when the room's flag byte carries 0x20, else against
params[7] = 10. Under → attribute 2;
over → attribute 1. A two-key gate additionally requires the
caller's count to be ≥ 2, so a two-key gate MUST NOT be created
when only one key can be placed for it.
Enemy-defeat gates are per ROOM, not per door
One roll per room. If it passes, every direction that
has a neighbour, is not already attributed, and is not the way-back direction
gets attribute 4. A room's forward exits are therefore
all gated or none — the capture set has
268 rooms gating every eligible exit, 106 gating none, and 0 mixed.
An implementation that rolls per door MUST be treated as wrong
even though it would look similar in play.
The chance is 75, not 10. level_topology_builder.json
lists default_params_8: 10, but
level_generation_topology.json records the runtime
value as 75, and the 268/106 split is ≈72% — which is 75 with
sampling noise, and nothing like 10. Use 75. The 10 is the
table's default, not the value the game runs with.
Key placement
Keys scatter in the gated room's own neighbourhood: BFS from the gated room to depth < 2, skipping the start and goal rooms. A room is dropped from the pool once it holds 2 keys, which is why no room in the capture set ever holds more than two. The key economy closes — gates demanding exactly the keys the rooms hold — in 62 of 62 captures.
Divergence (psz-godot): dead ends first. The measured rule picks from that pool uniformly; psz-godot SHOULD offer the pool's dead-end rooms — degree-1 rooms that are neither start nor goal — before any other room in it. A dead end that holds a key is a chosen side objective: the player sees the branch, fights it, and the gate ahead opens. A dead end that holds nothing is a wall you walk back from — forced backtracking once you learn it is not the way on. This is an intentional divergence from the measured rule, same category as the per-room treasure-box removal: the original's economy is count-based, and the counts are untouched — the same depth-<2 pool, the same 2-per-room cap, the same required == available balance. Preference stays within the pool for now; widening to any pre-gate dead end is a follow-up knob if detours feel too shallow in play.
Keys stay section-scoped and fungible, as Field Stage Lifecycle already defines them: any key opens any key gate in the same section. That reconciles with the measured rule rather than contradicting it — the original's invariant is over counts (required vs available), not identities. The original does store per-room key ids, and what those ids are for is not established; an implementation MUST NOT pair keys to gates on the strength of them.
The solvability invariant — the part that MUST NOT be skipped
Walk up the way-back chain from the candidate room to the start, accumulating the
gate requirement at +0x06 and the keys available at
+0x07. A gate is only eligible when the two are
equal.
This is what guarantees a field is completable, and it is simultaneously the part most likely to be dropped as an optimisation and the most expensive to add back later. A generator MUST evaluate it before committing a gate, and the seeded test sweep MUST include "no field is ever unsolvable" as its headline assertion.
Where we are today
Implemented: the generator assigns every door an attribute with the budget, the adjacency rejection and the solvability invariant (#595 step 2, PR #602); the runtime reads the generated attributes, builds two-key gates with their own HUD state, persists opened gates across visits, and locks rooms that hold enemies. The pre-#595 behaviour below is gone and is kept only as history:
- Was:
valley_field_controller._lock_gates_for_enemies()locked every non-entry, non-visited exit whenever a room held enemies — always on, keyed to enemy presence, rather than a 75% per-room roll. - Was:
portal_gate_manager._get_locked_gates()readkey_gate_directions/key_gate_directionoff a cell hand-authored by the editor, never generated. - Was: no two-key gates, no budget, no key placement, no solvability check.
Order of work
- This page. (done — it is the contract the rest is tested against.)
- Generator: assign door attributes during layout, honouring the budget, the adjacency rejection and the invariant. Seeded, so a seed reproduces a field's gates exactly. (done, #595 step 2 / PR #602.)
- Runtime:
portal_gate_managerreads generated attributes instead of only authored ones; two-key gates need their own HUD and minimap states. (done, PR #602 and follow-ups.) - Keys: placement per the BFS rule with the dead-end preference above, and the pickup/consume path against a gate. Keys spawn with the generator's own data rather than waiting on the authored key object in #594; when they appear — on room clear — is Key Placement's contract. (done; the dead-end preference and drop-on-clear are #639.)
Verification the implementation owes
- Seeded unit tests for: the budget formula; gated rooms never adjacent; the way-back door never gated; enemy-defeat all-or-nothing per room; and no field unsolvable over a large seed sweep — that last one is the test that matters.
- Autopilot: the nav backbone has to learn "clear the room to open the exit" and "fetch two keys". Expect harness work, and expect the matrix to be the long pole.
What is NOT claimed
- What the per-room key ids are for. The original stores them and the invariant that closes the economy is count-based, so nothing here pairs a key to a gate.
- The exact taper on the non-
0x20acceptance probability. psz-re records that it scales with remaining budget; the precise curve is not transcribed here, and an implementation should readlevel_topology_builder.jsonrather than invent one. - What flag
0x20means. It selects the higher accept chance and the higher two-key chance, and it is the same flag the room builder uses for paired cells — but it is used here as an input, not explained.