qproj eye icon qproj design docs

Quell hex terrain, round 10: terrain and layout layers

design · superseded · Procedural generation · edited 2026-11-04

Superseded by Quell hex terrain, round 11: the generator as it stands.

Status: Round 10, superseded by round 11 (Quell hex terrain, round 11: the generator as it stands), 2026-10-13. Kept for the experiment record.

Builds on Quell hex terrain: terrain-first trail generation (rounds 1–9). It changes three things:

Everything not mentioned here stays as it is.

Why

  1. Fear regenerates the trails. Reaching a Fear disorients the player, and the trails change while the terrain stays put. So the terrain and the layout (nodes and trails) need separate generation, and the layout must be able to rerun on the same ground.

  2. Walks should get you lost. Rounds 5–8 optimised for a tidy network: the spanner only adds a trail when the detour is over 2×, there are no trees, and loops are added when cheap. Seed 7 of the typing experiment is where that leads: one big clean ring, efficient and easy to read. That’s the opposite of the intent.

    The goal is lost but readable: nodes act as landmarks, but the way between them is uncertain. You get surprised by what’s at the end of a trail, and you sense the world changing behind you, perhaps literally, later. The player has no map. Their device only points to the general location of the nearest node, and only when they check it.

Layers: liquid geometry

The displayed ground is a stack:

displayed terrain = heightmap   (terrain seed; never edited)
                  + model layer (locations: pads, benches, … dig in or flatten out)
                  + trail layer (trails settle into the ground)

What this changes from round 9:

Seeds

Fear regeneration

When the player reaches a Fear, the layout regenerates:

After a Fear
Terrain (heightmap)Unchanged.
TrailsRegenerated from scratch. The player has to find their way again.
NodesKept, each displaced randomly within 3–5 tiles so it stays in the same general area. Visited nodes move too, and nodes may swap places. Model layers move with them.
Visited nodesStill revealed.
DeckUnchanged: cards stay with their nodes.
PlayerStays where they are. Their corner becomes the new layout’s spawn, so teases and walking order follow from it.

Timing doesn’t matter: the event isn’t instantaneous, and the player keeps doing things while the layout regenerates. Generation can run in the background, or be spread over frames.

Pinned regeneration: the shape of the layout step

Regeneration must work with some nodes and trails pinned. For example, everything within a radius of the player stays put while the rest changes, and a Fear pins almost nothing. So the layout step is one function, and the first layout is just the case with nothing pinned:

layout(terrain, pins, seed) -> nodes, trails, teases
pins = {
  player:  the corner the player stands on (the new spawn),
  nodes:   fixed nodes (tile, type, card),
  movable: nodes to re-place near their previous tile (type, card, old tile, radius),
  trails:  fixed trail edges,
}

What each stage has to do with pins:

This mostly constrains the Rust structure. Generation is a pure function of (terrain, pins, seed), with no state kept between runs. The Python experiment should be built the same way, so partial regeneration can be tested too.

Lost but readable

Bring back route noise. The original design had it, and it got dropped on the way to terrain-first routing.

Measures, tracked like the others:

  1. Detour: walk ÷ crow-fly between node pairs, and between random pairs of trail corners. Higher means more winding. Report the median and the spread.

  2. Branch count: how many genuinely different ways connect two places. Two candidate definitions, to be compared in the experiment:

    • the number of routes between node pairs within 1.5× of the shortest walk, counting only routes that don’t share the same trail;
    • junctions passed per walk between neighbouring nodes.

    “One big ring” scores low on both: few junctions, two ways round.

Knobs to try:

Placement (from round 9, adjusted)

Python experiment (before Rust)

Rework misc/hex-lab to the layer model, then compare arms over 1000 seeds with figures in the design-doc style:

Statistics:

Figures:

Partial-regeneration statistics: ports that fail to join, dead ends at the pin boundary, and how much of the network outside the radius actually changed.

Results: first run (1000 seeds, 20 × 20)

Code: misc/hex-lab/src/hexlab/layout.py, tag exp/04-layers. CSV: misc/hex-lab/results/terrain/layout-1000.csv. Every layout is layout(ground, arm, seed, pins): there is no escarpment raising, and pads are a model layer only.

Arms (all typed placement with 6 POIs and 3 benches, spread 2):

regen uses wander1 and runs three stages per seed:

  1. fresh: a first layout;
  2. partial: the player at the first POI reached, with everything within 4 hexes pinned;
  3. fear: the player at the Fear, with only the player pinned and every node moved 2–4 hexes.
basebench6wander0.5wander1wander1 stretch1.5regen partialregen fear
Benches together (boards)79%64%68%74%76%88%85%
Bench spur (boards)0.6%0.5%1.4%2.6%2.1%3.9%3.6%
Connection pass used (boards)27%28%35%52%52%52%50%
Teases held / placed1.84 / 1.961.84 / 1.961.77 / 1.961.60 / 1.961.60 / 1.961.36 / 1.861.51 / 1.85
Trail edges151153159168169175160
Detour, node pairs (median)1.511.511.551.621.611.631.64
Detour, trail corners (median)1.441.431.471.501.531.611.51
Branches (routes within 1.5×)1.171.171.171.151.161.171.16
Junctions per walk to nearest pad0.510.500.580.700.730.930.69

Every arm: 100% connected, spawn on a loop on at least 98.9% of boards, no dead ends, no errors. Pads move the playable ground by 0.02 edge lengths on average.

Regeneration works as designed:

Figures: quell-hex-terrain/experiments/04-layers/arms-seed-{2,3,7}.png and regen-seed-{2,3,7}.png. Grey trails are pinned, and orange trails join ports.

Findings.

  1. Natural teases are enough. With no raising, 94% of placed teases hold at base.
  2. Lostness hardly moves. Route noise adds wiggle (detour 1.51 → 1.62, junctions per walk 0.51 → 0.70). The structure stays efficient, though: between any two nodes there’s essentially one good route (branches ≈ 1.15 in every arm). The spanner and loop passes are built to avoid redundancy, so lostness needs a structural change, not just noise.
  3. Noise costs teases and pushes routes into the connection pass. Noisy routes fail the tease guard more often, so the connection pass is used on 52% of boards against 27%, and fewer teases hold (1.60 against 1.84).
  4. Bench spacing helps only a little (79% → 64% of boards with benches together). The network is dense enough to join benches through junctions wherever they are. The rule needs a guard, or relaxing.

Next candidates:

Results: forks (1000 seeds, 20 × 20)

Route noise is removed (designer, 2026-10-12). The bench-together rule is dropped as a rule and kept as a statistic. Typed placement may make it unnecessary.

Forks are unmarked side trails:

Tag exp/05-forks. CSV: misc/hex-lab/results/terrain/layout-forks-1000.csv. Figures: quell-hex-terrain/experiments/05-forks/, now with tease arrows: red if the tease holds, grey with the reason if not, labelled with walk ÷ crow-fly. Fork trails are green.

base4 local8 local4 far4 local + 4 farregen partialregen fear
Forks laid02.53.04.08.02.82.8
Trail edges151177182208248188172
Independent loops5.37.88.49.413.510.47.8
Junctions per walk to nearest pad0.510.610.630.881.070.870.63
Detour, trail corners (median)1.441.451.451.971.941.541.45
Branches (routes within 1.5×)1.171.181.181.211.221.191.18
Teases held / placed1.84 / 1.961.85 / 1.961.85 / 1.961.84 / 1.961.84 / 1.961.52 / 1.821.73 / 1.86
Connection pass used (boards)27%27%27%27%27%33%28%
Benches together (boards)79%80%80%96%98%91%90%

The regeneration stages use 4 local forks.

Findings.

  1. Forks cost nothing in teases or connections. The guard keeps teases intact, and connection-pass use is unchanged.
  2. Local forks are scarce. Only 2.5–3 of 4–8 fit. A 10–30 edge reach with 3 edges of clearance at both ends leaves few candidates on a 20 × 20 board.
  3. Far forks make the walk confusing. Unmarked choices between neighbouring nodes double, from 0.51 to 1.07 per walk with 4 + 4. Walking between random trail points gets much longer (detour 1.44 → 1.94), because far forks are long side routes.
  4. “Branches” barely moves, and is the wrong measure for this. It counts whole routes between node pairs that share no trail. A fork usually rejoins the same corridor, so it adds a choice, not a disjoint route. Junctions per walk is the better measure of “an unmarked fork in the path”.
  5. Forks densify. 4 + 4 forks bring the network to 248 trail edges for 400 tiles, up 64%. Fewer spanner trails (a higher stretch) could make room for forks at similar density.

Effect on the Rust port

Steps 1–4 stand. The rest of quell-hex-terrain-plan.md gets re-planned once the experiment settles:

Decisions (2026-10-12)

  1. During a regeneration: deferred. The leaning is that trails within a certain radius of the player stay fixed, nodes stay fixed, and only a Fear reshuffles fully.
  2. Displacement: visited nodes move too, and nodes may swap places. Radius 3–5 tiles for now, to tune later.
  3. The deck never changes. Cards stay with their nodes.
  4. The trail layer changes the ground’s material, and grades it very slightly at most, never enough to change the topology. So routing can ignore it.