How it works
A stranger's guide: three short answers — how a world point becomes a pixel, how the painter's sort stays correct under camera rotation, and how the seed drives everything. Node count is measured live by the page and shown in the HUD.
1 · The projection (2:1 isometric)
World cells are integer lattice points (i, j) with height z. The city centre (9, 9) is re-centred to the origin, then the camera azimuth rotates the plan in 90° steps about the origin (four rigid orientations, so occlusion can never disagree with the geometry):
x = i − 9, y = j − 9
az 0: u = x, v = y az 1: u = −y, v = x
az 2: u = −x, v = −y az 3: u = y, v = −x
screen X = u − v
Y = (u + v) / 2 − z
Derivation: rotate the plan 45° (mixing the axes gives X = x − y and
view-depth = (x + y)/2), then apply the classic vertical squash that turns the
45° plan's √2 depth into a ½ factor — the standard 2:1 dimetric "isometric"
grid. +z maps to (0, −1). A unit cube therefore projects to the classic
2-unit-wide, 3-unit-tall hexagon; its three visible faces share edges exactly
because every corner is emitted from the same world vertex list, so there are
no seams. Pan/zoom is a single translate·scale on the one
<g id="world"> group — no per-node layout work.
2 · The depth sort (painter's algorithm)
Every raised object (building, quay strip, breakwater, tree, crane, boat, container) is a grid-aligned solid. It is drawn exactly once, in ascending order of its front-corner depth d = u + v of its deepest footprint corner (ties broken by i then j, so order is deterministic). Because depth is an affine function of the rotated plan, the same key re-computed per azimuth is correct at all four orientations: any two disjoint grid boxes have non-overlapping depth ranges in the direction the viewer sees, so the closer box always paints over the farther one, and a tall box behind a short one is covered, never covered-in-reverse. Flat ground (water, beach, roads, square) sits at z = 0 and is provably never able to occlude anything raised (a raised point and a ground point at the same screen pixel always have the raised point at greater depth), so it is painted once, first, unsorted.
3 · The seeded pipeline
One string hash (xmur3) seeds mulberry32; no Math.random()
anywhere. The stream drives, in fixed order: 1-D value-noise coastline and
bay, beach selection, road jitter, a constraint pass (quay = any land cell
touching water; road grid; the square; the crane yard), 2-D value noise for
block occupancy, then per-building parametric parts — roof type, pitch,
floors, window counts, dormers, chimneys, awnings, the lit-window subset, and
the dossier text (name, year, use, occupant). Given a seed, the model is a
pure function: the page builds it twice on load and asserts the two
serialisations are byte-identical (badge in the HUD), and the URL hash
(#seed=…) reopens any view from disk. One light direction: every face colour
is material × (ambient + diffuse·max(0, n̂·L)) with the same L for
the whole scene; the time slider moves L (sun arc + golden tint, moonlight at
night), retints the sky and water ramps, and switches window lights / beacon /
lamps via a class — lit windows are the seeded subset, so nothing flickers.
4 · Measured node count
Live count of every node inside the scene SVG (geometry + defs, excluding
the separate minimap SVG): 2,789, measured by
scene.querySelectorAll('*').length after the first build (headless
Chromium, seed zeezand-01; varies ±2% with the seed).
The minimap adds 328 nodes. Total ≈ 3,117 —
under the ~4,000 budget, with headroom left by merging multi-cell
geometry (quay, roads, windows, zebras) into single multi-subpath paths.
Requirement checklist
- DONE — G1 true 2:1 iso: derivation above; unit cube emits one vertex list per face, hex silhouette, shared edges (verified visually in screenshots at all four azimuths).
- DONE — G2 district contents: 18×18 cells with water + bay, quay, shingle beach, warehouse / terrace / market-hall / crane-yard zones, roads with zebra crossings, breakwater; lighthouse is a single <symbol> instanced by <use>.
- DONE — G3 correct painter's sort: front-corner depth key, re-sorted on every azimuth change; tall-behind-short case holds at N/W/S/E views (screenshot-checked).
- DONE — G4 one light direction: every face shaded by n̂·L from the same L (materials × ambient + diffuse, golden-hour tint, moonlight at night); lighthouse symbol faces are driven by the same L through inherited CSS variables.
- DONE — G5 few gradients: 3 linear + 2 radial gradients total; roof texture from 5 <pattern>s in <defs> as overlays, not per-tile rects.
- DONE — Gen1 PRNG + noise: mulberry32 + xmur3; 1-D and 2-D value noise; constraint pass; zero Math.random (searchable in source).
- DONE — Gen2 seed in URL hash: #seed=… parsed on load, written back on change; reopens identically from disk.
- DONE — Gen3 parametric parts: base / roof (gable, hip) / chimney / dormer / awning / door / windows / sign, all from one code path with seeded variation; no hand-written per-instance path strings.
- DONE — Int1 pan / zoom / rotate keys: drag pan, wheel zoom-to-cursor clamped overview→close-up, R / Shift+R azimuth, + − / arrows / 0.
- DONE — Int2 LOD: three tiers (massing → windows/doors/awnings → signage detail) toggled by scale with hysteresis.
- DONE — Int3 hover + dossier: footprint outline + glow on hover/focus, click or Enter opens dossier with seeded, per-building consistent data.
- DONE — Int4 time of day: slider moves light direction, sky/water ramp, dusk switches seeded window lights, beacon, lamps.
- DONE — Int5 minimap: per-cell map with live viewport rectangle, click-to-jump.
- DONE — Int6 grid toggle: G or the checkbox reveals grid lines + generator cell kinds (water / quay / beach / road / square / yard / block).
- DONE — P1 one SVG document, <use> reuse, <4000 nodes: single scene document; trees/lamps/cranes/lighthouse via <use>; measured count in §4 and the HUD.
- DONE — P2 one-group transform: pan/zoom/rotate only rewrite one transform attribute and (on rotate) path data; no layout, no per-frame work while idle.
- DONE — P3 nothing hand-placed: change the seed and quay line, roads, buildings, boats, lamps and cranes all move; the grid toggle shows the cell kinds it was generated from.
- DONE — Standing: no-JS content, focus order, reduced-motion, no console errors: docs + checklist readable without JS (noscript notice in the stage); real tab order through header → buildings in spatial order → controls; prefers-reduced-motion disables shimmer/spin/transitions and the rotate crossfade; verified clean in a headless Chromium console run.
- DONE — Reproducibility: two same-seed builds on one load are byte-identical (HUD badge); cross-load equivalence follows from the pure gen() and the seeded URL hash.
patternUnits="userSpaceOnUse" tiles in the referencing
element's user space, so textures scale with the single world transform
(MDN, SVG 2 §Paint servers); transform lists apply right-to-left, hence the
translate(tx,ty) scale(s) form; <use>
instances render in document order, which is why they live inside the
painter-sorted group. Served from disk by double-click — no server, no
build, no network.