Captures the pre-Godot-port state of the codebase. This is the rollback anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md). All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
46 KiB
Theriapolis — Implementation Plan: Phase 2 + Phase 3
Handoff Document for Sonnet
Version 1.0 — 2026-04-12
0. Purpose
This document is the agreed implementation plan for Phase 2 (Hydrology + Linear Features) and Phase 3 (Settlements + Infrastructure) of Theriapolis. It continues from the Phase 0+1 work that is now complete and accepted.
Sonnet: read this entire file before writing code. Also re-read these files from the prior plan, as they remain authoritative:
theriapolis-rpg-implementation-plan.md— the original architectural spec (Sections 5, 6, 12 are still binding)theriapolis-rpg-procgen.md— world generation design (Layers 2–4, 5–7)theriapolis-rpg-procgen-addendum-a.md— border organics AND linear feature exclusion rules (Issue 2 is the critical new constraint)theriapolis-rpg-questline.md— narrative anchor requirementstheriapolis-rpg-clades.md— clade sizes, affinities, demographicstheriapolis-rpg-reputation.md— faction definitions and influence
All hard rules from the original plan (Section 12) remain in force. This document adds no new hard rules — it only extends scope.
1. What Phase 0+1 Built (Your Foundation)
You are building on top of a working, tested codebase:
- 9-stage worldgen pipeline (
WorldGenerator.BuildPipeline()): SeedInit, MacroTemplateLoad, ElevationGen, MoistureGen, TemperatureGen, CoastalFeatureGen, BorderDistortion, WaterBodyClamp, BiomeAssign WorldStatewithWorldTile[1024,1024]— elevation, moisture, temperature, BiomeId, FeatureFlags, macro cell coordinates all populatedSeededRngwith 13 named sub-streams (RNG_HYDRO, RNG_SETTLE, RNG_ROAD, RNG_RAIL, RNG_FACTION, RNG_POI are unused and waiting for you)FeatureFlagsbitmask with HasRiver, RiverAdjacent, HasRoad, HasRail, RailroadAdjacent, IsSettlement, IsPoi all defined but unsetCamera2D,WorldMapRenderer,TileAtlas, screen stack, input manager- Macro grid with per-cell BiomeType, Development, Covenant, CladeAffinities, elevation/moisture constraints
- Tests: determinism, macro constraints, border organics, biome coverage, architecture (Core has no MonoGame dependency)
- Tools:
worldgen-dumpexports PNG of biome map
You do NOT need to modify any Phase 0+1 stage code. Build on top of it.
2. What This Phase Delivers
When Phase 2+3 is complete, dotnet run --project Theriapolis.Desktop shows:
- A world map with rivers (blue polylines, varying width by classification) flowing from mountains to sea, with natural meanders.
- Lakes at drainage collection points, visible as water bodies.
- Settlement icons at correct tiers, correctly constrained (narrative anchors at valid locations, general settlements by habitability score).
- Rail lines (dark gray polylines) connecting the capital to Tier 2 cities.
- Roads (brown polylines) connecting Tier 1–3 settlements, routed around rivers and rail per Addendum A §2 exclusion rules.
- Settlement labels visible above a zoom threshold.
- All of the above visible at any zoom level thanks to the shared polyline coordinate space (world-pixel space).
The worldgen-dump tool produces a PNG showing all of this overlaid on the
biome map.
3. New Constants
Add these to Constants.cs alongside the existing constants. Do not modify
existing constants.
// ── Phase 2: Hydrology ──────────────────────────────────────────────────
public const int RIVER_MIN_FLOW_ACCUM = 80; // tiles of upstream catchment to become a stream
public const int RIVER_MAJOR_THRESHOLD = 400; // flow accumulation for "major river"
public const int RIVER_MODERATE_THRESHOLD = 180; // flow accumulation for "river" (vs stream)
public const float RIVER_CARVE_DEPTH = 0.02f; // elevation reduction along river paths
public const int LAKE_MIN_AREA = 12; // tiles; smaller basins stay dry
public const float MEANDER_AMP_FLAT = 5f; // max world-pixel lateral offset on plains
public const float MEANDER_AMP_MOUNTAIN = 1.5f; // max world-pixel lateral offset in mountains
public const float MEANDER_FREQ = 0.08f; // noise frequency for meander offset
public const int SPLINE_SUBDIVISIONS = 4; // Catmull-Rom subdivisions per control point
public const float RDP_TOLERANCE = 2.0f; // Ramer-Douglas-Peucker LOD tolerance (world px)
// ── Phase 3: Settlements ────────────────────────────────────────────────
public const int SETTLE_TIER1_COUNT = 1; // capitals
public const int SETTLE_TIER2_MIN = 4;
public const int SETTLE_TIER2_MAX = 6;
public const int SETTLE_TIER3_MIN = 15;
public const int SETTLE_TIER3_MAX = 25;
public const int SETTLE_TIER4_MIN = 40;
public const int SETTLE_TIER4_MAX = 80;
public const int SETTLE_TIER5_MIN = 100;
public const int SETTLE_TIER5_MAX = 200;
public const int SETTLE_MIN_DIST_TIER1 = 120; // min tile distance between same/higher tier
public const int SETTLE_MIN_DIST_TIER2 = 60;
public const int SETTLE_MIN_DIST_TIER3 = 20;
public const int SETTLE_MIN_DIST_TIER4 = 8;
public const int SETTLE_MIN_DIST_TIER5 = 5;
public const float ANCHOR_MIN_DIST = 80f; // min tile distance between narrative anchors
// ── Phase 3: Infrastructure ─────────────────────────────────────────────
public const float ROAD_SHORTCUT_FRACTION = 0.30f; // fraction of MST edges to add as shortcuts
public const float BRIDGE_COST = 50f; // A* cost to cross a river
public const float CROSSING_COST = 20f; // A* cost for road-rail crossing
public const float SETBACK_COST_SCALE = 8f; // multiplier for river/rail proximity penalty
public const int SETBACK_DISTANCE = 4; // ideal tile separation from parallel features
public const float RAIL_BRIDGE_COST = 80f; // rail crossing a river is expensive
// ── Phase 3: Factions ───────────────────────────────────────────────────
public const float FACTION_INFLUENCE_RADIUS = 60f; // tiles; falloff radius from seed points
public const float FACTION_DECAY_RATE = 0.015f; // per-tile decay
// ── Phase 3: PoIs ───────────────────────────────────────────────────────
public const int POI_MIN_DIST_FROM_SETTLE = 6; // tiles from any settlement
public const int POI_MIN_DIST_FROM_POI = 4; // tiles from other PoIs
// ── Phase 2+3: RNG sub-streams (new, non-colliding) ────────────────────
public const ulong RNG_LAKE = 0x1A4EUL;
public const ulong RNG_MEANDER = 0xE41DE7UL;
public const ulong RNG_HABITAT = 0x4AB17A7UL;
public const ulong RNG_ANCHOR = 0xA1C407UL;
public const ulong RNG_SETTLE_ATTR = 0x5A774UL;
public const ulong RNG_TRADE = 0x74ADE5UL;
public const ulong RNG_ENCOUNTER = 0xE1C0U17UL;
4. New Data Structures
4.1 Polyline (the backbone of this phase)
Create Theriapolis.Core/World/Polylines/Polyline.cs:
/// <summary>
/// A polyline in world-pixel space (0..32768 on each axis).
/// Source of truth for rivers, roads, and rail. Per-tile flags are derived.
/// </summary>
public sealed class Polyline
{
public PolylineType Type { get; init; } // River, Road, Rail
public int Id { get; init; } // unique per type
public List<Vec2> Points { get; } // control points in world-pixel space
public List<Vec2>? SimplifiedPoints { get; set; } // RDP-reduced for LOD
public float Width { get; set; } // world-pixel width for rendering
// River-specific
public RiverClass RiverClassification { get; set; }
public int FlowAccumulation { get; set; }
// Road-specific
public RoadType RoadClassification { get; set; }
// Source/destination settlement IDs (for roads, rail)
public int FromSettlementId { get; set; } = -1;
public int ToSettlementId { get; set; } = -1;
}
public enum PolylineType : byte { River, Road, Rail }
public enum RiverClass : byte { Stream, River, MajorRiver }
public enum RoadType : byte { Footpath, DirtRoad, PostRoad, Highway }
Use a plain Vec2 struct (two floats) instead of pulling in System.Numerics
— Core has no external dependencies. Create Theriapolis.Core/Util/Vec2.cs:
public readonly struct Vec2
{
public readonly float X, Y;
public Vec2(float x, float y) { X = x; Y = y; }
public static Vec2 operator +(Vec2 a, Vec2 b) => new(a.X + b.X, a.Y + b.Y);
public static Vec2 operator -(Vec2 a, Vec2 b) => new(a.X - b.X, a.Y - b.Y);
public static Vec2 operator *(Vec2 a, float s) => new(a.X * s, a.Y * s);
public float LengthSquared => X * X + Y * Y;
public float Length => MathF.Sqrt(LengthSquared);
public Vec2 Normalized => this * (1f / Length);
public Vec2 Perp => new(-Y, X); // 90-degree CCW rotation
public static float Dot(Vec2 a, Vec2 b) => a.X * b.X + a.Y * b.Y;
public static float DistSq(Vec2 a, Vec2 b) => (a - b).LengthSquared;
}
4.2 Settlement
Create Theriapolis.Core/World/Settlement.cs:
public sealed class Settlement
{
public int Id { get; init; }
public string Name { get; set; } = "";
public int Tier { get; init; } // 1–5
public int TileX { get; init; } // world tile coords
public int TileY { get; init; }
public float WorldPixelX => TileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS / 2f;
public float WorldPixelY => TileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS / 2f;
// Narrative anchor tag (null for non-anchor settlements)
public NarrativeAnchor? Anchor { get; init; }
// Generated attributes (Phase 3, stage 14)
public SettlementEconomy Economy { get; set; }
public SettlementGovernance Governance { get; set; }
public string[] CladeRatios { get; set; } = Array.Empty<string>();
public float WealthLevel { get; set; }
public int Population { get; set; }
public float HybridPct { get; set; }
public string ScentProfile { get; set; } = "";
// Derived after road/rail gen
public bool HasRailStation { get; set; }
public bool IsOnRiver { get; set; }
}
public enum NarrativeAnchor : byte
{
Millhaven, Thornfield, FortDustwall, TheTangles,
SanctumFidelis, Heartstone
}
public enum SettlementEconomy : byte
{
Farming, Mining, Manufacturing, Trade, Military, Fishing
}
public enum SettlementGovernance : byte
{
Council, Mayor, MilitaryCommandant, ClanElder, Corporate, Anarchic
}
4.3 Faction Influence
Create Theriapolis.Core/World/FactionInfluence.cs:
public sealed class FactionInfluenceMap
{
// Per-faction influence at each world tile: [factionIndex, x, y]
// Faction indices: 0=Enforcers, 1=Inheritors, 2=ThornCouncil
public float[,,] Influence { get; }
= new float[3, C.WORLD_WIDTH_TILES, C.WORLD_HEIGHT_TILES];
public int DominantFaction(int x, int y)
{
float best = 0; int idx = -1;
for (int f = 0; f < 3; f++)
if (Influence[f, x, y] > best) { best = Influence[f, x, y]; idx = f; }
return idx;
}
}
public enum FactionId : byte
{
CovenantEnforcers = 0,
Inheritors = 1,
ThornCouncil = 2,
}
4.4 Extend WorldState
Add these fields to WorldState:
// ── Polylines (source of truth for linear features) ─────────────────
public List<Polyline> Rivers { get; } = new();
public List<Polyline> Roads { get; } = new();
public List<Polyline> Rails { get; } = new();
// ── Settlements ─────────────────────────────────────────────────────
public List<Settlement> Settlements { get; } = new();
// ── Faction influence (3-layer float map) ───────────────────────────
public FactionInfluenceMap? FactionInfluence { get; set; }
// ── Habitability score (reusable across stages) ─────────────────────
public float[,]? Habitability { get; set; }
// ── Encounter density (derived) ─────────────────────────────────────
public float[,]? EncounterDensity { get; set; }
// ── Hash helpers for new data ───────────────────────────────────────
public ulong HashSettlements() { /* FNV-1a over sorted settlement ids + tile coords */ }
public ulong HashPolylines() { /* FNV-1a over all polyline point arrays */ }
5. The Polyline System (critical — read twice)
This is the architectural core of Phase 2+3, extending the seamless zoom model from the original plan (Section 5). Read Section 5 of the original plan again before implementing this.
5.1 Coordinate space
All polyline points are in world-pixel space: (0..32768, 0..32768).
Convert from world tile coords: worldPixelX = tileX * 32 + 16 (tile center).
5.2 Generation pipeline
- Cell-path stage produces a sequence of world tile coordinates
(tileX, tileY)— for rivers this comes from drainage simulation, for roads/rail from A* pathfinding. - Convert to control points in world-pixel space (tile centers).
- Catmull-Rom spline interpolation through control points, producing
SPLINE_SUBDIVISIONSintermediate points per segment. - Perpendicular noise offset at each subdivided point, sampled from FastNoiseLite with a per-polyline sub-seed. Amplitude varies by terrain (see constants). Rivers get more meander; roads get less.
- RDP simplification produces a second point list for LOD rendering at low zoom (fewer vertices, same visual shape).
- Tile flag derivation: rasterize the polyline onto the tile grid, setting
HasRiver/HasRoad/HasRail+ adjacency flags on each tile the polyline crosses and its neighbors.
Create Theriapolis.Core/World/Polylines/PolylineBuilder.cs with static
methods: CatmullRomSmooth(...), ApplyMeanderNoise(...),
RDPSimplify(...), RasterizeToTileFlags(...).
5.3 Tile flag derivation
After each feature type's polylines are finalized:
for each polyline P:
for each segment (p0, p1) in P.Points:
rasterize the segment (Bresenham in world-pixel space scaled to tile coords)
for each tile (tx, ty) the segment passes through:
set FeatureFlags.HasRiver/HasRoad/HasRail on that tile
for each of the 8 neighbors:
set FeatureFlags.RiverAdjacent/RailroadAdjacent as appropriate
The tile flags are a derived cache, not a source of truth. The polyline points are authoritative.
6. World Generation Stages 10–22
All stages implement IWorldGenStage and live in
Theriapolis.Core/World/Generation/Stages/. Add them to
WorldGenerator.BuildPipeline() after BiomeAssignStage.
Stage 10: HydrologyGenStage
Input: Elevation map, moisture map, biome map.
Output: WorldState.Rivers populated with polylines; HasRiver and
RiverAdjacent flags set; lakes carved into elevation map.
Algorithm:
-
Flow direction map: For each land tile, compute gradient descent — the neighbor (of 8) with the lowest elevation. Store as a
byte[1024,1024]direction map. Handle flat areas by finding the nearest downhill tile (BFS from flat regions to their lowest-elevation edge). -
Flow accumulation: Traverse tiles in elevation order (highest first). Each tile sends its accumulated flow (starts at 1) downstream along the flow direction. After all tiles are processed, each tile's accumulation count indicates how many upstream tiles drain through it.
-
Lake detection: Tiles where flow direction points to a neighbor of equal or higher elevation are sink tiles. Flood-fill from each sink to find the basin. If basin area >=
LAKE_MIN_AREA, carve the basin to a flat elevation (the basin's outlet elevation) and set biome to Ocean (inland lakes reuse the water biome). Mark basin tiles with HasRiver flag so they connect visually. Re-run flow direction for filled basins. -
River extraction: Starting from each tile with
accumulation >= RIVER_MIN_FLOW_ACCUM, trace downstream to the coast or a lake. Merge paths that converge (keep the higher-accumulation trunk). Classify each river by peak flow accumulation: Stream / River / MajorRiver per the threshold constants. -
Constraint check: Verify at least one river with
accumulation >= RIVER_MODERATE_THRESHOLDexists in each required macro region (Eastern Industrial Belt, Central Grasslands, Subtropical Lowlands). If a region lacks a river, seed an artificial spring at the highest point in that region and re-run accumulation for that drainage. -
Elevation carving: Along each extracted river path, reduce tile elevation by
RIVER_CARVE_DEPTHto create natural-looking valleys. Clamp to sea level minimum. -
Polyline conversion: Convert each extracted cell-path to a polyline in world-pixel space. Apply Catmull-Rom smoothing and perpendicular meander noise. Set river width: Stream=1 world-px, River=2, MajorRiver=3. Compute RDP simplification.
-
Tile flag derivation: Rasterize all river polylines onto the tile grid, setting
HasRiveron crossed tiles andRiverAdjacenton neighbors. -
Hash: FNV-1a over all river polyline points (sorted by river ID).
Stage 11: RiverMeanderGenStage
Input: River polylines from stage 10. Output: Enhanced polylines with natural meander.
This stage applies additional organic shaping that is too expensive for the extraction pass:
-
Oxbow generation: For rivers crossing flat terrain (average elevation < 0.45 along a 20-tile window), check if any meander loop brings two points of the polyline within 3 world-pixels of each other. If so, with 30% probability (from RNG_MEANDER), cut the loop into an oxbow lake — a disconnected water body adjacent to the river.
-
Width variation: Rivers passing through wetland biomes widen by 1.5x. Rivers in mountain canyons narrow by 0.7x.
-
Re-derive tile flags after meander modifications.
Stage 12: HabitabilityScoreStage
Input: Full terrain + rivers + lakes.
Output: WorldState.Habitability — float[1024,1024] score map.
Per-tile formula (from the procgen design doc):
habitability = (water_proximity * 3.0)
+ (flatness * 2.0)
+ (fertility * 2.0)
+ (trade_route_potential * 1.5)
+ (resource_proximity * 1.0)
- (elevation_extreme * 2.0)
- (hazard_proximity * 1.5)
Where:
water_proximity:1.0 / (1 + distance_to_nearest_HasRiver_or_lake_tile), computed via BFS from all water tiles outward (fast O(n) wavefront).flatness:1.0 - local_elevation_variancein a 5×5 neighborhood.fertility:moisture * temperature(good growing conditions).trade_route_potential: At this stage, approximate as centrality — average distance to the 8 nearest land tiles withhabitability_so_far > median. (This is a chicken-and-egg problem; compute in two passes: first pass without trade_route_potential, second pass adds it.)resource_proximity:1.0 / (1 + min_distance_to_mountain_or_forest_or_coast).elevation_extreme:max(0, elevation - 0.7) * 4 + max(0, 0.38 - elevation) * 4.hazard_proximity:1.0 / (1 + distance_to_nearest_deep_wilderness)where deep wilderness = tile > 30 tiles from any high-habitability tile.
Normalize the result to [0, 1] range. Parallelize with Parallel.For over
rows.
Stage 13: NarrativeAnchorPlaceStage
Input: Habitability map, macro grid, rivers.
Output: 6 Settlement records added to WorldState.Settlements with their
Anchor field set.
This stage runs BEFORE general settlement placement. (Hard rule #7.)
For each narrative anchor, define constraints as code (not data — these are bespoke):
| Anchor | Constraints |
|---|---|
| Millhaven | Tier 3. MacroCell.BiomeType == "temperate_forest" or "temperate_deciduous". Tile has RiverAdjacent or adjacent tile has HasRiver. At least 40 tiles from any Tier 2. Top 30% habitability within qualifying region. |
| Thornfield | Tier 2. MacroCell.BiomeType contains "industrial" OR Development == "industrial". Tile has HasRiver or RiverAdjacent. Top 20% habitability in qualifying region. |
| Fort Dustwall | Tier 2. MacroCell.BiomeType == "temperate_grassland" or Development == "agricultural" AND near a clade-majority border (within 3 macro cells of a cell with different primary CladeAffinities). Not required to be on rail. Top 30% habitability in qualifying region. |
| The Tangles | Not a single settlement — tag 2–4 macro cells in the subtropical lowlands region (MacroCell.BiomeType == "subtropical_forest" AND Covenant == "weak" or "nominal"). Place 2–4 Tier 4 settlements within those cells, one tagged as Thornback Hollow. |
| Sanctum Fidelis | Tier 1. Central-eastern continent (macro X in [12,22], Y in [10,22]). Adjacent to a HasRiver tile with FlowAccumulation >= RIVER_MAJOR_THRESHOLD. Top 5% habitability globally. |
| Heartstone | Tier 2. MacroCell.BiomeType contains "mountain". Elevation >= 0.65. Not adjacent to any rail line (enforced after rail gen — place it, then verify). Top 20% habitability in mountain region. |
Placement algorithm:
-
For each anchor (in order: Sanctum Fidelis first — it's the most constrained, then Heartstone, Thornfield, Fort Dustwall, Millhaven, The Tangles last): a. Collect all tiles matching the anchor's constraints. b. Score them by habitability. c. Filter out any tile within
ANCHOR_MIN_DISTof an already-placed anchor. d. Place at the highest-scoring remaining tile. e. If no tile qualifies, relax constraints one at a time (drop the river adjacency requirement first, then widen the macro cell search) and log a warning. -
For The Tangles: identify qualifying macro cells, then run the Tier 4 placement sub-routine within those cells only.
Stage 14: SettlementPlaceStage
Input: Habitability map, narrative anchors already placed.
Output: Remaining settlements added to WorldState.Settlements.
Algorithm:
-
Create a sorted list of all land tiles by habitability (descending).
-
For each tier (Tier 1 through Tier 5 — Tier 1 is already placed as Sanctum Fidelis; this stage places Tier 2–5): a. Target count is drawn from RNG_SETTLE within
[TierN_MIN, TierN_MAX]. b. Walk the sorted habitability list. For each candidate tile:- Skip if within
SETTLE_MIN_DIST_TIERnof any already-placed settlement of the same or higher tier. - Skip if tile is ocean.
- For Tier 2: require river or coast adjacency.
- For Tier 3: require within 60 tiles of a Tier 2 settlement OR on a
tile with
HasRiver. - For Tier 5 (PoIs): invert — require LOW habitability (bottom 40%). Place in mountains, deep forest, swamps, tundra.
- Place settlement, set IsSettlement flag on tile + 3×3 neighborhood. c. Stop when target count is reached or candidates exhausted.
- Skip if within
-
Assign sequential IDs. Narrative anchors keep their IDs from stage 13.
Stage 15: SettlementAttributesStage
Input: Placed settlements, macro grid, biome map, rivers. Output: Settlement attribute fields populated.
For each settlement:
-
Name: Generate from a syllable table keyed by macro region biome type and clade affinity. (Create a simple name generator in
Theriapolis.Core/Util/NameGenerator.cs— 3-5 syllable tables, combine prefix + root + suffix. Narrative anchors keep their canonical names.) -
Population: Random within tier range (from procgen doc), biased by habitability score.
-
Economy: Derived from surrounding terrain in a 10-tile radius:
- Many mountain tiles → Mining
- Many forest tiles → Manufacturing (timber)
- Many grassland/farmable tiles → Farming
- Coast adjacency → Fishing or Trade
- High trade_route_potential → Trade
- MacroCell.Development == "military" → Military
-
Governance: Weighted random from macro cell clade affinity:
- Canid-heavy → Mayor or MilitaryCommandant
- Cervid-heavy → Council or ClanElder
- Bovid-heavy → Council
- Mixed/Industrial → Mayor or Corporate
- Frontier/low covenant → Anarchic
-
CladeRatios: Base from MacroCell.CladeAffinities with ±15% noise (from RNG_SETTLE_ATTR).
-
HybridPct: 0.5–5% normally, 10–30% in Tangles-tagged cells or if MacroCell.Covenant == "nominal" or "weak".
-
WealthLevel: Derived from habitability + trade_route_potential.
-
ScentProfile: Derived from economy type (see procgen doc).
-
IsOnRiver: Check if any tile within 2 tiles of settlement has HasRiver.
Stage 16: RailNetworkGenStage
Input: Settlements (Tier 1–2), elevation map, rivers.
Output: WorldState.Rails populated with polylines; HasRail and
RailroadAdjacent flags set.
Algorithm:
-
Identify rail nodes: Sanctum Fidelis (hub) + all Tier 2 settlements except Heartstone (which is explicitly NOT rail-connected per the design).
-
A* pathfinding from the capital to each Tier 2 node:
- Cost function per the original plan + Addendum A §2:
cost(tile) = base_terrain_cost(elevation, biome) + (if HasRiver: INFINITY) + (if RiverAdjacent AND direction_parallel: INFINITY) + (if RiverAdjacent AND direction_perpendicular: RAIL_BRIDGE_COST) base_terrain_cost: proportional to elevation (mountains are expensive for rail) + biome modifier (wetland expensive, grassland cheap).- Direction tracking: A* nodes carry the entry direction (which of 8 neighbors they came from). Parallel check = angle between entry direction and river flow direction is ≤ 45°.
- Cost function per the original plan + Addendum A §2:
-
Transcontinental line: Find the easternmost coastal Tier 2/3 settlement and the westernmost Tier 2 settlement. A* between them using the same cost function but with a strong westward bias (add a small bonus for tiles further west to prefer direct routing).
-
Polyline conversion: Convert each A* path to a polyline. Apply Catmull-Rom smoothing with LOW meander amplitude (rail is engineered). Width = 1.5 world-pixels.
-
Tile flag derivation: Set
HasRailandRailroadAdjacenton the tile grid. -
Set HasRailStation on each rail-connected settlement.
-
Heartstone check: Verify Heartstone's tile is NOT within 3 tiles of any rail polyline. If it is, log a warning (anchor placement should have prevented this, but verify).
Stage 17: RoadNetworkGenStage
Input: Settlements (Tier 1–3), elevation map, rivers, rail.
Output: WorldState.Roads populated with polylines; HasRoad flags set.
This is the most complex stage. Follow Addendum A §2 exactly.
Algorithm:
-
Build settlement graph: All Tier 1–3 settlements are nodes. Edge weight = A* shortest path cost through terrain.
-
Minimum Spanning Tree: Prim's or Kruskal's on the settlement graph. This ensures all Tier 1–3 settlements are connected.
-
Add shortcuts: Sort non-MST edges by weight. Add the cheapest
ROAD_SHORTCUT_FRACTION * |MST_edges|edges (rounded up). These provide alternate routes. -
A* routing for each edge, using the full Addendum A §2 cost function:
cost(tile) = base_terrain_cost + (if HasRiver: INFINITY) + (if HasRail: INFINITY) + (if RiverAdjacent AND direction_parallel: INFINITY) + (if RailroadAdjacent AND direction_parallel: INFINITY) + (if RiverAdjacent AND direction_perpendicular: BRIDGE_COST) + (if RailroadAdjacent AND direction_perpendicular: CROSSING_COST) + SETBACK_COST_SCALE / max(1, distance_to_nearest_river_or_rail)The setback cost gently pushes roads away from rivers/rail while keeping them in the same valley (see Addendum A, "The Setback Rule").
-
Road classification by connected tier:
- Tier 1↔Tier 1 or Tier 1↔Tier 2 = Highway
- Tier 2↔Tier 2 or Tier 2↔Tier 3 = PostRoad
- Tier 3↔Tier 3 = DirtRoad
-
Tier 4 connections: For each Tier 4 settlement, find the nearest Tier 3+ settlement and route a Footpath/DirtRoad to it. Use the same cost function but with relaxed exclusion (footpaths can be narrower, so reduce setback).
-
Polyline conversion: Same as rail but with slightly higher meander amplitude (roads are less rigid than rail). Width by type: Footpath=0.5, DirtRoad=1, PostRoad=1.5, Highway=2 world-pixels.
-
Tile flag derivation: Set
HasRoadflags.
Stage 18: TradeRouteGenStage
Input: Settlements with economies, road/rail network. Output: Trade route metadata on settlements (WealthLevel adjustments).
This is a light overlay stage, not a visual feature:
- For each settlement, determine produced goods (from Economy enum) and demanded goods (everything it doesn't produce locally).
- For each (producer, consumer) pair connected by road/rail, compute a
trade score =
supply * demand / transport_cost. - Settlements on high-trade-score routes get a WealthLevel boost.
- Tag road polylines that carry trade (optional metadata for future use: merchant caravan spawns in Phase 5).
Stage 19: FactionInfluenceGenStage
Input: Settlements, macro grid, biome map.
Output: WorldState.FactionInfluence — 3-layer influence map.
Algorithm per faction:
Covenant Enforcers (faction 0):
- Seed points: Sanctum Fidelis (strength 1.0), Tier 2 cities (0.7), Tier 3 towns in macro cells with Covenant == "strong" (0.4).
- Falloff:
strength * max(0, 1 - distance / FACTION_INFLUENCE_RADIUS). - Boost in macro cells with Covenant == "strong" or "moderate" (+0.2).
Inheritors (faction 1):
- Seed points: settlements in macro cells where CladeAffinities includes predator clades (canid, felid, ursid) AND Covenant == "weak" or "nominal" (strength 0.6). Frontier settlements (Development == "frontier") get 0.4.
- Anti-correlated with Enforcer influence: where Enforcers are strong, Inheritors are suppressed.
Thorn Council (faction 2):
- Seed points: settlements in macro cells where CladeAffinities includes prey clades (cervid, bovid, leporid) (strength 0.5). Progressive urban centers (Development == "urban" or "industrial") get 0.4.
- Moderate presence in Tangles region.
For each faction, BFS outward from seed points with distance decay. Store result in the 3D influence array. Normalize so max influence per faction is 1.0.
Stage 20: PoIPlacementStage
Input: Habitability map, settlements, biome map, macro grid.
Output: Tier 5 PoI settlements added to WorldState.Settlements with
IsPoi flag set.
PoIs are placed in LOW habitability areas (bottom 40%). For each PoI:
- Select a tile from the low-habitability pool.
- Enforce
POI_MIN_DIST_FROM_SETTLEfrom any existing settlement. - Enforce
POI_MIN_DIST_FROM_POIfrom any other PoI. - Tag with a PoI type based on surrounding terrain:
- Mountain biome → AbandonedMine or NaturalCave
- Forest biome → CultDen or OvergrownSettlement
- Tundra/boreal → ImperiumRuin
- Wetland/subtropical → NaturalCave or CultDen
- Place
SETTLE_TIER5_MINtoSETTLE_TIER5_MAXPoIs (count from RNG_POI).
Stage 21: EncounterDensityGenStage
Input: Settlements, terrain, faction influence, roads.
Output: WorldState.EncounterDensity — float[1024,1024].
Per-tile density:
density = base_biome_density
* distance_from_settlement_factor // closer = safer
* (1.0 - road_proximity * 0.5) // on-road is safer
* macro_hostility_factor // frontier = more hostile
* (1.0 - enforcer_influence * 0.6) // enforcer presence = safer
Normalize to [0, 1]. This map is consumed by the encounter spawner in Phase 5.
Stage 22: ValidationPassStage
Input: Complete world state with all features. Output: Validation results logged; throws if critical violations found.
Checks:
-
Border organics (Addendum A §1): Re-run the straight-line detector from BorderDistortionGenStage. Expect 0 violations (stages 10+ should not have introduced new straight borders).
-
Linear feature exclusion (Addendum A §2): For every tile with >1 linear feature, verify they are crossing (angle diff >= 60°) or within a settlement. Log and count violations. Assert 0 violations.
-
River drainage: Every river terminates at the ocean, a lake, or a wetland biome tile. No rivers that dead-end on dry land.
-
Settlement reachability: BFS from Sanctum Fidelis along tiles with
HasRoad. All Tier 1–3 settlements must be reachable. Warn (don't fail) if Tier 4 settlements are unreachable. -
Narrative anchor constraints: Re-verify each anchor's placement constraints are still satisfied after all generation is complete (e.g., Heartstone is not adjacent to rail after rail was generated).
-
No overlapping settlements: No two settlements share the same tile.
7. A* Pathfinder
Create Theriapolis.Core/Util/AStarPathfinder.cs.
This is used by RailNetworkGen, RoadNetworkGen, and later phases. It needs to be efficient on a 1024×1024 grid.
public static class AStarPathfinder
{
/// <summary>
/// Find the lowest-cost path from (sx,sy) to (gx,gy) on the world grid.
/// costFn(fromX, fromY, toX, toY) returns the cost of moving between
/// adjacent tiles, or float.PositiveInfinity for impassable.
/// Returns null if no path exists.
/// </summary>
public static List<(int X, int Y)>? FindPath(
int sx, int sy, int gx, int gy,
Func<int, int, int, int, float> costFn,
int width = C.WORLD_WIDTH_TILES,
int height = C.WORLD_HEIGHT_TILES);
}
Implementation notes:
- Use a binary heap priority queue (implement in
Theriapolis.Core/Util/BinaryHeap.cs). The standardPriorityQueue<T,P>in .NET 6+ lacks decrease-key, so use a custom min-heap with handle-based decrease-key. - 8-directional movement (diagonals cost sqrt(2) * tile cost).
- Heuristic: octile distance (max(|dx|,|dy|) + (sqrt(2)-1) * min(|dx|,|dy|)).
- Allocate the open/closed arrays once and reuse via a generation counter to avoid clearing (the grid is 1M tiles; clearing per query is expensive if many paths are computed).
8. Rendering Updates
8.1 LineFeatureRenderer
Create Theriapolis.Game/Rendering/LineFeatureRenderer.cs.
Draws polylines in world-pixel space at the current camera zoom. This is the shared renderer for rivers, roads, and rail (original plan Section 7.4).
Implementation:
- Input: list of polylines, Camera2D, SpriteBatch.
- For each polyline, use the
SimplifiedPointsat low zoom (zoom < 0.15),Pointsat high zoom. - Render as screen-space thick lines (build a quad strip along the polyline with perpendicular width). Use a 1×1 white pixel texture and tint with the feature color.
- Colors: River = blue (#4488CC), Road = brown (#8B6914), Rail = dark gray (#444444).
- Width scales with zoom:
screenWidth = worldPixelWidth * zoom. Clamp minimum to 1 screen pixel so lines are always visible. - Frustum cull: skip polyline segments whose bounding box doesn't overlap the camera's visible rect.
8.2 Settlement rendering
Update WorldMapRenderer to draw settlement icons after terrain:
- Tier 1: 8×8 gold diamond
- Tier 2: 6×6 white square
- Tier 3: 4×4 light gray circle (approximated with a small square)
- Tier 4: 3×3 brown dot
- Tier 5 (PoIs): 2×2 red dot (only visible above zoom threshold 0.3)
Generate these as runtime textures in TileAtlas (same pattern as biome
tiles — no external art files needed for placeholder).
8.3 Settlement labels
Above zoom threshold 0.2, draw settlement names as SpriteFont text
centered above the settlement icon. Use Myra's default font or MonoGame's
built-in SpriteFont (already available in the project). Tier 1–2 labels
always show; Tier 3 labels show above zoom 0.3; Tier 4+ labels show above
zoom 0.5.
8.4 WorldMapScreen update
In WorldMapScreen.Draw(), after drawing terrain:
1. worldMapRenderer.DrawTerrain(...) // existing
2. lineFeatureRenderer.DrawRivers(...) // NEW
3. lineFeatureRenderer.DrawRails(...) // NEW
4. lineFeatureRenderer.DrawRoads(...) // NEW
5. worldMapRenderer.DrawSettlements(...) // NEW
6. worldMapRenderer.DrawLabels(...) // NEW
9. Tools Update
Update worldgen-dump to render the new features on the exported PNG:
- Rivers: draw as blue lines (width proportional to classification).
- Rail: draw as dark gray lines.
- Roads: draw as brown lines.
- Settlements: draw as colored dots (gold for Tier 1, white for Tier 2, gray for Tier 3, small brown for Tier 4).
- PoIs: draw as small red dots.
- Narrative anchors: draw with a named label.
Add a new command settlement-report --seed <n> that runs the full pipeline
and prints a table of all settlements with their tier, name, anchor tag,
coordinates, economy, governance, and whether they have rail access.
10. Data Files
10.1 factions.json
Create Content/Data/factions.json:
[
{
"id": "covenant_enforcers",
"display_name": "Covenant Enforcers",
"color": "#2266AA",
"seed_covenant_levels": ["strong", "moderate"],
"seed_development_levels": ["urban", "industrial"],
"base_strength": 0.7,
"affinity_clades": []
},
{
"id": "inheritors",
"display_name": "The Inheritors",
"color": "#AA2222",
"seed_covenant_levels": ["weak", "nominal"],
"seed_development_levels": ["frontier", "wilderness"],
"base_strength": 0.6,
"affinity_clades": ["canid", "felid", "ursid"]
},
{
"id": "thorn_council",
"display_name": "Thorn Council",
"color": "#22AA44",
"seed_covenant_levels": ["moderate", "weak"],
"seed_development_levels": ["urban", "agricultural"],
"base_strength": 0.5,
"affinity_clades": ["cervid", "bovid", "leporid"]
}
]
Create a FactionDef record in Theriapolis.Core/Data/FactionDef.cs
and load it in ContentLoader.
10.2 No changes to macro_template.json or biomes.json
The existing data files are sufficient. Settlement names are procedurally generated, not loaded from JSON (except narrative anchor names which are hardcoded).
11. Tests
All new tests go in Theriapolis.Tests/. Run with dotnet test.
11.1 Determinism tests (extend existing)
- Full pipeline determinism: Seed
0xCAFEBABErun twice. Hash settlements (sorted by ID: tier + tileX + tileY), hash all polyline points (sorted by type then ID), hash faction influence map. Assert identical. - Different seeds diverge: Seeds
0xCAFEBABEand0xDEADFACEproduce different settlement locations and river paths.
11.2 Hydrology tests
- Rivers drain to water: For every river polyline, the final point is within 2 tiles of an ocean or lake tile. Test on 5 seeds.
- Required rivers exist: Eastern Industrial Belt, Central Grasslands, and
Subtropical Lowlands each contain at least one river with
FlowAccumulation >= RIVER_MODERATE_THRESHOLD. Test on 10 seeds. - No dry-land dead ends: No river polyline's final point is on a tile with elevation > SeaLevel + 0.05 that isn't a lake.
11.3 Settlement tests
- Narrative anchor placement: On 100 random seeds, all 6 narrative anchors are placed successfully (no relaxation warnings). Each satisfies its constraints.
- Tier counts: For 10 seeds, settlement counts per tier fall within the
[MIN, MAX]ranges. - Minimum distance: No two settlements of the same or higher tier are
closer than
SETTLE_MIN_DIST_TIERn. - Sanctum Fidelis is on a major river: Verify on 20 seeds.
- Heartstone is not on rail: Verify on 20 seeds.
- Millhaven is near a river: Verify within 2 tiles of a HasRiver tile on 20 seeds.
- All Tier 1–3 reachable by road: BFS from Sanctum Fidelis along HasRoad tiles reaches all Tier 1–3 settlements. Test on 10 seeds.
11.4 Linear feature exclusion tests (Addendum A §2)
- Zero parallel violations: Run ValidationPassStage on 10 seeds. Assert 0 tiles with parallel linear features outside settlements.
- Crossings are near-perpendicular: For every tile with >1 linear feature (outside settlements), verify the angle between features is >= 60°.
- Rivers have exclusion zones: For every tile with
HasRailorHasRoad, verify it is notRiverAdjacentwith a parallel direction (or is a crossing tile within a settlement).
11.5 Road network tests
- MST connectivity: All Tier 1–3 settlements are in the same connected component of the road graph.
- Shortcut count: Number of road edges beyond the MST is within
[0.2, 0.4] * |MST_edges|.
11.6 Architecture test (existing — no change needed)
The existing CoreNoDependencyTests already ensures Core has no MonoGame
reference. No new tests needed for this.
12. Implementation Order
Sonnet should implement in this order, building up iteratively. Each step should compile and the existing tests should continue to pass.
Step 1: Foundation types
Vec2structPolylineclass and enumsSettlementclass and enumsFactionInfluenceMapclass- Extend
WorldStatewith Rivers, Roads, Rails, Settlements, FactionInfluence, Habitability, EncounterDensity lists/arrays - Add new constants to
Constants.cs BinaryHeap<T>priority queueAStarPathfinderPolylineBuilder(CatmullRom, MeanderNoise, RDP, RasterizeToFlags)NameGenerator(simple syllable combiner)- Hash methods on WorldState for new data
Step 2: Hydrology (stages 10–11)
HydrologyGenStage— flow direction, accumulation, lakes, river extraction, polyline conversion, tile flagsRiverMeanderGenStage— oxbows, width variation- Add stages to
WorldGenerator.BuildPipeline() - Hydrology tests
Step 3: Settlements (stages 12–15)
HabitabilityScoreStageNarrativeAnchorPlaceStageSettlementPlaceStageSettlementAttributesStageFactionDef+factions.json+ content loading- Settlement placement tests
- Narrative anchor tests (100-seed sweep)
Step 4: Infrastructure (stages 16–18)
RailNetworkGenStageRoadNetworkGenStageTradeRouteGenStage- Linear feature exclusion tests
Step 5: Factions, PoIs, density, validation (stages 19–22)
FactionInfluenceGenStagePoIPlacementStageEncounterDensityGenStageValidationPassStage- Full determinism tests
- Validation pass tests
Step 6: Rendering
LineFeatureRenderer- Settlement icons in
TileAtlas - Settlement rendering in
WorldMapRenderer - Label rendering
WorldMapScreendraw order update
Step 7: Tools
- Update
worldgen-dumpto render rivers, roads, rail, settlements - Add
settlement-reportcommand
13. Acceptance Criteria for the Phase 2+3 Branch
dotnet buildsucceeds.dotnet testpasses all tests (both old Phase 0+1 tests AND all new tests).- Running
Theriapolis.Desktop:- Click "New World", enter or accept a seed.
- World map shows colored biome tiles (as before) PLUS:
- Blue river lines flowing from mountains to coast/lakes.
- Dark gray rail lines connecting the capital to major cities.
- Brown road lines connecting all Tier 1–3 settlements.
- Gold/white/gray/brown settlement dots at correct positions.
- Settlement names as labels when zoomed in.
- Pan and zoom work smoothly. Lines are visible at all zoom levels.
- Rivers, roads, and rail are clearly separated (no visual overlap except at crossing points and within settlements).
dotnet run --project Theriapolis.Tools -- worldgen-dump --seed 12345 --out world.pngproduces a PNG with all features overlaid.dotnet run --project Theriapolis.Tools -- settlement-report --seed 12345prints a settlement table showing all narrative anchors placed correctly.- On 10 random seeds tested manually via the tool, the world looks plausible: rivers flow downhill, settlements cluster near water and flat terrain, roads follow valleys, rail connects major cities, narrative anchors are in their correct macro regions.
14. Performance Notes
- The A* pathfinder is likely the hot spot. Rail needs ~5 paths, roads need ~30–50. Each path on a 1024×1024 grid takes 50–200ms with a good heap. Total: 2–10 seconds. Acceptable within the 60-second budget.
- Flow accumulation over 1M tiles is O(n log n) for the sort + O(n) for the sweep. ~1–2 seconds. Fine.
- Habitability BFS is O(n). Sub-second.
- If total generation exceeds 30 seconds, profile with BenchmarkDotNet before optimizing. The most likely optimization: parallelize independent A* paths (rail paths are independent of each other; road MST edges are independent once computed).
- Do NOT speculatively parallelize A* itself. Single-threaded A* with a good heap is faster than thread-synchronization overhead for individual pathfinding queries.
15. What NOT to Build
Do not implement any of the following in this phase:
- Tactical map / chunk streaming (Phase 4)
- Player entity or movement (Phase 4)
- Combat (Phase 5)
- Quest engine (Phase 6)
- Dungeon room templates / interior generation (Phase 7)
- Weather simulation (Phase 8)
- Any UI panels (inventory, character sheet, dialog) — those are Phase 5+
- Save/load system (Phase 4+)
- Touch input (Phase 9)
Stick to world generation stages 10–22, the A* pathfinder, the polyline system, rendering the features on the world map, and testing.
Theriapolis Implementation Plan v2.0 — Phase 2+3 — 2026-04-12 Authored by ENI for LO, for handoff to Claude Sonnet