commit 6a9e592fca4e376aa963e4fbb2e7670494142095 Author: Jeffrey Wilcke Date: Mon Oct 12 16:19:45 2026 +0200 docs: city generator design spec Approved design for the seeded multi-stratum cyberpunk city generator, its ratatui app (generate, watch, explore, walk), the Net graph and digital overlay, persistence, and testing strategy. Co-Authored-By: Claude Fable 5.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43beaa6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +/cities +**/*.rs.bk +.DS_Store diff --git a/docs/superpowers/specs/2026-10-12-city-generator-design.md b/docs/superpowers/specs/2026-10-12-city-generator-design.md new file mode 100644 index 0000000..4d46254 --- /dev/null +++ b/docs/superpowers/specs/2026-10-12-city-generator-design.md @@ -0,0 +1,523 @@ +# City Generator — Design Spec + +Date: 2026-10-12 +Status: approved in brainstorming, ready for implementation plan + +## 1. Purpose + +A terminal game in a full cyberpunk setting, built from scratch in this repo. +The first deliverable is the **city generator and its interactive app**: a +seeded, deterministic generator that produces a multi-level cyberpunk city as +walkable tile maps, wrapped in a full-screen terminal application with animated +tiles and animated menus, rich UTF-8 glyphs, and a controllable `@` to prove the +city is playable as a roguelike space. + +### Success criteria + +Run the binary, land in an animated title menu, configure a seed and +parameters, watch the city grow stage by stage, pan across four strata that read +as cyberpunk at a glance, toggle a digital overlay showing the Net, then walk an +`@` from a sewer tunnel up a lift to a corp rooftop. Save the city, quit, reload +it: same city. Same seed and parameters: byte-identical city, every time. + +### Out of scope for this deliverable + +- Combat, inventory, NPCs with logic, dialogue, quests. +- The cyberspace stratum as a playable space, ICE behavior, hacking actions, + netrunning rules. The Net *graph* and its *overlay* are in scope; playing from + inside the Net is a later spec. +- Pedestrian simulation. Traffic is in scope as a purely visual overlay. +- Game saves. Only the city is persisted. +- Mouse input, 256-color design target, Windows testing (crossterm keeps the + door open; macOS is the development target). + +## 2. Decisions + +| Topic | Decision | +|---|---| +| Game type | Roguelike / RPG on foot: tile-level walkable maps with interiors | +| Vertical model | Named strata, each a full tile map: Undercity, Street, Skyways, Heights. Open-ended list; a `Net` stratum is reserved for later | +| Stack | Rust (edition 2024, stable toolchain) + ratatui + crossterm | +| First cut | Generate, watch, explore, walk (`@` with FOV, collision, stairs and lifts), save and load | +| Fiction | Shared cyberpunk canon (Night City, Blade Runner, the Sprawl). Night City's district model. Original names by default, kept in a swappable flavor data file | +| Generation approach | Top-down hierarchical plan (districts → roads → blocks → lots → buildings → verticals → interiors → connectivity → dressing → naming → net), with noise fields used only for wealth/decay modulation in dressing | +| Default size | 240 × 120 tiles per stratum (reads roughly square in 1:2 terminal cells) | +| Districts | 6–8 per city by default, each with a theme driving building mix, street style, palette and signage | +| Determinism | Same seed + params ⇒ byte-identical city | +| Terminal target | Modern truecolor terminals (iTerm, Ghostty, kitty, WezTerm). Must not crash on 256 colors | + +## 3. Architecture + +### Workspace + +``` +cyberpunk/ + Cargo.toml workspace, resolver 3, edition 2024 + rust-toolchain.toml stable + crates/ + citygen/ lib: world model + generator + net graph + deps: rand, rand_chacha, noise, serde, thiserror + NO terminal dependencies, ever + city/ bin: the app and CLI + deps: ratatui, crossterm, citygen, serde, postcard, anyhow, clap + src/main.rs CLI parse → run app or headless `gen` + src/app.rs App state, screen state machine, frame loop + src/screens/ title, configure, generating, explore, walk, files + src/render/ tileset, lighting, weather, traffic, overlay (net), viewport, minimap + src/anim/ timeline, easing, effects (glitch, typewriter, flicker, slide) + src/ui/ menu, form, panel, status bar widgets built on anim + src/walker/ player, fov, movement + flavor/ + names.ron embedded by citygen: syllables, corp names, landmark templates + style.ron embedded by city: theme palettes, glyph sets + docs/superpowers/specs/ + cities/ runtime save directory (gitignored) +``` + +The future game links `citygen` directly and lifts `render`, `anim`, `ui` and +`walker` out of `city` into crates when it needs them. They are modules with +clean boundaries now, crates later. + +### Boundaries + +- `citygen` knows nothing about colors, glyphs or terminals. It produces data. +- `render` is a set of pure functions from world data + render context to + styled cells. It is testable with ratatui's `TestBackend`. +- `anim` knows nothing about the city. It maps ticks to progress values and + applies effects to text/cells. +- `screens` compose `render`, `ui` and `walker` and own input handling. + +### Frame loop + +Fixed 30 frames per second. Each frame: + +1. Poll crossterm events with a timeout equal to the remaining frame budget. +2. Increment the global `tick: u64`. +3. `screen.update(&mut app, input)` — may transition to another screen. +4. `screen.draw(frame, &app)`. + +All animation is a pure function of `tick` (and tile position/variant), never +of wall-clock time, so any frame can be reproduced in a test by setting `tick`. + +## 4. World model (`citygen`) + +All types derive `Serialize, Deserialize, Clone, Debug`; enums also `Copy, +PartialEq, Eq, Hash` where sensible. + +```rust +pub struct City { + pub seed: u64, + pub params: GenParams, + pub name: String, + pub size: Size, // { w: u32, h: u32 } + pub strata: Vec, // physical strata, lookup by kind + pub districts: Vec, + pub buildings: Vec, + pub connectors: Vec, + pub net: Net, +} + +pub enum StratumKind { Undercity, Street, Skyways, Heights, Net /* reserved */ } + +pub struct Stratum { + pub kind: StratumKind, + pub tiles: Grid, // row-major, w*h + pub light: Grid, // 0 dark .. 255 bright, precomputed +} + +pub struct Tile { + pub terrain: Terrain, + pub feature: Option, + pub district: Option, + pub building: Option, + pub variant: u8, // hash(seed, x, y, stratum): glyph pick + anim phase +} + +pub enum Terrain { + Void, Water, Sewage, + Road, Sidewalk, Alley, Plaza, + Floor, Wall, Door, Window, + Rooftop, Walkway, Bridge, Tunnel, Rail, + Stair, Lift, Ladder, Grate, +} + +pub enum Feature { + Neon(Hue), HoloAd, Lamp, Vent, Puddle, Trash, Barrier, Stall, + Sign(SignGlyph), Terminal, Camera, AcUnit, Helipad, Pipe, Cable, +} + +pub struct District { + pub id: DistrictId, + pub name: String, + pub theme: Theme, + pub center: Pos, + pub wealth: f32, // 0..1 + pub decay: f32, // 0..1 + pub bounds: Rect, +} + +pub enum Theme { + CorpCore, NightMarket, Megablocks, CombatZone, + Docks, Industrial, NeonStrip, OldTown, +} + +pub struct Building { + pub id: BuildingId, + pub kind: BuildingKind, + pub district: DistrictId, + pub footprint: Rect, // at Street level + pub span: StrataSpan, // lowest..=highest physical stratum occupied + pub name: Option, +} + +pub enum BuildingKind { + CorpTower, Office, Megablock, Apartment, Hotel, + Shop, NoodleBar, Bar, Club, Arcade, Pawn, Clinic, Temple, + Warehouse, Factory, Depot, Garage, Parking, Market, +} + +pub struct Connector { + pub kind: ConnectorKind, // Stair, Lift, Ladder, Manhole, Jack /* reserved */ + pub pos: Pos, + pub serves: Vec, // ordered low → high; Stair/Ladder/Manhole have exactly 2 + pub public: bool, // reachable from the street without entering a building +} +``` + +Walkability and opacity are **methods** on `Tile`, derived from terrain and +feature (`is_walkable()`, `blocks_sight()`), never stored. `variant` is +deterministic per tile so two neon signs never flicker in sync. + +Glyph constraint: every tile is exactly one terminal cell. Signage uses +single-width glyphs only: half-width katakana (U+FF66–FF9D), Cyrillic, Greek, +currency and symbol blocks. No full-width CJK, no Hangul. + +### Net graph + +```rust +pub struct Net { + pub subnets: Vec, + pub nodes: Vec, + pub links: Vec, // (NodeId, NodeId), undirected +} +pub struct Subnet { pub id: SubnetId, pub owner: SubnetOwner, pub security: u8 } +pub enum SubnetOwner { Building(BuildingId), District(DistrictId), City } +pub struct Node { + pub id: NodeId, + pub kind: NodeKind, + pub anchor: (StratumKind, Pos), + pub subnet: SubnetId, + pub building: Option, +} +pub enum NodeKind { + Terminal, Camera, DoorLock, LiftCtrl, AdBoard, LampGrid, + Server, DataVault, Ice, Backbone, +} +``` + +The Net lives beside the tile grid, not inside it. Tiles are looked up by anchor +when rendering the overlay. + +## 5. Generation pipeline (`citygen`) + +### API + +```rust +pub struct GenParams { + pub seed: u64, + pub size: SizePreset, // Small 160×80, Medium 240×120, Large 320×160, Custom{w,h} + pub districts: u8, // 4..=12, default 7 + pub density: f32, // 0..1, default 0.6 + pub wealth_skew: f32, // -1..1, default 0.0 + pub decay: f32, // 0..1, default 0.4 + pub water: WaterMode, // None, Bay, River; default Bay + pub flavor: String, // flavor pack name, default "default" +} +impl GenParams { pub fn validate(&self) -> Result<(), GenError>; } + +pub struct Generator { /* partial city + stage cursor */ } +impl Generator { + pub fn new(params: GenParams) -> Result; + pub fn step(&mut self) -> Progress; // Running { stage, pct } | Done + pub fn city(&self) -> &City; // partial while running + pub fn finish(self) -> Result; // runs City::validate() + pub fn run(params: GenParams) -> Result; // convenience +} +pub enum Stage { + Terrain, Districts, Arterials, Streets, Lots, Verticals, + Interiors, Connectivity, Dressing, Naming, Net, +} +``` + +Each stage is chunked into small steps (one road, one block, one building per +step). The UI calls `step()` several times per frame and draws `city()` in +between: that is the entire "watch it grow" animation. Headless callers loop to +completion. No threads, no snapshots. + +### Determinism + +One `ChaCha8Rng` seeded from `params.seed`. Each stage derives its own sub-RNG +from `seed ^ STAGE_CONST[stage]` so a change to one stage does not reshuffle +earlier ones. Noise fields (`noise` crate, OpenSimplex) take the same seed. +Iteration order over collections is always index order, never hash order. + +### Stages + +1. **Terrain.** Choose water per `WaterMode`: Bay carves a noisy coastline along + one random edge covering 10–20% of the map; River cuts a meandering channel + 6–10 tiles wide; None leaves all land. Water is `Terrain::Water` on Street, + `Void` above, `Sewage` channels later in Undercity. +2. **Districts.** Place `params.districts` seeds with Poisson-style minimum + spacing on land. Grow Voronoi regions. Assign themes by rule, in order: CorpCore to the + seed nearest the land centroid; Docks to a seed touching water (if any); + Industrial adjacent to Docks (or edge if no water); CombatZone to the seed + farthest from CorpCore; remaining seeds draw from the mix + {Megablocks, NightMarket, NeonStrip, OldTown} without repeating until + exhausted. Wealth = theme base + `wealth_skew` + low-frequency noise, clamped. + Decay = theme base + `params.decay` + noise, clamped. +3. **Arterials.** Minimum spanning tree over district centers plus + `max(2, districts/3)` extra edges forming loops. Route each edge with A* on a + cost grid: water impassable, turns cost extra, existing roads cheap. Arterials + are 4 tiles of `Road` with 1 tile of `Sidewalk` each side. +4. **Streets.** Per district, subdivide its region into blocks in its style: + - CorpCore: orthogonal grid, blocks ~24×12, streets 2 wide + sidewalks. + - NightMarket, OldTown: recursive split with jitter, alleys 1 wide, dead ends allowed. + - Megablocks: 2–4 enormous blocks per district. + - Docks, Industrial: long parallel rows, access roads every 20–30 tiles. + - CombatZone: grid with 30% of streets removed and `Barrier` features at cuts. + - NeonStrip: one wide main strip with tight side alleys. +5. **Lots.** Split each block into lots along the long axis (BSP) with minimum + lot size from theme. Each lot draws a `BuildingKind` from the theme's + weighted mix and a height class (Low: Street only; Mid: Street+Skyways; + Tall: Street..Heights). CorpCore and Megablocks also get basements + (Undercity). `density` scales the fraction of lots that become Plaza or + Parking instead of buildings. Footprints are written as `Wall` perimeter + + `Floor` interior on Street. +6. **Verticals.** + - Undercity: `Tunnel` under every arterial and ⅓ of streets; `Sewage` + channels under arterials with `Grate` crossings; maintenance chambers under + buildings with basements; one `Rail` line across the map. + - Skyways: `Walkway` between buildings with span ≥ Skyways when within 12 + tiles; `Walkway` platforms over intersections in CorpCore and NeonStrip; + Mid/Tall buildings get `Wall`/`Floor` footprints at this stratum. + - Heights: `Rooftop` over Tall buildings with `Helipad`/`AcUnit` features; + `Bridge` between Tall buildings within 8 tiles; everything else `Void`. +7. **Interiors.** For each building and each stratum it spans: BSP rooms within + the footprint (min room 3×3), corridors joining rooms, a `Door` on a + street-facing wall at Street level, `Window` on street-facing walls, internal + `Stair` (span 2) or `Lift` shaft (span ≥ 3) linking every stratum spanned. +8. **Connectivity.** Flood fill walkable tiles per stratum. Components smaller + than 1% of walkables are filled `Void` (Undercity/Skyways/Heights) or joined + (Street). Remaining disconnected components are joined by carving the + shortest `Door`/`Tunnel`/`Walkway` path to the main component. Then build the + connector graph across strata; add public connectors (street stairwells to + Skyways, `Manhole` to Undercity, lift kiosks, fire ladders) in each district + until every stratum is reachable from Street. At most 3 repair rounds, then + `GenError::Unconnected { stratum }`. +9. **Dressing.** `Neon(hue)` and `HoloAd` on street-facing facades, density by + theme and wealth; `Lamp` along sidewalks every 8–12 tiles; `Vent` (steam), + `Puddle`, `Trash` scaled by decay; `Barrier` in CombatZone; `Stall` in + NightMarket plazas; `Terminal` in lobbies and on some sidewalks; `Camera` on + walls facing streets/corridors scaled by wealth; `Pipe`/`Cable` in Undercity; + `Sign` glyphs above shop doors. Compute `light` per stratum: lamps and neon + radiate with falloff, sky exposure adds base light (Heights > Street > + Skyways > Undercity). Assign `variant` for every tile. +10. **Naming.** City name, district names (theme syllable tables), corp names + for CorpTower/Office/Megablock, landmark templates for a few notable + buildings per district. Flavor pack from `flavor/names.ron`. +11. **Net.** One `Subnet` per building (owner Building, security from theme + + wealth), one per district (owner District, `Backbone` node at a Terminal in + the district), one city core (owner City) in CorpCore. Nodes from existing + features: `Terminal`→Terminal, `Camera`→Camera, `Door`→DoorLock, + `Lift`→LiftCtrl, `HoloAd`→AdBoard, one `LampGrid` per district, one + `Server` per building in an interior room, `DataVault` in CorpTower/Clinic, + `Ice` anchored at servers with security ≥ 160. Links: node → its building's + Server; Server → district Backbone; Backbone → city core. + +### Validation + +`City::validate() -> Result<(), Vec>` checks: + +- every `Connector` endpoint tile is walkable on every stratum it serves; +- every `Building` has at least one `Door` on Street; +- every stratum is reachable from Street via connectors; +- every tile's `district`/`building` ids exist; +- no two building footprints overlap; +- every Net node anchors to a tile of the expected terrain/feature; +- every district has ≥ 1 building and ≥ 1 public connector. + +## 6. Rendering and animation (`city/src/render`) + +### Tileset + +```rust +pub fn cell(tile: &Tile, ctx: &RenderCtx) -> Cell +// RenderCtx: theme palette, light value, tick, pos, visibility, stratum kind +``` + +Pure function: glyph from terrain+feature+variant, color from theme palette, +light applied, animation phase from tick+variant. Tested by exact glyph/color +assertions. + +### Glyph vocabulary (single width) + +| Thing | Glyphs | +|---|---| +| Walls | light `─│┌┐└┘├┤┬┴┼` shops; heavy `━┃┏┓┗┛` megablocks; double `═║╔╗╚╝` corp | +| Mass / rooftop | `█▓▒░▀▄` by height class | +| Stairs / lifts / ladders / manholes | `▲▼` `⇅≡` `╫` `◍` | +| Water / sewage | `≈~∼` cycled | +| Rain | braille `⠁⠂⠄⡀⢀⠈⠐⠠` | +| Lamps | `●○◉`; vents `☰`, steam `°∘○` | +| Signage | half-width katakana, Cyrillic, Greek, `¥$€` | +| Rail / grate / cable / barrier / stall / holo ad | `╬` `▦▩` `╌` `▬` `▤` `▣` | +| Net overlay nodes | `◇◆⬡⌗` | + +### Palette + +Dark night base (`#0a0b10` background family). Theme accents: + +| Theme | Accent pair | +|---|---| +| CorpCore | cold cyan, steel white | +| NightMarket | magenta, orange | +| Megablocks | concrete grey, amber | +| CombatZone | red, sodium yellow | +| Docks | teal, rust | +| Industrial | sulfur, sick green | +| NeonStrip | hot pink, electric blue | +| OldTown | warm paper, jade | + +Stratum tint: Undercity toward green-black, Heights toward blue with more +light. Palettes live in `flavor/style.ron`. + +### Overlay stack + +Rendering a frame of the map is: base tiles → lighting → overlays in order: + +1. **Tile phase** (inside `cell()`): neon flicker schedule from variant, holo ad + glyph cycle, water ripple, vent puff, lift indicator, rail blink. +2. **Lighting**: static light map × slow flicker factor near neon. Undercity + dark with pools of light; Heights brightest. +3. **Weather**: rain streaks falling one cell per tick with wind drift; density + by stratum (none Undercity, light Street, medium Skyways, heavy Heights); + thin fog overlay in Undercity. +4. **Traffic**: `▬` cars with headlight tint moving along arterials; spawned and + despawned at viewport edges; position + direction only, no simulation. +5. **Digital overlay** (toggle `d`): Net nodes drawn as wireframe glyphs in + signal cyan over their anchor tile; building subnet outline faint; when the + inspector points at a node, its subnet's nodes brighten and links draw as + Bresenham lines. +6. **Walker**: FOV shading (Visible normal, Remembered dimmed and unanimated, + Unseen black) and the `@`. + +### Viewport and panels + +- Map viewport fills the left. Camera in tile coords, clipped to the stratum, + eased toward its target over a few frames. +- Right panel (toggle `i`): tile inspector (coords, stratum, terrain, feature, + district, building name, Net node if any), legend for the current stratum + (toggle `l`), stratum selector showing all strata with the current one + highlighted. +- Minimap (toggle `m`), bottom right: 1 cell per 4×4 tiles, colored by + district, viewport rectangle and `@` marked. +- Status bar: city name, seed, stratum, FPS, key hints. +- Minimum terminal 100×30; below that, draw a centered resize message. + +## 7. App flow (`city/src/screens`, `ui`, `anim`) + +``` +Title ──▶ Configure ──▶ Generating ──▶ Explore ◀──▶ Walk + │ │ + └──▶ Files (load) ─────────────────────┘ (save from Explore/Walk) +``` + +- **Title.** Game name in block-element letters with neon flicker and a + scanline sweep. Menu: New City, Load City, Quit. Items slide in from the left + with typewriter reveal; selected item has pulsing `▸` and accent color. +- **Configure.** Form over `GenParams`: seed (random / typed), size preset, + districts, density, wealth skew, decay, water, flavor. Sliders as `━━━◉━━━`. + Generate button. Esc back. +- **Generating.** Street stratum grows in the viewport. Right panel: eleven + stages as a checklist, active one with progress bar + `[▸] Routing arterials ▓▓▓▓▓░░░ 62%`. Space pause, `+`/`-` steps per + frame (1..64, default 8), Enter skip to end. On `GenError`, show the error + and offer `r` reseed. Glitch wipe into Explore. +- **Explore.** Free camera. Keys: arrows/hjkl pan, Shift fast pan, `[`/`]` + stratum, `i` inspector, `m` minimap, `l` legend, `d` digital overlay, `w` + Walk, `s` save, `r` regenerate with fresh seed, `g` Configure, `?` help, + `q` Title. +- **Walk.** Camera follows `@`. hjkl/yubn/arrows move, `<`/`>` use connector, + `.` wait, Tab back to Explore leaving `@` in place. Panels and `d` as above. +- **Files.** List `./cities/*.city` with name, seed, size, date. Enter loads, + Esc back. Save prompts for a name, default the city name. + +**Transitions**: every screen change is a 150–250 ms effect (glitch wipe of +shuffled cells, or vertical slide); any key skips it. Panels fade in by +sweeping their border. + +**`anim`**: `Timeline` of tweens (start tick, duration, easing); `Effects` +providing glitch, typewriter, flicker, slide as functions of progress ∈ [0,1]. + +## 8. Walker (`city/src/walker`) + +```rust +pub struct Player { pub pos: Pos, pub stratum: StratumKind, pub memory: HashMap> } +pub enum Visibility { Unseen, Remembered, Visible } +``` + +- Eight-direction movement; a move succeeds if the target `is_walkable()`. + Doors are passable and render open while occupied. +- `<`/`>` on Stair/Ladder/Manhole: move to the other stratum. On Lift: step + through `serves` in order; inspector shows the lift panel with current floor. +- FOV: symmetric shadowcasting. Radius 14 on Street/Skyways/Heights, 8 in + Undercity. Memory per stratum. +- Spawn on first entering Walk: a random public connector on Street in a + non-CorpCore district. `@` persists across Explore/Walk until regeneration. +- Camera follows with a deadzone of ⅓ viewport, eased. + +## 9. Persistence and CLI (`city`) + +- File: magic `CITY` (4 bytes), `u16` format version, postcard body of `City`. + Extension `.city`, directory `./cities/`. +- Unknown/newer version ⇒ clear error, not a panic. +- CLI: `city` (interactive) and `city gen --seed N --size small|medium|large + --out file.city [--json]`. `--json` writes serde_json instead. Nothing else. +- Player position is not saved. + +## 10. Error handling + +- `citygen`: `thiserror`, `GenError { InvalidParams { field, reason }, + Unconnected { stratum }, Flavor(String) }`. Never panics on valid params. +- `city`: `anyhow`. A terminal guard restores screen, raw mode and cursor on + exit, error and panic (panic hook). Resize below 100×30 shows a message. + +## 11. Testing + +- `citygen` property-style tests over fixed seeds: 50 Small, 10 Medium, 3 Large + in the default run; an `#[ignore]` extended test runs 200 seeds. Each seed: + byte-identical output on two runs, `validate()` passes, Street reaches every + stratum, every district has a building and a public connector, every Net node + anchors correctly, no footprint overlap. One timing test: Medium under 2 s in + debug. +- Per-stage unit tests on small hand-built cities: arterial router never + crosses water; BSP never emits a room under 3×3; connectivity repair joins two + halves; theme assignment rules hold. +- `city` via `TestBackend`: tileset exact glyph/color at fixed ticks; viewport + clipping at all four edges; title menu at tick 0 and after slide-in; file + header round-trip. +- FOV: symmetry, walls block, radius respected, Undercity radius. +- Gate: `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test`. + +## 12. Dependencies + +ratatui, crossterm, rand, rand_chacha, noise, serde (derive), postcard, +serde_json, thiserror, anyhow, clap (derive), ron. Latest stable majors at +implementation time. + +## 13. Future work (not this deliverable) + +- `StratumKind::Net` generated as a projection of the physical city; `Jack` + connectors from terminals; ICE behavior; netrunning actions. +- NPCs and pedestrians, combat, inventory, factions per district. +- Extracting `render`, `anim`, `ui`, `walker` into crates for the game.