qproj eye icon qproj design docs

Quell hex terrain: terrain-first trail generation

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

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

Status: Round 9, 2026-10-12. Being ported to Rust on feat/hex-paths. Nodes are now typed at placement, not after trails (see “Rust port changes”). Trialling loops that rejoin trails (§6); may be reverted.

Replaces the generators in Quell hex paths prototype design (backbone + budget, growing tree). The prototype work continues on feat/hex-paths with linear history: old generators and tile-centre movement are stripped out, and vertex-to-vertex movement replaces them.

Prototype: misc/hex-lab/src/hexlab/terrain.py (illustration only, no metrics). It draws each figure as SVG and writes it as PNG. Regenerate the figures, and print the per-seed checks quoted below, with:

cd misc/hex-lab && uv run python -m hexlab.terrain ../../.docs/wiki/static/media/pcg

Rust port changes (round 9)

The Rust port (bin/quell/src/screen/dev/hex_paths/) follows this document except where listed here. Figures and statistics below still come from the Python prototype and show the round-8 rules.

Terms

TermMeaning
NodeA place on the board: a POI or a bench. (Round 8: untyped until the trails exist. Round 9: typed at placement.)
POIPoint of interest. The deck guarantees one each of key, NPC and Fear. Every other POI is empty (undefined for now; later perhaps holy places, abandoned zones, …).
BenchRest and view stop along the way. Inventory management and similar activities happen here.
SpawnWhere the player starts. Not a node.
PadThe levelled hex a node or spawn sits on.
SpurA node with only one way in and out.

Idea

Real trails come from how easy the ground is to walk. So generate the ground first, then let people walk it. Trails bend to the terrain, not the other way round: generation changes the terrain as little as it can (pads, and the shortfall of a tease drop; §4).

Hard guarantees, which hold by construction with no retries:

GuaranteeWhy
Every node placedFarthest-point sampling always picks a site. Spacing tightens rather than failing.
No softlockStep costs are finite, and a final pass joins anything left over (§5).
Dead ends only at POIsEvery trail runs pad to pad, and spurs are typed POI.
Two benches always have another node between themTyping rule (§7).
Key, NPC and Fear always dealtTyping keeps at least 3 POIs (§7).
Max 3-way junctionsHoneycomb geometry.

Soft rule. Each tease stays at least tease_detour × the crow-fly distance on foot, and its viewer is reached before its target. Every new trail is rejected if it would break more teases than are already broken (§5).

See Statistics for results over 1000 seeds.

Steps

All step figures use one board: default knobs, seed 2, 24 × 20. The numbers are indices: 0 is spawn and 1–9 are nodes. Steps 3–6 show nodes untyped (grey).

1. Heightmap

heightmap

Fractal noise sampled at every corner, over the board and its margin:

  1. Sum 4 octaves of smoothed white noise. Each octave has half the feature size of the previous one and roughness × its amplitude.
  2. Blend with a ridged copy (1 − |noise|), which turns zero-crossings into sharp ridgelines. ridges is the blend amount.
  3. Normalise to 0–1, raise to the power 1.5 (flattens lowlands a little), and scale to relief.

Heights are measured in hex edge lengths, so slope is simply the height difference along an edge.

2. Ease of travel

cost

Each edge costs exp(3.5 × |Δh|), a symmetric form of Tobler’s hiking function. Flat ground costs 1. A rise of 1 edge length over 1 edge costs about 33, so it’s steep but never impassable. Darker red means costlier.

3. Node sites

node sites

Suitability measures how buildable a tile is: exp(−range / median range), where range is the height range over the tile and its 6 neighbours. The fog covers unsuitable ground.

Weighted farthest-point sampling places all nodes in one pass. Starting from spawn (the centre tile), repeatedly take the tile that maximises:

distance to nearest placed pad × suitability ^ spread

4. Teases: escarpments, then levelling

levelled

A tease is a viewer node that can see a target node a long way round on foot. The target will be typed a POI (§7). The drop-off between them is an escarpment: a steep, wandering edge with the viewer standing at the lip, looking down.

Picking pairs. Find, don’t build.

Raising the escarpment.

Levelling (grading). Each pad’s 6 corners are set to their median height. Around the pad, ground may rise or fall at most 0.5 per edge length away from the pad edge:

The effect fades out between 1 and 2 edge lengths beyond the pad edge. So on gentle ground the pad disappears into the terrain, and on steep ground it becomes a terrace with a graded apron and a step where the grading ends.

5. Which nodes to join

graph

Greedy spanner.

  1. Compute the direct cost between every pair of pads (spawn included): the cheapest walk over open terrain.
  2. Go through the pairs, cheapest first. Tease pairs are never joined directly.
  3. For each pair, build a trail if the existing network makes you walk more than 2 × the direct cost.

Solid lines are built trails, numbered in build order. Dashed lines are skipped pairs.

Each built pair is routed with Dijkstra over the terrain:

Guarding. A route is rejected if it breaks more teases than are already broken. The spanner makes two passes over the pairs, because a pair rejected early can pass later. For example, a tease target can’t connect until its viewer has.

Connection pass. Any node still unconnected is joined to the network. It tries guarded routes first. Only if none work does it take the cheapest route regardless, so softlock is impossible.

6. Loops

trails

“On a loop” means two separate ways, sharing no trail corner, from the pad to some other node. Two exits that merge a step later don’t count.

Two passes:

  1. Cheap loops. Spawn always tries. Any other node tries if the new trail costs at most 3 × its existing link to its nearest node, and is at most 18 edges long.
  2. No trees. Every node still on a spur tries again at any length and cost, except tease targets. A tease target has to be far on foot and reached after its viewer, so almost any loop near it shortcuts the tease. As a spur it’s a POI at the end of the long way round, which is the reward.

With rejoin (current). The second trail leaves the pad by a fresh corner and stops at the first trail it meets, or at another node:

Without rejoin (REJOIN = False). Each loop is a second trail to another node:

If nothing passes, the node stays a spur, and so becomes a POI.

Black trails come from the spanner, blue from cheap loops, purple from the no-tree pass, and red from the connection pass (none on this board). On this board, rejoin gives 9, 3, 4, 7 and 8 loops. 1 and 2 stay spurs: 1 is a tease target, and 2 found no loop that passed every check.

7. Typing

types

Nodes are typed by where they ended up:

  1. Spurs and tease targets are POIs.
  2. The rest are on trails. Going through them in order of bench score (best first), each becomes a bench unless that would put two benches together with no other node (or spawn) between them. Enough nodes are held back as POIs for the deck.
  3. Everything left is a POI.
  4. Deal the deck: key, NPC and Fear go to random POIs. Other POIs are empty.

Bench score is how well a tile suits a bench. Look at tiles within 6 hexes from eye height (including the margin):

view blends the two by rank: 0 is all quiet, 1 is all overlook. The fog covers low-scoring ground. View now only chooses which trail nodes become benches. It no longer moves where nodes are placed.

8. Teases

teases

Each arrow points from viewer to target, labelled with walk ÷ crow-fly. Here node 9 looks down at the NPC (3.8 ×), and node 4 at node 1 (3.5 ×). A failing tease would be grey and labelled “hidden” or “met late”.

9. Smoothed

smoothed

How the final output might look. Trails are split into runs between junctions and pads. Each run is relaxed (4 passes of neighbour averaging, which removes the honeycomb zigzag), then corner-rounded (3 passes of Chaikin’s algorithm). Run ends stay put, so runs still meet at junctions.

Terrain here is interpolated smoothly (cubic) instead of per-triangle, and pads are drawn as round clearings. The zigzag is purely a lattice artefact, so smoothing doesn’t change which ways exist.

Knobs

KnobDefaultEffect
columns, rows24, 20Playable board size.
scale8Size of hills, in hexes.
relief6Total height range, in edge lengths.
roughness0.5Small-scale bumpiness (amplitude falloff per octave).
ridges0.3Rolling hills → sharp ridgelines.
nodes9Nodes to place (typed later). Round 9: replaced by pois (6) and benches (3).
spread10 = even spacing; higher = sites follow good ground.
view0.7Which trail nodes become benches: 0 = quiet, 1 = overlook. Round 9: where benches are placed.
teases2Number of tease pairs.
tease_height2–4Escarpment drop, drawn from this range per tease.
tease_detour3Minimum walk ÷ crow-fly for a tease.

The two panel sections would be terrain (scale to ridges) and network (the rest).

Internal constants:

Terrain knobs

terrain knobs

Each row varies one knob at 18 × 15 (7 nodes), with everything else at defaults, drawn in the final style.

Network knobs

network knobs

How much terrain to change

tease height

The same board at three tease_height ranges. (0, 0) changes no terrain for teases; only pads are graded. Measured in round 5, averaged over seeds 1–12:

tease_heightCorners moved > 0.25Mean changeTeases holding
037 (grading only)0.0320/23
1–2740.0620/23
2–4 (default)970.0921/23

The trail rules do most of the work of keeping a tease. The escarpment mainly explains it, and it makes trails go around naturally. tease_height stays a knob, to be judged in 3D.

Gallery

gallery

30 × 26, 14 nodes, final style.

Statistics (1000 seeds)

Default knobs, 24 × 20, 9 nodes, seeds 1–1000. Each board takes about 1 s in Python (max 2 s). Rerun with uv run python -m hexlab.terrain_stats results/terrain/<name>.csv 1000. One row per board is written to misc/hex-lab/results/terrain/: stats-1000.csv is node-to-node loops only, and stats-1000-rejoin.csv is with rejoin.

Rejoin vs node-to-node

MeasureNode-to-nodeRejoin
Boards with a loop route > 18 edges562190
Boards with a loop route > 30 edges24621
Longest loop route7444
Boards with ≥ 10 loop edges on the outer ring455328
Spurs per board0.460.24
Boards with a spur that isn’t a tease target2120
Independent loops per board (mean)4.755.29
Junctions per board (mean)2.65.6
Benches per board (mean)3.323.09
Trail density (mean)0.3570.341
Teases held93.3%93.3%
Spawn on a loop99.9%99.7%

Rejoin all but removes the long trails around the edge (over 30 edges: 246 → 21 boards), halves spurs, and adds loops, at the same density. The outer-ring count stays high because it also counts ordinary short loops between nodes near the edge, which sit in the outer two rings anyway. Junctions double, because rejoining makes 3-way junctions along trails. That also costs a few benches: two benches on one stretch of trail can’t both be benches.

Some thin fingers remain in the gallery (two trails close together for a short stretch), from rejoins that start next to another node’s trail.

Node-to-node loops only

The detail below is for node-to-node loops (stats-1000.csv).

MeasureResult
Errors0
Connected100%
Spawn on a loop99.9%
Benches together (no node between)0 boards
POIs per board4–8 (median 6)
Benches per board1–5 (median 3, mean 3.3)
Spurs per board0–3 (mean 0.46)
Boards with a spur that isn’t a tease target2.1%
Independent loops per board2–9 (median 5)
Trail density (edges per tile)0.21–0.58 (5–95%: 0.26–0.46)
Teases placed / held1996 / 1862 (93.3%). 100 met late, 101 too short, 1 hidden
Boards needing the connection pass17.5%
Boards using the no-tree pass62.3%
Corners moved > 0.25median 80 (min 2, max 108)

Long trails from the no-tree pass do show up. Its longest route per board has a median of 28 edges and a maximum of 74. 562 boards have one longer than the old 18-edge cap, and 246 longer than 30. On 295 boards, 10 or more of its edges run along the outer ring of the board. It’s structural: a trail that can’t cross other trails reaches the far side of the network by going around the outside of it. Banning the outer ring only moves the route one ring in (still 199 boards).

Capping the no-tree pass trades rim trails back for spurs:

No-tree capBoards with a non-tease spurBoards with ≥ 10 no-tree rim edgesBenches per board
1862922.97
241591343.35
30512073.35
none (current)212953.32

The spanner’s own routes can run long too: its longest route reaches 68 edges, and 38 boards have one over 40.

Quell port

Viewing tools:

Known issues

  1. The connection pass can break a tease. It only happens when there’s no other way to connect a node. That’s working as designed, but worth knowing.
  2. Teases cost loops. Without teases, the same 12 seeds have 16 spurs and 39 benches; tease targets are spurs by design.
  3. Benches are about a third of nodes (38 of 108). The “another node between two benches” rule caps them at about half of any loop. Bench selection is greedy by view score; a selection that maximises the bench count would get closer to that cap.
  4. Long trails around the board edge. With node-to-node loops only, the uncapped no-tree pass lays them on about 30% of boards. Rejoin cuts long ones (> 30 edges) to 2%. See Statistics.
  5. Thin fingers still appear occasionally with rejoin.
  6. Escarpments read weakly in 2D. The 3D view should fix this.

Open questions

  1. Keep rejoin? It’s on in every figure.
  2. More benches? Either maximise the bench count under the current rule (instead of greedy by view), or relax the rule.
  3. Creeks: later, as agreed.