Four-round workshop (Gestalt, Tyre, Paula, Burnelli-Sheldon, Miri) mapping the full generation pipeline from planetary heightmap to walkable tile. 25 D-records produced. Ticket dependency chain for Tier 0-4 implementation identified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
22 KiB
title, description, type, status, workshop, agent, round, created
| title | description | type | status | workshop | agent | round | created |
|---|---|---|---|---|---|---|---|
| Tyre — Round 2: Spatial Generation Architecture Proposal | Technical architecture for the spatial pipeline: coordinate translation, city-to-district decomposition, Phase 1/2 generation, chunk wiring, seed boundary, testing, and effort | workshop | active | generation-cascade | tyre | 2 | 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:
- Rendering the city dot on the planetary map UI
- 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):
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<BodyId, MarkersJson>(or a hash of it for validation)chunk_mutations: BTreeMap<ChunkCoord, ChunkMutations>(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: &strcity: &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:
fn decompose_city(city: &AtlasCity, body: &Body, world_seed: u64) -> Vec<DistrictPlacement> {
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:
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
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 →RegionalorBackwater; transit stops →Passage.ComplexityTier: derived from WorldTier ceiling table (workshop-outcomes.md)SettingType: derived fromplanet_class+economic_role+ citykind. Capital on a standard planet →Urban. Rural body →Agricultural. Orbital station →Station.DistrictType: derived from citykind+ district index within city (center district →CommercialorMixedUse; 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
fn generate_block_grid(district_seed: u64, district_type: &DistrictType,
complexity: &ComplexityTier) -> [[BlockSkeleton; 4]; 4]
DistrictLayoutMode: seeded choice betweenGridandOrganic. Weight toward Grid for Commercial/Administrative, toward Organic for Residential/Mixed. Lead decision L-2: both modes must coexist.- Per block: assign
ZoningTypefrom 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_pctseeded 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 enumVerticalStructure::Flatfor nowbreach_only_zones: Vec<ZoneId>— add asVec<String>, empty for minimum slicederived_analysis: DerivedDistrictAnalysis— add as stub struct, all fieldsNone/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:
- Street tiles (walkable, visually distinct)
- Building exterior tiles (wall, non-walkable)
- Building interior floor tiles (walkable)
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:
// 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<bool> must become a proper struct to carry tile IDs, not just walkability. Minimum:
pub struct GeneratorChunkData {
pub tiles: Vec<TileEntry>, // 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
#[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<ChunkCoord, DistrictId>,
/// The Phase 1 skeleton for each loaded district.
skeletons: BTreeMap<DistrictId, DistrictSkeleton>,
}
Addition 2: Replace load_chunk() placeholder
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
#[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
#[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
#[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:
- All chunks within
ChunkLoadRadiusload without panic - Player can move (walkability map is non-empty)
- 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.