validateTownCache now warns when medianShopSpacing(cache) > MAX_MEDIAN_SPACING_M (150 m). Metric + threshold exported from the barrel so no lane re-derives them. WARN, NEVER ERROR: a flagged cache still boots a valid district (toowoomba shipped green through the entire v4.0 pack) — E curates, A flags. selfcheck ALL GREEN 161,372/161,372 (+72); synthetic 0x3fa36874 frozen; NO golden moved (only E's toowoomba re-bbox may move one this round, per the round's law). THE THRESHOLD IS MEASURED, NOT PICKED. The brief offered darwin 27 m / toowoomba 388 m as "plenty of daylight for a line" — but that's 2 points. Measuring all 23 towns shows a near-continuum, and the line is FORCED into (119.3, 254.9]: braddon at 119.3 m is ALIVE by D's own hub test (7 shops within 160 m, identical to darwin's 7), ballarat at 254.9 m is not. 150 m sits in the pack's widest gap (x2.14), with 1.26x margin below and 1.70x above. Flags 2 of 23. TWO TOWNS FLAG, NOT ONE. The brief names only toowoomba (D spot-checked the two 12-shop towns). The measurement adds BALLARAT at 255 m — nobody has looked at it, and it's independently one of the three towns my R22 graded cluster fallback fired on (no venue candidate at the >=3-shops/160 m floor). Two signals agree. Filed for E as a curation call, counted either way. THE BRIEF'S ANCHORS WERE IN THE WRONG SPACE, AND IT MATTERS. D's 27 m is PLAN space (post-lift metres); validateTownCache is CACHE space (raw lat/lon — the only thing that exists at ingest, and the layer where a bad bbox must be caught). Darwin is 68.3 m in cache space, not 27. Interpolating a threshold between D's figures would have silently mixed spaces. I verified the hypothesis before trusting it: computing the metric plan-side reproduces D's numbers exactly (darwin 27.2 vs 27, toowoomba 386.8 vs 388, hubs 7 and 3). Cache space is correct HERE and provably safe: the lift marches shops at a regularised cadence, so it COMPRESSES dense towns (darwin plan/cache 0.40) but not sparse ones (toowoomba 0.98, ballarat 1.01) -- the error lives at the dense end, the warn only fires at the sparse end. Verified all 23 towns in both spaces: exactly ONE disagrees, newtown_godverse (94.4 cache passes / 158.3 plan would warn). Filed for G/Fable, NOT acted on: no cache-space line catches it without false-positiving braddon, and godverse is a census subset (18 thriftgod shops over the footprint that carries 72 OSM shops) so its sparsity is coverage, not a bad bbox — re-bboxing, the warn's only remedy, cannot fix it. Worth knowing since R23 stocks the real crate in that town and the beta asks "fitzroy_godverse if the census is dense there". Also fixed en route: the R17 silent-fallback trap bit again in my own scratch harness — an unregistered town key resolves to the melbourne fixture rather than erroring, so five towns silently measured identical. Registered the caches; noting it because it has now cost two rounds. Coverage: sparse cache warns + still boots (structural + district suites on a flagged town), compact cache does NOT warn (no false positive), metric edge cases (< 2 shops -> null, coincident -> 0, no throw on empty), and an exact-value check (a 100 m pair measures 100 m, projection sane). Cost: ~1 ms/town at registration (25.6 ms for all 23 incl. fitzroy's 160 shops) vs a <100 ms plan budget. E -> use the warn as your acceptance test for #4: re-bbox, re-run, warn goes silent = the town has a high street. If your fix moves toowoomba's or ballarat's cache, ping me — goldens are mine to re-pin, and only what you move. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
44 KiB
PROCITY — CITY_SPEC (the shared contract)
Every lane reads this first. If a lane needs to change something in here, it changes this file in the same commit and says so loudly in the commit message — this doc is the treaty.
What we are building
A standalone, procedurally generated, fully walkable Australian shopping town — the Vuntra City trick (every building enterable, interiors generated on demand) applied not to cyberpunk towers but to record stores, op shops, toy shops, book barns, video rental, pawnbrokers, milk bars, market stalls. 90s-Australia aesthetic, low-poly + generated texture skins. Content (real item data, lore, economy) plugs in later via the GODVERSE/BaseGod feeds — v1 is the system: plan → streets → facades → doors that open → themed interiors → NPCs walking around.
Engine decision (settled — do not relitigate)
three.js r175, vendored, plain JS ES modules, importmap, zero build step, zero npm.
This is the proven house stack (90sDJsim + thriftgod both ship on it), it runs great on Apple
Silicon, and it lets us lift working code (fittings kit, rig stack, dig.js) verbatim. Unreal was
considered and parked: nothing in v1 needs it, and the asset pipeline (GLB, web-ok licensing,
3GOD depot) is web-native. Serve with cd web && python3 -m http.server 8130.
threeandthree/addons/resolve via importmap toweb/vendor/(copied from 90sDJsim).- No DRACO requirement (house GLBs ship uncompressed/webp-optimized); DRACOLoader is vendored if needed.
- No external CDNs at runtime. Everything local or from
https://digalot.fyi/3god(CORS-enabled).
Units, axes, determinism
- Metres. +Y up. Ground plane is XZ. City origin (0,0) = centre of the main square.
- Right-handed three.js defaults. Buildings face their street; a facade's outward normal points at its street edge. GLB convention: origin at base, facing −Z, real-world scale.
- Seeded everything,
Math.random()is banned in generation code.web/js/core/prng.jsis the only randomness source:seedFor(citySeed, kind, id)→ stable uint32 (xmur3 hash of"seed:kind:id").rng(citySeed, kind, id)→ mulberry32 stream.- Same citySeed ⇒ byte-identical city, forever. This is the thriftgod/90sDJsim house rule and it's what makes the authored-override layer possible later.
The three-layer architecture
LAYER 1 CityPlan (pure data, no THREE) Lane A
LAYER 2 Streetscape (chunked 3D realization) Lane B
LAYER 3 Interiors (on-demand room scenes) Lane C
Citizens (NPCs over layers 2+3) Lane D
Assets (skins + GLB depot) Lane E
Layer 1 — CityPlan (JSON-serializable, generated in <100ms)
The whole town as lightweight data, generated up front from citySeed, dumpable to JSON.
Schema v1 (Lane A owns it; extend, don't break):
{
version: 1,
citySeed: 20261990, // uint32
name: "…", // generated town name
size: { w: 1024, d: 1024 }, // metres, city core v1 = 1km²
districts: [ { id, kind, cx, cz } ],
// kind: 'mainstreet' | 'arcade' | 'backstreets' | 'warehouse' | 'residential' | 'market'
streets: {
nodes: [ { id, x, z } ],
edges: [ { id, a, b, width, kind } ] // kind: 'main' | 'side' | 'lane' | 'arcade'
},
blocks: [ { id, district, kind, poly: [[x,z],…] } ],
// district = district ID (index into districts[]); kind = that district's kind string, denormalized
// onto the block so consumers can theme it without a lookup (Lane A extension, non-breaking).
lots: [ { id, block, x, z, w, d, ry, frontEdge, use } ],
// use: 'shop' | 'anchor' | 'house' | 'yard' | 'stall' | 'infill'
// ry: Y-rotation (radians) so a GLB modelled facing −Z ends up with its facade's OUTWARD normal
// pointing at frontEdge. World facing = (−sin ry, −cos ry). Lane B rotates the shell by ry.
// frontEdge: id of the street edge this lot fronts (guaranteed to exist and be geometrically adjacent).
shops: [ { id, lot, type, name, sign, seed, facadeSkin, storeys, hours: [open, close], openLate? } ]
// type: see SHOP TYPES below. hours: [open, close], 24h integers, 0 ≤ open < close ≤ 23.
// storeys: integer in [registryMin, permittedMax] where permittedMax = registryMax≥2 ? min(registryMax+1,3)
// : registryMax. The "+1 (cap 3)" is the CITY_SPEC "occasional 3-storey corner anchor" — it fires ONLY
// for tall-capable types (registry max ≥ 2); single-storey types (video/milkbar/stall, [1,1]) are NEVER
// boosted. So `storeys > registryMax` on a corner anchor is EXPECTED, not plan↔registry drift.
// openLate: present and `true` on EXACTLY ONE shop per town — the designated late-night landmark
// (closes ≥ 22:00; a naturally-late type, video rental or milk bar, never a market stall). Absent
// on every other shop. Lane B lights it after dark; Lane F's night gate keys off this field
// (`plan.shops.find(s => s.openLate)`), NOT a magic hours threshold.
}
Field-type contract (Lane A is the source of truth; consumer fixtures must match this, not the reverse). Every
idand every reference to one —lot.block,lot.frontEdge,edge.a/b,block.district,shop.lot— is an integer, not a string.facadeSkin, and every entry in a registryfacadespool, is a full filename underweb/assets/gen/(e.g."facade-fibro-blue.jpg") — load it verbatim; do not prependfacade-or append an extension (the shop-types table below abbreviates skin names for readability only). The same rule holds for Lane E's ground/awning/sky/wall/ interior skins: store the file exactly as the manifest names it, never re-derive a key from it.
Layer-1 invariants Lane A guarantees (enforced by
web/js/citygen/selfcheck.js): fully deterministic percitySeed(with a committed golden fingerprint guarding against drift); everyfrontEdge/block/district/lotreference resolves; every lot faces itsfrontEdge; no two building lots (shop/anchor/house/stall) overlap, within OR across blocks; exactly one shop carriesopenLate(the late-night landmark);chunkIndexcovers every lot and lists every edge in every chunk its road corridor (road + verge, centreline ± width/2) touches — so verge-placed street furniture never resolves to a chunk that omits its edge; strictly JSON round-trippable (all finite numbers, no stray keys).
Plan sources. A CityPlan can come from two producers behind this one contract, chosen via
generatePlanFor(seed, source, { town })(web/js/citygen/index.js) — the shell selects with?plansrc=(+&town=for osm):
'synthetic'(default) —generatePlan(seed), the procedural town. Byte-identical, golden0x3fa36874.'osm'—generatePlanOSM(seed, town), a real-data import from a checked-in fixture (osm_fixture.js→OSM_TOWNS; real AU shops from thriftgod's OSM cache; zero network). Towns:melbourne(default, golden0x34cfdec0),katoomba(0x0f652510).osmTownKeys()lists them.An
'osm'plan setssource:'osm', reports its real bounding-boxsize(not 1024²), and contains only real retail types — no synthetic market square, arcade, dept anchor, milk bars, or stalls. All structural invariants above hold identically. Consumers that assume a synthetic-only feature (dept/market/milkbar/stall) must guard onsource. The invariant suite runs on every (source, town). Full frozen v2 producer contract below (§"CityPlan v2 — frozen producer contract").
Chunk key: cx = floor(x/64), cz = floor(z/64). Chunk size 64m. Lane A ships a
chunkIndex(plan) helper: chunk key → { lots, shops, edges } touching it. Edges are bucketed across
the full road corridor (centreline ± width/2), so anything placed in the verge (street furniture,
footpath NPCs) resolves to a chunk whose edges list includes its edge.
CityPlan v2 — frozen producer contract
FROZEN at
v2.0(round 9). This is the authoritative Layer-1 contract — the v3 epoch (editor / override layer) reads THIS. It matches the code as shipped;web/js/citygen/selfcheck.jsenforces every law here. Changes are a CITY_SPEC amendment in the same commit + a golden re-pin, never a silent drift. Import everything fromweb/js/citygen/index.js(the barrel) — internals (plan.js,plan_osm.js,osm_fixture.js,names.js) may move behind it.
Producer API (all from web/js/citygen/index.js):
generatePlanFor(seed, source = 'synthetic', { town } = {}) → CityPlan— the selector the shell uses.generatePlan(seed) → CityPlan— synthetic producer (unchanged, byte-identical).generatePlanOSM(seed, town = 'melbourne', { report } = {}) → CityPlan— osm producer; pass areportobject to receive the normalization log (below).osmTownKeys() → string[].chunkIndex(plan, chunkSize = 64) → { chunkSize, chunks };CHUNK = 64;chunkKey(cx, cz).- Geometry helpers (shared with the harness):
lotCorners(lot),obbOverlap(a, b).
Determinism & goldens. Same inputs ⇒ byte-identical plan, forever. The determinism gate keys on
(seed, plansrc, town). Committed goldens (xmur3(JSON.stringify(plan)) at seed 20261990):
| source / town | golden |
|---|---|
| synthetic | 0x3fa36874 |
| osm / melbourne | 0x34cfdec0 |
| osm / katoomba | 0x0f652510 |
Adding a town is mechanical (recipe in docs/LANES/LANE_A_NOTES.md): append to OSM_TOWNS, run
selfcheck, paste the printed fingerprint into OSM_GOLDENS, tell Lane F the new hash.
Trading-hours law. shop.hours = [open, close], 24h integers, 0 ≤ open < close ≤ 23. Openness is
half-open: isOpen(shop, h) ⟺ open ≤ h < close (a shop closing at 17 is shut at 17:00 exactly).
Both the shell (index.html) and the NPC sim (citizens/sim.js _openAt) use this identical predicate.
openLate law. Exactly one shop per town is the late-night landmark. It is the sole shop that
closes ≥ 22:00 (LATE_HOUR), so the field and the threshold are equivalent: {s: s.openLate} ≡
{s: s.hours[1] ≥ 22} (a singleton). It is the video rental (a town with no video falls back to
another non-stall type, logged). Every other shop closes by 21:00 — so after ~21:00 the streets thin
to that one lit destination.
Storeys law. shop.storeys ∈ [registryMin, permittedMax], permittedMax = registryMax ≥ 2 ? min(registryMax + 1, 3) : registryMax. The occasional 3-storey corner anchor adds ≤1 storey (tall
types only); single-storey types (video/milkbar/stall, [1,1]) are never boosted.
OSM normalization rules (the importer bends the data to the contract, never the contract; each
change is logged into report): unknown OSM shop= kind → opshop (report.typesRemapped); any shop
closing ≥ 22:00 → clamped to 21:00 so only the one openLate landmark is late (report.hoursClamped);
the openLate landmark prefers a video, else a fallback non-stall type (report.openLate). Shop names
are the real OSM names; sign is derived. osm plans carry source:'osm', a real bbox size, and no
synthetic dept/market/milkbar/stall/arcade.
Closing-time occupant ruling (Lane A, round 9 — ratifies Lane D's sim.js). When a shop closes:
- No new entry. Patronage only ever routes a ped into an open shop (
_nearestOpenShopgates on_openAt); the player likewise gets a CLOSED plate/toast and cannot enter (index.html). - Occupants drain, they don't pop. A ped already inside when the shop closes finishes its current
bounded visit (seeded 5–20s dwell) and re-emerges through the door normally — it is never
teleported out or culled at the close instant. Occupancy therefore drains to 0 within one dwell
window;
sim.occupancyOf(shopId)falls to 0 on its own. Any browser-rig presence (Lane D/F) tracksoccupancy, so it clears with the same drain — no orphaned rigs in a shut shop. - The player is never force-ejected. Closing locks the door to entry only; a player already inside stays until they choose to leave (leaving restores the street where they stood, as always).
- The openLate video shop is the one place still filling after ~21:00 (the night crowd); at its own close (22:00/23:00) it drains identically. Determinism holds: entry/dwell/emerge are seeded + clock driven, so same (seed, time) ⇒ same occupancy.
Layer 2 — Streetscape (Lane B)
Realizes CityPlan chunks around the player. Hard requirements the two parent games skipped:
- Contiguous geometry — no teleport seams; you can walk the whole core.
- Chunk streaming — build/dispose chunk Groups at radius ~2–3; never rebuild the world.
- InstancedMesh from day one for: building shells, awning boxes, verandah posts, street furniture, trees, streetlights. Canvas signage batched into a sign atlas texture per chunk (one CanvasTexture, many UV-mapped planes) — not one canvas per sign.
- Facade formula (proven in both games): instanced box shell + front
PlaneGeometrywith afacade-*.jpgskin + parametric awning/verandah + sign band + door + window planes. Skins are generated with blank signboards — the game overlays the shop name.
Layer 3 — Interiors (Lane C)
Interiors are not street geometry. Walking into a doorway triggers
enterShop(shop) → separate room Group built from shop.seed (thriftgod archetypes + the
90sDJsim fittings.js parametric kit), themed by shop.type. Leaving restores the street
exactly where you stood. Street windows show a cheap fake (tinted glass + emissive warm glow at
night in v1; interior-mapping shader is a stretch goal).
CityPlan v3 — gig layer (?gigs=1) — THE DISTRICT
FROZEN at
v3.0(round 14). The authoritative Layer-1 gig-layer contract — venues, the week's schedule, posters, thegigKeymap, the corridor law, and the runtime surface.web/js/citygen/selfcheck.jsenforces every law here, including the round-14 400-seed district invariant sweep + an osm graceful-placement case. Changes are a CITY_SPEC amendment in the same commit + a gig-golden re-pin, never silent drift. One edit is still pending and lands with Lane F's alias-retirement commit (R13 debt #1): the R12 single-venue compat alias in the runtime block below retires; everything else here is final for thev3.0tag.(Recorded amendment — ROUND15, ledger #1: the frontage poster was flipped to the
+Zstreet facade (it had been on the−Zback, invisible from the street); this movedplan.posters, so the gig golden re-pinned0x1f636349 → 0x4f4a549din the same commit under the amendment law above. The synthetic golden0x3fa36874did not move — this is the round's one sanctioned gig-golden move.)*(Recorded amendment — ROUND16, ledger #6: on a narrow through-lot a band_room/rsl's
+Zfacade can abut the block's other street, putting its frontage poster over that carriageway.pickVenuesnow prefers a band_room/rsl lot whose facade clears every carriageway by ≥POSTER_CLEAR(0.5 m; broadens off-spine if the warehouse fringe is uniformly narrow). This movedplan.posters, so the gig golden re-pinned0x4f4a549d → 0xb1d48ea1; synthetic0x3fa36874still frozen. The v3.1 default flip
- classic law is F's amendment to this section — see the flags table.)*
(Recorded amendment — ROUND20, ledger #2: the R16 clearance bias above widened to every venue kind. R16 exempted
pubbecause on the synthetic/marched towns a pub's+Zis always open; on a real road graph at E's widened density it is not (newtown: a pub whose facade overhung a cross street). On the synthetic town every pub/rsl candidate already clears ⇒ no-op there, gig golden0xb1d48ea1frozen.)(Recorded amendment — ROUND21 ledger #1 + ROUND22 ledger #2: the venue cluster bias. D's relocation finding: a venue a kilometre from the retail cluster is a gig with no passing crowd (katoomba's pub had 1 shop within 160 m; its RSL had 13).
pickVenuesnow draws each venue from a cluster field — candidates with ≥CLUSTER_MIN(3) other shops withinCLUSTER_R(160 m) — and lets the kind flavours bias within that field rather than override it. Venues under the floor: 11 of 15 → 0 across the five R21 towns; Newtown's RSL moved 2,228 m → 29 m from the hub. Real towns only, by construction: the bias readsplan.source === 'osm', which the synthetic generator never sets, so it cannot move0x3fa36874/0xb1d48ea1— measured, not assumed (4 synthetic candidates per seed sit under the floor, so a blanket filter would have moved them). ROUND22 graded fallback: at pack scale three towns have no candidate at the floor at all (ballarat/toowoomba/newtown_godversetop out at 2 within 160 m). R21 dropped the bias entirely there, stranding venues at 0 neighbours; it now falls back to the densest available candidates instead. Fires only where the floor is unreachable ⇒ no already-pinned town moved. Acceptance at pack scale: 20 of 23 towns have every venue ≥CLUSTER_MIN; those three sit at their town's genuine ceiling — the carved-out case, stated rather than papered over.)
Prime flag law. The gig layer is a post-hoc augmentation applied by the selector when the gig flag is on. With it off,
generatePlanFor(seed, src)returns the byte-identical v2 base plan — all v2 goldens stay frozen. So this section adds fields that exist only on gig-on plans; it does not touch the v2 contract above. Deterministic from(citySeed, customBands); works on synthetic AND osm towns. Node-pure (no THREE, no runtime fetch — the shell loads the band drop-in and passes it in).Prime flag law — REWRITTEN at
v3.1(ROUND16, THE FLIP; Lane F amendment). The covenant MOVED, it did not disappear.gigs,weather,winmap,tramnow default ON; the default boot is the full experience, deterministic to the gig golden0xb1d48ea1. Each flag keeps a=0opt-out, and?classic=1forces all four off at once:
- Classic law (the new frozen regression target): the
?classic=1boot is byte-identical to the v2 baseline forever — synthetic fingerprint0x3fa36874, zero gig layer, zero fetch delta (nocustom_bands.json, nosit.glb). The old "flags-off regression" gate is now the classic-regression gate.- Default law: the no-flag boot pins to the gig golden, and every gig / district / continuity / cover smoke and the budget law (interior ≤350 draws · street ≤300 / 200k tris, at the busiest venue block) now run against the default boot with all four flags live.
?noassets=1composes with it and stays silent-and-fine.- The four flags read through one rule in the shell —
flagOn(name) = !classic && param !== '0'(winmap reads the same rule inbuildings.js); the shell publishes the intent onwindow.PROCITY.flags, and the seated drummer'ssit.glbrides!classicso classic keeps its zero-fetch-delta.(v3.0-alpha was one pub, one genre, one night's posters. ROUND13 → v3.0-beta, the district: 2–4 venues of three kinds, a full week's schedule across town, town-wide posters. Same flag, same laws.)
Turning it on (Lane F): generatePlanFor(seed, source, { gigs:true, customBands }) → plan with the
gig fields below (else the base plan, untouched). generatePlanFor stays synchronous — F fetches
custom_bands.json in the bootstrap and passes the array. withGigs(plan, seed, opts) is also exported.
The venues (2–4 per town — a seeded count). Three registry venue kinds — pub, band_room,
rsl — each with no district weights (never auto-placed). The layer converts that many plain
shops in place (keeps each lot id + geometry ⇒ enterable, no overlap), tags them
shop.venue === true, shop.venueKind, shop.genreKey, and never takes the openLate landmark (the
town keeps exactly one openLate shop). Kinds are added in the order pub, band_room, rsl, pub (a 4-venue
town gets a second pub at the far end of the spine). Placement rules (each degrades to "any remaining
plain shop" so osm towns still get venues):
| kind | genreKey → gigKey |
placement | hours | reads as |
|---|---|---|---|---|
pub |
pubrock → gig-pubrock |
spine end/corner (main edge, farthest) | [17,23] |
≥2-storey corner hotel |
band_room |
grunge → gig-grunge |
warehouse-district fringe | [18,23] |
single-storey tin shed |
rsl |
covers → gig-covers |
off-spine (not main, not warehouse) | [16,23] |
carpet-and-bistro club |
The cluster field comes first (ROUND21/22 — real towns only). On an osm plan every kind rule in the
table runs inside the retail cluster. Order per venue: cluster field (≥CLUSTER_MIN other shops within
CLUSTER_R, or the densest available if none reach the floor) → the kind flavour → the facade-clearance
filter → farthestFirst shortlist → seeded pick, each step degrading to the previous set rather than to
the whole town. A fringe band_room in the cluster beats a fringe band_room a kilometre from anyone.
Synthetic plans skip the field entirely (plan.source is unset) ⇒ goldens frozen. See the amendment note.
Venue hours all close 23 (open through DUSK+NIGHT so the gig runs and D's surge fills the room; drains
at close per the closing-time ruling above). Venues are the only gig-plan shops allowed to close ≥22
besides the openLate video (selfcheck exempts them). genreKey is set from the registry kind→genre map
(SHOP_TYPES[kind].genre, mirrored by genreForVenueKind(kind)); John may re-flavour the two new genres
(names only — E synthesises to order).
The gigKey contract (ROUND13 debt #1 — one key, zero mapping). The audio-manifest bed key for a
genre IS the gigKey, and it is always gig-<genreKey>. There is no mapping table anywhere: A
emits shop.genreKey + gig.genreKey, C sets audio.gigKey = 'gig-'+genreKey, E names the manifest
bed identically, B's spill reads it and F resolves it — all through the single exported helper
gigKeyFor(genreKey) => 'gig-'+genreKey (web/js/citygen/gigs.js, re-exported from the barrel).
Import it rather than building the string; R12 lost a bed to a private rename (gig-pubrock vs
pubrock-live) and, because audio fails soft, the band just mimed.
plan.gigs — the week's schedule across the whole district (present only on gig-on plans):
gigs: [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover } ]
- 7 nights × every venue, but not every venue plays every night: each venue takes a seeded 4–6
of the 7 and goes dark the rest (dark nights read true).
gigIdunique town-wide;night= 0-based, F selects tonight's (night0) per venue.venueShopId→ the venue shop id;genreKey≡ that venue's genre. startSeg = 5(NIGHT / 22:00),endSeg = 5. F's state machine (now per venue):quiet → doors (startSeg−1 = DUSK) → on → done. (Day-segment→hour map islighting.js:0 DAWN … 5 NIGHT.) Runtime surface F publishes onwindow.PROCITY.gigs(B'svenue.js/audio.jsand D'ssetGigread it):.byVenue → { [venueShopId]: 'quiet'|'doors'|'on'|'done' }plus per-venue gettersstateOf/onOf/openOf/gigOf/coverOf/bandNameOf/nightOf/paidOf(id)+markPaidOf(id)and.venueShopIds. Every read is keyed byvenueShopId— there is no scalar alias (the R12 single-venue alias retired in R15).cover— integer in{0} ∪ [2,10], ~half free town-wide, skewed by kind: pub 50% free /$2–10, RSL 45% free /$2–5(cheap — the bistro pays the bills), band_room 70% free /$2–6(a hat on a milk crate). F debits the wallet per venue at each door; a night at two venues is two covers.- No band plays two venues on one night (≤1 slot per name per night across town): custom names are deduped and used at most once; a generated name that would clash tonight advances its stream until it doesn't (deterministic — the generator index only moves forward).
plan.posters — town-wide gig advertising (Lane B renders): posters: [ { id, gigId, x, z, ry } ].
Only tonight's (night 0) playing venues are advertised. Each such venue gets one poster on its own
frontage — seated on the +Z street facade (B's canon: buildings.js facade + venue.js door/queue/
camera are all +Z; position lot + (sin ry, cos ry)·(d/2+ε)), turned to read from the street
(ry = lot.ry + π so its printed face points +Z at the queue-side camera — ROUND15 ledger #1; before
R15 it was on the −Z back and invisible from the street). So the poster never sits over a back-street's
bitumen, pickVenues prefers a lot whose facade clears every carriageway by ≥ POSTER_CLEAR
(ROUND16 ledger #6, widened from band_room/rsl to every venue kind in ROUND20 — exported from the
barrel; the selfcheck asserts the same floor). Plus a seeded run along
the spine — 2 per main edge (or, when a town has no main spine, every edge), at t ∈ [0.2,0.8] of the block, on either verge;
a candidate that would overhang a real crossing carriageway is skipped (ROUND19, E's finding), and the
run is capped at POSTER_PER_SHOP × shops (ROUND20) — a sparse real town has few shops but a big edge
graph, so the raw "2 per edge" run reads busy (katoomba 438 → 31). The cap never binds on the shop-dense
synthetic town, so its gig golden is frozen. Different venues' posters share the same pole
runs — that IS the district reading. ry follows the house convention (a poster's printed face =
(−sin ry, −cos ry), so atan2 of the away-from-viewer normal); B places E's poster skin at (x, z)
rotated ry; F wires "poster → that venue's tonight-gig".
The street-corridor law (ROUND13 debt #5 — published as functions, not plan data). edge.width is
the full corridor (carriageway + verge/footpath, centreline ± width/2), never the carriageway alone —
so a naive node.x + 2 seats furniture mid-bitumen. web/js/core/registry.js now exports the split as
pure functions over an edge (no plan fields change ⇒ base goldens stay frozen), re-exported from the
barrel: roadWidth(edge) (full carriageway width — main 10 m, side 6–8, lane = all, arcade 0; the
kerb sits at roadWidth/2 from the centreline),
vergeBand(edge) → [inner,outer] (where footpath furniture lives), poleOffset(edge) (where a
pole/poster seats — just behind the kerb). Poles, verandah posts and spine posters belong in the verge
band; the selfcheck asserts no spine poster stands within roadWidth/2 of any edge centreline. A
frontage poster is exempt — it is bound to its venue building (the +Z facade B draws), so at a corner
venue whose facade abuts a crossing street it may legitimately sit near that street's kerb (B's building
geometry, not a mid-road defect).
Band names + the drop-in. bandName(seed) generates 90s-Aussie pub-rock names from wordlists.
web/assets/custom_bands.json is an OPTIONAL editable drop-in (schema { "bands": ["The Feral Galahs", …] }, Lane-A-owned per ROUND12): present → those names get priority, deduped and shuffled
by seed, spread night-major across the whole week's slots town-wide (not one venue's residency);
absent → pure generator, zero errors, zero fetch (the ?noassets=1 path). Deterministic given
(seed, file contents). John edits the file and reloads.
Closing-time law as code (v2 debt paid). isOpen(shopOrHours, hour) is exported from the barrel — the
canonical half-open predicate (open ≤ hour < close) for D's gig-night patronage surge and everyone else.
Gig golden (guards gig-layer drift; pure-generator path, no custom file), seed 20261990 synthetic:
0xb1d48ea1 (ROUND16 re-pin — the band_room/rsl facade-clearance bias, ledger #6, moved
plan.posters; was 0x4f4a549d the R15 +Z flip, 0x1f636349 the R13 district, 0xa6ae5a5e the R12
alpha). Asserted by selfcheck.js alongside the frozen v2/classic golden; changing gig output re-pins it.
The gig sweep now also runs a no-spine osm fixture (silverton, single-row) proving pickVenues
places venues with no main spine (ROUND16 ledger #7).
Town caches — the real-map scout (v3.2, ROUND17 ledger #6)
Bounded groundwork for v4 THE REAL MAP (John's pick). The
?plansrc=osmproducer already eats processed OSM data (three fixtures inosm_fixture.js); v3.2 lets it eat real town caches E scrapes from Overpass — no new plan features, the existing contract proven against real geography.
The town-cache contract (Lane-A-owned; authoritative implementation is validateTownCache() in
web/js/citygen/plan_osm.js, home web/assets/towns/<key>.json, full spec in that dir's README.md):
a processed town is { schema:"procity-town-cache/1", key, town, source:"osm", license, attribution, center:{lat,lon}, shops:[ { id, name, type, lat, lon, suburb? } ] } (+ optional bbox/counts/fetchedAt).
E's build_towns.py produces it (raw Overpass → dedupe/suburb/parody-name/seeded-hours); plan_osm marches
it identically to the fixtures. Hard requirements: finite center.{lat,lon}; shops ≥ MIN_TOWN_SHOPS
(6 = up to 4 venues + the openLate landmark + a spare); finite shop lat/lon. Absorbed (warnings):
blank name → type label, unknown type → opshop, dup ids, and span > 5 km → "likely more than one
town" (a cache is ONE compact town — an over-broad bbox marches into an unusable multi-km strip).
The spacing floor (v5.0-alpha, ROUND23 ledger #5 — D's R22 finding as law). MIN_TOWN_SHOPS counts
shops; what makes a town read alive is clustering. D measured both 12-shop towns in the pack and they
disagree — which is the finding: darwin ALIVE (one strip; 1,455 patronage checks → 18 visits) vs
toowoomba DEAD (scattered over a 4.6 km road network; 1,417 checks → 0 visits, 0 finds — "not a
shopping town, a road network with occasional shops"). Both pass every other law. So validateTownCache
now warns when medianShopSpacing(cache) > MAX_MEDIAN_SPACING_M (150 m):
- The metric (exported from the barrel — import it, don't re-derive it): for each shop, the distance to
its nearest other shop; take the median. Equirectangular metres about
center.lat, the same convention as the span check.nullfor < 2 usable shops. - Warn, never error. A flagged cache still boots a valid district — toowoomba shipped green through the entire v4.0 pack. E curates (re-bbox onto the retail strip, or retire the town, counted either way); the validator only flags. The span warn catches a town too wide; this catches one too sparse, a case span cannot see (toowoomba is inside the 5 km law).
- The threshold is measured, not chosen. Across the 23-town pack the line is forced into
(119.3, 254.9]: braddon at 119.3 m is alive by D's own hub test (7 shops within 160 m — the same as darwin's 7), and ballarat at 254.9 m is not. 150 m sits in that gap — the pack's widest (×2.14) — with 1.26× margin below and 1.70× above. It flags 2 of 23: ballarat 255 m, toowoomba 396 m. - ⚠ CACHE space, not PLAN space — they do not agree. This runs on raw lat/lon, before any lift; D's
notes quote plan figures (post-lift metres). The lift marches shops at a regularised cadence, so it
compresses dense towns (darwin 68.3 m cache → 27.2 m plan) and leaves sparse ones alone
(toowoomba 396.1 → 386.8). Both numbers are right; they measure different spaces. Cache space is correct
here — the validator's job is to flag a bad bbox at ingest, before a lift exists — and it is
faithful exactly where the warn lives (ballarat plan/cache 1.01, toowoomba 0.98). Known limit:
one town flips (
newtown_godverse, cache 94.4 → plan 158.3); no cache-space line can catch it without false-positiving braddon. SeeLANE_A_NOTES§R23.
Wiring: generatePlanOSM(seed, key, { cache }) marches a cache; registerTownCache(key, cache) adds it
to the registry (osmTownKeys() + generatePlanFor(seed,'osm',{town:key}) then see it). selfcheck.js
auto-loads web/assets/towns/*.json — validates, runs the structural + gig suites, pins a per-town golden.
Provenance: OSM is ODbL; attribution ships with the data (E owns SOURCES.md + the license/
attribution fields; raw Overpass responses cached so re-runs never re-fetch).
Schema v2 — real roads (v4.0-alpha, ROUND18 REAL ROADS)
The gap the scout left: the marched fallback lays real shops onto synthetic parallel avenues. Schema v2 closes it — a cache carries the town's real OSM street geometry and
plan_osmbuilds the CityPlan street graph FROM it. Opt-in by the cache (a v2 cache withroads[]= real roads; a v1 cache, orroadsabsent, = the marched fallback), so default + classic boots are untouched by construction (they never load a town cache) and every shipped cache stays valid.
A v2 cache adds roads: [ { kind, pts:[[lat,lon],…], id?, name? } ] — simplified OSM ways, kind the
OSM highway=* class (validateTownCache accepts schema …/1 and …/2; a road needs ≥2 finite points,
ROAD_KIND maps the class to a CityPlan edge kind). The graph lift (plan_osm.buildRealRoads):
- project ways + shops to the local metre frame; simplify each way (Douglas–Peucker, ε≈6 m — the fidelity knob, charter risk #4);
- snap coincident points (≤3 m) to shared nodes = real intersections; straight-segment edges;
- classify
main/sidefrom the OSM class, promoting the shop-densest edge to themainspine if none came from OSM; - seat each shop on its nearest real edge at its real order + real side, marched with regularised spacing (real shops overlap — pixel-exact positions can't satisfy the no-overlap invariant), a frontage-strip block per edge, then a deterministic overlap-resolve pass (crowded intersections drop the later-id lot). Same CityPlan schema out — B/C/D/F consume it unchanged by contract.
The fragmentation ruling (ROUND19, Fable — A owns the details). Real geometry fragments the naïve graph
(katoomba: 105 components, 58% of street-metres on islands, D's finding). Two mechanisms, both handled:
(a) junction-protected simplification — a point shared by ≥2 ways is a junction; Douglas–Peucker runs
only between junctions, never through one (the collinear junction-drop that tee-off side streets hit). That
alone reconnects to 94–100% main-net. (b) cull/bridge — the town is the main component + joined
near-fragments (endpoints ≤ JOIN_TOL 12 m, or a shop-bearing island ≤ 200 m, get a bridge edge); a far
shopless island is culled (bbox-clipped noise + dead streaming budget). A stranded shop is always
connected or dropped with a count (norm.dropped). Result on all five towns: one connected component.
A spine poster that would overhang a real crossing carriageway is skipped (E's finding; no-op on the
synthetic town, so its gig golden is frozen). Golden re-pins per the amendment law (all five real towns pin
on their real-roads output; synthetic + marched fixtures + classic/default untouched — the alpha changed
nothing outside the cache-schema path).
FINAL at
v4.0(ROUND22 ledger #6, the epoch close). This section — schema v2, the lift, the connectivity rulings above, the venue cluster bias and poster cap in §v3 — is the authoritative real-map contract at the tag. Changes are an amendment + a golden re-pin in the same commit, as always.The alpha's five became the epoch's 23 (22 real towns + Lane G's
newtown_godverse; 1,210 shops), and every ruling written above held at that scale with no new hardening: one connected component and three venues on every one of the 23 towns, fitzroy's 160 shops — 2× anything the pipeline had seen — included. All 36 goldens (REAL_TOWN_GOLDENS+REAL_TOWN_GIG_GOLDENS, 23 each) are pinned; selfcheck runs 161,300 checks green, cross-process deterministic. Read "all five towns" above as the measurement of record when the ruling was written — the ruling itself is now pack-wide.The honest tail, carried not hidden: 6.4% of shops (77 of 1,210) don't seat, every drop attributed by cause, dominated by overflow where a town has more shops than its road graph has frontage (daylesford 19%, the pack's thinnest graph; fitzroy 13%). See
V4_REAL_MAP.mdrisk #6. The R20 ruling stands: drop and count beats teleport. The frozen set is unmoved by the entire epoch: synthetic0x3fa36874, gig0xb1d48ea1, marched fixtures, classic.
Shop types v1 (registry lives in web/js/core/registry.js, Lane A writes it, all lanes read)
web/js/core/registry.jsis the machine-readable source of truth — importSHOP_TYPESfor the exact facade pools, sign hints, interior archetype, fittings, storeys, hours and district weights. This table mirrors it. (Lane A amendment 2026-07-14: added a variety facade totoy/book/pawnand rounded out the fittings mixes — acounterto shops that sell over one, afreezerto milkbar, aglass_case+counterto dept — so this table matches the registry lanes actually consume. Non-breaking; all facades exist inweb/assets/gen/.)(Lane A amendment 2026-07-14 · round 2, responding to B/E/F integration notes: gave
stalla 2nd facadefacade-corrugated.jpg(a market-shed backdrop, matching Lane E's manifest facade→type map, so every type now has ≥ 2 facades per Lane E's validator). Replaced the old per-shop 6% "open late" dice — which yielded 0…many late shops and often none past 22:00 — with exactly one deterministicopenLatelandmark per town (see the schema note above). WidenedchunkIndexedge coverage to the full road corridor. All non-breaking; self-check green at 1283/1283; golden fingerprint refreshed to0x098eec2b.)
| type | facade pool | interior archetype | fittings mix |
|---|---|---|---|
record |
timber-teal, arcade-tile, djsim-record | crates + bins | record bins, crates, counter, listening corner |
opshop |
weatherboard, fibro-blue, djsim-opshop | racks + shelves | clothes racks, bric-a-brac shelving, book wall, counter |
toy |
stucco-pink, deco-pastel, boutique | shelves + glass case | cube shelves, display tables, glass case, counter |
book |
federation, sandstone, redbrick | halls of shelves | bookshelves, spinner racks, armchair, counter |
video |
stripmall, djsim-video | aisle shelves | VHS shelving, returns slot, poster wall, counter |
pawn |
besser, grimy, djsim-pawn, corrugated | counter-forward | glass cabinets, wall hooks, barred window, counter |
milkbar |
djsim-milkbar, brickveneer | counter + freezer | counter, fridge, magazine rack, freezer |
dept (anchor) |
terrazzo, djsim-dept | grand hall | mixed sections, escalator prop, glass case, counter |
stall (market) |
market, corrugated | open stall | trestle tables, crates |
pub (v3 venue) |
djsim-pub, federation | pub | bar counter, stage, PA stack, tables, stools |
band_room (v3 venue) |
warehouse-tin, besser, corrugated | band_room | bar counter, stage, PA stack, stools |
rsl (v3 venue) |
brickveneer, redbrick | rsl | bar counter, stage, PA stack, bistro tables, stools |
NPC contract (Lane D)
- Fleet rule (house law): 2 rigged base meshes + shared clip bank. Canonical rigs:
~/Documents/character_kit/(femalegrandma_game.glbroute, malehum_character.glb) plus the 19 peds in90sDJsim/web/world/models/peds/. Game-local copies are byte-identical builds — copy from character_kit / 90sDJsim, never edit locally. - Distance tiers: near <25m = rigged
SkeletonUtils.clone+ AnimationMixer; mid = billboard impostor (pre-rendered sprite, instanced); far = culled. Walkers path along street-edge footpath lanes from the CityPlan graph. - Licensing: 🟢 web-ok only (Mixamo, CC0/CC-BY, CGTrader RF). No ActorCore in web builds.
Performance budget (M-series laptop, 60fps)
- ≤ 300 draw calls in street mode, ≤ 200k tris in a typical view.
renderer.setPixelRatio(min(devicePixelRatio, 2)).- Textures: skins ≤ 1024px, sign atlases 2048px, total GPU texture < 512MB.
- Chunk build must not hitch: budget 4ms/frame (build incrementally or in idle callbacks).
- Adopt 90sDJsim's
GFX_TIERSidea later if needed; don't gold-plate now.
Modes & shell (Lane B owns the shell)
MODE ∈ { map, street, interior } — one renderer, one state machine, same as both parent games.
map = 2D canvas town directory (Lane A's debug view grows into it). Save/load = localStorage v1.
File ownership (parallel-safety treaty)
| path | owner |
|---|---|
web/js/core/* (prng, loaders, canvas) |
scaffold (frozen — propose changes via CITY_SPEC PR) |
web/js/core/registry.js |
Lane A |
web/js/citygen/*, web/map.html |
Lane A |
web/index.html, web/js/world/* |
Lane B |
web/js/interiors/*, web/interior_test.html |
Lane C |
web/js/citizens/*, web/citizens_test.html, web/models/* |
Lane D |
pipeline/*, web/assets/* + web/assets/manifest.json |
Lane E |
web/assets/custom_bands.json |
Lane A (v3 band drop-in — carve-out from web/assets/*, per ROUND12) |
web/package.json |
scaffold/shared — {"type":"module"} only, added by Lane A |
docs/* |
everyone, additively |
Each lane also ships its own standalone test page so it can be verified without the others. Never edit another lane's files. Integration (Lane F) happens after A–E land.
web/package.json(added by Lane A) contains only{"type":"module"}sonoderuns the pureweb/js/**ES modules directly (e.g.node web/js/citygen/selfcheck.js, an acceptance criterion). Browsers andpython3 -m http.serverignore it; no deps, no build step. It changes Node's module interpretation for every lane'sweb/js/**— which is what we want (all lanes can node-test their pure modules) — so it's flagged here as shared scope, not silently added.
Infrastructure map
- This mac — dev box, Blender + Unreal installed, Apple Silicon (no CUDA — all gen is cloud-API or tailnet).
- ultra
ssh johnking@100.91.239.7(key auth works, always-on M1 Ultra): MESHGOD Blender scripts (~/Documents/MESHGOD/scripts/finish_glb.py,render_glb.py,media_primitive.py), sorted model library~/Documents/3D=models/(shop-fittings / street-furniture / furniture — see itsMANIFEST.md+CH_MAPPING.md),character_kit/,mixamo-fetch/out/clip bank (34 clips — never re-download an existing clip). Caveats: no GNU coreutils/timeout/setsid, system python 3.9, Homebrew at /opt/homebrew. - m3ultra
http://100.89.131.57:11434— Ollama (qwen3:235b/32b/8b,gemma3:4bvision,gemma4). Free local LLM for name generation, lore, cataloguing. Use it before any paid API. - 3GOD depot
https://digalot.fyi/3god—POST /api/upload?name=<f>.glb,GET /a/<f>,GET /api/list(CORS on). The shared GLB CDN all sibling games use. - MeshGod
https://digalot.fyi/meshgod— image→GLB generation (~30¢/solid; check the DealGod canon-3D cache first; media/books use the free parametric skinner). - Image gen — house flow is prompt packs (see
~/Documents/FLOW_PROMPTS.mdformat) + thriftgod'sgen_assets.pybatch pipeline (OpenRoutergoogle/gemini-3.1-flash-image, ~$0.004/img, style-bible prefix, resumable) and 90sDJsim's Cloudflare Flux scripts (creds in~/Documents/backnforth/.env).
Style lock (visual)
Low-poly chunky geometry, flat-ish shading, muted 90s Australian palette, warm cinematic light —
the 90sDJsim POLY lock — skinned with the thriftgod generated-texture kit (69 skins already in
web/assets/gen/). Facade prompt template (blank signboards!) is in thriftgod
gen_assets.py:69-86 — reuse it for new skins so everything matches.
House patterns to keep (both games proved these)
- Promise-cached loaders; missing asset ⇒ placeholder box, never a crash. The game must run with zero assets.
CanvasTexturetext planes for ALL signage/text — no font files.SkeletonUtils.clonefor anything skinned; canonicalizemixamorig\d+→mixamorigso one clip drives every character; strip position tracks from shared clips (_rotOnly).- Height-normalize rigs off the head bone; plant feet by min bone Y.
- Seeded flat-colour fallbacks under every texture.
- A
shot()fixed-camera screenshot harness for visual regression (90sDJsimtools/shots.py).