Hex-based procedural world generator for TTRPGs, worldbuilding, and wargaming.
- Scale: 1 hex = 1 km
- Reproducible: any world can be regenerated from a single integer seed
- Pipelined: each generation stage is a pure transformer — swap or extend without touching others
For a full reference of every calculation, formula, and config value, see docs/REFERENCE.md.
| Phase | Description | Status |
|---|---|---|
| 0 | Foundation — hex grid, pipeline, config | ✓ Complete |
| 1 | Terrain & Elevation — fBm noise, erosion, terrain classification | ✓ Complete |
| 2 | Hydrology — sink filling, flow accumulation, river networks | ✓ Complete |
| 3 | Climate & Biomes — temperature gradient, orographic moisture, Whittaker biomes | ✓ Complete |
| 4 | Settlements & Roads — cities, towns, villages, inter-urban & track roads | ✓ Complete |
| 5 | Export — JSON round-trip, SVG hex map, PNG raster | ✓ Complete |
| 6 | CLI — generate / render / presets commands | ✓ Complete |
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"worldgen generate --seed 42
worldgen generate --seed 42 --width 256 --height 256 --output-dir ./my_world
worldgen generate --seed 42 --config path/to/config.jsonOutputs go to ./output/ by default:
output/
├── config.json # WorldConfig used for this run
├── world.json # full WorldState (JSON round-trip)
├── elevation.png
├── terrain_class.png
├── river_flow.png
├── temperature.png
├── moisture.png
├── biome.png
├── habitability.png
├── settlements.png
├── roads.png
├── land_cover.png
└── cultivation.png
Re-render any attribute from a saved world without re-running the pipeline:
worldgen render --input output/world.json --attribute biome --output biome.pngAvailable attributes: elevation, terrain_class, river_flow, temperature, moisture,
biome, habitability, settlements, roads, land_cover, cultivation.
worldgen export --input output/world.json --output world.svg
worldgen export --input output/world.json --output topo.svg --style topographic
worldgen export --input output/world.json --output wargame.svg --style wargame --hex-size 8
worldgen export --input output/world.json --output custom.svg \
--color-mode land_cover --layers terrain,rivers,settlements,labelsOr via the Python API using SVGConfig:
from worldgen.core.pipeline import GeneratorPipeline
from worldgen.core.config import WorldConfig
from worldgen.export.svg_export import save, SVGConfig
state = GeneratorPipeline(seed=42, config=WorldConfig()).run()
# default atlas style — biome colors, all layers
save(state, "world.svg", SVGConfig())
# topographic — elevation colors, terrain + rivers + grid only
save(state, "topo.svg", SVGConfig(style="topographic"))
# wargame — terrain colors, roads + settlements + grid
save(state, "wargame.svg", SVGConfig(style="wargame", hex_size=8.0))
# fully custom
save(state, "custom.svg", SVGConfig(
color_mode="land_cover",
layers={"terrain", "rivers", "settlements", "labels"},
hex_size=16.0,
padding=30,
))SVGConfig options:
| Option | Default | Values |
|---|---|---|
style |
"atlas" |
"atlas", "topographic", "wargame" |
color_mode |
"biome" |
"biome", "terrain", "land_cover", "elevation" |
layers |
all | any subset of {"terrain", "rivers", "roads", "settlements", "labels", "grid"} |
hex_size |
12.0 |
pixels per hex |
padding |
20 |
border padding in pixels |
style is a shortcut that sets color_mode and layers together: "topographic" forces elevation coloring with terrain + rivers + grid; "wargame" forces terrain coloring with roads + settlements + grid. For "topographic" and "wargame", the color_mode and layers values are fixed by the style and any explicitly provided values are ignored. Only "atlas" (the default) uses the color_mode and layers you provide.
Presets are JSON files that override any subset of WorldConfig fields. Place them in a presets/ directory and load with --config:
worldgen presets # list available presets
worldgen generate --seed 42 --config presets/island.jsonNo built-in presets ship with the project — create your own. Any field omitted in the JSON falls back to its WorldConfig default:
{
"width": 96,
"height": 96,
"continent_falloff": 0.8,
"sea_level": 0.60,
"base_temperature": 0.75,
"target_city_count": 3,
"target_town_count": 12
}Key fields to customize per world type:
| Field | Effect |
|---|---|
sea_level |
fraction of hexes below sea (0.3 = lots of land, 0.7 = archipelago) |
continent_falloff |
edge-falloff strength — higher = more island-shaped |
base_temperature |
0 = arctic, 1 = tropical |
noise_octaves |
fBm detail levels — more = rougher terrain |
erosion_iterations |
more = sharper valleys |
target_city_count / target_town_count |
settlement density |
worldgen/
├── core/ # data types and pipeline only — no rendering, no file I/O
│ ├── hex.py # Hex dataclass, enums (TerrainClass, Biome, LandCover, ...)
│ ├── world_state.py # WorldState, River, Settlement, Road
│ ├── hex_grid.py # axial math, neighbors, A*, ring/range queries
│ ├── pipeline.py # GeneratorPipeline, GeneratorStage base class
│ └── config.py # WorldConfig — all tunable parameters
├── stages/ # pure transformers: stage.run(WorldState) -> WorldState
│ ├── elevation.py # fractal Brownian motion + domain warping
│ ├── erosion.py # particle-based hydraulic erosion
│ ├── terrain_class.py # ocean / coast / flat / hill / mountain
│ ├── hydrology.py # Priority-Flood, flow accumulation, river extraction
│ ├── climate.py # temperature gradient, orographic moisture
│ ├── biomes.py # Whittaker-style temp × moisture → biome
│ ├── land_cover.py # land cover classification
│ ├── habitability.py # composite score for settlement placement
│ ├── city_town.py # city & town placement
│ ├── interurban_roads.py # inter-settlement road network
│ ├── cultivation.py # city/town and village cultivation rings
│ ├── village_placement.py # village placement
│ └── village_tracks.py # village-scale track roads
├── export/ # all file I/O lives here
│ ├── json_export.py # WorldState ↔ JSON
│ ├── svg_export.py # hex map → SVG
│ └── png_export.py # rasterised map via Pillow
├── render/ # matplotlib debug viewer (never imported by stages)
│ └── debug_viewer.py
└── cli.py
Rules that are never violated:
core/has no rendering or I/O- Stages are pure transformers — they receive and return
WorldState - All random calls use a seeded
numpy.random.Generatorpassed explicitly — no global state - All tunable parameters live in
WorldConfig— nothing hardcoded in stage logic
ElevationStage → ErosionStage → TerrainClassificationStage → HydrologyStage → …
The full pipeline runs 14 stages, continuing through climate, biomes, land cover, habitability,
city/town placement, inter-urban roads, cultivation, village placement, village tracks, and
village cultivation. Each stage receives the full WorldState and returns it with new fields
populated. Stages are composed in GeneratorPipeline:
from worldgen.core.config import WorldConfig
from worldgen.core.pipeline import GeneratorPipeline
from worldgen.stages.elevation import ElevationStage
from worldgen.stages.erosion import ErosionStage
from worldgen.stages.terrain_class import TerrainClassificationStage
from worldgen.stages.hydrology import HydrologyStage
cfg = WorldConfig(width=128, height=128)
pipeline = GeneratorPipeline(seed=42, config=cfg)
pipeline.add_stage(ElevationStage).add_stage(ErosionStage) \
.add_stage(TerrainClassificationStage).add_stage(HydrologyStage)
state = pipeline.run()All parameters are in WorldConfig. Key knobs:
WorldConfig(
width=128, height=128,
sea_level=0.45, # fraction of hexes below sea
noise_octaves=6, # fBm detail levels
erosion_iterations=15000, # more = sharper valleys
river_flow_threshold=0.05, # top N% of flow accumulation becomes rivers
base_temperature=0.5, # 0 = arctic, 1 = tropical
target_city_count=6,
target_town_count=24,
road_mountain_cost=10.0, # cost multiplier for mountain hexes
)Save / load a config:
# save the config used for a run
worldgen generate --seed 42 --output-dir ./my_world
# reload it
worldgen generate --seed 42 --config ./my_world/config.jsonpython3 -m pytest # run tests
python3 -m ruff check . # lint
python3 -m ruff format . # formatTests assert structural invariants rather than exact values (outputs are seed-dependent). Key invariants checked: rivers reach ocean, no accumulation decrease downstream, river paths are connected, same seed → same output.
Requires Python 3.11+.