PROCITY/docs/CITY_SPEC.md
m3ultra acf734a1e5 Lane A round 12: v3.0-alpha gig layer (?gigs=1) — venue, plan.gigs, band names, closing-time debt
Publishes the gig interface the whole round hangs off (A runs first). Everything is
behind ?gigs=1 as a POST-HOC augmentation (gigs.js withGigs, applied by the selector),
so generatePlan is untouched and all v2 goldens stay frozen (synthetic 0x3fa36874, osm
melbourne 0x34cfdec0, katoomba 0x0f652510). Source-agnostic (synthetic + osm).

- Venue: one 'pub' per town — a spine-end plain shop converted in place (keeps lot
  id/geometry, enterable), shop.venue=true, never the openLate landmark. Registry gains
  a weight-0 'pub' type (interior:'band_room' for Lane C) → never auto-placed → base
  plan byte-identical.
- plan.gigs: 7 nightly {gigId,venueShopId,bandName,genreKey:'pubrock',night,startSeg:5,
  endSeg:5,cover}; cover ~half free / half $2-$10 (F charges at the door).
- Band names: bandName(seed) generator (90s pub-rock) + OPTIONAL web/assets/custom_bands.json
  drop-in (priority into the pool; absent → pure generator, no fetch under ?noassets).
- plan.posters: ~6 seeded {id,gigId,x,z,ry} for Lane B.
- Closing-time debt paid: isOpen(shopOrHours,hour) exported (half-open law as code, D consumes).
- map.html?gigs=1: venue ring + poster pins + gig panel line. Flags-off unchanged.

Self-check ALL GREEN 3074/3074 (gigSuite ×2 sources, flags-off≡base, gig golden
0xa6ae5a5e). qa.sh --strict GREEN 6/6. Verified in-browser both modes. CITY_SPEC v3
section + LANE_A_NOTES handshake for C/D/B/F. Atomic pathspec commit (shared-tree race).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:33:53 +10:00

25 KiB
Raw Blame History

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.

  • three and three/addons/ resolve via importmap to web/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.js is 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 id and 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 registry facades pool, is a full filename under web/assets/gen/ (e.g. "facade-fibro-blue.jpg") — load it verbatim; do not prepend facade- 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 per citySeed (with a committed golden fingerprint guarding against drift); every frontEdge/block/district/lot reference resolves; every lot faces its frontEdge; no two building lots (shop/anchor/house/stall) overlap, within OR across blocks; exactly one shop carries openLate (the late-night landmark); chunkIndex covers 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, golden 0x3fa36874.
  • 'osm'generatePlanOSM(seed, town), a real-data import from a checked-in fixture (osm_fixture.jsOSM_TOWNS; real AU shops from thriftgod's OSM cache; zero network). Towns: melbourne (default, golden 0x34cfdec0), katoomba (0x0f652510). osmTownKeys() lists them.

An 'osm' plan sets source:'osm', reports its real bounding-box size (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 on source. 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.js enforces every law here. Changes are a CITY_SPEC amendment in the same commit + a golden re-pin, never a silent drift. Import everything from web/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 a report object 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:

  1. No new entry. Patronage only ever routes a ped into an open shop (_nearestOpenShop gates on _openAt); the player likewise gets a CLOSED plate/toast and cannot enter (index.html).
  2. Occupants drain, they don't pop. A ped already inside when the shop closes finishes its current bounded visit (seeded 520s 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) tracks occupancy, so it clears with the same drain — no orphaned rigs in a shut shop.
  3. 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).
  4. 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 ~23; 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 PlaneGeometry with a facade-*.jpg skin + 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) — v3.0-alpha

Prime flag law. The gig layer is a post-hoc augmentation applied by the selector only when ?gigs=1. With the flag 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).

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 venue (one pub per town). A spine-END plain shop is converted in place to type:'pub' (keeps its lot id + geometry ⇒ enterable like any shop, no overlap), tagged shop.venue === true. It is never the openLate landmark (the video survives; the town still has exactly one openLate shop). The registry gains a pub type (facades, interior:'band_room', fittings, hours [17,23]) with no district weights so it is never auto-placed. Venue hours [17,23] keep it open through DUSK+NIGHT so the gig runs and D's patronage surge fills it — and it drains at close per the closing-time ruling above. The pub is the only gig-plan shop allowed to close ≥22 besides the openLate video (selfcheck exempts it).

plan.gigs — the nightly schedule (present only on gig-on plans):

gigs: [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover } ]
  • 7 nightly entries at the one venue; night = 0-based index, F selects tonight's (alpha: gigs[0]).
  • venueShopId = the pub shop id. genreKey = 'pubrock' (alpha's one genre). bandName — see drop-in.
  • startSeg = 5 (NIGHT / 22:00), endSeg = 5. F's state machine: quiet → doors (startSeg1 = DUSK) → on → done. (Day-segment→hour map is lighting.js: 0 DAWN … 5 NIGHT.)
  • cover — integer: ~half 0 (free), the rest $2$10. F debits the wallet at the door; free = walk in.

plan.posters — gig advertising (Lane B renders): posters: [ { id, gigId, x, z, ry } ] (~6 — one on the venue frontage, a seeded few along the spine, on poles/walls B already streams). B places E's poster skin at (x, z) rotated ry; F wires "poster → tonight's-gig discoverability".

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 on the schedule (shuffled by seed, so a town surfaces up to its 7 nightly slots of them); 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: 0xa6ae5a5e. Asserted by selfcheck.js alongside the frozen v2 goldens; changing gig output re-pins it.

Shop types v1 (registry lives in web/js/core/registry.js, Lane A writes it, all lanes read)

web/js/core/registry.js is the machine-readable source of truth — import SHOP_TYPES for 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 to toy/book/pawn and rounded out the fittings mixes — a counter to shops that sell over one, a freezer to milkbar, a glass_case+counter to dept — so this table matches the registry lanes actually consume. Non-breaking; all facades exist in web/assets/gen/.)

(Lane A amendment 2026-07-14 · round 2, responding to B/E/F integration notes: gave stall a 2nd facade facade-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 deterministic openLate landmark per town (see the schema note above). Widened chunkIndex edge coverage to the full road corridor. All non-breaking; self-check green at 1283/1283; golden fingerprint refreshed to 0x098eec2b.)

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 band_room bar counter, stage, PA stack, tables, stools

NPC contract (Lane D)

  • Fleet rule (house law): 2 rigged base meshes + shared clip bank. Canonical rigs: ~/Documents/character_kit/ (female grandma_game.glb route, male hum_character.glb) plus the 19 peds in 90sDJsim/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_TIERS idea 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 AE land.

web/package.json (added by Lane A) contains only {"type":"module"} so node runs the pure web/js/** ES modules directly (e.g. node web/js/citygen/selfcheck.js, an acceptance criterion). Browsers and python3 -m http.server ignore it; no deps, no build step. It changes Node's module interpretation for every lane's web/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 its MANIFEST.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:4b vision, gemma4). Free local LLM for name generation, lore, cataloguing. Use it before any paid API.
  • 3GOD depot https://digalot.fyi/3godPOST /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.md format) + thriftgod's gen_assets.py batch pipeline (OpenRouter google/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.
  • CanvasTexture text planes for ALL signage/text — no font files.
  • SkeletonUtils.clone for anything skinned; canonicalize mixamorig\d+mixamorig so 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 (90sDJsim tools/shots.py).