Standardized YAML frontmatter on all 38 files with title, description, type, workshop, agent, and round fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
52 KiB
title, description, type, status, workshop, agent, round, created
| title | description | type | status | workshop | agent | round | created |
|---|---|---|---|---|---|---|---|
| Tyre Round 3 — DistrictSkeleton and Mobile Chunks | Canonical DistrictSkeleton specification, mobile chunks, grid breathing, and vertical scale | workshop | archived | generator-architecture | tyre | 3 | 2026-02-27 |
Round 3: Tyre — Canonical DistrictSkeleton, Mobile Chunks, Grid Breathing, Vertical Scale
Workshop: Generator Architecture (#562) Agent: Tyre (Technical Architect) Date: 2026-02-27
Round 3 scope: Convergence. Seven directives from lead. This is where the architecture hardens.
1. Grid Breathing — BOTH Modes
Ozzie's been asking for this since Round 1, and she's right. The question: can the 4×4 block grid accommodate non-rectilinear layouts? Can streets curve? Can two adjacent districts have different orientations?
cracks knuckles — Let me be honest. The D-094 hierarchy (district = 4×4 blocks, block = 2×2 chunks, chunk = 64×64 sim tiles) defines data sizes, not geometry. A "block" is a 128×128 sim tile allocation unit. Nothing in D-094 says those allocation units must tile in a perfect Cartesian grid with perpendicular streets.
1.1 Two Layout Modes
The architecture supports both rectilinear and organic layouts through a DistrictLayoutMode that governs how blocks are placed within the district's 512×512 sim tile footprint.
#[derive(Serialize, Deserialize, Clone, Debug)]
enum DistrictLayoutMode {
/// Standard grid: blocks tile in a 4×4 Cartesian arrangement.
/// Streets are perpendicular. Blocks are axis-aligned.
/// Use for: station interiors, planned cities, institutional zones.
Grid,
/// Organic: blocks are placed with position + rotation offsets.
/// Streets follow terrain contours, historical paths, or natural features.
/// Use for: planet-side settlements, old-quarter stations, wilderness.
Organic {
/// Per-block placement offsets and rotations
placements: [[BlockPlacement; 4]; 4],
},
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct BlockPlacement {
/// Offset from grid-aligned position, in sim tiles.
/// (0, 0) = perfect grid alignment.
/// Max offset: ±16 sim tiles (quarter-chunk) in each axis.
offset: (i16, i16),
/// Rotation from grid-aligned orientation, in 15° increments.
/// 0 = aligned with grid. Values: 0, 1 (15°), 2 (30°), 3 (45°).
/// Maximum 45° rotation — steeper angles break tile-based pathfinding.
rotation_steps: u8,
/// Street width multiplier for streets adjacent to this block.
/// 1.0 = standard (4vt). Range: 0.75–2.0.
street_width_factor: f32,
}
1.2 How Organic Mode Works
In Organic mode:
-
Blocks shift and rotate. Each block has a position offset (max ±16 sim tiles) and a rotation (max 45°, in 15° increments). This produces streets that aren't straight — they follow the gaps between misaligned blocks.
-
Streets are the negative space. In grid mode, streets are a fixed-width band between blocks. In organic mode, streets are whatever space remains between the shifted/rotated block footprints. This naturally produces variable-width streets, curved paths, and irregular intersections.
-
Edge contracts still work. The edge contract system (from Round 2) defines connection points per chunk face. When blocks are rotated, edge contracts rotate with them. Two adjacent chunks know their relative orientation and align connection points accordingly. The connection logic becomes: "my north face at 15° needs to connect to your south face at 0°" — which resolves to a specific set of tile positions on the shared boundary.
-
The 45° rotation cap is hard. Beyond 45°, tile-based movement on the 0.5m sim grid produces unacceptable pathfinding artifacts — diagonal corridors narrower than 1 sim tile, inaccessible corner cells, ambiguous wall ownership. 45° is the safe maximum for a tile-based engine.
-
Chunks within a rotated block stay axis-aligned internally. The rotation applies to the block's position and orientation within the district. The chunk's internal 64×64 tile grid remains axis-aligned — walls, floors, and furniture are placed on the sim tile grid as normal. What changes is which tiles are "exterior" (facing the rotated street) versus "interior."
1.3 What This Produces Spatially
Grid mode (stations, planned cities):
┌────┬────┬────┬────┐
│ │ │ │ │
├────┼────┼────┼────┤
│ │ │ │ │ ← perpendicular streets
├────┼────┼────┼────┤ axis-aligned blocks
│ │ │ │ │
├────┼────┼────┼────┤
│ │ │ │ │
└────┴────┴────┴────┘
Organic mode (old quarter, planet-side settlement):
┌────┐ ╱────╲
│ │╱╱ ╲╲
├────╱ ┌─────┐╲
│ ╱ │ 15° ││ ← blocks offset and rotated
│ ╱ ┌──┤ ├┘ streets fill the gaps
│ ╱ │ └─────┘ variable-width, curved
├╱ │ ┌──────┐
│ └─────┤ │
└───────────┴──────┘
Mixed mode within a district: Not directly — a single district has one layout mode. BUT: adjacent districts can have different modes. A planned station district (Grid) can neighbor an old-quarter settlement (Organic), and the transition strip between them handles the orientation mismatch. This is exactly what Ozzie asked for: "two adjacent districts can have different orientations."
1.4 When Each Mode Is Used
| Setting | Mode | Reason |
|---|---|---|
| Station interior | Grid | Stations are constructed, engineered, planned |
| Planned city district | Grid | Modern urban planning = grid |
| Old-quarter / historic district | Organic | Built over centuries, never replanned |
| Planet-side settlement | Organic | Grew around geography, not on a blueprint |
| Agricultural district | Organic (minimal rotation) | Fields follow terrain contours |
| Wilderness | Organic (high rotation) | Paths follow geography, no grid at all |
| Transitional (suburb) | Grid (with offset only, no rotation) | Semi-planned, irregular edges |
The generator selects layout mode from TerrainType + DriftStage. Pioneer settlements are Grid (freshly planned). Ancient settlements that started as Pioneer may be Grid at center, Organic at edges — the city grew beyond its plan.
1.5 Performance and Complexity Cost
Implementation effort: ~3 dev-days for organic mode.
- 1 day:
BlockPlacementstruct + block footprint calculation with offsets and rotation - 1 day: rotated edge contract resolution (the hard part — computing shared boundaries between non-axis-aligned blocks)
- 1 day: street-as-negative-space chunk fill for organic gaps
Runtime cost: Negligible. Block placement is Phase 1 (computed once). The rotation math is a few matrix multiplies per block — 16 blocks = 16 multiplies. Not measurable.
Risk: Rotated edge contracts are the complexity hotspot. If two blocks are rotated by different amounts, the shared boundary is not a clean 64-tile line — it's a diagonal strip. The chunk fill needs to handle this diagonal interface. This is challenging but doable — essentially a variant of the same rasterization problem that angled walls already solve in the tile engine.
1.6 Answering Ozzie Directly
"Tell me the grid can breathe."
It can breathe. Organic mode produces districts where no two blocks are axis-aligned the same way, streets follow the gaps between shifted buildings, and the result looks like a settlement that grew rather than one that was stamped.
"Tell me two adjacent districts can have different orientations."
They can. District A = Grid, District B = Organic. The transition strip handles the mismatch.
"Tell me a street can curve because the geography required it."
It can. In organic mode, streets are negative space between rotated blocks. They curve because the blocks curve. And the blocks curve because the terrain, or the history, or the drift stage said they should.
What the grid CANNOT do: produce a smoothly curving boulevard. The minimum rotation step is 15°, and the minimum block width is 128 sim tiles (64m). This produces angular organic layouts, not flowing curves. For flowing curves, Araminta's seven anti-grid visual techniques (diagonal connectors, setback variation, overhead extensions, infrastructure routing, light territories, vegetation overflow, street width variation) remain the necessary visual layer. The architecture provides the skeleton; the visual grammar smooths the edges.
2. Entity-Carried Chunks — Mobile Interiors
Trains, ships, spaceships, traincars. The lead says: CORE requirement, not deferred.
2.1 The Concept
A mobile chunk is a chunk (64×64 sim tiles) that is attached to an entity rather than a fixed world coordinate. The entity moves; the chunk moves with it.
Examples:
- A train car: 1 chunk (maybe smaller — 32×16 interior). Moves along a rail.
- A ship cabin: 1 chunk. Moves on water.
- A spaceship interior: 1–4 chunks (depending on vessel size). Moves between systems.
2.2 Data Model
/// A chunk attached to a mobile entity instead of fixed coordinates.
#[derive(Serialize, Deserialize, Clone, Debug)]
struct MobileChunk {
/// Entity this chunk is attached to
entity_id: EntityId,
/// The chunk data itself — same format as static chunks
data: ChunkData,
/// Interior dimensions in sim tiles (may be smaller than 64×64)
interior_size: (u16, u16),
/// Current world position of the entity's anchor point
world_position: WorldPosition,
/// Movement state
movement: MobileMovementState,
/// Connection points to the outside world (doors, airlocks, gangways)
/// Active only when the entity is docked/stopped
access_points: Vec<MobileAccessPoint>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
enum MobileMovementState {
/// Docked at a fixed position — accessible from the world
Docked {
dock_position: WorldPosition,
connected_chunk: Option<ChunkCoord>, // which static chunk the door connects to
},
/// In transit — interior accessible, exterior is not the world
InTransit {
route: RouteId,
progress: f32, // 0.0–1.0 along route
speed: f32, // sim tiles per tick
},
/// Between systems — only the interior exists
InterSystem {
origin: SystemId,
destination: SystemId,
progress: f32,
},
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct MobileAccessPoint {
/// Position within the mobile chunk (interior coordinates)
interior_position: (u16, u16),
/// Direction the access point faces (relative to entity heading)
facing: CardinalDirection,
/// What kind of connection (gangway, airlock, cargo door)
connection_type: MobileConnectionType,
/// Is this access point currently active (entity is docked and aligned)?
active: bool,
}
2.3 How It Integrates with D-012 Streaming
The chunk streaming model (D-012) loads chunks within a radius of the player. For mobile chunks:
When the player is OUTSIDE the mobile entity:
- The mobile entity is a sprite on the world map (a train on a track, a ship on water).
- Its interior chunk is NOT loaded — the player can't see inside.
- If the entity is docked, its access points connect to adjacent static chunks. The player sees a door/gangway and can enter.
When the player is INSIDE the mobile entity:
- The mobile chunk IS loaded (it's within the player's radius — the player is in it).
- The surrounding 3×3 chunk grid still loads... but what's loaded depends on
MobileMovementState:- Docked: The grid loads the dock's static chunks. The player sees the dock through windows/doors.
- InTransit: The grid loads a scrolling exterior view. For a train: the landscape tiles outside the windows update based on
route+progress. For a ship: water tiles. This is a visual effect only — the sim doesn't load terrain tiles for the exterior during transit. The player sees a moving background through window tiles. - InterSystem: Only the mobile chunk is loaded. Exterior is void/space. This is the most constrained environment in the game — a sealed interior with no exit until arrival.
Key constraint: Only one mobile chunk is loaded at a time per player. No nested mobile chunks (a train carrying a car carrying a person). This is a v0.1 limitation that could be relaxed later but isn't worth the architectural complexity now.
2.4 Interior Sizing
Not all mobile interiors need a full 64×64 chunk:
| Vehicle type | Interior size | Chunk usage |
|---|---|---|
| Small boat / shuttle | 16×8 sim tiles (8m × 4m) | Partial chunk (padded to 64×64 with void) |
| Train car | 32×8 sim tiles (16m × 4m) | Partial chunk |
| Large ship cabin | 32×32 sim tiles (16m × 16m) | Half chunk |
| Spaceship (small) | 64×64 sim tiles | Full chunk |
| Spaceship (large) | 2×2 chunks (128×128) | Multiple chunks — treated as a mobile "block" |
Partial chunks are standard 64×64 allocations where only a portion contains interior tiles. The rest is void/impassable. This avoids needing a variable-size chunk type.
2.5 Generation
Mobile chunk interiors are generated like static social site chunks:
- Phase 1 assigns the vehicle type, interior template tag, and NPC roster (crew/passengers).
- Phase 2 fills the chunk from the template when the player first enters.
- The filled chunk is cached — entering the same train car later loads from cache.
Key difference from static chunks: Mobile chunks can be instanced. All train cars of the same type on the same route share a template. The seed varies by entity ID, producing cosmetic variation (different cargo, different graffiti, different maintenance state) on the same floor plan.
2.6 Loading and Unloading During Movement
When a docked entity begins transit:
- The access point deactivates (
active: false). - The static chunks adjacent to the dock remain loaded (other entities or NPCs may be there).
- The mobile chunk transitions to
InTransitstate. - The exterior visual buffer switches from "static world tiles" to "scrolling route tiles."
- The player's chunk grid recenters on the mobile chunk. Static world chunks unload as they leave the grid.
When a transit entity docks:
- The entity reaches
progress: 1.0on its route. - The dock's static chunks enter the loading grid.
- Access points activate.
- The player can exit normally.
NPC behavior during transit: NPCs on the mobile chunk continue their simulation. They have routines within the vehicle interior (crew patrols, passengers read/sleep/talk). The D-031 day-phase system applies — NPCs on a ship have meal times, rest times, watch times. Miri's bounded_mobile social site tag governs the trust-building acceleration and privacy-level reduction for vessel interiors.
2.7 Cost and Risk
| Component | Effort | Risk |
|---|---|---|
| MobileChunk data structure | 1 dev-day | Low |
| Docked state + access point connection | 2 dev-days | Medium — aligning mobile and static chunk doors |
| InTransit exterior visual scroll | 3 dev-days | Medium — scrolling tile buffer is new rendering code |
| InterSystem void state | 0.5 dev-days | Low — simplest case |
| Mobile chunk template library | 2 dev-days (3-4 templates) | Low |
| NPC routines on mobile chunks | 1 dev-day | Low — D-031 already handles bounded spaces |
| Total | ~9.5 dev-days | Medium overall |
Target milestone: v0.3. Mobile chunks require the base streaming model (v0.1–v0.2) to be working first. The scrolling exterior visual is the highest-risk component — it's new rendering code that doesn't exist in the static chunk model.
Deferred: Multi-chunk vehicles (large spaceships). These are v0.5+ — treat as a mobile "block" with 2×2 mobile chunks and internal chunk boundaries. Same principle, more plumbing.
3. Vertical Scale — Skyscrapers and Z-Level Cap
How does a 50-floor skyscraper emerge? The current model (D-094) describes 3 z-levels for stations. Planet-side cities need more.
3.1 The Z-Level Architecture
Let me be honest about what this means technically.
D-094 specifies 3 z-levels for the v0.1 station. But the chunk data structure has no hard limit on z-levels — z_levels: u8 in the DistrictSkeleton is a count, not a cap. A 50-floor skyscraper = z-levels 0–49.
The real constraints on vertical scale are:
-
Memory per chunk: Each z-level adds a full 64×64 tile layer. At ~4 bytes per tile (tile ID + flags), that's ~16 KB per z-level per chunk. A 50-floor chunk = ~800 KB. A 4-chunk skyscraper footprint at 50 floors = ~3.2 MB. This is within budget — the LRU cache can hold it.
-
Chunk loading time: Each z-level adds ~10-20ms to chunk fill (template stamping per floor). 50 floors = ~500ms–1s. This is right at the edge of the per-chunk budget. For skyscrapers, the mitigation is: only fill floors the player is on + adjacent floors. Floors 30–50 of a skyscraper don't need tile data until the player approaches them.
-
Rendering: The Godot client renders one z-level at a time (the player's current floor) plus visibility into adjacent floors through stairwells, balconies, and open shafts. This is already the rendering model — it doesn't change with more floors.
-
Pathfinding: NPCs need to navigate between floors. A 50-floor building with stairwells and elevators produces a tall navigation graph. The pathfinding cost scales linearly with z-levels used in a path (not total z-levels in the building). Most NPCs stay within 2-3 floors. Acceptable.
3.2 How Skyscrapers Emerge
A skyscraper is a multi-block, multi-z-level reservation in the DistrictSkeleton:
struct MultiBlockReservation {
/// Which blocks this reservation covers (e.g., [(1,1), (1,2)] for 2-block footprint)
blocks: Vec<(u8, u8)>,
/// Template tag for this multi-block structure
template_tag: String,
/// Number of z-levels
z_levels: u8,
/// Base z-level (usually 0 for ground-up construction)
base_z: u8,
/// Function (residential tower, corporate HQ, government building, mixed-use)
function: ReservationFunction,
/// Per-floor zone assignment (different floors can have different zones)
floor_zones: Vec<FloorZone>,
}
struct FloorZone {
z_level: u8,
zone_type: ZoningType,
zone_palette: ZonePalette,
access_tier: AccessTier,
}
A 50-floor skyscraper occupies 1–4 blocks (2×2 max footprint) and reserves z-levels 0–49. The floor zone list assigns different functions to different floors:
| Floors | Zone | Access | What's there |
|---|---|---|---|
| 0–2 | Commercial lobby | Public | Entrance, shops, reception |
| 3–10 | Office (lower) | Semi-private | Worker floors, open plan |
| 11–30 | Office (mid) | Private | Corporate, fewer NPCs per floor |
| 31–45 | Residential (luxury) | Restricted | Apartments, private balconies |
| 46–49 | Penthouse/Executive | Restricted+ | Power center, panoramic views |
| Basement (-1 to -3) | Service/Parking | Restricted | Maintenance, deliveries, the informal zone |
3.3 Lazy Z-Level Loading
The key optimization: don't fill all 50 floors at once.
enum ZLevelLoadState {
/// Full tile data loaded (player is on or adjacent to this floor)
Loaded(ChunkData),
/// Skeleton only — we know the floor plan and zone, but no tile data
Skeleton(FloorZone),
/// Not yet generated — will be filled on demand
Ungenerated,
}
When the player enters a skyscraper at the lobby (z=0), only z=0, z=1, and z=-1 are filled. As they take an elevator to floor 20, floors 19–21 fill during the elevator "transit" time (elevator = a vertical mobile chunk, conceptually). The player never waits.
NPCs on unfilled floors: NPCs on floors the player hasn't visited are simulated at reduced fidelity (D-026 simulation tiers). They have positions and states but no tile-level pathfinding. When the player arrives at their floor, the full tile data is generated and the NPC's position is resolved to specific tiles.
3.4 Z-Level Cap
Practical cap: 64 z-levels. This is a u8 field, but 64 is the engineering recommendation:
- 64 floors × 16 KB per floor per chunk = ~1 MB per chunk column. Manageable.
- 64 floors × 20ms fill time = ~1.3s for a full column fill (but lazy loading means you never do this).
- Beyond 64: diminishing returns. A 100-floor skyscraper has 36 floors of content the player likely never visits. Better to have 3 interesting 20-floor buildings than 1 boring 100-floor tower.
For v0.1–v0.3, the effective cap remains 3 z-levels (station model). Skyscrapers (z > 3) are a v0.4+ feature gated on lazy z-level loading.
3.5 Multi-Block Vertical Structures
A skyscraper that spans 2×2 blocks (4 chunks footprint × 50 z-levels = 200 chunk-layers) is handled by the existing MultiBlockReservation. The reservation locks the block positions and assigns a shared template tag. During block planning, the reserved blocks get a SkyscraperFootprint chunk layout that overrides normal quarter-based generation.
Stairwells and elevators are vertical connection points — tile positions that are passable between z-levels. They appear in the same position on every floor, creating a vertical spine through the building. The reservation template defines these positions; each floor's chunk fill respects them.
3.6 Cost
| Component | Effort | Target |
|---|---|---|
FloorZone + per-floor zone assignment |
1 dev-day | v0.3 |
| Lazy z-level loading | 2 dev-days | v0.4 |
| Elevator as vertical transit | 1 dev-day | v0.4 |
| Skyscraper template (1 template) | 2 dev-days | v0.4 |
| NPC reduced-fidelity on unfilled floors | 1 dev-day | v0.4 |
| Total | ~7 dev-days | v0.4 |
4. Dynamic World Modification — The Mutation Model
A gas main explodes in a previously visited district. Does the generator re-render affected chunks? Or is destruction handled as an overlay?
4.1 Principle: Overlays, Not Re-Generation
Chunks are never re-generated. Once a chunk is filled (Phase 2), its ChunkData is canonical. Modifications are applied as overlay mutations on top of the base data.
/// Mutations applied to an already-generated chunk.
/// Stored alongside the chunk in the save file.
#[derive(Serialize, Deserialize, Clone, Debug)]
struct ChunkMutations {
/// Tile-level changes (destroyed walls, new debris, fire damage)
tile_overrides: Vec<TileOverride>,
/// Structural changes (wall removed, floor collapsed)
structural_changes: Vec<StructuralChange>,
/// New objects placed by simulation events
placed_objects: Vec<PlacedObject>,
/// Objects removed by simulation events
removed_objects: Vec<ObjectId>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct TileOverride {
position: (u16, u16, u8), // x, y, z
new_tile: TileId,
cause: MutationCause,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
enum MutationCause {
Explosion { radius: u8, source: EntityId },
Fire { spread_from: Option<(u16, u16)> },
Construction { builder: EntityId },
Decay { time_since_maintenance: u32 },
PlayerAction { action: ActionId },
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct StructuralChange {
/// Region affected (bounding box)
min: (u16, u16, u8),
max: (u16, u16, u8),
change_type: StructuralChangeType,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
enum StructuralChangeType {
/// Wall segment destroyed — creates passable tile
WallDestroyed,
/// Floor collapsed — creates hole to z-level below
FloorCollapsed,
/// Ceiling breached — creates opening to z-level above
CeilingBreached,
/// Area sealed — formerly passable area blocked by debris
AreaSealed,
/// New wall constructed
WallConstructed,
}
4.2 How Mutations Apply
When the client renders a chunk, it applies mutations in order:
- Load base
ChunkData(from cache or save file). - Apply
tile_overrides— replace specific tiles with their mutated versions. - Apply
structural_changes— update passability map for destroyed/created walls. - Apply
placed_objects/removed_objects— modify object layer. - Render the result.
Why overlays, not re-generation:
- Re-generation would require re-running the Phase 2 template stamping with modified inputs. But the template system is forward-only — it doesn't know how to "partially re-stamp" a chunk.
- Overlays are cheaper (apply a delta to existing data) and composable (multiple mutations stack).
- Overlays preserve player familiarity — the base layout is unchanged, the damage is visible on top of it.
- Overlays serialize cleanly to the save file —
base_chunk + mutations = current state.
4.3 Explosion Propagation
A gas main explosion in the sim produces:
- Sim event:
Explosion { center: (x, y, z), radius: 8, force: High }. - Mutation generator: For each tile within radius, evaluate wall/floor structural integrity. Walls within 3 tiles of center:
WallDestroyed. Floors within 2 tiles:FloorCollapsed. Objects within radius:removed_objects. - Debris placement:
placed_objectsfills the blast zone with debris tiles (rubble, shattered glass, buckled panels). - Fire spread: Adjacent tiles with flammable objects may catch fire — additional
TileOverridemutations applied over subsequent ticks. - Chunk boundary: If the explosion overlaps a chunk boundary, mutations are generated for both chunks. The mutation generator runs in world coordinates, not chunk-local coordinates.
4.4 Previously Visited vs. Unvisited Districts
Previously visited (chunk exists in cache/save):
- Mutations apply to the cached chunk. The player returns to find the damage.
Unvisited (chunk not yet generated):
- The explosion event is recorded as a pending mutation on the
PreparedDistrict. - When the chunk is eventually generated (Phase 2), the mutation is applied immediately after generation.
- The player arrives to find a district that looks like it was built and then damaged — not a district that was never built.
This second case is important: simulation events can affect districts the player hasn't visited. The architecture handles this by storing mutations at the district level and applying them either to existing chunks (if cached) or to newly generated chunks (at fill time).
4.5 Cost
| Component | Effort | Target |
|---|---|---|
| ChunkMutations struct + serialization | 1 dev-day | v0.2 |
| Mutation application in rendering pipeline | 2 dev-days | v0.3 |
| Explosion mutation generator | 2 dev-days | v0.4 |
| Pending mutations on unvisited districts | 1 dev-day | v0.3 |
| Total | ~6 dev-days | v0.2–v0.4 |
5. Destructible Boundaries — What's Behind Walls
When a player blasts through a wall, what does the generator reveal?
5.1 The Problem
Chunks are filled from templates. Templates define walls as boundaries. When a wall is destroyed, the tiles behind it must contain something. The question: does the generator always place geometry behind walls, or can walls be terminal boundaries with void behind them?
5.2 The Answer: Bounded Void with Infill Rules
Walls can be terminal. Not every wall has space behind it. But the player must never see void — they must see something that makes physical sense.
enum WallBackside {
/// Another room/corridor exists behind this wall.
/// The tiles are already generated (part of the chunk data).
AdjacentSpace,
/// Structural fill — solid material (concrete, rock, hull plating).
/// Destroying this wall reveals 1-2 tiles of fill, then another wall.
StructuralFill,
/// Service void — narrow gap between structural walls.
/// 1-3 tiles deep, contains pipes/conduits, not navigable.
ServiceVoid,
/// Chunk boundary — this wall is the edge of the chunk.
/// Destroying it reveals the adjacent chunk's boundary tiles.
ChunkBoundary,
/// Exterior — this wall faces outside (hull, exterior wall).
/// Destroying it has catastrophic consequences (decompression, weather).
Exterior,
}
5.3 How the Generator Handles This
At chunk fill time (Phase 2), every wall tile is tagged with its WallBackside:
-
Walls between rooms within the same chunk:
AdjacentSpace. Both sides are already generated. Destroying the wall just removes the LOS blocker — the tiles on both sides exist. -
Walls at chunk boundaries:
ChunkBoundary. The adjacent chunk's edge tiles are the "backside." If the adjacent chunk is loaded, the player sees into it. If unloaded, the chunk loads on demand. Edge contracts guarantee compatible geometry. -
Walls at the building exterior:
Exterior. Destroying these triggers a different consequence system (hull breach on a station, weather exposure on a planet). The tiles beyond are outdoor/void tiles. -
Walls with no designed space behind them:
StructuralFill. The template places wall tiles in the "behind" positions — solid fill that reads as thick structural material. Destroying the wall reveals 1-3 tiles of fill (rubble, exposed conduit, insulation) and then another wall. The player CAN dig through structural fill, but it takes multiple actions and reveals only service-level geometry. -
Walls against narrow utility gaps:
ServiceVoid. Template places 1-3 tiles of void with pipe/conduit objects. Destroyable but non-navigable (too narrow for a character, too full of infrastructure). Useful for gameplay: the player can see through the gap (modified LOS), hear through it, or pass small objects through.
5.4 The Critical Rule
No tile in a generated chunk is ever "void" in the sense of "ungenerated." Every tile has a type — even if that type is SolidFill or HullPlating. This means:
- The player can never "break out of the map" by destroying walls.
- Every destructive action reveals something that makes physical sense.
- The computational cost of supporting destruction is bounded — we're not generating new content when walls break, we're revealing content that was always there but occluded.
5.5 Template Authoring Requirement
This adds a requirement to D-025 templates: every wall tile must have a WallBackside tag. Template authors need to specify what's behind each wall segment. For most walls, this is mechanical:
- Interior walls between rooms:
AdjacentSpace(auto-tagged during template stamping). - Perimeter walls:
ExteriororChunkBoundary(auto-tagged based on position within chunk). - Thick walls:
StructuralFill(author decision — how thick is this building?). - Walls adjacent to pipe runs:
ServiceVoid(author places pipe objects behind the wall).
90% of wall tagging can be automated. 10% is author choice that adds personality to the space.
5.6 Cost
| Component | Effort | Target |
|---|---|---|
| WallBackside tagging system | 1 dev-day | v0.3 |
| Auto-tagging in template stamping | 1 dev-day | v0.3 |
| StructuralFill + ServiceVoid tile types | 0.5 dev-days | v0.3 |
| Wall destruction → mutation pipeline | 1 dev-day | v0.4 |
| Total | ~3.5 dev-days | v0.3–v0.4 |
6. Canonical DistrictSkeleton — Reconciled Definition
The big reconciliation. My Round 2 additions + Gestalt's Round 2 additions + Round 3 requirements, unified into ONE canonical struct.
6.1 Reconciling SignificanceTier / ComplexityTier / DramaDensity
Qatux flagged that these three concepts overlap. Let me resolve this.
These are THREE distinct parameters, not one:
| Parameter | What it measures | Set by | Changes during gameplay? |
|---|---|---|---|
SignificanceTier |
How important this location is in the galaxy network | Phase 1 (pre-pipeline, from seed + galaxy topology) | No — structural |
ComplexityTier |
How much generator content this district receives | Phase 1 (derived from SignificanceTier + TerrainType) | No — generator budget |
DramaDensity |
How much active drama the storyteller can inject | Storyteller (D-005, D-023), dynamic per session | Yes — the storyteller adjusts this |
They are related but not redundant:
- A Center-stage significant location with Full complexity might have Zero drama density in a seed where the storyteller has decided this world is quiet this playthrough.
- A Backwater significant location with Moderate complexity might have High drama density because the storyteller fired a Tier 1 module here.
- An Insignificant location with Minimal complexity ALWAYS has Zero drama density — the generator didn't produce enough social infrastructure for drama.
Resolution:
SignificanceTierandComplexityTierstay on the DistrictSkeleton (static, generation-time).DramaDensitydoes NOT go on the DistrictSkeleton. It's a runtime storyteller parameter stored on the simulation state, not the generator output. The storyteller reads the DistrictSkeleton to know what's possible, then sets drama density dynamically.
6.2 Reconciling SettingGeometry and TerrainType
Gestalt proposed SettingGeometry (Station/Urban/Rural/Maritime/Wilderness/Specialized). I proposed TerrainType (Station/Urban/Agricultural/Wilderness/Water/Transitional/Orbital). These describe the same thing.
Resolution: Merge into SettingType. Taking the union of both proposals:
#[derive(Serialize, Deserialize, Clone, Debug)]
enum SettingType {
/// Station interior — zone-and-level grid, fully enclosed
Station,
/// Planet-side city — terrain-influenced urban spread
Urban,
/// Agricultural — low-density farmland/settlement
Agricultural,
/// Maritime — coastal or aquatic, port-oriented
Maritime,
/// Wilderness — minimal infrastructure
Wilderness { biome: Biome },
/// Water body — ocean, lake, river
Water { water_type: WaterType },
/// Transitional — urban edge, suburbs, outskirts
Transitional,
/// Orbital — small installation, different geometry
Orbital,
/// Specialized single-function (resort, research, military)
Specialized { function: SpecializedFunction },
}
#[derive(Serialize, Deserialize, Clone, Debug)]
enum SpecializedFunction {
Resort, Research, Military, Mining, Religious,
}
6.3 TrianglePurpose — Simple Tag, Not Complex
Gestalt proposed adding triangle_purpose: TrianglePurpose to SocialSitePlacement.triangles. Does this add implementation complexity?
No. It's a simple enum tag on an existing struct. The triangle template already exists; this is one additional field.
#[derive(Serialize, Deserialize, Clone, Debug)]
enum TrianglePurpose {
/// Economic conflict — competition, trade dispute, resource control
Economic,
/// Political conflict — power struggle, factional, institutional
Political,
/// Social conflict — personal, romantic, loyalty
Social,
/// Investigation — evidence trail, conspiracy link, surveillance target
Investigation,
/// Mundane — workplace friction, neighbor dispute, family tension
Mundane,
}
A triangle can have multiple purpose tags (a romantic rivalry that's also political = [Social, Political]). The scenario instantiation stage activates triangles based on which purposes are relevant to the current gameplay context. Implementation: one Vec<TrianglePurpose> field on the triangle assignment. ~20 lines of code.
6.4 The Canonical DistrictSkeleton
Here it is. Every field from both my Round 2 and Gestalt's Round 2, reconciled and unified.
/// The canonical generator output for one district.
/// Produced by Phase 1. Consumed by Phase 2.
/// This is the contract between world prep and local generation.
#[derive(Serialize, Deserialize, Clone, Debug)]
struct DistrictSkeleton {
// ── Identity ──────────────────────────────────────────
district_id: DistrictId,
seed: u64,
district_type: DistrictType,
context: DistrictContext,
// ── Classification (from Gestalt + Tyre, reconciled §6.1) ──
/// How important this location is in the galaxy network
significance: SignificanceTier,
/// How much generator content this district receives
complexity: ComplexityTier,
/// Physical setting type (merged SettingGeometry + TerrainType)
setting: SettingType,
/// Layout mode: grid or organic (§1)
layout_mode: DistrictLayoutMode,
// ── Spatial Structure ─────────────────────────────────
/// The 4×4 block grid
blocks: [[BlockSkeleton; 4]; 4],
/// Multi-block reservations (skyscrapers, parks, terminals)
reservations: Vec<MultiBlockReservation>,
/// Corridor spines connecting key access points
corridors: Vec<CorridorSpine>,
/// Z-level count for this district
z_levels: u8,
// ── Social Structure ──────────────────────────────────
/// Social site placements with template tags and triangle configs
social_sites: Vec<SocialSitePlacement>,
/// District-level access points (entries/exits to neighboring districts)
access_points: Vec<AccessPoint>,
// ── Cultural/World Context ────────────────────────────
/// Society profile reference (serde YAML, Miri's ingredients)
society_profile: SocietyProfileRef,
/// Zone palette definitions for this district
zone_palette: Vec<ZoneDefinition>,
// ── Boundary System (edge bleed, §Round 2) ───────────
/// Edge descriptors for transition strips with neighbors
boundaries: DistrictBoundaries,
// ── Validation ────────────────────────────────────────
/// Gestalt's 11-check guarantee audit result.
/// Records which spatial archetypes are satisfied and where.
/// Only populated for ComplexityTier::Full districts.
guarantee_audit: Option<GuaranteeAuditResult>,
}
/// Significance in the galaxy network.
/// Set at pre-pipeline from seed + galaxy topology.
#[derive(Serialize, Deserialize, Clone, Debug)]
enum SignificanceTier {
/// Hub world — multiple districts, full content, high faction pressure
CenterStage,
/// Regional importance — 1-4 districts, moderate content
Regional,
/// Small community — 1 district, local-only importance
Backwater,
/// Transit stop — minimal, pass-through only
Waypoint,
/// Not generated until player approaches
Insignificant,
}
/// How much generator budget this district receives.
/// Derived from SignificanceTier + SettingType.
#[derive(Serialize, Deserialize, Clone, Debug)]
enum ComplexityTier {
/// Full gameplay — all spatial guarantees, rich NPC population
Full,
/// Moderate — partial guarantees, moderate NPCs
Moderate,
/// Minimal — pass-through, sparse NPCs, no gameplay guarantees
Minimal,
/// Empty — no social sites, no NPCs, pure terrain
Empty,
}
/// Per-block skeleton.
struct BlockSkeleton {
position: (u8, u8),
zoning: ZoningType,
reservation: Option<ReservationId>,
chunk_layout: ChunkLayout,
hosted_sites: Vec<SocialSiteId>,
/// Construction era
era: Era,
/// Era modifications (retrofits, additions)
era_modifications: Vec<EraModification>,
/// Gestalt addition: WHY the era differs from district norm
era_cause: Option<EraCause>,
/// Density parameter (0.0-1.0) for quarter fill
density: f32,
/// Landmark reservation (at most 1 per district quadrant)
landmark: Option<LandmarkSlot>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
enum EraCause {
/// Original construction — no deviation from district era
Original,
/// Corporate merger/acquisition changed ownership
CorporateMerger,
/// Emergency extension after capacity crisis
EmergencyExtension,
/// Organic growth over time
OrganicGrowth,
/// Institutional incursion (Commission, Syndic)
InstitutionalIncursion,
/// Economic disruption (abandonment/repurposing)
EconomicDisruption,
/// Cultural shift (new community moved in)
CulturalShift,
}
/// Social site placement with triangle configuration.
struct SocialSitePlacement {
site_id: SocialSiteId,
/// Which blocks this site occupies
blocks: Vec<(u8, u8)>,
/// D-025 template tag
template_tag: String,
/// Access tier
access_tier: AccessTier,
/// Triangle assignments for this site
triangles: Vec<TriangleAssignment>,
/// NPC role slots (filled in the NPC population stage)
role_slots: Vec<RoleSlot>,
/// Active day-phases for this social site (D-031)
active_phases: Vec<DayPhase>,
}
struct TriangleAssignment {
template: TriangleTemplate,
/// Gestalt addition: what this conflict is FOR
purposes: Vec<TrianglePurpose>,
/// Which role slots are involved
participants: Vec<RoleSlotId>,
/// Spatial requirement (staging ground block)
staging_block: Option<(u8, u8)>,
}
/// Gestalt's 11-check guarantee audit.
/// Serialized into the skeleton for validation and debugging.
#[derive(Serialize, Deserialize, Clone, Debug)]
struct GuaranteeAuditResult {
/// 7 spatial archetype checks
traffic_chokepoint: AuditCheck,
informal_zone: AuditCheck,
social_hub: AuditCheck,
institutional_space: AuditCheck,
insider_space: AuditCheck,
economic_node: AuditCheck,
encounter_corridor: AuditCheck,
/// 4 playstyle-specific checks
economic_asymmetry_signal: AuditCheck,
temporal_encounter_window: AuditCheck,
power_gradient_visibility: AuditCheck,
density_contrast: AuditCheck,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct AuditCheck {
passed: bool,
/// Which social site / block satisfies this check
satisfied_by: Option<SatisfiedBy>,
}
enum SatisfiedBy {
SocialSite(SocialSiteId),
Block(u8, u8),
Corridor(CorridorSpineId),
}
6.5 Memory Budget (Revised)
With all Round 3 additions:
| Component | Size per district | Notes |
|---|---|---|
| Identity + classification | ~64 bytes | Fixed |
| Blocks (4×4 × BlockSkeleton) | ~2 KB | Increased from R2 with era_cause, density, landmark |
| Social sites + triangles | ~1-4 KB | Depends on complexity tier |
| Reservations + corridors | ~0.5-2 KB | Depends on multi-block structures |
| Boundaries | ~4 KB | 4 edges × ~1 KB |
| Society profile ref | ~32 bytes | Reference, not full profile |
| Zone palette | ~0.5 KB | |
| Guarantee audit | ~256 bytes | 11 checks |
| Layout mode (organic) | 0-1 KB | Only for organic mode |
| Total per district | ~8-14 KB | Up from 5-10 KB in Round 2 |
300 worlds × ~6 districts average × ~12 KB = ~21 MB for all skeletons. Still trivial.
7. Palette Granularity — Modifiers, Not More Base Palettes
The lead says: 5 non-urban palettes is too few. Industrial farming ≠ rustic farming.
7.1 The Architecture: Base Palette + Modifiers
The answer is palette modifiers, not more base palettes. Same architecture that era modifications use for urban palettes.
#[derive(Serialize, Deserialize, Clone, Debug)]
struct ZonePalette {
/// Base palette type (the 5 urban + 6 natural from Araminta)
base: BasePalette,
/// Modifiers that shift the palette without replacing it
modifiers: Vec<PaletteModifier>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
enum BasePalette {
// Urban (from Round 1/2)
GateCluster, Terminal, Maintenance, Social, Residential,
Industrial, Commercial, Administrative, Cargo,
// Natural (from Araminta Round 2)
Farmland, WildernessForest, OceanCoastal, Beach,
MountainSnow, SecludedTown,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
enum PaletteModifier {
/// Economic function shifts the palette character.
/// Industrial farming = Farmland + Industrial modifier.
/// Rustic farming = Farmland + no modifier (base).
EconomicFunction(EconomicModifier),
/// Era modifier — older/newer construction on the base palette.
Era(Era),
/// Faction modifier — institutional presence shifts lighting/materials.
FactionPresence(FactionModifier),
/// Condition modifier — well-maintained vs. decaying.
Condition(ConditionModifier),
/// Cultural modifier — heritage root shifts material choices.
Heritage(HeritageRoot),
/// Seasonal modifier (agricultural/natural terrain only).
Season(Season),
}
#[derive(Serialize, Deserialize, Clone, Debug)]
enum EconomicModifier {
/// Industrial-scale: larger equipment, uniform rows, metal infrastructure
Industrial,
/// Artisanal/traditional: smaller scale, varied, wood/stone materials
Artisanal,
/// Corporate: branded, maintained, standardized
Corporate,
/// Subsistence: minimal infrastructure, improvised
Subsistence,
/// Luxury: high-quality materials, careful maintenance
Luxury,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
enum ConditionModifier {
/// Well-maintained — clean lines, functioning fixtures
Maintained,
/// Worn — functional but showing age
Worn,
/// Neglected — failed fixtures, cracked surfaces
Neglected,
/// Abandoned — no maintenance, natural reclamation
Abandoned,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
enum Season {
Growth, // green, active crops
Harvest, // golden, busy
Dormant, // brown, sparse
Snow, // white overlay
}
7.2 How Modifiers Compose
A ZonePalette is the base palette plus a stack of modifiers. At chunk fill time, the renderer resolves the final hex values:
Example: Industrial farming
- Base:
Farmland(dark warm brown soil, amber sparse lighting) - Modifier:
EconomicFunction(Industrial)→ shift floor toward grey-metal tones, add uniform-row crop patterns, replace wood fencing with metal, increase object density - Modifier:
Condition(Maintained)→ clean lines, functioning equipment - Result: industrialized farmland that still reads as "farmland" but with metal silos, irrigation machinery, and uniform crop rows
Example: Abandoned rustic farm
- Base:
Farmland - Modifier:
EconomicFunction(Artisanal)→ no shift from base (artisanal IS the farmland base) - Modifier:
Condition(Abandoned)→ failed fixtures, natural reclamation (overgrown), darkened surfaces - Modifier:
Season(Dormant)→ brown/sparse crop cover - Result: an abandoned traditional farm in winter — melancholy, overgrown, quietly decaying
7.3 Combination Space
With 15 base palettes × 5 economic modifiers × 4 condition modifiers × (optional era, faction, heritage, season) = 300+ distinct palette combinations from a compact set of primitives.
This addresses the lead's concern: "industrial farming ≠ rustic farming" is solved by Farmland + Industrial vs Farmland + Artisanal. No new base palette needed. The modifier system is the palette expansion mechanism.
7.4 Cost
The modifier system is a rendering-time concern, not a generator-time concern. The generator assigns the ZonePalette (base + modifiers) at Phase 1. The renderer resolves final hex values at Phase 2 chunk fill.
| Component | Effort | Target |
|---|---|---|
| PaletteModifier enum + resolver | 2 dev-days | v0.3 |
| Integration with chunk fill palette lookup | 1 dev-day | v0.3 |
| Test palette combinations (Araminta visual review) | 1 dev-day | v0.3 |
| Total | ~4 dev-days | v0.3 |
8. Updated Cost Summary — All Rounds Combined
Here's the full cost picture including Rounds 1-3.
| System | Dev-Days | Target | Risk |
|---|---|---|---|
| Core Pipeline | |||
| SeedChain + derivation | 0.5 | v0.2 | Low |
| SocietyProfile serde schema | 1 | v0.2 | Low |
| DistrictSkeleton (canonical, with all R3 fields) | 3 | v0.1 stub, v0.2 impl | Low |
| BlockPlan + ChunkFillSpec | 1 | v0.1 stub | Low |
| Phase 2 chunk fill (template stamping) | 5 | v0.2 | Medium |
| Transit District validation fixture | 3 | v0.1 | Low |
| Sub-total core | 13.5 | ||
| Edge Bleed + Transitions | |||
| TransitionStrip generation | 2 | v0.3 | Low |
| Palette modifier system | 4 | v0.3 | Low |
| Sub-total transitions | 6 | ||
| Grid Breathing | |||
| Organic layout mode | 3 | v0.3 | Medium |
| Sub-total grid | 3 | ||
| Vertical Scale | |||
| FloorZone + per-floor zones | 1 | v0.3 | Low |
| Lazy z-level loading | 2 | v0.4 | Medium |
| Skyscraper template + elevator | 3 | v0.4 | Medium |
| NPC reduced-fidelity on unfilled floors | 1 | v0.4 | Low |
| Sub-total vertical | 7 | ||
| Mobile Chunks | |||
| MobileChunk data model | 1 | v0.3 | Low |
| Docked state + access points | 2 | v0.3 | Medium |
| InTransit exterior scroll | 3 | v0.3 | Medium |
| InterSystem void state | 0.5 | v0.3 | Low |
| Mobile templates (3-4) | 2 | v0.3 | Low |
| NPC routines on mobile chunks | 1 | v0.3 | Low |
| Sub-total mobile | 9.5 | ||
| Dynamic Modification | |||
| ChunkMutations struct | 1 | v0.2 | Low |
| Mutation rendering pipeline | 2 | v0.3 | Medium |
| Explosion mutation generator | 2 | v0.4 | Medium |
| Pending mutations on unvisited | 1 | v0.3 | Low |
| WallBackside tagging | 1.5 | v0.3 | Low |
| Wall destruction pipeline | 1 | v0.4 | Low |
| Sub-total destruction | 8.5 | ||
| Other | |||
| Spatial prerequisite validator | 0.5 | v0.3 | Low |
| Phase 1 background thread | 2 | v0.3 | Low |
| ComplexityTier + minimal/empty gen | 1 | v0.3 | Low |
| TerrainType + non-urban chunk fill | 3 | v0.4+ | Medium |
| Sub-total other | 6.5 | ||
| GRAND TOTAL | ~54 dev-days | v0.1–v0.4 |
Milestone Breakdown
| Milestone | Dev-Days | What Ships |
|---|---|---|
| v0.1 | ~7 | DistrictSkeleton stub, BlockPlan stub, Transit District validation fixture |
| v0.2 | ~10.5 | SeedChain, SocietyProfile, DistrictSkeleton impl, Phase 2 chunk fill, ChunkMutations struct |
| v0.3 | ~22.5 | Edge bleed, palette modifiers, organic layout, mobile chunks (docked+transit), mutation rendering, wall backside, lazy z-levels, Phase 1 background thread |
| v0.4 | ~14 | Skyscrapers, explosion mutations, non-urban terrain, NPC reduced-fidelity floors |
v0.1–v0.2 is the foundation. v0.3 is where the generator becomes a real system. v0.4 extends it to the ambitious features (vertical scale, destruction, non-urban terrain).
9. Open Questions Remaining After Round 3
OQ-R3-F: Organic Mode Edge Contract Resolution
When two blocks are rotated by different amounts, the shared boundary is a diagonal strip rather than a clean 64-tile line. The exact algorithm for resolving edge contracts across rotated boundaries needs prototyping. Recommendation: Prototype this as the first task in v0.3 organic mode work. If it proves too complex, fall back to offsets-only (no rotation), which still breaks the grid significantly.
OQ-R3-G: Multi-Chunk Mobile Vehicles
Large spaceships (2×2+ chunks) are deferred to v0.5+. The data model (a mobile "block" containing multiple mobile chunks with internal boundaries) is straightforward in principle but needs careful design around inter-chunk access within a moving vessel. Not blocking for v0.3.
OQ-R3-H: Elevator as Vertical Mobile Chunk
An elevator is conceptually a mobile chunk that moves vertically within a building rather than horizontally through the world. The MobileChunk model could handle this, but it's architecturally simpler to treat elevators as instantaneous z-level transitions (like staircases but with a loading screen). Recommendation: Simple transition for v0.4, full elevator-as-mobile-chunk for v0.5+ if the gameplay warrants it.
Tyre — Round 3 complete. Seven directives addressed. The DistrictSkeleton is canonical. The grid can breathe. Mobile chunks are core. Vertical scale is lazy-loaded. Destruction is overlay-based. Walls have backsides. Palettes compose through modifiers. ~54 dev-days spread across v0.1–v0.4. The architecture is ready for the D-record.