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>
24 KiB
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
citygenknows nothing about colors, glyphs or terminals. It produces data.renderis a set of pure functions from world data + render context to styled cells. It is testable with ratatui'sTestBackend.animknows nothing about the city. It maps ticks to progress values and applies effects to text/cells.screenscomposerender,uiandwalkerand own input handling.
Frame loop
Fixed 30 frames per second. Each frame:
- Poll crossterm events with a timeout equal to the remaining frame budget.
- Increment the global
tick: u64. screen.update(&mut app, input)— may transition to another screen.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
- 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 isTerrain::Wateron Street,Voidabove,Sewagechannels later in Undercity. - Districts. Place
params.districtsseeds 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. - 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 ofRoadwith 1 tile ofSidewalkeach side. - 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
Barrierfeatures at cuts. - NeonStrip: one wide main strip with tight side alleys.
- Lots. Split each block into lots along the long axis (BSP) with minimum
lot size from theme. Each lot draws a
BuildingKindfrom 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).densityscales the fraction of lots that become Plaza or Parking instead of buildings. Footprints are written asWallperimeter +Floorinterior on Street. - Verticals.
- Undercity:
Tunnelunder every arterial and ⅓ of streets;Sewagechannels under arterials withGratecrossings; maintenance chambers under buildings with basements; oneRailline across the map. - Skyways:
Walkwaybetween buildings with span ≥ Skyways when within 12 tiles;Walkwayplatforms over intersections in CorpCore and NeonStrip; Mid/Tall buildings getWall/Floorfootprints at this stratum. - Heights:
Rooftopover Tall buildings withHelipad/AcUnitfeatures;Bridgebetween Tall buildings within 8 tiles; everything elseVoid.
- Undercity:
- Interiors. For each building and each stratum it spans: BSP rooms within
the footprint (min room 3×3), corridors joining rooms, a
Dooron a street-facing wall at Street level,Windowon street-facing walls, internalStair(span 2) orLiftshaft (span ≥ 3) linking every stratum spanned. - 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 shortestDoor/Tunnel/Walkwaypath to the main component. Then build the connector graph across strata; add public connectors (street stairwells to Skyways,Manholeto Undercity, lift kiosks, fire ladders) in each district until every stratum is reachable from Street. At most 3 repair rounds, thenGenError::Unconnected { stratum }. - Dressing.
Neon(hue)andHoloAdon street-facing facades, density by theme and wealth;Lampalong sidewalks every 8–12 tiles;Vent(steam),Puddle,Trashscaled by decay;Barrierin CombatZone;Stallin NightMarket plazas;Terminalin lobbies and on some sidewalks;Cameraon walls facing streets/corridors scaled by wealth;Pipe/Cablein Undercity;Signglyphs above shop doors. Computelightper stratum: lamps and neon radiate with falloff, sky exposure adds base light (Heights > Street > Skyways > Undercity). Assignvariantfor every tile. - 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. - Net. One
Subnetper building (owner Building, security from theme + wealth), one per district (owner District,Backbonenode 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, oneLampGridper district, oneServerper building in an interior room,DataVaultin CorpTower/Clinic,Iceanchored 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
Connectorendpoint tile is walkable on every stratum it serves; - every
Buildinghas at least oneDooron Street; - every stratum is reachable from Street via connectors;
- every tile's
district/buildingids 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:
- Tile phase (inside
cell()): neon flicker schedule from variant, holo ad glyph cycle, water ripple, vent puff, lift indicator, rail blink. - Lighting: static light map × slow flicker factor near neon. Undercity dark with pools of light; Heights brightest.
- 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.
- Traffic:
▬cars with headlight tint moving along arterials; spawned and despawned at viewport edges; position + direction only, no simulation. - 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. - 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 (togglel), 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. OnGenError, show the error and offerrreseed. Glitch wipe into Explore. - Explore. Free camera. Keys: arrows/hjkl pan, Shift fast pan,
[/]stratum,iinspector,mminimap,llegend,ddigital overlay,wWalk,ssave,rregenerate with fresh seed,gConfigure,?help,qTitle. - Walk. Camera follows
@. hjkl/yubn/arrows move,</>use connector,.wait, Tab back to Explore leaving@in place. Panels anddas above. - Files. List
./cities/*.citywith 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 throughservesin 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),u16format version, postcard body ofCity. Extension.city, directory./cities/. - Unknown/newer version ⇒ clear error, not a panic.
- CLI:
city(interactive) andcity gen --seed N --size small|medium|large --out file.city [--json].--jsonwrites 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
citygenproperty-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.
cityviaTestBackend: 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::Netgenerated as a projection of the physical city;Jackconnectors from terminals; ICE behavior; netrunning actions.- NPCs and pedestrians, combat, inventory, factions per district.
- Extracting
render,anim,ui,walkerinto crates for the game.