Ship round. Docs + one selfcheck log-line only; no generator change ⇒ all three goldens unchanged (synthetic 0x3fa36874, osm/melbourne 0x34cfdec0, osm/katoomba 0x0f652510). 1. CITY_SPEC v2 contract FROZEN — new "CityPlan v2 — frozen producer contract" section: producer API, goldens table (keyed seed/plansrc/town) + how to pin a new one, town-key mechanism, osm normalization rules, hours/openLate/storeys laws. Matches code as shipped (F diffs claims vs code). Refreshed the stale round-6 Plan-sources blockquote to multi-town reality. Layer-1 changes now require a CITY_SPEC amendment + golden re-pin in the same commit. 2. Recipe sanity — DRY-RAN end-to-end: appended a throwaway Newtown (42 shops), selfcheck auto-ran the full parity suite (1931/1932, only golden unpinned) and printed a paste-ready `newtown: 0x9045e577,`. Reverted; no 3rd town shipped. Added the selfcheck `⚠ UNPINNED — add to OSM_GOLDENS` line so the recipe is mechanical; fixed the recipe wording to match. 3. Closing-time occupant ruling (D's on-call Q) — ratifies + freezes what D's sim.js already does: half-open hours, no new entry when closed, occupants DRAIN (finish bounded dwell) never popped, player not force-ejected, openLate video is the night crowd. In CITY_SPEC + LANE_A_NOTES. No code change asked of D. qa.sh --strict GREEN; selfcheck 1727/1727. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
321 lines
21 KiB
Markdown
321 lines
21 KiB
Markdown
# 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):
|
||
|
||
```js
|
||
{
|
||
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.js` → `OSM_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 `name`s
|
||
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 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) 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 ~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 `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).
|
||
|
||
## 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 |
|
||
|
||
## 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/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"}` 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/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.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`).
|