Files
TheriapolisV3/theriapolis-rpg-procgen-addendum-a.md
T
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
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>
2026-04-30 20:40:51 -07:00

18 KiB
Raw Blame History

Theriapolis — Procedural World Generation

Addendum A: Border Organics & Linear Feature Tile Rules


ISSUE 1: Organic Borders — No Straight Lines

The Problem

Biome-to-biome boundaries and coastline edges render as straight lines when biome assignment is done per-cell without edge treatment. A grid of cells, each tagged with a biome, produces staircase patterns at best and ruler-straight borders at worst. Neither looks natural.

The Rule

No border between any two biome types, and no border between land and ocean, may contain a straight segment longer than 2 tiles.

"Straight segment" is defined as: 3 or more consecutive border-edge tiles that form a line along any cardinal or diagonal axis without deviation.

This rule applies to:

  • Biome-to-biome transitions (forest ↔ grassland, grassland ↔ mountain, etc.)
  • Land-to-ocean transitions (coastline)
  • Land-to-lake transitions
  • Land-to-river transitions (riverbanks)

Implementation: Border Noise Injection

Biome borders are NOT the raw edges of macro-cell assignments. They are post-processed using a dedicated border-shaping pass.

Step 1 — Identify Raw Borders

After biome assignment (Layer 1), scan the tile grid for every tile that is adjacent (including diagonal) to a tile of a different biome type or a water tile. Tag these tiles as BORDER tiles.

Step 2 — Generate Border Distortion Map

Create a separate high-frequency Simplex noise layer (distinct from the terrain noise). This is the Border Noise Layer. Parameters:

frequency:   high (4×–8× the base terrain noise frequency)
octaves:     23
amplitude:   scaled to 13 tile displacement
seed:        world_seed + BORDER_OFFSET (constant offset to decouple from terrain)

The purpose of this layer: it tells each BORDER tile how far and in what direction to "push" the border from its mathematically derived position.

Step 3 — Apply Distortion to Border Tiles

For each BORDER tile:

  1. Sample the Border Noise Layer at the tile's coordinates. This yields a displacement vector (dx, dy), each component ranging from -3 to +3 tiles.
  2. The tile's biome assignment is re-evaluated: instead of asking "what biome does THIS cell belong to?", ask "what biome does the cell at (x + dx, y + dy) belong to?"
  3. This effectively warps the border, creating irregular, organic-looking transitions.

The result: borders between biomes become wavy, irregular curves with peninsulas of one biome jutting into another, concavities, and natural-looking interlock patterns.

Step 4 — Coastline-Specific Treatment

Coastlines receive an ADDITIONAL distortion pass with higher amplitude (25 tile displacement) and lower frequency (producing broad bays and headlands rather than jagged noise). This is layered on top of the standard border distortion.

Coastline distortion parameters:

frequency:   2×–4× base terrain noise
octaves:     34 (more detail than biome borders)
amplitude:   25 tile displacement
persistence: 0.5 (each octave contributes less — smooth large shapes with fine detail)

This produces coastlines with large-scale features (bays, peninsulas, capes) and small-scale features (coves, rocky outcrops, sandbars) simultaneously.

Step 5 — Validation Pass

After distortion, run a straight-line detector across all borders:

for each BORDER tile B:
    look at the 6 consecutive border tiles in each of the 8 directions
        (N, NE, E, SE, S, SW, W, NW)
    if 3+ consecutive tiles form a collinear segment:
        flag for correction

Flagged tiles receive a forced 1-tile perpendicular displacement (randomly left or right of the border line). This is the safety net — it should rarely trigger if the noise parameters are tuned correctly, but it guarantees the rule is never violated.


Implementation: Biome Transition Zones

Beyond border shape, the visual and mechanical transition between biomes should never be a hard line. Implement a transition band of 24 tiles on each side of every biome border where tiles are assigned a MIXED biome type:

  • Forest ↔ Grassland border: transition tiles are FOREST_EDGE (scattered trees, tall grass, mixed flora).
  • Grassland ↔ Mountain border: transition tiles are FOOTHILLS (rocky grassland, increasing elevation, scrub).
  • Forest ↔ Wetland border: transition tiles are MARSH_EDGE (muddy ground, standing water between trees).
  • Any biome ↔ Coastline: transition tiles are BEACH, CLIFF, TIDAL_FLAT, or MANGROVE depending on biome type and elevation.

Transition bands are also distorted by the Border Noise Layer, so their width varies organically (wider in some spots, narrower in others).

Visual Rule: A player walking from forest into grassland should cross: dense forest → thinning forest → scattered trees with grass → tall grass → open grassland. Never a single-tile hard switch.


Diagram: Border Distortion Visualization

RAW BIOME ASSIGNMENT (bad — straight edges):

FFFFFFGGGGG
FFFFFFGGGGG
FFFFFFGGGGG
FFFFFFGGGGG
FFFFFFGGGGG

AFTER BORDER NOISE (good — organic edges):

FFFFfgGGGGG
FFFFFfGGGGG
FFFfggGGGGG
FFFFfgGGGGG
FFFFFfgGGGG

F = Forest (core)
G = Grassland (core)
f = Forest Edge (transition)
g = Grassland Edge (transition)

COASTLINE EXAMPLE:

Raw:                    After Distortion:

LLLLLOOOOO              LLLLllOOOO
LLLLLOOOOO              LLLLLlOOOO
LLLLLOOOOO              LLLllOOOOO
LLLLLOOOOO              LLLLlOOOOO
LLLLLOOOOO              LLLLLllOOO

L = Land
O = Ocean
l = Coastal transition (beach/cliff/tidal)

Additional Organic Features

Peninsula and Bay Generation:

Beyond noise distortion, explicitly generate 24 large-scale coastal features per continental edge:

  1. Peninsulas: Select a random coastline point. Extend a noise-distorted finger of land 1030 tiles into the ocean at a random angle. Width tapers from 510 tiles at base to 13 tiles at tip. Peninsulas are high-value settlement locations (natural harbors on the flanks).

  2. Bays: Select a random coastline point. Carve a noise-distorted concavity 1020 tiles into the land. Width varies 515 tiles. Bays are natural harbor sites — settlement placement scores bay interiors highly.

  3. Islands: 38 islands generated offshore. Each is a small noise-generated heightmap (1030 tiles diameter), placed 520 tiles off the nearest coast. Islands can host Tier 45 settlements, PoIs, or remain wilderness.

These features are placed BEFORE border distortion (they modify the raw land/ocean mask), ensuring the distortion pass treats them as natural coastline.

River Meander:

Rivers generated by drainage simulation (Layer 2) also receive meander treatment:

  1. After the base river path is computed (gradient descent), apply a lateral sine-wave displacement along the river's length.
  2. Amplitude: 13 tiles (wider in flat terrain, tighter in mountain valleys).
  3. Frequency: varies by river length (longer rivers have broader, slower meanders).
  4. In very flat terrain (grasslands, floodplains), meander amplitude increases and occasional oxbow lakes are generated (abandoned meander loops that became disconnected).


ISSUE 2: Linear Feature Tile Exclusivity

The Problem

Rivers, roads, and railroads are all drawn as tile-based linear features on the world map. When two or more of these features occupy the same tile and run in the same direction (parallel), they become visually indistinguishable, cause rendering conflicts, and create gameplay ambiguity (is the player on the road or in the river?).

The Rule

No tile may contain more than one linear feature running in the same direction.

Specifically:

  • A river and a road may NOT occupy the same tile running parallel (same or adjacent direction).
  • A river and a railroad may NOT occupy the same tile running parallel.
  • A road and a railroad may NOT occupy the same tile running parallel.
  • A river, road, and railroad may NEVER all occupy the same tile under any circumstances.

Crossings ARE permitted. A road may cross a river (bridge tile). A railroad may cross a river (bridge tile). A road may cross a railroad (crossing tile). These are perpendicular or near-perpendicular intersections, not parallel co-occupation.

"Parallel" Definition:

Two linear features are considered parallel on a tile if their traversal directions through that tile differ by 45 degrees or less.

PERMITTED (crossing — ~90° intersection):

    Road →→→
         ↓
    River ↓↓↓

FORBIDDEN (parallel — same direction):

    Road  →→→→→→
    River →→→→→→    (both heading east through the same tiles)

FORBIDDEN (near-parallel — ~45° on same tile):

    Road  →→↗
    River →→→    (road angling NE while river goes E — too close)

PERMITTED (diverging after cross):

    Road  →→→↗↗↗
    River →→→→→→    (road crosses river then diverges — crossing tile is fine)

Implementation: Linear Feature Priority and Exclusion Zones

Linear features are generated in a fixed priority order. Higher-priority features claim tiles first. Lower-priority features must route around claimed tiles.

Priority Order:

  1. Rivers (highest — water flows where gravity dictates; it was here first)
  2. Railroads (second — expensive infrastructure, routes are surveyed and committed)
  3. Roads (third — roads are the most flexible and can divert most easily)

Step 1 — River Generation (Layer 2, unchanged)

Rivers are generated first via drainage simulation. Each river tile is tagged with:

  • feature: RIVER
  • direction: [N, NE, E, SE, S, SW, W, NW] (flow direction through this tile)
  • exclusion_zone: true

The exclusion zone for a river tile extends to all 8 adjacent tiles. These adjacent tiles are tagged:

  • river_adjacent: true
  • river_direction: [inherited from the river tile's direction]

This means: no other linear feature may enter a river-adjacent tile traveling in the same direction (±45°) as the river. They CAN enter the tile traveling perpendicular (crossing).

Step 2 — Railroad Generation

Railroads are generated using A* pathfinding between rail-connected settlements (Layer 4). The pathfinding cost function is modified:

cost(tile) = base_terrain_cost
           + (if tile.feature == RIVER: INFINITY)                    // never on river
           + (if tile.river_adjacent AND direction_parallel: INFINITY) // never parallel adjacent
           + (if tile.river_adjacent AND direction_perpendicular: BRIDGE_COST) // cross OK, expensive

Where:

  • INFINITY = impassable (pathfinder routes around)
  • BRIDGE_COST = high but finite (bridges are expensive but buildable; the pathfinder will cross rivers when routing around is worse)
  • direction_parallel = the railroad's proposed direction through this tile is within 45° of river_direction
  • direction_perpendicular = the railroad's proposed direction is 60°+ different from river_direction

When a railroad tile is placed, it receives the same treatment as rivers:

  • feature: RAILROAD
  • direction: [direction]
  • exclusion_zone: true
  • Adjacent tiles tagged railroad_adjacent, railroad_direction

Step 3 — Road Generation

Roads are generated last, using the same modified A* pathfinding but now respecting BOTH river and railroad exclusion zones:

cost(tile) = base_terrain_cost
           + (if tile.feature == RIVER: INFINITY)
           + (if tile.feature == RAILROAD: INFINITY)
           + (if tile.river_adjacent AND direction_parallel: INFINITY)
           + (if tile.railroad_adjacent AND direction_parallel: INFINITY)
           + (if tile.river_adjacent AND direction_perpendicular: BRIDGE_COST)
           + (if tile.railroad_adjacent AND direction_perpendicular: CROSSING_COST)

Where CROSSING_COST is lower than BRIDGE_COST (road-rail crossings are cheaper to build than bridges).


Crossing Tile Types

When a linear feature crosses another, the intersection tile receives a special type that handles both features:

Crossing Tile Type Visual Gameplay
Road crosses River BRIDGE Road surface over water, bridge supports visible Traversable on road; river continues beneath. Bridge can be a chokepoint.
Railroad crosses River RAIL_BRIDGE Rail tracks on bridge structure over water Rail travel continues; river flows beneath.
Road crosses Railroad RAIL_CROSSING Road surface with rail tracks crossing it, warning markers Both traversable. Possible train-encounter event at crossing.
Road crosses Road CROSSROADS Intersection Standard crossroads — signpost, possible encounter point.

Crossing Angle Constraint:

Crossings must occur at 60°–120° angles (near-perpendicular). If the pathfinder produces a crossing at a shallower angle, the crossing tile is nudged: the lower- priority feature's path is locally adjusted to approach the crossing more perpendicularly.

BAD CROSSING (too shallow, ~30°):

    River →→→→→→→
    Road    ↗↗↗↗

CORRECTED (road approaches at ~80°):

    River →→→→→→→
    Road     ↑↑↗
             ↑

This local adjustment affects only the 23 tiles immediately before and after the crossing point. The rest of the road's path is unchanged.


River-Following Roads: The Setback Rule

In real geography, roads often follow river valleys because valleys are flat and rivers indicate gentle terrain. The generation system should produce roads that travel NEAR rivers (in the same valley) without occupying the same tiles.

The Setback Rule:

When road pathfinding produces a path that would run parallel to a river (same general direction, within exclusion zone), the road is pushed to a setback distance of 35 tiles from the river centerline. The road follows the river valley's general direction but maintains visual and mechanical separation.

Implementation:

// During road A* pathfinding, for tiles near a river:

if (tile.river_adjacent AND proposed_direction is parallel to river):
    // Don't use INFINITY — we WANT the road nearby, just not ON the river
    // Instead, add a moderate cost that pushes it a few tiles away
    cost += SETBACK_COST × (1 / distance_to_river)
    // Closer to river = higher cost, pushing the path outward
    // But the valley terrain is still cheaper than mountains,
    // so the road stays in the valley, just offset

Visual result:

Terrain Cross-Section (valley):

    Mountains    Road    River    Meadow    Mountains
    /\/\/\      ====    ~~~~     ........   /\/\/\
                  ↑        ↑
            3-5 tiles separation

The player sees: a road running through a valley with a river visible 35 tiles to one side. Realistic, readable, no tile conflicts.


Railroad-Following Roads: Same Principle

Railroads and roads frequently follow the same corridors (both seek flat, direct routes). The same setback rule applies:

  • Railroad is placed first (higher priority).
  • Road pathfinding applies SETBACK_COST near railroad tiles, pushing the road 24 tiles to one side.
  • The road and railroad travel the same general corridor but on parallel paths with clear tile separation.

Visual result:

Top-Down View:

    ====  Railroad
    ....  (2-4 tile gap, terrain visible between)
    ----  Road

Edge Case: Convergence Points (Settlements)

All three linear features may converge at settlement tiles. Settlements are special:

  • Settlement tiles are multi-layer. A town can have a river running through it, a rail station, and roads entering from multiple directions.
  • Within settlement boundaries (the tile cluster that forms the town), the exclusion rules are RELAXED. Rivers, roads, and rail can coexist within a town's footprint because the settlement's own infrastructure (bridges, crossings, canal walls, station platforms) handles the overlap.
  • The exclusion rules resume at the settlement boundary. As features exit the town, they must separate to compliant distances within 2 tiles.

Implementation:

if (tile.is_settlement):
    // All linear feature costs revert to base terrain cost
    // No exclusion zones within settlement boundaries
    // Crossing types still apply (bridges, rail crossings)
else:
    // Full exclusion rules in effect

This means: a river, road, and railroad can all enter the same town (because towns form at resource convergence points), but they must diverge onto separate tile paths once they leave.


Validation Pass

After all three linear feature layers are generated, run a validation sweep:

for each tile T on the map:
    features_on_tile = list of linear features present on T
    if len(features_on_tile) > 1:
        if T.is_settlement:
            PASS (settlements are exempt)
        else:
            for each pair (A, B) in features_on_tile:
                angle_diff = abs(A.direction - B.direction)
                if angle_diff < 60°:
                    FLAG AS VIOLATION
                    // Reroute the lower-priority feature around this tile
                    // using local A* with the violating tile set to INFINITY

Rerouting is local (affects 35 tiles around the violation). The global path is not recomputed — only the immediate conflict area is adjusted. This keeps the validation pass cheap.

Violation Logging:

In development builds, log all violations with coordinates, feature types, and directions. Target: 0 violations after the validation pass. Any logged post- validation violation indicates a bug in the exclusion cost functions.


Summary: Tile Content Rules (Quick Reference)

Tile State River Railroad Road Valid?
Empty Yes
River only Yes
Railroad only Yes
Road only Yes
River + Road parallel NO
River + Road crossing ✓ (beneath) ✓ (bridge) Yes
River + Railroad parallel NO
River + Railroad crossing ✓ (beneath) ✓ (bridge) Yes
Road + Railroad parallel NO
Road + Railroad crossing ✓ (crossing) Yes
River + Road + Railroad NO (never, outside settlements)
Any combination in settlement Yes (settlement exempt)

Addendum A, Version 1.0 — Border Organics & Linear Feature Tile Rules Compiled by ENI for LO