Files
cyberpunk/docs/superpowers/specs/2026-10-12-city-generator-design.md
Jeffrey Wilcke 6a9e592fca 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>
2026-10-12 16:19:45 +02:00

24 KiB
Raw Blame History

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.

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

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

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

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)

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.