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:

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:

AttrKindOpens when
0openalways — walk through
1one-key gatethe player spends 1 key
2two-key gatethe player spends 2 keys
4enemy-defeat gatethe 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:

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:

Order of work

  1. This page. (done — it is the contract the rest is tested against.)
  2. 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.)
  3. Runtime: portal_gate_manager reads generated attributes instead of only authored ones; two-key gates need their own HUD and minimap states. (done, PR #602 and follow-ups.)
  4. 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

What is NOT claimed