- JavaScript 37.7%
- TypeScript 36.3%
- Haxe 25.5%
- HTML 0.5%
| agent-preset | ||
| analysis | ||
| assets | ||
| dist-test | ||
| js | ||
| scripts | ||
| source/TownGeneratorOS | ||
| .dockerignore | ||
| .gitignore | ||
| AGENTS.md | ||
| ARCHIVED.md | ||
| compose-strip.mjs | ||
| count-teeth.mjs | ||
| DEPLOY.md | ||
| diag-hatch-direction.mjs | ||
| diag-topology.mjs | ||
| diag-water.mjs | ||
| docker-compose.prod.yml | ||
| Dockerfile | ||
| example-generate.mjs | ||
| how-terrain-generation-works.pptx | ||
| index.html | ||
| NEXT.md | ||
| nginx.conf | ||
| package-lock.json | ||
| package.json | ||
| PROGRESS.md | ||
| README.md | ||
| render-habitat.mjs | ||
| reproducible-manifest.md | ||
| shot-board.mjs | ||
| test-city.mjs | ||
| test-f4-dom-qa.mjs | ||
| test-f4-sweep.mjs | ||
| test-habitat.mjs | ||
| test-hexgrid.mjs | ||
| test-layer-order.mjs | ||
| test-tactical-tab.mjs | ||
| test-tree-layers.mjs | ||
| test-voronoi-activation.mjs | ||
| test-voronoi-lattice.mjs | ||
| tsconfig.json | ||
| vite.config.ts | ||
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.mdis the whole plan, the measured findings and the verification contract. The two consequences worth knowing before you edit: the canonical/OG tags inindex.htmlpoint atprocmap.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.shopens SSH to PC02, which is off-limits to agent sessions (DEPLOY.mdF8).npm run deploystill works and is the rollback path; see §"Deploy" under Tech Stack.
Docs map:
analysis/00-INDEX.mdsays what every document inanalysis/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-mapsto thegi7borg, 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 (sha256f093dcab…), 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
gpitchruns 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 — seeanalysis/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.
- 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. - Jitter each seed inside its slot by
jitter× the slot spacing. - Lloyd-relax
relaxtimes (each seed moves to its cell's centroid). Measured effect: worst-case cell-size ratio improves 0.255 → 0.593 (test-voronoi-lattice.mjs). - 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.
-
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. -
Chamber budget, derived from the eligible area — a fraction, never an absolute count:
0.26 / 0.40 / 0.55for 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 explicitvchambersoverride beats the floor. -
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 withinhopmean-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. Atmargin = 0the legacy whole-board best-candidate spread runs instead, bit-exact. -
Growth by weighted flood fill over shared-edge adjacency. The weight is a function of the candidate's ring distance
afrom the hub, and it is the shape vocabulary (js/generator/cave.ts, ported from upstreamArea):vshapeweight reads as organica^k,k = 4·√mean₃ − 1clamped to [0.4, 2.2]compact, rounded chambers cavitiesa⁶strong outward pull — wide, bulging rooms corala^(−1−f),f = 0.2 + randinward pull — branching, filamentous chaotica < 3 ? a⁴ : 1e−4grows hard to radius 3, then stops variedone 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.
-
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.
-
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.
-
Egress mouths (
margin > 0only). 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.
a. The URL is the permalink
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 withls -l dist/index.html. ⚠️ SetBUILD_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 stampsdevand the deploy verification fails.scripts/serve.shserves the built app athttp://127.0.0.1:8123/.scripts/deploy-docker.sh→procmap.gi7b.org(the live target): SSH to PC02,git pull --ff-onlyin 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.mdF8,agent-presetisn't the only place it is written down). ⚠️ There is no agent-safe mode —preflight()opens two SSH sessions of its own, so even--preflightcrosses 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 indist/, run the suites.npm run deploy→ build + push thestatic-pagesbranch to Forgejo Pages. The bake-period fallback, not the target (DEPLOY.mdD3/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 — checkgit statusfirst.
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:
- Two RNG streams, never one. The lattice takes
seed; the activation takesnew Random(seed ^ 0x5bd1e995)— the same constantjs/main.tsuses. 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. - Harnesses live at the repo root. They import
esbuildto compile the TypeScript in-memory, and Node resolves it from./node_modules; the same file in/tmpfails withERR_MODULE_NOT_FOUND. Everytest-*.mjsandrender-habitat.mjsis a root file for this reason. - Read
floor/areaId, don't re-derive shape.floor[i] === 1is floor;areaId[i]is the chamber index (≥ 0),AREA_ROCK(−1), orAREA_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:
gpitchkeeps 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 thoughanalysis/30carries 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.