qproj eye icon qproj design docs

Quell hex paths prototype design

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

Superseded by Quell hex terrain, round 11: the generator as it stands (generators, via Quell hex terrain: terrain-first trail generation). The screen and walking shipped in quell #49 and #51.

Status: Approved by Ada on 2026-10-09. Implementation proceeds one step at a time.

Before pushing: rebase and sign the unsigned step 1 commit b094306.

Source: handwritten notes 27-Sept-26 c.i, 7.Oct.26.a.i, 7.Oct.26.b (exploration / traversal / overworld — “hiking in the woods”).

Goal

A dev screen in Quell that visualizes the hex path generation and lets a player token walk it. Board generation settings and play-time view toggles are tweakable Resources, edited from an in-screen bevy_ui / feathers debug panel.

In scope:

Out of scope (later): Fear reshuffle, path reset on reaching a node, the sub-games, any win condition, art.

1. Board model

Tiles are entities. Generation produces plain data (§3); a system spawns it.

TypeKindResponsibility
HexPos(Hex)ComponentGrid position of a tile or the player.
SpokesComponentbitflags u8, one bit per hex edge (6 directions).
TileKindComponentSpawn | Node(NodeTrait) | Path.
NodeTraitenumKeyLocation | Npc | Fear | Empty.
RevealedComponent (marker)Tile has been entered. Spawn starts revealed. A revealed node is flipped — no separate state.
BoardResourceHashMap<Hex, Entity> index for neighbor lookup, grid bounds, HexLayout. Empty cells are absent.
PlayerComponentPlayer token; carries HexPos and Walk (§5).

Tile entities are ScreenScoped and despawned on regenerate.

Crossing rule: moving from a to neighbor b in direction d is legal iff a has spoke d and b has spoke -d. A spoke with no partner is a dead end (the ✕ in the sketch) or points off-map (the arrows). Backtracking is always possible, so softlock ⇔ spawn and nodes are not in one connected component. The generator guarantees they are.

Coordinates and geometry come from hexx 0.25 (glam 0.32, matches Bevy 0.19): Hex, EdgeDirection, HexLayout for world positions and cursor → hex, hex distance, and algorithms::a_star for click-to-walk routing. The grid is a rectangle in offset coordinates.

2. Settings and view toggles

Two Resources, both Reflect:

HexPathsSettings — board generation. Changing it regenerates the board (resource_changed::<HexPathsSettings>).

FieldDefaultMeaning
columns, rows10, 8Grid size.
seedNoneNone: fresh random seed every generation. Some(n): pinned, same seed + settings ⇒ same board.
spawnCenterCenter | Random | Fixed(Hex).
deckkey 1, NPC 1, Fear 1, empty 3Trait counts. Node count = deck size.
min_node_distance2Hex distance between any two nodes.
min_spawn_distance3Hex distance from spawn to any node.
spawn_spokes2..=3Spoke count range for the spawn tile.
noise2.0Amplitude of per-cell random cost in A*. 0 = shortest paths.
shape_weightssharp 1, wide 1, straight 1Relative preference for 2-spoke shapes: 60°, 120°, 180°.
junction_chance0.25Chance a 2-spoke path tile is upgraded to 3 spokes (≈ the 3:1 deck).
branch_length1..=4Length range of side branches spawned by junctions.
branch_join_chance0.3Chance a branch connects into an adjacent path tile (loop) instead of ending in a dead end.

HexPathsView — play-time. Changing it never regenerates.

FieldDefaultMeaning
show_pathsfalseDraw unrevealed tiles’ spokes.
show_node_traitsfalseDraw face-down nodes’ traits.
step_duration0.5 sTime to walk one tile (§5).

Neither toggle affects routing: the player only plans with what they know.

A BoardStats Resource reports what generation actually produced: the seed used (copy it into seed to pin a board you like), 2- vs 3-spoke counts, shape counts, attempts, and any GenError. Shape weights and junction chance are biases, not exact ratios — the stats make the real distribution visible.

3. Generation

generate(&HexPathsSettings, &mut impl Rng) -> Result<BoardLayout, GenError> works on plain data — BoardLayout is a HashMap<Hex, (TileKind, Spokes)> plus spawn — so the algorithm can be unit tested without an App. A spawn_board system turns the layout into the components in §1. The ECS representation is the source of truth at play time; BoardLayout is only the generator’s output.

3.1 Place spawn and nodes

Place spawn per spawn. Rejection-sample node cells uniformly from the grid, rejecting any closer than min_spawn_distance to spawn or min_node_distance to a placed node. Bounded retries; on failure return GenError::Placement (shown in the debug panel) — settings that cannot fit are a user error, not something to silently relax.

Shuffle the deck and deal one trait per node. Traits are fixed from here.

3.2 Backbone: noisy A*

Sample a cost field once per generation: each cell gets 1 + noise × rand(). Fixed per-cell costs (rather than per-step) give paths that share “easy ground”, which reads more like trails than per-step jitter.

Network = {spawn}. For each node in shuffled order, A* from the node to the nearest joinable network cell:

Commit the path: add mutual spokes along it. Interior cells get exactly 2 spokes; the join cell gains one. Every tile stays within its cap by construction.

If a node has no route (walled in by capped tiles), retry the whole generation with the next RNG draw, up to a bound, then GenError::Unreachable.

3.3 Spawn minimum

If spawn has fewer than spawn_spokes.min spokes, grow branches from spawn (§3.4) until it does.

3.4 Decorate: junctions and branches

For each 2-spoke Path tile, with junction_chance, add a third spoke toward an empty neighbor and grow a branch from it: a random walk of branch_length steps through empty cells, choosing each turn by shape_weights. The branch ends:

3-spoke shapes (Y, T, claw) fall out of the junction’s neighbor choice; they have no weights of their own.

Decoration only adds connections, so it cannot break the §3.2 guarantee.

Cells not touched by any of this stay empty.

4. Screen

New HexPathsScreen in bin/quell/src/screen/dev/hex_paths/, following camera_test’s layout (mod.rs, data.rs, screen.rs). Its own Camera2d, ScreenScoped.

Board rendering uses gizmos — the board is small and this is a visualization:

Debug panel: a bevy_ui side panel built from feathers controls (requires enabling Bevy’s bevy_feathers feature for quell):

The existing world inspector is unchanged.

5. Movement

Click a tile to walk there. A system maps the cursor to a hex via HexLayout::world_pos_to_hex and sends a WalkTo(Hex) message. Clicks on the debug panel are not forwarded.

Routing uses hexx::algorithms::a_star without noise. Cost of a → b is Some(1) iff the crossing rule holds and the player knows b is reachable:

So a route passes only through revealed tiles and may end one tile into the frontier. No route ⇒ the click is ignored.

Walking: Walk { route: VecDeque<Hex>, t: f32 } on the player. The token eases (ease-in-out) between tile centers, one tile per step_duration. On arrival at each tile: update HexPos, insert Revealed. A new click during a walk replaces the remaining route, replanned from the tile being entered, so the token never stops mid-edge.

6. Testing

Unit tests in src/ against generate, over a range of seeds:

Unit tests for routing: passes only through revealed tiles, may end one tile into the frontier, refuses to cross a dangling spoke.

One fixture integration test in tests/, driven from WalkTo with explicit schedule stepping via q_test_harness: a walk reveals each tile on arrival and ends at the destination; an unreachable WalkTo leaves the player in place. q_test_harness has mouse buttons but no cursor position, so the click → hex mapping is not covered here; adding cursor input to the harness is a separate change.

7. Dependencies

8. Implementation steps

  1. Board model, settings, generate + unit tests.
  2. Screen, board spawn, gizmo rendering, feathers debug panel, view toggles.
  3. Click-to-walk: routing, eased walking, reveal, route highlight + tests.