# p7 · Hex Caves (v5): build spec

Written 2026-09-26, to be built in the next session. The user's brief: "generate 16x32 profile,
horizontally tiling caves, 4 at a time. No need for any fancy graphics, make it a '6yo in MS Paintbrush'
project feel or anything that's easy." The design behind it: `concepts/callisto_design.md`, sections
*Generation: wave function collapse* and *Geological layers*.

Decided by the user: procedural, WFC for terrain (and later the lattice) in one pass, not as separate
layers; hex tiles, **pointy-top**, **3 points per side**, tiles **generated by code**; **Markov-weighted
pairing**. Everything else here is Claude's default: change freely if it fights the build.

## The map
- **16 columns × 32 rows** of pointy-top hexes, a side-view profile (16 wide, 32 deep; the brief's
  "16x32" read as width × depth). Rows are offset ("odd-r": odd rows shifted half a hex right).
- **Wraps horizontally:** column 15's east neighbour is column 0 (the width is even, so the offset rows
  wrap cleanly). No vertical wrap.
- **Fine grid:** each hex is drawn into a fine cell grid (the future game grid). A hex is exactly
  **10 cells wide**, rows **9 cells apart** (a slightly stretched regular hex; it keeps the wrap exact):
  the fine grid is **160 × 291** cells, wrapping at 160. A fine cell belongs to the nearest hex centre
  (in the stretched metric).

## Sockets
- Each of a hex's 6 sides is split into **3 equal segments**; each segment is a socket, **open** or
  **solid**. 18 sockets per tile.
- **Matching:** side *k* of a hex meets side *k+3* of its neighbour, with the segment order reversed.
  Two neighbours are compatible when all 3 shared sockets are equal.
- On the vertical E and W sides the three sockets mean floor level, middle and ceiling level.

## Tiles, generated by code
A tile is a shape in hex-local coordinates (a signed-distance function: open where < 0). Its 18 sockets are
sampled at the segment midpoints. The families:
1. **solid** and **open**;
2. **walls:** half-planes, the boundary line at 12 angles (every 30°) × 3 offsets (−½, 0, +½ of the
   radius); the open side is a cave, the solid side rock (floors, ceilings, slopes);
3. **passages:** a thick line from one socket through the centre to another socket on a different side,
   about one segment thick (all side pairs; drop near-U-turns);
4. **junctions:** three sockets joined at the centre;
5. **pockets:** an open disk in the middle of solid rock (no open sockets; later: gas bubbles).
Deduplicate by socket signature (keep the first shape for each). Each tile keeps its family (its
**class**) and, for passages, its axis. Expect a few hundred tiles; log the count.

## WFC
- Each hex holds a domain of possible tiles (a bitset). Seeded with `mulberry32` (`src/sim/rng.js`);
  no `Math.random`.
- Loop: pick the undecided hex with the lowest entropy (from base weights; ties by the rng), choose a tile
  by **context weight** (below), then propagate: remove neighbour tiles whose sockets can't match, and
  repeat through the queue (AC-3), across the wrap.
- **Contradiction** (an empty domain): restart with the next sub-seed; cap 50 restarts, then report a
  failure. Count restarts per map.
- **Fixed rows:** row 0 is the surface (solid, the sky is drawn above it); rows 30–31 are the ocean
  (open, drawn as black water, "no diving"). They constrain the rows next to them through sockets.

## Markov-weighted pairing
- **Context weight** of a tile = `base(class, depth) × Π affinity(class, neighbour's class)` over the
  already-collapsed neighbours, × a **continuation** bonus when a passage lines up with a neighbouring
  passage on the same axis (corridors run on).
- Knobs (sliders on the page, defaults in one object): how open each layer is; how readily caves grow
  (open next to open); corridor continue / turn / branch; wall straightness.
- Entropy uses the base weights only (cheap); the context weights apply when choosing.

## Layers
Depth bands (row numbers, defaults): **0** surface · **1–9** ice crust · **10–20** raisin pudding ·
**21–29** brine zone · **30–31** ocean.
- The bands set the **base weights** (e.g. the ice has more halls and walls, the pudding bigger caverns
  and fewer passages, the brine zone more pockets and small caves).
- The bands set the **material** of solid cells: ice, pudding, brine-zone rock. A band's border wobbles
  ±2 rows by a hash per column, so layers blend instead of meeting along a line.

## Drawing the fine grid
- For each fine cell: find its hex and the local position; **within 1.5 cells of the hex edge**, it takes
  the value of the nearest socket (so every edge joins seamlessly); **inside**, it follows the tile's
  shape plus small hashed noise (value noise, amplitude about 1 cell), which is what hides the grid.
- Cavities: connected open regions of the fine grid (wrapping), measured for the stats.

## The page: `v5.html` · Hex Caves
- **Four maps side by side:** seeds s, s+1, s+2, s+3. Each is drawn with a faded copy of its first four
  columns appended on the right, to show the wrap.
- Controls: seed box, ◀ ▶ (step by 4), reroll, the knob sliders, and view toggles: **painted** (the
  default) · **classes** (each tile coloured by its family) · **hex grid** · **sockets**.
- **Stats under each map:** tile count in the set, restarts, ms, cavities, the largest cavity's share of
  open cells, tiny isolated pockets (under 20 cells).
- **Style: a 6-year-old in MS Paint.** Flat colours, 1 px black outlines where open meets solid, no
  gradients. Sky dark blue with a big orange Jupiter circle; ice pale blue; pudding brown; brine-zone rock
  teal-grey; open cave near-black; ocean black-blue. Each fine cell 2 × 2 px (a map ≈ 320 × 582 px).
- The page loads `src/errors.js` (R15), follows the iOS 14 syntax rules (R14), and is listed in
  `spelunking/index.html`.

## Gallery
A node script writes the stages of seeds 1–4 as PNGs (`tools/png.js`) to `gallery/p7/`:
1. tile classes, 2. sockets, 3. the fine grid before noise, 4. with noise, 5. painted.
The user likes seeing the stages. The same script checks: every pair of
neighbours matches on all 3 sockets (across the wrap too); the same seed gives the same map twice.

## Code layout
- Pure modules (no DOM) in `src/bundles/v5/`: `hex.js` (coordinates, neighbours, wrap), `tiles.js`
  (families → tiles, sockets), `wfc.js` (collapse, propagate, weights), `raster.js` (fine grid, noise,
  cavities); `main.js` for the page.
- Throwaway tier (R7): it moves into `src/sim/gen/` with tests when the next prototype (the lattice)
  needs it. Type-checked by `tsc` (JSDoc), zero dependencies (R3).

## Done when
- The page shows 4 maps; every toggle and knob works; stats show.
- The gallery PNGs exist for seeds 1–4, and the script's checks pass.
- `tsc`, `node --test` and `tools/timeline.js --strict` pass; smoke loads `v5.html` with no errors.
- Screenshots of the page in `gallery/p7/`; the entry's **Built** section filled in; decision **D066**
  records the generator as built.
- Then the user looks at it and judges the Question.
