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 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:19:45 +02:00
commit 6a9e592fca
2 changed files with 527 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/target
/cities
**/*.rs.bk
.DS_Store

View File

@@ -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<Stratum>, // physical strata, lookup by kind
pub districts: Vec<District>,
pub buildings: Vec<Building>,
pub connectors: Vec<Connector>,
pub net: Net,
}
pub enum StratumKind { Undercity, Street, Skyways, Heights, Net /* reserved */ }
pub struct Stratum {
pub kind: StratumKind,
pub tiles: Grid<Tile>, // row-major, w*h
pub light: Grid<u8>, // 0 dark .. 255 bright, precomputed
}
pub struct Tile {
pub terrain: Terrain,
pub feature: Option<Feature>,
pub district: Option<DistrictId>,
pub building: Option<BuildingId>,
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<String>,
}
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<StratumKind>, // 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<Subnet>,
pub nodes: Vec<Node>,
pub links: Vec<Link>, // (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<BuildingId>,
}
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<Self, GenError>;
pub fn step(&mut self) -> Progress; // Running { stage, pct } | Done
pub fn city(&self) -> &City; // partial while running
pub fn finish(self) -> Result<City, GenError>; // runs City::validate()
pub fn run(params: GenParams) -> Result<City, GenError>; // 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<Violation>>` 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<StratumKind, Grid<Visibility>> }
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.