• JavaScript 37.7%
  • TypeScript 36.3%
  • Haxe 25.5%
  • HTML 0.5%
Find a file
2026-10-13 18:00:11 +08:00
agent-preset agent-preset: QA round — menus are canvas-only, and the SVG oracle is real 2026-10-04 21:30:01 +08:00
analysis rename: retire Wildmap — the app is Procmaps, the tab is clearings-caves 2026-10-10 23:14:14 +08:00
assets Automated backup commit 2026-10-03 18:00 PST 2026-10-03 18:00:18 +08:00
dist-test Automated backup commit 2026-09-18 09:00 PST 2026-09-18 09:00:17 +08:00
js rename: retire Wildmap — the app is Procmaps, the tab is clearings-caves 2026-10-10 23:14:14 +08:00
scripts rename: retire Wildmap — the app is Procmaps, the tab is clearings-caves 2026-10-10 23:14:14 +08:00
source/TownGeneratorOS Initial: TownGeneratorOS source + analysis docs 2026-05-27 20:41:30 +08:00
.dockerignore rename: retire Wildmap — the app is Procmaps, the tab is clearings-caves 2026-10-10 23:14:14 +08:00
.gitignore deploy: publish the runbook and deploy helper (owner's call: public + forkable) 2026-10-06 21:48:31 +08:00
AGENTS.md rename: retire Wildmap — the app is Procmaps, the tab is clearings-caves 2026-10-10 23:14:14 +08:00
ARCHIVED.md docs: an index for analysis/, the canonical-URL cut-over, and two stale claims corrected 2026-10-08 22:57:04 +08:00
compose-strip.mjs Automated backup commit 2026-10-08 12:00 PST 2026-10-08 12:00:15 +08:00
count-teeth.mjs Automated backup commit 2026-10-08 12:00 PST 2026-10-08 12:00:15 +08:00
DEPLOY.md Automated backup commit 2026-10-13 18:00 PST 2026-10-13 18:00:11 +08:00
diag-hatch-direction.mjs Automated backup commit 2026-10-08 12:00 PST 2026-10-08 12:00:15 +08:00
diag-topology.mjs topology: "can a script detect that?" answered by MEASUREMENT, not opinion 2026-10-08 22:27:22 +08:00
diag-water.mjs Automated backup commit 2026-10-08 12:00 PST 2026-10-08 12:00:15 +08:00
docker-compose.prod.yml rename: retire Wildmap — the app is Procmaps, the tab is clearings-caves 2026-10-10 23:14:14 +08:00
Dockerfile rename: retire Wildmap — the app is Procmaps, the tab is clearings-caves 2026-10-10 23:14:14 +08:00
example-generate.mjs Automated backup commit 2026-10-08 12:00 PST 2026-10-08 12:00:15 +08:00
how-terrain-generation-works.pptx Automated backup commit 2026-10-03 18:00 PST 2026-10-03 18:00:18 +08:00
index.html rename: retire Wildmap — the app is Procmaps, the tab is clearings-caves 2026-10-10 23:14:14 +08:00
NEXT.md rename: retire Wildmap — the app is Procmaps, the tab is clearings-caves 2026-10-10 23:14:14 +08:00
nginx.conf rename: retire Wildmap — the app is Procmaps, the tab is clearings-caves 2026-10-10 23:14:14 +08:00
package-lock.json EOD 2026-07-03 14:29 PST — Boot drive inventory captured before machine migration (saved to tools/IT-knowledge/); backed up ~/.ssh, ~/scripts, and two Desktop files to NAS PERSONAL share ahead of drive swap 2026-07-03 14:35:13 +08:00
package.json habitats: the plans DRAW — renderer, legend, title block (analysis/35 §10) 2026-10-05 17:01:27 +08:00
PROGRESS.md Automated backup commit 2026-10-13 18:00 PST 2026-10-13 18:00:11 +08:00
README.md rename: retire Wildmap — the app is Procmaps, the tab is clearings-caves 2026-10-10 23:14:14 +08:00
render-habitat.mjs habitats: the plans DRAW — renderer, legend, title block (analysis/35 §10) 2026-10-05 17:01:27 +08:00
reproducible-manifest.md reproducible-manifest.md: document gitignored replaceable assets + fetch instructions (gen-reproducible-manifest.sh) 2026-08-09 16:53:14 +08:00
shot-board.mjs Automated backup commit 2026-10-08 12:00 PST 2026-10-08 12:00:15 +08:00
test-city.mjs Automated backup commit 2026-10-08 12:00 PST 2026-10-08 12:00:15 +08:00
test-f4-dom-qa.mjs Automated backup commit 2026-10-08 12:00 PST 2026-10-08 12:00:15 +08:00
test-f4-sweep.mjs overlay grid: the Character Grid scaled smaller (?goverlay=), 1-5 bounded by the 1 m floor 2026-10-06 23:30:15 +08:00
test-habitat.mjs habitats: the ruleset, executable and measured (analysis/35) 2026-10-05 13:24:28 +08:00
test-hexgrid.mjs hexgrid: a scalable SVG hex-grid module, hardened and with four claims corrected 2026-10-04 19:01:41 +08:00
test-layer-order.mjs overlay grid: the Character Grid scaled smaller (?goverlay=), 1-5 bounded by the 1 m floor 2026-10-06 23:30:15 +08:00
test-tactical-tab.mjs the Tactical map tab: one row in the workstream strip, gated on the origin 2026-10-10 16:20:06 +08:00
test-tree-layers.mjs trees: log the 1x-3x size rule, the 4 ground layers, and the measured CONFLICT 2026-10-05 12:02:58 +08:00
test-voronoi-activation.mjs grid: cell ceiling 5 m -> 20 m (default unchanged at 5 m) — "x2, x3, x4 bigger" 2026-10-08 22:43:03 +08:00
test-voronoi-lattice.mjs VG: heterogeneity variable, 512 cells, and the NEXT.md handover 2026-10-01 01:55:15 +08:00
tsconfig.json Phase 1: wilderness encounter map generator (terrain + roads) 2026-05-27 21:34:58 +08:00
vite.config.ts rename: retire Wildmap — the app is Procmaps, the tab is clearings-caves 2026-10-10 23:14:14 +08:00

procedural-maps

Procmaps — a watabou-style cave-glade encounter-map generator for TTRPGs. The product is Procmaps; this surface is clearings-caves (analysis/45, naming canon — Wildmap was retired 2026-10-10).

One map, one pipeline: a Voronoi mother grid whose cells are activated into chambers and carved corridors (everything unclaimed stays solid rock), rendered with the Watabou wall treatment — or as a glade: the same activation as a forest biome (clearing floor, dense-forest rock, procedural vector tree canopies) with ?vglade=1.

Live: https://procmap.gi7b.org/ (canonical since 2026-10-08) Mirror: https://pages.gi7b.org/gi7b/procedural-maps/ (Forgejo Pages, kept on the same build for a bake period — DEPLOY.md D3/D6) Source: https://git.gi7b.org/gi7b/procedural-maps

Deployed to procmap.gi7b.org (2026-10-06, cut over 2026-10-08). The app moved off Forgejo Pages onto a container on PC02 behind the estate's proxy — DEPLOY.md is the whole plan, the measured findings and the verification contract. The two consequences worth knowing before you edit: the canonical/OG tags in index.html point at procmap.gi7b.org (they pointed at Pages until 2026-10-08, which told every shared link that the copy being retired was authoritative), and the Docker path is run by hand — scripts/deploy-docker.sh opens SSH to PC02, which is off-limits to agent sessions (DEPLOY.md F8). npm run deploy still works and is the rollback path; see §"Deploy" under Tech Stack.

Docs map: analysis/00-INDEX.md says what every document in analysis/ decides and which one wins where two disagree. Start there, not by browsing the folder.

Moved (2026-10-05): this repo was transferred from justin-admin/procedural-maps to the gi7b org, and is now public. The old git path (git.gi7b.org/justin-admin/…) 301-redirects to the new one. The old Pages path (pages.gi7b.org/justin-admin/procedural-maps/) is still served, and still tracks this repo — measured after a redeploy: both paths returned byte-identical builds (sha256 f093dcab…), so it is an alias on the pages host, not a stale copy. Prefer the URLs above; the old ones are not guaranteed to be kept.

Scope note (2026-10-03): the earlier terrain, village, tactical and site-kind (hex-cave, ruin, village, castle, town, dungeon) modes were removed — this repo is now the cave-glade map only. They remain in git history. Old links with removed params (?mode=…, ?kind=…) still work: the unknown parameters are ignored and the cave-glade map is shown.

Companion workstreams in this repo (own folders, not modes of this app — AGENTS.md: the app has one pipeline):

workstream folder what it is
Procedural Habitats / Structures js/habitat/ floor plans — perimeter → hall → rooms → furniture (analysis/35, analysis/37)
City & Wilderness js/city/ city footprints, walls and gates; the countryside beyond them (js/city/README.md)

The map's scale

1 board unit = 1 metre, pinned by the owner 2026-10-05 (js/state.ts, METRES_PER_BOARD_UNIT). Everything downstream reads in metres:

  • the default board is 160 m × 155 m (bw / bh);
  • a glade tree canopy spans 1–4 mother-grid cells, so roughly 7–28 m on the default 512-cell board (spacing 6.96 m) — measured on the mother grid, never on the play grid, which is why the forest does not resize when you change the hex size;
  • the play grid's cell is 1–20 m, so gpitch runs 0.5–10 for hexes (circumradius — a hex's size is its corner-to-corner diameter) and 1–20 for squares (the side). 5 m is the default (×1); 10/15/20 m are ×2/×3/×4, which exist because the grid's cost scales with the board's area — a 480 m board carries 14,448 spaces at 5 m against 1,655 on the default 160 m board (measured 2026-10-08). The ceiling was 5 m until then.
  • the overlay grid (goverlay) is that grid scaled smaller, and it obeys the same 1 m floor: a 5 m hex can be divided ÷2 … ÷5, a 1 m hex cannot be divided at all. That is why the ceiling moves with the character cell instead of being a free 1–5 — see analysis/44.

To map a different real-world area, change the board, not the constant.


How it works

Three stages. The first two are pure and deterministic; only the third touches the DOM.

1. The mother grid — js/generator/voronoi-lattice.ts

A relaxed, jittered Voronoi lattice tiling the board exactly. Built on d3-delaunay, whose cellPolygon(i) clips correctly to the bounds — a mother grid must tile the board completely, with every cell a usable polygon.

  1. Seed on a triangular lattice sized so the cell count lands near cells. Triangular rather than square because it is the densest packing; after relaxation the arrangement is invisible, but the relaxed result is markedly more even than random points give.
  2. Jitter each seed inside its slot by jitter × the slot spacing.
  3. Lloyd-relax relax times (each seed moves to its cell's centroid). Measured effect: worst-case cell-size ratio improves 0.255 → 0.593 (test-voronoi-lattice.mjs).
  4. Warp the seed field by a smooth low-frequency displacement scaled by hetero, so seed density varies: compressed regions get small cells, stretched regions large ones. Applied after relaxation on purpose — warping first would let Lloyd smooth the variety straight back out. Because cell size and cell side count are linked, this is the variable that stops the mesh reading as a honeycomb.

Out comes cells[], each with a closed polygon, centroid, area, and a symmetric neighbours[] list. The realised count varies slightly from the request (512 → 504 at the defaults).

The grid is the substrate, not a decoration. Drawing Voronoi polygons and overlaying an outline afterwards can never produce this look — it inverts cause and effect.

2. Activation — js/generator/voronoi-activation.ts

A chamber is a set of activated cells. Every cell not claimed stays rock, and the outline the renderer draws is a consequence of the activation set. Nothing here draws anything.

  1. Eligibility mask. A cell is eligible only if every polygon vertex lies inside the board inset by margin (a fraction of the smaller half-extent). Ineligible cells are permanent rock, so no floor vertex can ever come within M of the rim — the violation is made unrepresentable rather than checked afterwards. At the defaults 504 cells reduce to 353 eligible.

  2. Chamber budget, derived from the eligible area — a fraction, never an absolute count: 0.26 / 0.40 / 0.55 for sparse / standard / dense, of which 85 % is chambers and the rest carved links. A Watabou-parity floor forces ≥ 8 chambers on boards of ≥ 256 cells (the reference cave averages 8.6 areas on a board this size — analysis/15), scaling to 2 below 48 cells. An explicit vchambers override beats the floor.

  3. Hub placement. With margin > 0, hubs chain: the first is the centremost draw inside the central region, and each later one is a farthest-point draw among eligible cells within hop mean-spacings of the floor that already exists — so the cave grows as one centre-seeded cluster. A hub landing in a cramped pocket fails to grow and the pick is rerolled (up to 6 times) rather than handing its budget to a later chamber. At margin = 0 the legacy whole-board best-candidate spread runs instead, bit-exact.

  4. Growth by weighted flood fill over shared-edge adjacency. The weight is a function of the candidate's ring distance a from the hub, and it is the shape vocabulary (js/generator/cave.ts, ported from upstream Area):

    vshape weight reads as
    organic a^k, k = 4·√mean₃ − 1 clamped to [0.4, 2.2] compact, rounded chambers
    cavities a⁶ strong outward pull — wide, bulging rooms
    coral a^(−1−f), f = 0.2 + rand inward pull — branching, filamentous
    chaotic a < 3 ? a⁴ : 1e−4 grows hard to radius 3, then stops
    varied one of the four, drawn per chamber mixed board

    A chamber may not grow onto a cell touching a different chamber. That one-cell rock buffer is what makes the map read as chambers joined by passages — and what makes the carved links visible at all.

  5. Top-up. Failed seeds take their budget with them and jittered targets undershoot as often as they overshoot, so while the floor is short of the budget and any chamber still has a frontier, growth continues.

  6. Connectivity — Prim's MST over chamber centroids, one carved link per tree edge, each a BFS shortest cell path through rock (confined to eligible cells; it relaxes only if the mask is pinched into two lobes). The rock buffer guarantees the chambers start as separate components, so links = chambers − 1, exactly. The floor is always one connected region.

  7. Egress mouths (margin > 0 only). 2–4 of them, each: a random rim direction in an unused 30° sector, the nearest floor cell to that rim point, then the shortest rock tunnel from it out to a rim cell — carved open. Mouth cells sit outside the eligible area, so they do not count against the coverage budget, and a mouth never chains off another mouth.

Measured against the targets, 5 seeds × 3 densities at the defaults:

vdensity target measured coverageEligible
sparse 0.26 0.249 – 0.295
standard 0.40 0.372 – 0.411
dense 0.55 0.486 – 0.584

The targets are means, not guarantees; dense has the widest spread and undershoots more often than the other two.

3. Render — js/render/voronoi-activation.ts + vg-*.ts

The boundary of the activated set — every edge where floor touches rock — is traced into closed loops once, treated once, and that one geometry is both the floor fill and the wall ink (the ink being the loop set minus the egress-mouth edges). Fill and ink cannot disagree, which is structural, not a convention.

The wall is four members (analysis/29), and only the biome's dressing changes:

member what it is cave glade
mask the floor curve set — pure shape, no ink same same
dressing (outside the mask, behind the floor) the material hatch band, rubble, outer shade halo tree canopies + shadows
silhouette (on the mask, in front of the floor) the wall line, 0.070 · spacing same same
openings mouths break the loops into open chains, so no phantom edge crosses a mouth; then the fade and the arrows same same

The silhouette runs the merged loops through Chaikin (corner-cutting smoothing) → Spiker (rock-side teeth) → Bloater (outward wobble), then inks a bold silhouette with thin cartographic hachures on the rock side — the line-weight hierarchy that keeps it legible at VTT zoom. The glade scatters procedural vector canopies ("noise of circles", vg-tree.ts: a base circle plus 12–19 hash-jittered bumps, filled union, outward contour arcs, shaded south bumps, trunk dot), constrained behind the treeline so the clearing stays clean.

Paint order, read from the live DOM (not from source order — the grid is inserted):

layer-root → layer-rock → layer-floor → layer-grid → layer-wall
           → layer-egress → layer-egress-arrows → layer-title

Everything is seed-deterministic and pure SVG — no raster anywhere in the app.


Generate your own

Three routes, in increasing order of control.

Every parameter below is a query parameter, and the address bar always reflects what is rendered. Nothing else is needed to reproduce a map — hand someone the link.

https://pages.gi7b.org/gi7b/procedural-maps/?seed=1618&vshape=cavities&vdensity=dense&vglade=1&grid=hex&gpitch=2.5

Unknown parameters are silently ignored, so links from removed modes still resolve.

b. Run the app

npm install          # one-time (see the environment note below)
npm run dev          # Vite dev server → http://localhost:5173  (HMR live updates)

The Menu button opens the options panel: Mother Grid (Cells, Relax, Jitter, Hetero), Cave (Shape, Density, Chambers, Margin, Hop), Appearance (Wall ink, Grid, Size, Line, Flat-top, Overlay, Codes, Glade, Water). Export writes the SVG; Share link copies the permalink.

⚠️ The panel is scrollable: it is taller than a short window, so on a 700 px-tall window the bottom rows (Overlay, Codes, Glade, Water) sit below the fold and need scrolling to reach. They work once scrolled — a visible scrollbar marks it.

Overlay draws the same grid again, scaled smaller — the dungeon/character scale. It is derived from the grid above it, never chosen: divide the cell ÷2 … ÷5 and a passage one square wide measures that many squares across. The note under the control states the resulting cell size in metres, and the options are disabled when the current cell is already at the 1 m floor (there is nothing left to subdivide). With no Grid chosen at all it is disabled too, because it subdivides the character grid and there is nothing to subdivide — the note says so. Full reasoning and the measurements: analysis/44.

Water — None (default) / Pools / Flooded — wets a share of the floor cells and draws them inside the floor-clipped masked region, which is where the reference puts its water: after the floor, before the inner shadow, the grid and the wall, so the near-wall shadow falls on the water and the cave's water runs under the wall. Cave water follows analysis/18 §1.10 (ink outline → water stroke and fill → a dashed ink ripple); the glade gets a wet-sand rim, a translucent shallow band and a deeper core, with no ink outline. None draws no water layer at all, so existing links are unaffected. Full layer reasoning: analysis/29 §5 row 4 and NEXT.md B1.

⚠️ Water is a first pass, and two things are still missing. It is a field proxy, not the reference's flood model — we have no floor height field, so pools do not know which parts of the cave are low, and the outline is the wet↔dry Voronoi cell boundary, so it is visibly polygonal where the reference's is a smoothed flood contour. And B1's headline — "shallow water you can see the terrain through" — needs a floor texture that does not exist yet (F5), so the translucent shallow band currently has nothing underneath it to show.

  • npm run build → dist/index.html (single inlined file; tsc --noEmit + Vite). Roughly 87 kB inlined, ~32 kB gzipped, and byte-reproducible for a given tree. No exact figure is quoted here on purpose: it moves with the build config, and a stale byte count in a README is worse than none — measure it with ls -l dist/index.html. ⚠️ Set BUILD_STAMP=$(git rev-parse --short HEAD) when building for a deploy: the Docker build cannot see .git (it is in .dockerignore), so without it the build stamps dev and the deploy verification fails.
  • scripts/serve.sh serves the built app at http://127.0.0.1:8123/.
  • scripts/deploy-docker.sh → procmap.gi7b.org (the live target): SSH to PC02, git pull --ff-only in the stack clone, docker compose up -d --build, then two verification probes (origin port 8621, then the public URL). 👤 Run this by hand — it crosses the SSH boundary that agent sessions are forbidden to cross (DEPLOY.md F8, agent-preset isn't the only place it is written down). ⚠️ There is no agent-safe mode — preflight() opens two SSH sessions of its own, so even --preflight crosses that boundary (its own usage comment used to claim otherwise; corrected 2026-10-08). An agent may run the genuinely-local half by hand: clean tree, BUILD_STAMP=<sha> npm run build, check the stamp in dist/, run the suites.
  • npm run deploy → build + push the static-pages branch to Forgejo Pages. The bake-period fallback, not the target (DEPLOY.md D3/D6): it is the rollback path and the only way to keep the two URLs showing the same build. ⚠️ It builds from the working tree, so it publishes uncommitted changes — check git status first.

Environment note (this host): the global npm cache (~/.npm) is read-only, so install with a writable project-local cache if a plain npm install complains:

npm install --cache .npm-cache --no-audit --no-fund

c. Headless, in Node — the generator without the renderer

The generator is pure and runs anywhere. The renderer is not headless — js/render/* calls document.createElementNS, so pictures come from the app and its SVG export.

A runnable worked example lives at the repo root:

node example-generate.mjs                    # seed 1618, organic, standard
node example-generate.mjs 1618 cavities dense

It prints the activation and asserts the two load-bearing invariants. Output at the defaults:

{ "seed": 1618, "name": "Gloom Maze", "cellsRealised": 504, "eligibleCells": 353,
  "chambers": 8, "links": 7, "egressMouths": 3, "floorCells": 146,
  "coverageEligible": 0.394 }

Three things to copy when you build on it:

  1. Two RNG streams, never one. The lattice takes seed; the activation takes new Random(seed ^ 0x5bd1e995) — the same constant js/main.ts uses. Share one stream and the cave reshuffles whenever a grid variable moves. Use a different constant and your output will not match what the app shows for that seed.
  2. Harnesses live at the repo root. They import esbuild to compile the TypeScript in-memory, and Node resolves it from ./node_modules; the same file in /tmp fails with ERR_MODULE_NOT_FOUND. Every test-*.mjs and render-habitat.mjs is a root file for this reason.
  3. Read floor / areaId, don't re-derive shape. floor[i] === 1 is floor; areaId[i] is the chamber index (≥ 0), AREA_ROCK (−1), or AREA_CORRIDOR (−2). Any outline you want is a consequence of that set.

What must always be true

The invariants the test suite holds to — assert these in anything you build:

# invariant
1 every chamber is contiguous over shared-edge adjacency
2 the floor is one connected region — 0 disconnected regions, ever
3 floor[i] === 1 ⇔ areaId[i] !== AREA_ROCK
4 deterministic: same lattice + same seed → same cave
5 coverage tracks the density fraction of the eligible area
6 carved links = chambers − 1, exactly
7 ≥ 8 chambers on boards of ≥ 256 cells; an explicit override wins
8 margin > 0: no floor cell has any vertex within M of a border
9 margin > 0: the floor centroid sits closer to the board centre than a margin-0 control
10 margin = 0 reproduces the pre-margin generator bit-exactly

Recipes — what to turn for a given look

you want set
big open halls vchambers=3, vdensity=dense, vshape=cavities
a tight warren vchambers=20, vdensity=standard, vshape=coral
branching, filamentous tunnels vshape=coral, vhetero=0.8
rounded, even chambers vshape=organic, vrelax=6, vhetero=0.1
a lumpy, irregular mesh vhetero=0.9, vrelax=0, vjitter=1
a forest clearing instead of a cave vglade=1
floor reaching the board edge (no mouths) vmargin=0 — also disables hub chaining
one sprawling cluster vmargin=0.2, vhop=1.5
a VTT-ready battle map grid=hex, gpitch=0.5 (a 1 m hex), gcodes=1, scale=4
a fine-lined print scale=4, gwidth=0.25

Same seed, one variable at a time, is the way to read what anything does.


Parameter reference

The query string is the permalink. Out-of-range values are clamped, not rejected; anything unrecognised is silently ignored.

param meaning range default
seed map seed 1 … 2147483646 random
bw / bh board extent, metres 60 … 480 160 / 155
vcells mother-grid cells (target) 8 … 512 512
vrelax Lloyd relaxation iterations 0 … 12 1
vjitter seed jitter 0 … 1 0.80
vhetero cell-size heterogeneity 0 … 1 0.50
vshape growth weight organic cavities coral chaotic varied organic
vdensity coverage target sparse standard dense standard
vchambers chamber-count override 0 = auto, else 1 … 24 0
vmargin border margin, fraction of the smaller half-extent 0 … 0.45 0.11
vhop hub-chaining hop, in mean cell spacings 0 … 8 3.5
vwall wall ink 0 flat · 1 treated · 2 criss-cross 1
vglade forest biome 0 / 1 0
grid play-grid overlay none hex square none
gpitch grid pitch, metres — hex = circumradius, square = side hex 0.5 … 2.5 · square 1 … 5 (type-aware clamp) 2.5 (a 5 m hex)
gwidth grid line weight × the derived base 0.1 · 0.25 · 0.5 · 1 · 2.5 (unlisted → 1) 1
gflat hex orientation: 1 = flat-top (edge up), 0 = pointy-top 0 / 1 0
gcodes two-hex-digit X/Y code per cell, masked to the floor 0 / 1 0
goverlay overlay grid — the character grid scaled smaller by this factor, drawn as a finer layer under it. A 1-square passage then measures N squares across. Derived, never chosen: same lattice, orientation and floor mask. Bounded by the 1 m cell floor, so the ceiling depends on the character cell (÷5 at 5 m, nothing at 1 m) 0 (off) · 1 (1:1 → off) · 2 … 5, clamped to the ceiling 0
scale render scale — bigger drawing, same absolute line weight 1 · 2 · 4 (else 1) 1

A #v1: hash permalink is folded into the query string at startup, so there is exactly one place that reads parameters.

Note: gpitch keeps meaning pitch, unchanged, so old links resolve — only the UI control speaks in cell size. A clamped value is written back to the address bar after init, so the URL cannot claim a pitch the render is not using.

Tests

node test-voronoi-lattice.mjs      # the mother grid tiles, is symmetric, deterministic
node test-voronoi-activation.mjs   # chambers contiguous, floor connected, margin invariant,
                                   # wall/tree/egress render invariants
node test-hexgrid.mjs              # the standalone SVG hex-grid generator (analysis/20)
node test-habitat.mjs              # the habitat workstream, suites A-E

Last run (2026-10-06): lattice 0 failures, activation 0 failures — 120 ink-coverage seeds, 30 wall runs, hex tiling exact at √3·pitch across 2400 interior centres, and the overlay grid's scale law swept over 22 legal combinations with the cost bound intact (the finest legal overlay is 118,603 segments — the same as the finest character grid, so the overlay adds no new worst case). test-f4-dom-qa.mjs measures the rendered DOM: 129 overlay checks / 0 failures across 12 overlay URLs, and 17 grid-tiling cases.

The DOM harness exits non-zero on any failure, and treats an empty measurement as a failure. It used to verify nothing at all — see analysis/44 §4.

Reading the design docs

analysis/ is numbered and cumulative; the entry points that matter:

doc why you'd open it
27 the VC / VB / VE / VS vocabulary — clusters, bridges, egress, single shape
29 the layer model — our stack and Watabou's, side by side
16–18 the cave-glade canon (appearance layers, texture techniques)
33 glade trees — supersedes 18 §2.4
31–32 the masking model and its RCA
30 the bug register — the source of truth for defects
01 the original Town Generator pipeline, spiral seeds onward
35 / 37 habitats: the ruleset and the build plan

Known defects

Canonical list: analysis/30-bug-register.md — every defect found across all sessions, with the measurement behind it and its status (open / fixed-verified / fixed-reported / verify). The short form:

id defect status
BUG-30.1 A dead external font URL (fonts.gstatic.com/.../sharetech/v17/...ttf) 404s on every page load; the UI silently falls back, and it is the app's only runtime CDN dependency. 🔴 open
BUG-30.2 permalink.ts still omits chambers and renderScale (it carries their newer gridWidth, so the encoder can be kept current — it just is not). Mitigated: the live query-string permalink is unaffected. 🔴 open
BUG-30.3 F3's "2–4 mouths" guarantee falls to zero at vcells 8 and 12 — both reachable from the Cells slider, whose minimum is 8. 🔴 open
BUG-30.4 The floor was not a clean fill (corridor tint with raw, un-inked Voronoi edges). ✅ fixed, verified
BUG-30.5 The egress fade painted over the wall, erasing wall ink at the mouths. ✅ fixed, verified
BUG-30.6 The hatch band was too shallow and read as fur (framing corrected: the canon band starts AT the wall). ✅ closed
BUG-30.7 No ink-weight hierarchy — the silhouette had drifted from its canon 0.070·spacing. ✅ fixed, verified
BUG-30.8 Outer shade halo and inner shadow were absent. ✅ fixed, verified
BUG-30.9 No door concept — the reference's doors (ink circles r=6) are a distinct layer we do not have. 🔴 open
BUG-30.10 Panning changed the zoom: a drag grew the viewBox 144.4 → 1188.1 units and shrank the map to a speck. ✅ fixed, verified
BUG-30.11 npm run deploy was not idempotent and stranded the checkout on static-pages. ✅ fixed, verified
BUG-30.12–30.17 Hex tiling (hexes read as circles); the hex grid drawing every interior edge twice; the glade frame keeping the cave palette; glade palette drift; the glade ground having no bands; title-band clipping. 4 fixed-verified, 2 reported, 1 verify
BUG-30.18 The halo carries more total ink than the silhouette; the floor carries a competing ink outline. ⚪ recorded
BUG-30.19 Our ink cannot reach black where the reference's wall does. ⚪ accepted limit (owner ruling)
BUG-30.20 Wall outlines missing on whole stretches of floor — the loop tracer fragmented the mouth chains and dropped pieces. ✅ fixed, verified (0 / 300 seeds)
BUG-30.21 Zooming in slid the map sideways and cut a side off (letterbox anchor). ✅ fixed, verified — uncommitted
BUG-30.22 The glade draws the cave wall stack (hatch, halo, silhouette, inner shadow); Watabou's glade draws no wall ink — the trees are the wall. 🔴 open → analysis/33
BUG-30.23 Glade trees don't follow the canon model (tiered particles vs one crown per Poisson-lattice cell, painted in rings). 🔴 open — owner decision on tree size
BUG-30.25 The grid's worst case was measured on the default board: a 1 m hex on a 480 m board is 1,075,884 segments / 27.78 MB (9.07× the documented 118,603) — reachable from the two sliders. The square grid is unaffected (962 subpaths). 🔴 open → analysis/44 §2b
BUG-30.26 Overlay was disabled with its reason hidden — dead-looking, not broken: it is disabled while Grid = None, and the note explaining that was hidden in exactly that case. 🟢 fixed — the note now says "Choose a Grid (Square or Hex) first"; verified with real clicks
BUG-30.27 The options panel is taller than a short window (content 714 px; 145 px overflow at a 700 px viewport, 285 px at 560 px), so Overlay, Codes, Glade and Water sit below the fold and read as absent. They work once scrolled. 🟡 mitigated — scrollbar made visible; a tighter panel is the durable fix
BUG-30.29 The Size control does not say which measurement it means. Hex size here is corner-to-corner; the sibling tactical repo speaks centre-to-centre spacing, so carrying 1.5 across gives 1.299 m spacing — a grid 15.47 % denser than intended. Enter 1.732 m for the tactical lattice. 🔴 open — backlogged by Justin 2026-10-10; workaround documented in both repos (ttrpg-tactical-pwa/docs/PROCEDURAL-ENCOUNTER-TERRAIN.md §1)

⚠️ A pointer gap noticed 2026-10-10: BUG-30.28 (the deploy silently shipping dev) is absent from this short form, even though analysis/30 carries it as fixed-and-verified. This README list skips from 30.27 to 30.29 as a result. Recorded rather than silently renumbered — the register is the source of truth and this table is meant to mirror it.

⚠️ npm run deploy builds from the working tree, so it publishes whatever is in the tree, committed or not. Check git status first.

Structure

procedural-maps/
├── source/                 ← full original source (Haxe, GPL-3.0, read-only reference)
│   └── TownGeneratorOS/
├── analysis/               ← design docs 01–43 (16–18 are the cave-glade canon)
├── js/
│   ├── generator/          ← random, voronoi-lattice, cave (shape vocabulary),
│   │                         voronoi-activation, hexgrid (standalone)
│   ├── render/             ← render plumbing, palette, voronoi-activation
│   │                         + vg-* (wall / glade / tree / grid / title / egress)
│   ├── habitat/            ← floor-plan workstream (own harness, not a mode)
│   ├── city/               ← city & wilderness workstream (own README + log)
│   ├── state.ts            ← AppState, the metre scale, grid caps
│   ├── permalink.ts        ← the #v1: hash codec
│   ├── ui.ts               ← toolbar + options panel
│   └── main.ts             ← param parsing, the single re-render path
├── example-generate.mjs    ← the README's runnable headless example
├── test-*.mjs              ← harnesses (root files — they resolve esbuild from node_modules)
├── scripts/deploy.mjs      ← Forgejo Pages deployment
├── scripts/serve.sh        ← serve the built app locally
└── dist/                   ← build output (gitignored)

Tech Stack

  • Language: TypeScript (strict mode)
  • Build: Vite 6 + vite-plugin-singlefile
  • Voronoi: d3-delaunay
  • Render: SVG (vanilla, no framework)
  • Deploy: Forgejo Pages (PC03, ronmi/forgejo-pages)

License Note

source/TownGeneratorOS/ is covered by GPL-3.0 (original author: watabou). All original code in js/ and scripts/ is original work in this repo.

One exception, added 2026-10-05. js/render/vg-tree.ts is not original work in the sense above: it is a reimplementation of algorithms read out of Watabou's shipped Cave.js bundle — Bloater.bloat, Spiker.simple/bent, Cubic.smoothClosed, the four TreeType.getCrown* builders, TreeView.drawTree's radial-gradient construction and TreeType.drawShaded1/2/4. Method and provenance are recorded in that file's header and in analysis/25. This does not change the repo's overall position — a port of TownGeneratorOS is a GPL-3.0 derivative either way — but the blanket claim above would otherwise read as covering it, and the estate's standing rule is that harvested code must not be presented as original. Any further port from the bundle belongs under this same exception.