Planet-down cascade workshop (3 rounds, 5 agents): layer-by-layer generation from empty world through population overlay, city planning, and street rendering. Includes consultant review by Troblum. Also commits: pre-Sprint-35 DB backup, Claude Code team-mode tmux test log (team-test.md). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
47 KiB
title, description, type, status, workshop, agent, round, created
| title | description | type | status | workshop | agent | round | created |
|---|---|---|---|---|---|---|---|
| Tyre — Round 2: Algorithm Proposals | Concrete algorithm proposals for all four cascade layers: attractor-matching with geographic plausibility guarantees, four blocker schemas, spatial hierarchy confirmation, three-component district mix in Rust, and background thread architecture. | workshop | active | planet-down-cascade | tyre | 2 | 2026-05-01 |
Planet-Down Cascade — Round 2: Algorithm Proposals
Tyre — Technical Architect
Round 1 framed the problems. Round 2 proposes the concrete algorithms. I'm applying four lead decisions (district mix, spatial rotation, explicit arrangement, threshold cache) as locked facts and moving directly to implementation-level specification.
1. Attractor-Matching Algorithm
The problem from Round 1: Four algorithm proposals (Gestalt, Tyre, Burnelli-Sheldon, Paula) with different structures. Ozzie's hard requirement: no fishing port in the mountains.
My proposal: Hungarian assignment on a scored compatibility matrix, with synthetic attractor fallback.
This synthesizes all four Round 1 proposals into one concrete design. Hungarian instead of greedy because at N ≤ 30 cities it's O(N³) ≈ 27,000 operations — trivially fast — and it maximizes global plausibility rather than locally optimal greedy choices. Paula's priority ordering is preserved via the HQ-lock constraint. Burnelli-Sheldon's comparative advantage framing becomes the score matrix. Ozzie's "no fishing ports in mountains" is enforced through score shape, not binary gates.
Data structures
pub struct GeographicAttractor {
pub position: (u8, u8), // (grid_row, grid_col) in 64×32 regional grid
pub attractor_type: AttractorType,
pub quality_score: f32, // 0.0–1.0; higher = more capable of supporting settlement
pub is_synthetic: bool, // generated by seed when naturals are exhausted
}
pub enum AttractorType {
RiverConfluence,
CoastalHarbor,
MountainPass,
ArablePlain,
ResourceConcentration,
Defensible,
RiverMouth, // CoastalHarbor + RiverConfluence combined
SecondaryRiver, // seed-derived overflow attractor; lower quality
}
pub struct NameReservation {
pub city_name: String,
pub economic_role: EconomicRole,
pub population: u32,
pub political_archetype_override: Option<PoliticalArchetype>,
pub hq_for_corp: Option<CorporationId>,
pub hq_corp_primary_operation: Option<CommodityClass>,
}
pub struct PlacedSettlement {
pub city_name: String,
pub economic_role: EconomicRole,
pub population: u32,
pub position: (u8, u8), // 64×32 grid cell
pub founding_orientation: FoundingOrientation,
pub geographically_triggered: bool, // Paula's flag: false for OrganicGrowth/overflow
pub placement_score: f32, // for debugging / lead review flagging
pub flagged_for_review: bool, // score < 0.15: placement is implausible
}
Compatibility score matrix
This is the operative mechanism for Ozzie's constraint. No entry is zero — even a bad match is possible if the body has no better option — but implausible matches score so low they only happen when all good options are exhausted.
| RiverConf | CoastalHarbor | MtPass | ArablePlain | ResourceConc | Defensible | SecondaryRiver
transit | 0.70 | 0.90 | 0.20 | 0.30 | 0.10 | 0.30 | 0.50
commercial | 0.70 | 0.90 | 0.20 | 0.50 | 0.10 | 0.25 | 0.50
agricultural | 0.60 | 0.50 | 0.10 | 0.90 | 0.10 | 0.20 | 0.40
extraction | 0.40 | 0.30 | 0.70 | 0.10 | 0.90 | 0.40 | 0.40
mining | 0.30 | 0.20 | 0.80 | 0.10 | 0.95 | 0.50 | 0.30
manufacturing | 0.60 | 0.70 | 0.20 | 0.40 | 0.30 | 0.20 | 0.60
research | 0.40 | 0.40 | 0.60 | 0.40 | 0.30 | 0.80 | 0.50
service_mixed | 0.70 | 0.60 | 0.20 | 0.60 | 0.10 | 0.30 | 0.70
frontier | 0.40 | 0.40 | 0.50 | 0.40 | 0.40 | 0.60 | 0.60
energy | 0.30 | 0.40 | 0.40 | 0.20 | 0.70 | 0.30 | 0.40
Reading this table for Ozzie's constraint: Transit and Commercial at MountainPass score 0.20. That's not zero — a small transit waypoint at a pass is historically real. But a major transit hub (large population, high economic_tier) will almost never land there because every other attractor scores higher for transit roles, and the Hungarian algorithm takes the global best assignment. The "fishing port in mountains" failure case requires: (a) no CoastalHarbor or RiverConfluence attractors exist on the body, AND (b) the transit city's population/economic_tier is low enough to land at an overflow position. If that happens, placement_score < 0.25 triggers flagged_for_review = true. Lead gets the flag; the world doesn't silently lie.
HQ override to score matrix:
if res.hq_for_corp.is_some() {
if !is_compatible_corp_operation(res.hq_corp_primary_operation, attr.attractor_type) {
score = 0.0; // hard zero: corporate cross-reference must resolve geographically
}
}
This is the only hard zero. Everything else is a continuous score.
The algorithm
fn assign_settlements_to_attractors(
reservations: &[NameReservation],
attractors: &mut Vec<GeographicAttractor>,
body: &BodyDefinition,
body_seed: u64,
) -> Vec<PlacedSettlement> {
let n_cities = reservations.len();
// Step 1: Augment with synthetic attractors if natural ones are exhausted.
// Synthetics are seeded river-bend or coastal positions — lower quality (0.35–0.45)
// but plausible secondary sites. Manufacturing and service_mixed prefer these
// because they follow infrastructure, not terrain (Burnelli-Sheldon's observation).
if n_cities > attractors.len() {
let needed = n_cities - attractors.len();
generate_synthetic_attractors(attractors, needed, body, body_seed);
}
// Step 2: Build compatibility score matrix [n_cities × n_attractors].
let n_attractors = attractors.len();
let mut scores = vec![0.0f32; n_cities * n_attractors];
for (i, res) in reservations.iter().enumerate() {
for (j, attr) in attractors.iter().enumerate() {
let base = COMPATIBILITY[res.economic_role][attr.attractor_type];
let quality_mult = attr.quality_score; // better attractors score higher
let hq_override = if hq_incompatible(res, attr) { 0.0 } else { 1.0 };
scores[i * n_attractors + j] = base * quality_mult * hq_override;
}
}
// Step 3: Hungarian algorithm on the score matrix.
// Maximizes the sum of assignment scores across all cities.
// At N ≤ 30: O(N³) ≈ 27,000 operations. Negligible.
let assignment = hungarian_maximize(&scores, n_cities, n_attractors);
// Step 4: Derive PlacedSettlement per assignment.
let mut placements = Vec::with_capacity(n_cities);
for (city_idx, attr_idx) in assignment.iter().enumerate() {
let score = scores[city_idx * n_attractors + attr_idx];
let attr = &attractors[*attr_idx];
let res = &reservations[city_idx];
placements.push(PlacedSettlement {
city_name: res.city_name.clone(),
economic_role: res.economic_role,
population: res.population,
position: attr.position,
founding_orientation: derive_founding_orientation(attr.attractor_type),
geographically_triggered: score >= 0.15,
placement_score: score,
flagged_for_review: score < 0.15,
});
}
placements
}
Synthetic attractor generation
fn generate_synthetic_attractors(
attractors: &mut Vec<GeographicAttractor>,
needed: usize,
body: &BodyDefinition,
seed: u64,
) {
// Synthetics are seeded positions that represent secondary settlements:
// river bends, coastal plains not quite dramatic enough to be CoastalHarbor,
// flat terrain along existing attractor corridors.
// Quality range: 0.35–0.45 (below all natural attractors, above 0.30 floor).
for i in 0..needed {
let s = child_seed(seed, 0xDEAD_CAFE ^ (i as u64));
let (row, col) = synthetic_position(attractors, body, s);
attractors.push(GeographicAttractor {
position: (row, col),
attractor_type: AttractorType::SecondaryRiver,
quality_score: 0.35 + (rng_01(s) * 0.10),
is_synthetic: true,
});
}
}
fn synthetic_position(
existing: &[GeographicAttractor],
body: &BodyDefinition,
seed: u64,
) -> (u8, u8) {
// Place synthetic near a natural attractor (within 4 cells) but not on top of it.
// If no natural attractors exist (extreme edge case), place on navigable land cell.
let anchor = existing[rng_range(seed, 0, existing.len()) % existing.len()];
let dr = (rng_range(child_seed(seed, 1), 0, 9) as i32) - 4;
let dc = (rng_range(child_seed(seed, 2), 0, 9) as i32) - 4;
let row = (anchor.position.0 as i32 + dr).clamp(0, 31) as u8;
let col = (anchor.position.1 as i32 + dc).clamp(0, 63) as u8;
(row, col)
}
FoundingOrientation derivation
fn derive_founding_orientation(attr_type: AttractorType) -> FoundingOrientation {
match attr_type {
AttractorType::CoastalHarbor | AttractorType::RiverMouth => FoundingOrientation::PortFacing,
AttractorType::Defensible => FoundingOrientation::DefenseFacing,
AttractorType::ResourceConcentration => FoundingOrientation::ExtractionFacing,
AttractorType::MountainPass => FoundingOrientation::CorridorFacing,
AttractorType::ArablePlain => FoundingOrientation::AgrarianFacing,
AttractorType::RiverConfluence => FoundingOrientation::RiverFacing,
AttractorType::SecondaryRiver | AttractorType::NaturalBarrier => {
FoundingOrientation::AdminFacing // OrganicGrowth default (Paula NEW-Q2)
}
}
}
OrganicGrowth disambiguation (NEW-Q2)
Paula's geographically_triggered: bool flag resolves this cleanly:
geographically_triggered = true: settlement landed at a natural attractor with score ≥ 0.15.FoundingOrientationderived from attractor type.geographically_triggered = false: overflow placement at synthetic position, OR score < 0.15 despite assignment.FoundingOrientation = AdminFacing. This is what OrganicGrowth looks like — the settlement exists because people were there, not because a river or resource brought them.
The flag is set at Layer 2 generation time and never changes. Confirmed as the right mechanism.
2. Four Technical Blockers — Concrete Schemas
ARCH-1: Heightmap Storage
CREATE TABLE IF NOT EXISTS atlas_body_heightmaps (
body_id TEXT PRIMARY KEY REFERENCES bodies(body_id),
grid_width INTEGER NOT NULL DEFAULT 512,
grid_height INTEGER NOT NULL DEFAULT 256,
-- float32 little-endian, row-major, grid_height × grid_width values
-- row 0 = north pole; col 0 = 180°W
elevation_f32_le BLOB NOT NULL,
sea_level REAL NOT NULL, -- elevation threshold; below = surface water
body_seed INTEGER NOT NULL -- simulation seed (for regression validation)
);
Size calculation:
- 512 × 256 × 4 bytes = 512KB per body
- ~400 inhabited bodies × 512KB = ~200MB total
- SQLite handles this fine; no application-layer compression needed
Write path (Python, in generate_atlas.py after simulation):
import struct
def store_heightmap(conn, body_id, terrain, body_seed):
elev = terrain["elevation"].astype(">f4") # native float32
blob = elev.tobytes() # raw LE bytes on x86
conn.execute(
"INSERT OR REPLACE INTO atlas_body_heightmaps "
"(body_id, elevation_f32_le, sea_level, body_seed) VALUES (?,?,?,?)",
(body_id, blob, float(terrain["sea_level"]), body_seed)
)
Read path (Rust, in Layer 1 background generation):
pub struct HeightmapData {
pub width: usize,
pub height: usize,
pub elevation: Vec<f32>, // row-major, [row * width + col]
pub sea_level: f32,
}
fn load_heightmap(conn: &Connection, body_id: &str) -> Result<HeightmapData> {
let (w, h, blob, sea_level): (i64, i64, Vec<u8>, f64) = conn.query_row(
"SELECT grid_width, grid_height, elevation_f32_le, sea_level
FROM atlas_body_heightmaps WHERE body_id = ?1",
[body_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)?;
let floats: Vec<f32> = blob
.chunks_exact(4)
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
.collect();
Ok(HeightmapData {
width: w as usize,
height: h as usize,
elevation: floats,
sea_level: sea_level as f32,
})
}
Loading 512KB from a local SQLite file: ~5ms. Within the performance budget.
ARCH-2: BodyWorldState Design
The key revision from Round 1: do not store the full 512×256 drainage accumulation grid in the cache. Run it at full resolution during generation; keep only the derived 64×32 regional summary. This brings per-body memory from ~750KB down to ~220KB.
// session-only — never serialized, fully reproducible from systems.db + world_seed
pub struct BodyWorldState {
pub body_id: String,
pub generation_seed: u64,
pub status: GenerationStatus,
// Layer 1 (coarse summary only — full resolution discarded after generation)
pub regional_cells: Vec<RegionalCell>, // 64×32 = 2048 cells
// Layer 2
pub settlements: Vec<PlacedSettlement>, // active + latent
pub road_graph: RoadGraph,
pub territorial_grid: Vec<TerritorialStatus>, // 2048 entries, one per cell
// Layer 3 (populated lazily when player approaches a city)
pub city_contexts: HashMap<SettlementIndex, CityGenerationContext>,
}
pub struct RegionalCell {
pub grid_row: u8,
pub grid_col: u8,
pub biome_class: u8,
pub sub_biome_variant: u8, // 0-3
pub terrain_roughness: f32,
pub terrain_modification_cost: f32,
pub is_coastal: bool,
pub water_fraction: f32,
pub river_flow_fraction: f32, // 0.0 = no river; 1.0 = major river (from D8 summary)
pub geographic_feature: Option<GeographicFeatureTag>,
pub land_use: LandUse, // set by Layer 2
}
pub enum GeographicFeatureTag {
RiverConfluence,
CoastalHarbor,
MountainPass,
ArablePlain,
ResourceConcentration,
Defensible,
NaturalBarrier,
}
pub enum LandUse {
Urban, Agricultural, Industrial, Wilderness,
Corridor, Ruins, Ocean, Impassable,
}
pub enum TerritorialStatus {
CoreTerritory,
FrontierTerritory,
ExtractiveZone,
ContestZone,
WildernessBuffer,
AbandonedZone,
Ocean,
Impassable,
}
Cache structure (Bevy Resource):
pub struct GenerationCache {
pub bodies: HashMap<String, BodyWorldState>,
pub access_order: VecDeque<String>, // LRU tracking
pub max_cached: usize, // default 50 bodies
}
impl GenerationCache {
pub fn insert(&mut self, body_id: String, state: BodyWorldState) {
if self.bodies.len() >= self.max_cached {
// Evict least recently used
if let Some(evicted) = self.access_order.pop_front() {
self.bodies.remove(&evicted);
}
}
self.access_order.push_back(body_id.clone());
self.bodies.insert(body_id, state);
}
pub fn get(&mut self, body_id: &str) -> Option<&BodyWorldState> {
if self.bodies.contains_key(body_id) {
// Refresh LRU position
self.access_order.retain(|id| id != body_id);
self.access_order.push_back(body_id.to_string());
}
self.bodies.get(body_id)
}
}
Memory estimate (50 bodies):
- RegionalCell: 2048 × ~36 bytes = ~73KB per body
- PlacedSettlement: 30 × ~128 bytes = ~4KB
- RoadGraph: ~100 nodes × 64B + ~200 edges × 96B = ~26KB
- TerritorialStatus: 2048 × 1 byte = 2KB
- Total: ~105KB per body × 50 = ~5MB total cache
5MB is negligible. The LRU cap of 50 can be raised substantially with no memory concern.
ARCH-3: atlas_city_names Schema
This replaces the authored-position role of atlas_cities. The atlas_cities table continues to exist and is populated at build time by generate_atlas.py for the atlas UI, but the source of truth for "what cities must exist" is now atlas_city_names.
CREATE TABLE IF NOT EXISTS atlas_city_names (
name_id TEXT PRIMARY KEY, -- "{body_id}/{city_local_id}"
body_id TEXT NOT NULL REFERENCES bodies(body_id),
city_local_id TEXT NOT NULL, -- slug from Gemma naming pipeline
city_name TEXT NOT NULL,
population INTEGER NOT NULL,
-- economic_role: NULL means inherit from body; override for cities with distinct role
economic_role TEXT,
-- political_archetype: NULL = runtime-derived from geography + economics
political_archetype_override TEXT,
prosperity_override REAL, -- NULL = runtime-derived
hq_for_corp TEXT REFERENCES corporations(corp_id),
hq_corp_primary_operation TEXT, -- commodity class; needed for attractor compat check
is_capital INTEGER NOT NULL DEFAULT 0,
UNIQUE(body_id, city_local_id)
);
CREATE INDEX IF NOT EXISTS idx_city_names_body ON atlas_city_names(body_id);
Rust load query:
fn load_name_reservations(
conn: &Connection,
body_id: &str,
) -> Result<Vec<NameReservation>> {
let mut stmt = conn.prepare(
"SELECT city_name, COALESCE(economic_role, b.economic_role), population,
political_archetype_override, hq_for_corp, hq_corp_primary_operation
FROM atlas_city_names n
JOIN bodies b ON n.body_id = b.body_id
WHERE n.body_id = ?1
ORDER BY population DESC"
)?;
// ... map rows to NameReservation structs
}
What happens to atlas_cities:
atlas_cities continues to hold the positions that generate_atlas.py outputs — but those positions are now the OUTPUT of the runtime generator (stored there for atlas UI display), not the INPUT. This is a reversal of data flow: the generator reads from atlas_city_names, writes to atlas_cities (or equivalently, the atlas UI reads from BodyWorldState.settlements at runtime). Positions in atlas_cities become a materialized view, not a source of truth.
For Phase 3 of the cascade roadmap, atlas_cities can be repopulated offline by running a headless generation pass on all bodies. For Phase 5, the runtime generator produces positions on-demand.
ARCH-4: Body Physical Size Field
After review of generate_atlas.py, the bodies table does NOT currently have a radius field. The wiki frontmatter has surface gravity and planet class but not radius.
Schema addition:
ALTER TABLE bodies ADD COLUMN body_radius_km REAL;
-- NULL acceptable; Rust reads with fallback by planet_class
Python population (in import_economics.py or generate_atlas.py):
Wiki authors should provide body_radius_km in frontmatter. For bodies without it, a planet_class-based default applies in Rust.
Rust area-count with fallback:
fn area_count_for_body(body: &BodyRecord) -> u8 {
let radius_km = body.body_radius_km.unwrap_or_else(|| {
// Fallback by planet_class — rough but adequate for area-count purposes
match body.planet_class.as_str() {
c if c.contains("super_terrestrial") => 9_000.0,
c if c.contains("terrestrial") || c.contains("garden") => 5_500.0,
c if c.contains("moon") => 1_200.0,
c if c.contains("dwarf") => 400.0,
"station" | "orbital_only" => return 1, // stations: 1 area always
_ => 3_000.0,
}
});
let surface_area_km2 = 4.0 * std::f64::consts::PI * (radius_km as f64).powi(2);
let count = (surface_area_km2 / 6_000_000.0_f64).sqrt().round() as u8;
count.max(1)
}
Planet-class fallbacks cover all cases that aren't stations. Wiki authors adding body_radius_km for any body improves that body's area count accuracy.
3. Spatial Hierarchy Dimensions — Confirmed with One Correction
The correction from Round 1
Round 1 described Province as "a drainage basin on the 64×32 grid." Paula's vocabulary mapping (Round 1 notes §6) correctly identified that the 64×32 grid cell IS the Province-level unit. These are compatible: a Province is one 64×32 regional cell. Province boundaries are the cell edges, which naturally align with the biome transition points baked into the downsampling.
Watershed analysis to find province boundaries is therefore not needed — the 64×32 grid already provides the Province grid. This simplifies the implementation significantly.
Confirmed dimension table
| Tier | Name | Base unit | Physical scale (ref. body, r=5500km) |
|---|---|---|---|
| 0 | Chunk | 64×64 sim tiles | ~32m × 32m |
| 1 | Block | 128×128 sim tiles | ~64m × 64m |
| 2 | District | 512×512 sim tiles | ~256m × 256m |
| 3 | Region | city district bounding box | ~0.5–3km (varies by city size) |
| 4 | Province | 1 cell on the 64×32 regional grid | ~540km × 270km |
| 5 | Area | K contiguous same-class cells | ~1000–5000km across |
| 6 | Body | full planetary surface | ~35,000km circumference |
| 7 | System | star system | — |
Reference body calibration: radius 5500km → circumference ≈ 34,600km → 512 pixels → ~67km/pixel at equator → regional cell (8×8 pixels) ≈ 540km × 270km.
Province count: 64×32 = 2048 total cells per body; roughly 600-800 inhabited (non-ocean, non-ice) depending on the body. Manageable.
Area count (body-size formula, from ARCH-4):
area_count = round(sqrt(surface_area_km2 / 6_000_000))
- r=5500km (reference): area_count ≈ 7
- r=500km (small moon): area_count ≈ 1
- r=10,000km (super-terrestrial): area_count ≈ 14
Region (tier 3) clarification: Region is a semantic tier for the "city + hinterland" concept, not a fixed grid cell. A Region's spatial extent is computed from CityGenerationContext.footprint_radius_km and is not stored — it's a lookup concept used in TerritorialStatus derivation and atlas UI. Tiers 0-4 are the generation grid hierarchy; Region is conceptual.
Sim-tile physical scale: 0.5m/sim-tile. Confirmed from prior workshop: "District spans ~256m" at 512 sim tiles → 0.5m/tile. A 4-tile street corridor = 2m (narrow alley); a 12-tile boulevard = 6m (comfortable road). Playable.
4. Three-Component District Mix — Rust Implementation
founding_age_years derivation
founding_age_years is not currently in systems.db. Options: (a) authored wiki field, (b) derived from WorldTier + economic_tier.
My recommendation: derive with authored override. Wiki authors shouldn't need to provide founding years for every settlement; the derivation is good enough for the age modifier's purpose (character, not structure). Add a founding_age_years_override column to atlas_city_names for the rare case where lore demands specificity.
fn derive_founding_age_years(
world_tier: WorldTier,
economic_tier: u8,
seed: u64,
) -> u32 {
let (base_min, base_max): (u32, u32) = match world_tier {
WorldTier::Epicenter => (150, 400),
WorldTier::Regional => (80, 200),
WorldTier::Backwater => (30, 100),
WorldTier::Passage => (20, 60),
WorldTier::Waypoint => (5, 20),
};
// Wealthier economies tend to be more established
let tier_bonus = (economic_tier.saturating_sub(1) as u32) * 20;
let variation = (rng_01(seed) * (base_max - base_min) as f32) as u32;
base_min + tier_bonus.min(base_max - base_min) + variation
}
BS-Q2 resolution: age modifier only activates at Town tier (population ≥ 1,000) and above. A Waypoint's founding age doesn't meaningfully increase its service variety.
Revised weight table (min 0.2 enforced)
The original 10×9 table had zeros. Every cell in the revised table has a floor of 0.2. This is Burnelli-Sheldon's non-negotiable: economic role modifies proportion, not presence.
| economic_role | Res | Com | Ind | Adm | Log | Ent | Mix | Trn | Spe |
|---|---|---|---|---|---|---|---|---|---|
| manufacturing | 0.80 | 0.40 | 1.00 | 0.40 | 0.80 | 0.20 | 0.40 | 0.40 | 0.20 |
| agricultural | 0.90 | 0.60 | 0.30 | 0.50 | 0.80 | 0.20 | 0.50 | 0.30 | 0.20 |
| extraction | 0.70 | 0.30 | 0.90 | 0.30 | 0.90 | 0.20 | 0.30 | 0.40 | 0.20 |
| transit | 0.50 | 0.70 | 0.30 | 0.40 | 0.80 | 0.40 | 0.60 | 1.00 | 0.20 |
| research | 0.70 | 0.30 | 0.30 | 0.70 | 0.30 | 0.30 | 0.50 | 0.20 | 1.00 |
| commercial | 0.60 | 1.00 | 0.30 | 0.40 | 0.50 | 0.60 | 0.60 | 0.20 | 0.20 |
| service_mixed | 0.80 | 0.70 | 0.30 | 0.50 | 0.30 | 0.60 | 0.80 | 0.20 | 0.20 |
| mining | 0.70 | 0.30 | 0.80 | 0.20 | 1.00 | 0.20 | 0.50 | 0.40 | 0.20 |
| frontier | 1.00 | 0.40 | 0.40 | 0.30 | 0.70 | 0.20 | 0.60 | 0.20 | 0.20 |
| energy | 0.50 | 0.20 | 0.70 | 0.30 | 0.90 | 0.20 | 0.30 | 0.40 | 0.80 |
Reading mining/Ent=0.20: This is the floor, not zero. A mining town with 4+ districts will draw from all 9 DistrictType bins, with Entertainment least likely but not impossible. The guarantee tier doesn't need to add Entertainment because the weight table already makes it reachable at larger settlements.
BS-Q3 (energy/Ent=0.20 may not produce dedicated Entertainment districts): Correct observation. At small district counts, 0.20 weight against higher competitors means Entertainment might never win a slot from the weight table. The guarantee tier handles it: energy cities above 10,000 population get the Entertainment guarantee regardless. Below 10,000, their entertainment is part of MixedUse (single-district settlement). This is correct behavior: a small energy extraction outpost doesn't have a dedicated entertainment district; a large energy hub does.
The Rust implementation
pub fn generate_district_type_distribution(
city: &CityGenerationContext,
founding_age_years: u32,
rng: &mut SimRng,
) -> Vec<DistrictType> {
let district_count = city.district_count as usize;
// --- Single-district settlements: always MixedUse ---
// The three-component model governs multi-district distribution only.
// A single-district settlement has all service types coexisting in miniature.
if district_count == 1 {
return vec![DistrictType::MixedUse];
}
// --- Component 1: Population tier guarantees ---
// Guarantees are satisfied first; they cannot be displaced by the weight table.
let mut guaranteed: Vec<DistrictType> = vec![DistrictType::Residential];
if city.population >= 1_000 {
guaranteed.push(DistrictType::Commercial);
}
if city.population >= 10_000 {
guaranteed.push(DistrictType::Entertainment);
}
if city.population >= 50_000
|| matches!(city.world_tier, WorldTier::Epicenter | WorldTier::Regional) {
guaranteed.push(DistrictType::Administrative);
}
// Self-contained: any settlement with road access gets at least one Transit node
// (bus station, road terminus, gate hub). Only if we have room.
if !city.road_entry_directions.is_empty()
&& !guaranteed.contains(&DistrictType::Transit)
&& guaranteed.len() < district_count
{
guaranteed.push(DistrictType::Transit);
}
// Cap guarantees to district_count (guarantees cannot exceed district count)
guaranteed.truncate(district_count);
// --- Component 2: Economic role weight table ---
let remaining_slots = district_count - guaranteed.len();
let weights = get_economic_role_weights(city.economic_role, city.political_archetype);
let mut additional: Vec<DistrictType> = (0..remaining_slots)
.map(|_| weighted_sample_district(&weights, rng))
.collect();
// --- Component 3: Founding age modifier ---
// Age adds variety: old settlements have accumulated more diverse infrastructure.
// Only at Town tier (1000+) per BS-Q2.
let age_variety = if city.population >= 1_000 && founding_age_years > 100
&& !matches!(city.world_tier, WorldTier::Waypoint) {
true
} else {
false
};
if age_variety && !additional.is_empty() {
// Find the dominant district type in `additional` and replace
// one occurrence with a less-represented type (increases variety).
promote_district_variety(&mut additional, &weights, rng);
}
// Merge and return
let mut all = guaranteed;
all.extend(additional);
all
}
// Weights lookup: applies political_archetype modifier on top of economic_role weights.
// CompanyTown: multiply dominant type by 1.5, others by 0.8.
// AdminCapital: Administrative × 1.5, Residential × 1.2.
// FreePort: MixedUse × 1.5, Transit × 1.3, flatten all by 0.9.
// Others: identity modifier.
fn get_economic_role_weights(
role: EconomicRole,
archetype: PoliticalArchetype,
) -> [f32; 9] {
let mut w = ECONOMIC_ROLE_WEIGHTS[role];
apply_archetype_modifier(&mut w, archetype);
w
}
Explicit spatial arrangement for political archetypes (L3-Q2, locked):
pub fn arrange_district_grid(
district_types: &[DistrictType],
archetype: PoliticalArchetype,
founding_orientation: FoundingOrientation,
grid_width: u8,
grid_height: u8,
) -> Vec<DistrictPlacement> {
match archetype {
PoliticalArchetype::CompanyTown => {
// Spine: dominant industrial/logistics type at one end,
// Residential fills behind it. FoundingOrientation determines which end.
spine_layout(district_types, founding_orientation, grid_width, grid_height)
}
PoliticalArchetype::AdminCapital => {
// Radial: Administrative forced to center (or closest-to-center position).
// Residential radiates outward. Prosperity gradient: center = high.
radial_layout(district_types, founding_orientation, grid_width, grid_height)
}
PoliticalArchetype::FreePort => {
// Multi-node: Transit and Commercial at all 4 corners.
// No single center. MixedUse fills interior.
multi_node_layout(district_types, founding_orientation, grid_width, grid_height)
}
PoliticalArchetype::Contested => {
// Two spatial clusters: districts 0..N/2 for faction A,
// N/2..N for faction B, meeting at a ContestZone seam.
// FoundingOrientation points at the seam.
contested_layout(district_types, founding_orientation, grid_width, grid_height)
}
PoliticalArchetype::OrganicGrowth => {
// Irregular: no fixed arrangement pattern. Districts assigned
// by local density weighting from FNV-1a seed. FoundingOrientation
// biases which quadrant has historically higher prosperity.
organic_layout(district_types, founding_orientation, grid_width, grid_height)
}
}
}
This is ~5 layout functions, each 15-20 lines. Total: ~100 lines of Rust. Matches my Round 1 estimate of 30-40 lines for three archetypes; Paula's request for all five adds ~60 more lines, still trivial.
FoundingOrientation spatial grid rotation (L3-Q1, locked):
fn orientation_to_edge(orientation: FoundingOrientation) -> CardinalDirection {
match orientation {
FoundingOrientation::PortFacing => CardinalDirection::South, // water is south edge
FoundingOrientation::ExtractionFacing => CardinalDirection::North, // resource is north
FoundingOrientation::DefenseFacing => CardinalDirection::any_elevated(), // terrain-driven
FoundingOrientation::CorridorFacing => CardinalDirection::East, // road entry
FoundingOrientation::AgrarianFacing => CardinalDirection::South, // farmland south
FoundingOrientation::RiverFacing => CardinalDirection::West, // upstream = west
FoundingOrientation::AdminFacing => CardinalDirection::Center, // no orientation
}
}
The spine_layout, radial_layout etc. receive the orientation and rotate the district grid so the "face" of the city points in the founding direction. For PortFacing: Transit + LogisticsHub districts at the southern edge (water side), Residential fills north. Player approaching from sea sees the harbor face.
5. Background Thread Architecture
Thread pool vs. async
CPU-bound work (drainage, A*): thread pool via rayon. Async (tokio) is for I/O-bound work; the generation pipeline is compute-bound. One rayon::ThreadPool with num_cpus::get().saturating_sub(1) threads.
SQLite reads: each background thread opens its own Connection to systems.db (SQLite WAL mode supports concurrent readers). No connection pool needed at this scale.
Priority queue design
#[derive(Eq, PartialEq)]
pub enum GenerationPriority {
Immediate = 0, // player's current body — handled SYNCHRONOUSLY at session start
High = 1, // body referenced in player-facing text (news, dialogue, mission)
Medium = 2, // gate-adjacent bodies, spidering outward
Low = 3, // breadth-first background fill
}
#[derive(Eq, PartialEq)]
pub struct GenerationRequest {
pub body_id: String,
pub priority: GenerationPriority,
pub requested_at_tick: u64,
}
// Ordering: lowest priority value = highest urgency
// Within same priority: earliest request first (FIFO)
impl Ord for GenerationRequest {
fn cmp(&self, other: &Self) -> Ordering {
(self.priority as u8)
.cmp(&(other.priority as u8))
.then(self.requested_at_tick.cmp(&other.requested_at_tick))
}
}
// The queue is a BinaryHeap<Reverse<GenerationRequest>> (min-heap by urgency)
pub struct GenerationQueue {
inner: BinaryHeap<Reverse<GenerationRequest>>,
in_flight: HashSet<String>, // body_ids currently being generated
completed: HashSet<String>, // body_ids already in cache (skip check optimization)
}
impl GenerationQueue {
pub fn push(&mut self, req: GenerationRequest) {
if self.completed.contains(&req.body_id) { return; }
if self.in_flight.contains(&req.body_id) { return; }
self.inner.push(Reverse(req));
}
pub fn pop(&mut self) -> Option<GenerationRequest> {
self.inner.pop().map(|Reverse(r)| {
self.in_flight.insert(r.body_id.clone());
r
})
}
pub fn mark_complete(&mut self, body_id: &str) {
self.in_flight.remove(body_id);
self.completed.insert(body_id.to_string());
}
}
The in_flight set prevents the same body being dispatched to multiple threads simultaneously. completed prevents re-queueing bodies already cached.
Worker architecture
pub fn launch_generation_workers(
db_path: PathBuf,
world_seed: u64,
queue: Arc<Mutex<GenerationQueue>>,
cache: Arc<RwLock<GenerationCache>>,
) {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(num_cpus::get().saturating_sub(1).max(1))
.thread_name(|i| format!("world-gen-{i}"))
.build()
.expect("failed to build generation thread pool");
// Coordinator: one lightweight thread that pulls from queue and dispatches to pool
std::thread::Builder::new()
.name("world-gen-coordinator".to_string())
.spawn(move || {
loop {
let request = {
let mut q = queue.lock().unwrap();
q.pop()
};
let Some(req) = request else {
// Queue empty — park briefly
std::thread::sleep(Duration::from_millis(50));
continue;
};
let (queue, cache, db_path) = (
Arc::clone(&queue),
Arc::clone(&cache),
db_path.clone(),
);
pool.spawn(move || {
let result = generate_body(&db_path, &req.body_id, world_seed);
match result {
Ok(state) => {
cache.write().unwrap().insert(req.body_id.clone(), state);
}
Err(e) => {
tracing::error!("generation failed for {}: {e}", req.body_id);
}
}
queue.lock().unwrap().mark_complete(&req.body_id);
});
}
})
.expect("failed to spawn coordinator thread");
}
Immediate priority: synchronous on session start
The player's current body cannot go to the background queue — the world must be ready before the first frame renders. This is handled synchronously in the Bevy startup system:
pub fn world_generation_startup(
db: Res<DatabaseConnection>,
world_seed: Res<WorldSeed>,
current_body: Res<PlayerCurrentBody>,
mut cache: ResMut<GenerationCache>,
mut queue: ResMut<GenerationQueue>,
) {
// Block on current body generation — ~133ms, imperceptible.
// UI shows "entering system..." diegetic loading frame during this time.
let state = generate_body(
&db.path,
¤t_body.body_id,
world_seed.seed,
).expect("current body generation failed — DB may be corrupt");
cache.insert(current_body.body_id.clone(), state);
// Queue background generation of gate-adjacent bodies (Medium priority).
for adj_body_id in current_body.gate_adjacent_bodies.iter() {
queue.push(GenerationRequest {
body_id: adj_body_id.clone(),
priority: GenerationPriority::Medium,
requested_at_tick: 0,
});
}
// Queue all remaining bodies at Low priority (breadth-first fill).
for body_id in db.all_inhabited_body_ids() {
queue.push(GenerationRequest {
body_id,
priority: GenerationPriority::Low,
requested_at_tick: 0,
});
}
}
Event-driven trigger for player-facing text
This is the mechanism for "by the time the player reads the sentence, the cascade is done."
// A lightweight name index built at startup from systems.db
// (all city names, body names → body_id)
pub struct SystemNameIndex {
// Aho-Corasick automaton for multi-pattern substring search
// At startup: build from ~5000-10000 names. Build time: ~5ms.
automaton: AhoCorasick,
patterns: Vec<String>,
body_ids: Vec<String>, // parallel to patterns: pattern[i] → body_ids[i]
}
impl SystemNameIndex {
pub fn scan(&self, text: &str) -> impl Iterator<Item = &str> {
self.automaton
.find_iter(text)
.map(|m| self.body_ids[m.pattern()].as_str())
}
}
// Bevy system: runs whenever player-facing text is produced
pub fn text_reference_detector(
mut text_events: EventReader<PlayerFacingTextEvent>,
name_index: Res<SystemNameIndex>,
cache: Res<GenerationCache>,
mut queue: ResMut<GenerationQueue>,
time: Res<SimulationTime>,
) {
for event in text_events.read() {
for body_id in name_index.scan(&event.content) {
if !cache.bodies.contains_key(body_id) {
queue.push(GenerationRequest {
body_id: body_id.to_string(),
priority: GenerationPriority::High,
requested_at_tick: time.tick,
});
}
}
}
}
PlayerFacingTextEvent is emitted by all text-producing systems: news ticker, dialogue display, mission briefing, corporate records viewer. The name detector runs before the text is displayed — by the time the player sees the name, the generation request is queued. At ~133ms generation time and background execution, the cache is populated well before the player can navigate to the referenced system.
Aho-Corasick justification: Single-pass multi-pattern substring search. One pass through a news ticker body with 5000 known names: O(|text| + total_match_length). For a 500-character ticker line, ~500 hash comparisons. Negligible. The aho-corasick crate is a standard Rust dependency.
6. TerritorialStatus — Reconciled Thresholds
Three proposals from Round 1 (Paula, Tyre, Burnelli-Sheldon). Reconciling to a single algorithm:
pub fn derive_territorial_status(
cell: &RegionalCell,
settlements: &[PlacedSettlement],
road_graph: &RoadGraph,
) -> TerritorialStatus {
// Priority order (first match wins)
// 1. Ocean / Impassable — terrain gates
if cell.water_fraction > 0.60 {
return TerritorialStatus::Ocean;
}
if cell.biome_class == BiomeClass::IceSheet || cell.terrain_modification_cost > 0.95 {
return TerritorialStatus::Impassable;
}
// 2. AbandonedZone — settlement placed at generation but corps departed
// Paula's `placed_at_generation` flag distinguishes from "never settled"
let placed_here = settlements.iter().find(|s| {
s.position == (cell.grid_row, cell.grid_col) && !s.is_active
});
if placed_here.is_some() {
return TerritorialStatus::AbandonedZone;
}
// 3. CoreTerritory — active settlement within footprint + road coverage
let nearest_active = nearest_active_settlement(cell, settlements);
if let Some((s, dist_cells)) = nearest_active {
let footprint_radius_cells = city_footprint_in_cells(s.population);
if dist_cells <= footprint_radius_cells
&& road_within_n_cells(cell, road_graph, 2)
&& matches!(s.population, p if p >= 1_000) {
return TerritorialStatus::CoreTerritory;
}
// 4. ExtractiveZone — ResourceConcentration + corporate road
if cell.geographic_feature == Some(GeographicFeatureTag::ResourceConcentration)
&& road_within_n_cells(cell, road_graph, 3)
&& nearest_corporate_road_maintenance(cell, road_graph).is_some() {
return TerritorialStatus::ExtractiveZone;
}
// 5. ContestZone — two CoreTerritory claims from different archetypes
let competing = settlements.iter().filter(|s2| {
s2.is_active
&& s2.city_name != s.city_name
&& dist_to_cell(s2, cell) <= city_footprint_in_cells(s2.population)
&& s2.political_archetype != s.political_archetype
}).count();
if competing >= 1 {
return TerritorialStatus::ContestZone;
}
// 6. FrontierTerritory — settlement exists nearby but not CoreTerritory conditions
if dist_cells <= 5 || road_within_n_cells(cell, road_graph, 5) {
return TerritorialStatus::FrontierTerritory;
}
}
// 7. WildernessBuffer — nothing within range
TerritorialStatus::WildernessBuffer
}
Key decisions in this reconciliation:
- AbandonedZone requires Paula's
placed_at_generationflag. Without it, a WildernessBuffer cell that an abandoned settlement once occupied looks identical to a never-settled one. The flag is set at Layer 2 generation and never changes. - ExtractiveZone condition uses
road_maintenance == Corporatefrom the road graph — Burnelli-Sheldon's most concrete threshold, adopted. - ContestZone requires at least two active settlements with different
political_archetypeAND overlapping footprints — cleaner than Paula's "road authority type" (two Corporate roads don't create a contest), cleaner than Tyre's "2 cells" (footprint-based overlap is more accurate). - FrontierTerritory threshold: 5 cells from settlement OR 5 cells from any road. Paula's "road coverage ≥ 0.6" is hard to compute at regional grid resolution; distance-from-road is equivalent and computable.
7. Performance — Updated Estimates
All operations above are for a single body. Background thread: one body at a time per thread.
| Operation | Notes | Estimate |
|---|---|---|
| Load heightmap BLOB from SQLite | 512KB read | ~5ms |
| D8 priority-flood drainage (512×256) | Rust, in-place | ~50ms |
| River centerline extraction + 64×32 summary | Discard full grid after | ~20ms |
| Geographic attractor extraction | 64×32 scan | ~5ms |
| Regional cell tagging (sub-biome, feature tags) | 2048 cells | ~3ms |
| Load name reservations from atlas_city_names | ~30 rows | ~2ms |
| Hungarian assignment (N=30, M=100) | O(N³) ≈ 27K ops | <1ms |
| Synthetic attractor generation (if needed) | seed-derived | <1ms |
| A* road routing (MST on 64×32 grid) | N=30 nodes | ~30ms |
| Sub-settlement placement | mining camps, waypoints | ~5ms |
| TerritorialStatus propagation | 2048 cells × N settlements | ~15ms |
| Total | ~136ms |
136ms per body on background thread. With 4 cores (3 worker threads): ~45ms average throughput. 400 inhabited bodies would fully fill the cache in ~18 seconds of background time if the player sat still. In practice the queue drains to completion long before the player visits most bodies.
8. Open Items After Round 2
Resolved by Round 2 (no further design needed)
| Item | Resolution |
|---|---|
| Attractor-matching algorithm | Hungarian on compatibility matrix; synthetic overflow; mismatch flag |
| NEW-Q2 (OrganicGrowth) | geographically_triggered: bool; false → AdminFacing (confirmed) |
| ARCH-1 (heightmap storage) | atlas_body_heightmaps table, float32 LE BLOB |
| ARCH-2 (BodyWorldState) | Bevy Resource; LRU 50 bodies; ~5MB total; no serialization |
| ARCH-3 (atlas_city_names) | New table schema; replaces authored position role of atlas_cities |
| ARCH-4 (body size) | body_radius_km column; planet_class fallbacks in Rust |
| Spatial hierarchy dimensions | Province = 1 regional cell; confirmed dimension table |
| Three-component district mix | Concrete Rust implementation above |
| founding_age_years | Derived from WorldTier + economic_tier; optional authored override |
| Background thread architecture | rayon pool + coordinator thread + Bevy event trigger |
| TerritorialStatus thresholds | Reconciled single algorithm above |
Still open for Round 3
| Item | Notes |
|---|---|
atlas_city_names population path |
How does this table get populated at build time? From existing wiki naming pipeline or new work? |
| L4-Q2 threshold values | 0.63/0.43/0.23 (Paula, avoids oscillation) vs. 0.60/0.40/0.20 (Gestalt, round numbers). Minor — pick one. |
| L4-Q5 (scatter) | Ozzie's "not optional" vs. brief deferral. Min viable question, not algorithm question. |
| L3-Q7 (port/station direction) | Paula designed the mechanism; Ozzie backs it; all others deferred. |
prosperity_baseline topographic modifier |
Paula: +0.05 hilltop, -0.05 flood-adjacent. Additive to Burnelli-Sheldon formula. Confirm in Round 3. |
| WorldTier enum fix | Still unaddressed in code. Ticket needed before Phase 5 can use any of this. |
Tyre — Round 2. Written 2026-05-01.