StrandmondHarbour District

An 18 by 18 dockland drawn as inline SVG from one seed string. No canvas, no WebGL, no libraries, no images. Reload the same seed and you get the same city, pane for pane.

15:30 sun

Generated isometric harbour district A fictional dockland in 2 to 1 isometric projection: quays, warehouses, terraced housing, a market hall, a crane yard, roads with zebra crossings, a shingle beach and a breakwater carrying a lighthouse. The projection, the depth sort, the seeded pipeline and the measured node count are written out in the How it works section below.

How it works

The projection

An axonometric drawing is fully decided by where the three world axes land on the screen. The brief asks for 2:1, so a unit step along x must travel twice as far sideways as it does down the page, and the same for y with the sign flipped:

Ex = (2u, u)   Ey = (−2u, u)

Then demand equal foreshortening so no axis looks squashed and a cube keeps its hexagonal outline: |Ex| = u√5, which forces the vertical axis to be

Ez = (0, −u√5)

so a world point becomes sx = 2u(X −Y), sy = u(X + Y) − u√5·z, where (X,Y) are plan coordinates after the camera rotation. With u = 12 a cell paints 48 px wide and 24 px tall, and a unit cube traces a hexagon of six equal edges (u√5 long, angles 126.87°, 116.57°, 116.57°, 126.87°, 116.57°, 116.57°). Two of the eight corners drop out of the silhouette, the near one and the far one, which is exactly why a cube here shows three faces. Faces are drawn as separate paths that each carry a one device pixel stroke of their own colour, so no hairline seam can open between them at any zoom.

The depth sort

The projection annihilates the view axis d = X + Y + (2/√5)z; larger d is nearer the camera. If two things share a pixel then they share sx, so X−Y is equal for both hit points, and they share sy, so (X−Y)… subtracting gives √5·(zb − za) = (Xb+Yb) − (Xa+Ya). Substitute into the difference of d and the heights cancel:

db − da = 1.4 × ((Xb+Yb) − (Xa+Ya))

So for anything whose plan does not overlap, the nearer thing always wins the pixel, at any height, and the painter key can be a single number: the largest X + Y over the footprint. Height never enters the key, so a tall block behind a low one cannot bleed through it, and the rule still holds after any of the four camera rotations because those only permute the plan axes. Parts inside one building are sorted by the same key, with facade details offset outward by 0.014 world units so they always land in front of the wall they hang on.

The seeded pipeline

The seed string is hashed to a uint32 (FNV-1a). One mulberry32 stream drives the layout; each structure then forks its own stream from strhash(seed | kind | id), so adding a building somewhere else cannot shift the facade of this one. Terrain uses value noise and fBm sampled from an integer lattice hash, which can be queried in any order and still agree with itself. Shore, streets and the nine superblock zones come from noise plus four repair rules (hall in the middle, a yard needs a berth, keep the terraced fabric, no terrace on the dockside). No call to Math.random exists in the project: the New seed button reads the clock to invent a string, and from that string everything below is fixed.

Measured

Node count is read from the live document with querySelectorAll and printed in the Reading panel beside the scene, together with the group and face counts, the build time, and a self test that rebuilds the seed twice, walks the camera through four azimuths and back, and compares the dossier table for drift. Current count: 0 nodes. Rotating, panning, zooming and moving the clock never add one.

Checked against the specs, because it mattered

Requirement log

  1. 2:1 projection, hexagonal cube, no seams. DONE. Derived above; documented in js/iso.js with the corner trace.
  2. 18 by 18 grid with quays, water, four block types, roads with crossings, beach, breakwater and a lighthouse as a reusable symbol. DONE. The lighthouse, chimney pot, bollard, lamp, cupola and bush are <symbol> instances; every one of them is a body of revolution or a flat silhouette, which is precisely the class of object that survives a 90 degree camera turn unchanged.
  3. Painter sort that is actually correct. DONE, and proved above rather than tuned. Height cancels out of the key, so occlusion survives rotation.
  4. One light direction, one shading rule, no flat grey. DONE. All 17 canonical facets run through a single Lambert plus hemispherical ambient plus specular expression; ambient is never zero, and no material albedo is neutral.
  5. At most a handful of gradients, roof texture from patterns. DONE. Three SVG gradients (the cylinder ramp, the beam, the lamp glow) and one CSS sky ramp, all where light really falls. Grain comes from generated <pattern> tiles whose lattice is derived from each face plane.
  6. Hand written PRNG and noise, no Math.random. DONE. FNV-1a, mulberry32, lattice value noise and fBm, in js/random.js.
  7. Seed in the URL hash. DONE, with azimuth, hour and view centre alongside it, so the link reopens the exact frame.
  8. Parametric parts with plausible variation. DONE for base, gable, mono and hipped roofs, chimney, dormer, bay grids of windows, door, sign, awning, cornice and rails. Pitch comes from three canonical values so the shading normal is exact rather than approximated.
  9. Pan, zoom, keyboard rotation. DONE. One transform on one group; the favicon to close-up range is 0.06× to 22× of the fit.
  10. Detail that appears as you zoom. DONE. Cornices, ridge tiles, door surrounds and awning feet arrive at about 2× fit, window mullions, surrounds and painted signage at about 5×; windows drop out altogether at a distance.
  11. Hover footprint and outline, click dossier. DONE. The outline is the group's own convex hull, computed at build time; the dossier carries name, year, use and occupant from the seed.
  12. Time of day control. DONE. Moves the light vector, re-resolves the paint stylesheet, changes the sky ramp, and switches on seeded window lights and lamp glows after dusk.
  13. Minimap with viewport rectangle, clickable. DONE, with a caveat worth stating: because the camera looks down at the ground, the viewport maps to a parallelogram in plan, not a rectangle. It is drawn as the true quadrilateral; clicking jumps there and the arrow keys step one cell when the map has focus.
  14. Toggle for the underlying grid. DONE. Cell outlines plus a tint per generated zone and road, which is the quickest way to see that the layout really is a grid.
  15. Under about 4000 nodes, reused geometry, one transform, no layout thrash. DONE and measured in the page. Multi-subpath batching is what keeps it there: ground tiles, window grids and markings each collapse into a handful of paths.
  16. Nothing hardcoded per instance. DONE. There is one place that knows what a warehouse is.
  17. Responsive, scriptless, focusable, reduced motion, quiet console. DONE with one honest exception: the city itself is generated, so with JavaScript disabled the frame is empty and the page says so while still reading as a document. The controls, notes, log and dossier fields are real HTML.