--- title: "Tyre — Round 2: Spatial Generation Architecture Proposal" description: "Technical architecture for the spatial pipeline: coordinate translation, city-to-district decomposition, Phase 1/2 generation, chunk wiring, seed boundary, testing, and effort" type: workshop status: active workshop: generation-cascade agent: tyre round: 2 created: 2026-04-30 --- # Generation Cascade — Round 2: Spatial Generation Architecture Proposal **Tyre — Technical Architect** --- ## Scope Declaration Per lead directive: this proposal covers the **spatial pipeline only** — from atlas city markers to walkable rendered tiles. Heritage grammar, NPC manifests, social sites, triangle wiring, culture overlays — all deferred. The remaining questions from Round 1 that fall outside spatial scope (OQ-R1-A heritage root count, OQ-R1-B SocietyProfileRef type) are not addressed here. Spatial scope items I am addressing: - OQ-R1-C: City marker schema approach - OQ-R1-D: Atlas coordinate translation formula (resolved below) - OQ-R1-E: Vertical slice effort estimate (revised) - Gap B: Coordinate system design - Gap C: Missing DistrictSkeleton fields --- ## The Core Architecture Insight: Two Separate Coordinate Systems This resolves OQ-R1-D and underpins everything below. The atlas grid (512×256 pixels) represents the **planetary surface** as a display coordinate system for the atlas UI. It shows continents, coastlines, city dots. It is not a world-space coordinate for the game simulation. The walkable world uses a **city-local coordinate system**, measured in sim tiles (0.5m each, per D-066). A city does not "exist" at pixel (row, col) in the sim world — it exists in its own local space starting at (0, 0). The atlas pixel position is only used for: 1. Rendering the city dot on the planetary map UI 2. Determining relative positions *between* cities on the same body (future: overland travel) **There is no pixel-to-sim-tile conversion formula** and none is needed. The translation is a lookup, not arithmetic: ``` atlas_city.center = [row, col] → planetary UI display position only walkable world origin → always (0, 0) in city-local sim tile space ``` This is the correct architectural answer to OQ-R1-D. Attempting to produce a linear formula from 512×256 pixels to a planetary sim-tile grid would require deciding how large the planet is in sim tiles — a meaningless number for gameplay. Players don't walk between cities. **What this means for district positioning:** Districts in a city are laid out in city-local sim tile space. District 0 is at world position (0, 0). District 1 is at (512, 0) (one district-width east). And so on. The atlas pixel coord stays in the atlas layer; the district layout algorithm works purely in sim tile space. --- ## Seed vs. Stored-Data Boundary This is the most important architectural decision in the spatial pipeline. I'll be direct about where the line is. ### What is stored (authored or generated-then-committed) | Data | Location | Why stored | |------|----------|-----------| | Star system and body definitions | `wiki/`, `systems.db` bodies table | Authored world content | | Economic data, corporations | `systems.db` economics tables | Generated-then-committed via pipeline | | City positions, populations, infrastructure | `markers.json`, `systems.db` atlas tables | Generated-then-committed; hand-edit possible | | City names | `markers.json` (Gemma-filled) | Curated; not reproducible on demand | | Chunk mutations (DamageOverlay, player changes) | Save file (future) | Playthrough-specific | ### What is NOT stored — always re-derived from seed | Data | Why seed-derived | |------|-----------------| | District count and positions per city | Function of (city_data, world_seed) | | DistrictSkeleton (all fields) | Function of (DistrictId, body_context, world_seed) | | BlockSkeleton per district | Function of (district_seed) | | ChunkData tile grid | Function of (block_skeleton, chunk_coord, district_seed) | | Building footprints within chunks | Function of (zoning, density, chunk_seed) | **The boundary is between Layer 4 and Layer 6:** everything from city markers downward is seed-derived and never persisted as generator output. The `markers.json` city entries are the stored anchor. Below that, the world is fully deterministic from `world_seed`. ### The SeedChain Every child seed is derived from its parent using FNV-1a (per D-010's determinism mandate): ```rust fn child_seed(parent: u64, discriminant: u64) -> u64 { let mut h = parent ^ 0xcbf29ce484222325; for byte in discriminant.to_le_bytes() { h ^= byte as u64; h = h.wrapping_mul(0x100000001b3); } h } district_seed = child_seed(world_seed, district_id) chunk_seed = child_seed(district_seed, (cx as u64) << 32 | (cy as u64)) ``` This means: - Any district can be generated in isolation, without generating its neighbors first - Any chunk can be generated in isolation, given its district_seed - Generation is embarrassingly parallelizable - No global state is required ### Implication for save files Save files store: - `world_seed: u64` (the single master seed) - `markers_snapshot: HashMap` (or a hash of it for validation) - `chunk_mutations: BTreeMap` (only player-modified chunks) Everything else is re-derived on load. A save file for a large world stays small. --- ## Layer-by-Layer Architecture Proposal ### Layer 6: City → District Decomposition **Inputs:** - `body_id: &str` - `city: &AtlasCity` (from systems.db atlas_cities table — `id`, `kind`, `population`) - `body: &Body` (from systems.db bodies table — `settlement_pattern`, `economic_role`, `planet_class`) - `world_seed: u64` **Algorithm:** ```rust fn decompose_city(city: &AtlasCity, body: &Body, world_seed: u64) -> Vec { let n = district_count(city.population, city.kind, body.settlement_pattern); let layout = city_layout_grid(n, city_seed(world_seed, &city.id)); layout.iter().enumerate().map(|(i, grid_pos)| DistrictPlacement { district_id: district_id(world_seed, &body.body_id, &city.id, i as u32), world_origin: grid_to_sim_tiles(grid_pos), // (col * 512, row * 512) in sim tiles city_id: city.id.clone(), }).collect() } ``` **District count formula:** ``` population → n_districts: 0 → 0 (uninhabited) 1–50k → 1 50k–500k → 2–4 (log-scaled) 500k–5M → 4–9 (log-scaled) 5M+ → 9–16 (capped at 4×4) capital → +1 bonus district domed/cave → fixed 1 regardless of population ``` **District positioning:** A simple grid arrangement in city-local space. District at grid position (col, row) gets world origin `(col * 512, row * 512)` sim tiles. Grid dimensions chosen to be roughly square for the given count (e.g., 4 districts → 2×2; 6 districts → 3×2). Exact grid shape is seeded for slight variety (some cities are long and thin, some square). **OQ-R1-C resolution:** `primary_function`, `planet_class`, and `settlement_pattern` are available in `systems.db` bodies table and do NOT need to be added to `markers.json`. The decomposition function reads them from systems.db directly, cross-referenced by body_id. `generate_atlas.py` does not need modification. **Output:** ```rust struct DistrictPlacement { district_id: DistrictId, // u64, deterministic from world_seed + body + city + index world_origin: (i64, i64), // sim tile position of district's (0,0) corner city_id: String, } ``` **Testing:** Pure function. Unit test: given (population, settlement_pattern), district count matches formula. Given same (city, body, seed), district positions are identical across runs. Given different seeds, positions vary. No ECS needed. --- ### Layer 7: Phase 1 — DistrictSkeleton (Spatial Structure Only) **Scope for minimum vertical slice:** Stages 1 and 2 only. Stages 3 (reservations), 4 (social sites), and 5 (guarantee audit) are deferred — they produce spatial enrichment but are not required for a walkable tile. **Stage 1: Classification** ```rust fn classify_district(placement: &DistrictPlacement, body: &Body, city: &AtlasCity) -> (WorldTier, ComplexityTier, SettingType, DistrictType) ``` - `WorldTier`: derived from system economic tier in systems.db (`economic_role`, system connectivity). Hub systems → `Epicenter`; standard → `Regional` or `Backwater`; transit stops → `Passage`. - `ComplexityTier`: derived from WorldTier ceiling table (workshop-outcomes.md) - `SettingType`: derived from `planet_class` + `economic_role` + city `kind`. Capital on a standard planet → `Urban`. Rural body → `Agricultural`. Orbital station → `Station`. - `DistrictType`: derived from city `kind` + district index within city (center district → `Commercial` or `MixedUse`; outer districts → `Residential`, `Industrial`, `Transit`) All deterministic from upstream data. No RNG needed at Stage 1 — classification is a pure lookup/mapping. **Stage 2: Block grid** ```rust fn generate_block_grid(district_seed: u64, district_type: &DistrictType, complexity: &ComplexityTier) -> [[BlockSkeleton; 4]; 4] ``` - `DistrictLayoutMode`: seeded choice between `Grid` and `Organic`. Weight toward Grid for Commercial/Administrative, toward Organic for Residential/Mixed. Lead decision L-2: both modes must coexist. - Per block: assign `ZoningType` from a seeded draw against the district's type-to-zoning probability table. Commercial district → mostly Commercial blocks with some Residential edges. - Per block: assign `density_pct` seeded from district_seed + block position. Creates density gradient (center blocks denser than edge blocks for Urban districts). - `Era`: stub string for now (`"standard"`). Real era differentiation is later work. - Everything else on `BlockSkeleton` (`chunk_layout`, `hosted_sites`, `landmark`) → left as empty/stub. **Key constraint:** The block grid is fully determined by `(district_seed, district_type, complexity)`. No external lookups at generation time. The entire `DistrictSkeleton` can be re-derived from those three inputs plus `world_seed`. **Missing fields to add:** Per Gap C from Round 1, these three fields are required by the workshop spec and absent from the current struct: - `vertical_structure: VerticalStructure` — add as stub enum `VerticalStructure::Flat` for now - `breach_only_zones: Vec` — add as `Vec`, empty for minimum slice - `derived_analysis: DerivedDistrictAnalysis` — add as stub struct, all fields `None`/default These are 0.5 days of type work. **WorldTier fix:** The enum must be corrected before any generation work starts. One-line change, 0.5 days including test update. --- ### Layer 8: Phase 2 — ChunkData Tile Generation (Spatial Only) This layer converts a `BlockSkeleton` into a 64×64 sim-tile grid of walkable/non-walkable tiles with tile type assignments. **Inputs:** - `block: &BlockSkeleton` (zoning, density_pct, layout from Phase 1) - `chunk_coord: ChunkCoord` (which 2×2 chunk-within-block this is) - `chunk_seed: u64` (derived from district_seed + chunk_coord) **Output:** A 64×64 tile grid (the raw `GeneratorChunkData`) **Minimum viable generation algorithm:** The key insight: for the minimum slice, we don't need heritage grammar, room templates, or furniture. We need: 1. Street tiles (walkable, visually distinct) 2. Building exterior tiles (wall, non-walkable) 3. Building interior floor tiles (walkable) ```rust fn generate_chunk_tiles(block: &BlockSkeleton, within_block_pos: (u8, u8), chunk_seed: u64) -> GeneratorChunkData { let density = block.density_pct; let mut rng = SimRng::from_seed(chunk_seed); // Step 1: Fill with street tile // Step 2: Place building footprints based on density // - density 0-20%: 0-1 small buildings // - density 20-60%: 2-4 medium buildings // - density 60-100%: 4-8 buildings, larger, tighter // Step 3: For each footprint: fill interior with floor tile, perimeter with wall tile // Step 4: Ensure street connectivity (no building can fully block a chunk edge) } ``` **Building footprint algorithm:** Simple non-overlapping rectangle placement using seeded random dimensions within density budget. Minimum building: 6×6 tiles. Minimum street gap between buildings: 4 tiles. This is not artistically sophisticated but produces walkable, navigable space. **Tile vocabulary for minimum slice:** ```rust // Minimum TileId set (just string constants to start) const TILE_FLOOR_STREET: &str = "floor_street"; const TILE_FLOOR_INTERIOR: &str = "floor_interior"; const TILE_WALL: &str = "wall"; ``` Three tile types. Everything needed for walkability and basic navigation. Visual polish is a later pass. **`GeneratorChunkData` type change:** The current type alias `pub type GeneratorChunkData = Vec` must become a proper struct to carry tile IDs, not just walkability. Minimum: ```rust pub struct GeneratorChunkData { pub tiles: Vec, // 64×64 = 4096 entries } pub struct TileEntry { pub tile_id: TileId, // "floor_street", "wall", etc. pub walkable: bool, // pre-computed for the movement system } ``` This is a type-level change that breaks the placeholder `ChunkData::new_walkable()` usage cleanly — making the transition visible rather than silent. --- ### Layer 9: Chunk Streaming → Generator Wiring The chunk streaming architecture is ready. The hookup requires two additions: **Addition 1: `DistrictMap` resource** ```rust #[derive(Resource)] pub struct DistrictMap { /// Maps a chunk coord to the district it belongs to. /// Built at world load time from DistrictPlacement data. chunks: BTreeMap, /// The Phase 1 skeleton for each loaded district. skeletons: BTreeMap, } ``` **Addition 2: Replace `load_chunk()` placeholder** ```rust pub fn load_chunk(&mut self, coord: ChunkCoord, district_map: &DistrictMap, world_seed: u64) -> bool { if self.chunks.contains_key(&coord) { return false; } let chunk_data = if let Some(district_id) = district_map.chunks.get(&coord) { let skeleton = &district_map.skeletons[district_id]; let block = block_for_chunk(skeleton, coord); let chunk_seed = child_seed(child_seed(world_seed, *district_id), coord_to_u64(coord)); generate_chunk_tiles(block, within_block_pos(coord), chunk_seed) } else { // Outside any district — wilderness, open terrain GeneratorChunkData::all_walkable() }; self.chunks.insert(coord, chunk_data); true } ``` This is the complete wiring. No other changes to `chunk_streaming.rs` are needed. --- ## Data Format Boundaries The handoff between Python tooling and Rust runtime is clean and already exists: ``` PYTHON TOOLING (offline pipeline) systems.db → bodies, star_systems, economics, atlas_cities tables markers.json → city positions and populations (per body) ↓ [HANDOFF — server startup reads these two sources] RUST SERVER (runtime) At startup: read systems.db + markers.json → build DistrictMap in memory On load_chunk(): derive ChunkData from district_seed + block_skeleton On save: persist only ChunkMutations (player-modified chunks) ``` **The handoff is currently wired:** `server/src/main.rs` references the generator. Systems.db is read at startup. The gap is not the boundary mechanism — it's that no code reads atlas_cities to build a DistrictMap. **No Python layer needed for Phase 1 or Phase 2.** The entire spatial generation below city markers is Rust code, working against data already in systems.db. This is clean. --- ## Testing Strategy Per Layer Each layer produces deterministic output and can be validated independently. ### City decomposition ```rust #[test] fn decompose_city_deterministic() { let result1 = decompose_city(&city, &body, 42); let result2 = decompose_city(&city, &body, 42); assert_eq!(result1, result2); } #[test] fn district_count_formula() { assert_eq!(district_count(0, "city", "standard"), 0); assert_eq!(district_count(10_000, "city", "standard"), 1); assert_eq!(district_count(1_000_000, "capital", "standard"), 5); // 4 + 1 capital bonus } ``` Fully unit-testable. No ECS, no file I/O. ### Phase 1 DistrictSkeleton ```rust #[test] fn skeleton_same_seed_identical() { let s1 = generate_skeleton(placement, body, city, 42); let s2 = generate_skeleton(placement, body, city, 42); assert_eq!(s1, s2); // requires PartialEq on DistrictSkeleton — needs real types, not String stubs } #[test] fn block_grid_coverage() { let s = generate_skeleton(placement, body, city, 42); // Every block has a valid ZoningType for row in &s.blocks { for block in row { assert!(block.zoning != ZoningType::Mixed || s.district_type == DistrictType::MixedUse); } } } ``` Requires replacing String stub types with real enums/structs (which is also needed to implement generation). Tests become the specification. ### Phase 2 ChunkData ```rust #[test] fn chunk_has_walkable_tiles() { let data = generate_chunk_tiles(&block, (0, 0), 99); let walkable_count = data.tiles.iter().filter(|t| t.walkable).count(); assert!(walkable_count >= 100, "chunk must have ≥100 walkable tiles for NPC routing"); } #[test] fn chunk_edges_walkable() { // Ensure no chunk boundary is fully blocked (prevents dead zones at chunk seams) let data = generate_chunk_tiles(&block, (0, 0), 99); // at least 4 walkable tiles on each edge } #[test] fn chunk_deterministic() { let d1 = generate_chunk_tiles(&block, (0, 0), 42); let d2 = generate_chunk_tiles(&block, (0, 0), 42); assert_eq!(d1.tiles, d2.tiles); } ``` For visual/rendering validation: a Gauntlet room with a generated district. Run `--test-mode SR_LIVE=1` and walk the tiles manually. Per CLAUDE.md, new room added — existing Gauntlet rooms are not modified. ### End-to-end integration Spawn a player in a generated city. Verify: 1. All chunks within `ChunkLoadRadius` load without panic 2. Player can move (walkability map is non-empty) 3. Second load produces identical tile layout (determinism check) This is a live server test using existing `--test-mode` infrastructure. --- ## Effort Estimates — Revised My Round 1 estimate (9 days) was contaminated by heritage grammar work that is now out of scope. Revised for spatial-only: | Item | Effort | Notes | |------|--------|-------| | Fix WorldTier enum + test updates | 0.5d | One enum change, propagate through compilation errors | | Add missing DistrictSkeleton fields as stubs | 0.5d | Type-only change; no logic | | Replace String stubs with real types (Stages 1-2 scope) | 1d | `DistrictContext`, `DistrictBoundaries`, etc. — enough to implement Stages 1-2 | | City-to-district decomposition | 1d | Pure function, well-defined algorithm, unit-testable | | Server startup: read atlas data → build DistrictMap | 0.5d | Reading systems.db + markers.json; already have DB access patterns | | Phase 1 generation (Stages 1-2 only) | 1.5d | Classification lookup + seeded block grid | | `GeneratorChunkData` type upgrade (Vec → TileEntry grid) | 0.5d | Type change + update `new_walkable()` caller | | Phase 2 minimal tile generation | 1.5d | Floor/wall placement from density, seeded rectangles | | Chunk streaming wiring | 0.5d | `DistrictMap` resource + dispatch in `load_chunk()` | | Integration test (walkable generated city) | 0.5d | Gauntlet room + live test | **Total: ~8 days** — but only ~6 of those are truly sequential (critical path). The type changes and test writing can overlap with algorithm work. **Minimum parallelizable scope:** If two developers were available: - Dev A: Layers 6-7 (city decomp + Phase 1) - Dev B: Layers 8-9 (Phase 2 + streaming wiring, stubbing Phase 1 output) Parallelized: ~4 days wall-clock. ### Reconciling with Gestalt's estimate Gestalt's 5-7 days appears to assume the String stub replacements are free (fold into Phase 1 work) and the startup/DistrictMap wiring is minimal. Both are reasonable simplifications. The key actual divergence: I have explicit line items for the type upgrade (`GeneratorChunkData`) and the server startup path that Gestalt may have assumed as already wired. Neither of us included heritage grammar — that was the bulk of the Round 1 overestimate. Gestalt's floor estimate (~5 days) represents the fastest possible path with no unexpected compilation surprises from the String-to-real-type migrations. My 8 days is the conservative estimate including that friction. True answer is somewhere between: **~6-7 days** under reasonable conditions. --- ## Open Question Resolutions ### OQ-R1-D (Atlas coordinate translation) Resolved: there is no pixel-to-sim-tile formula. The atlas coordinates are UI-only. The walkable world uses city-local coordinates starting at (0, 0). See "Core Architecture Insight" section above. ### OQ-R1-C (City marker schema) Resolved: `primary_function`, `planet_class`, `settlement_pattern` are read from `systems.db` bodies table by cross-reference on `body_id`. `generate_atlas.py` does not need modification. ### OQ-R1-E (Effort estimate) Resolved: ~6-7 days for minimum spatial vertical slice (spatial only, no heritage grammar, no social sites). Conservative estimate is 8 days including type migration friction. ### OQ-R1-F (#615 cascade position) Per lead directive: #615 is NPC territory and out of scope for this workshop. No position taken. --- ## What This Proposal Defers To be explicit about what is NOT in scope for the vertical slice: - Heritage grammar (Phase 2 tile aesthetics beyond floor/wall) - Room templates (rectangular buildings replace proper room grammar) - DistrictSkeleton Stages 3-5 (reservations, social sites, guarantee audit) - Building interiors with furniture or objects - Vertical structure / z-levels beyond ground floor - MobileChunk - NPC placement None of these are needed for the question "can the player walk through a generated district?" They can be layered in after the spatial plumbing exists.