Skip to main content

Domain-neutral procedural fantasy map & world generation: Voronoi terrain, hydraulic erosion, biomes, rivers, Markov place-names, and shaded-relief SVG.

Project description

mapwright

⚠️ Early development (v0.x, alpha). The API is still moving and may change without notice between versions. Usable today, but pin a version (e.g. mapwright==0.10.0) if you depend on it.

Domain-neutral procedural fantasy map & world generation — Voronoi terrain with hydraulic erosion, climate-driven biomes, rivers, Markov place-names, and shaded-relief SVG rendering. Pure Python, numpy-only, fully seed-deterministic.

mapwright produces neutral data (cells, biomes, rivers, polygons) and a self-contained SVG renderer. It has no opinion about your application's models — map its output onto your own tiles/entities however you like.

Gallery

AtlasRenderer — the same neutral terrain, skinned with a hand-drawn art pack. The art here is original, generated through mapwright's companion image service and stamped where the physics put it (mountains on the ranges, forests by climate, sea serpents offshore). mapwright itself ships no art — the pack is the skin:

hand-drawn atlas rendered from a sample art pack

Render themes — the same continent (same cells, rivers, roads, settlements), re-skinned by swapping a Theme (palette + biome vocabulary). No regeneration:

neon (Tron) theme
theme="neon"
dune (sand) theme
theme="dune"
blueprint theme
theme="blueprint"

The same theme= drives the town and dungeon renderers too — one skin across all three:

neon-themed walled citadel
Settlement, theme="neon"
blueprint-themed dungeon
Dungeon, theme="blueprint"

Below: deterministic shaded-relief renders of each built-in preset (or a dungeon), produced by examples/gallery.py:

continent preset
continent
archipelago preset
archipelago
islands preset
islands
highlands preset
highlands
desert preset
desert
arctic preset
arctic
pangaea preset
pangaea
tropical preset
tropical
generated dungeon
DungeonGenerator
generated town
SettlementGenerator
generated coastal port
Settlement (port)
generated walled citadel
Settlement (citadel)
settlements linked by terrain-routed roads
RegionalRoadGenerator
land partitioned into named territories
RegionGenerator
isthmus heightmap template
template="isthmus"
atoll heightmap template
template="atoll"
young jagged terrain
land_age=0 (young)
old worn terrain
land_age=1 (old)
continent shaped by a caller-supplied elevation hint
elevation_hint=…
A continent whose macro shape is art-directed by a caller-supplied elevation_hint (a coarse painted land/elevation mask). The host (or an LLM) draws the shape; mapwright fills in organic coasts, erosion, rivers and climate — so maps need never look circular.

The land_age pair above are the same continent at land_age=0 (young, jagged, snow-capped peaks) vs land_age=1 (old, worn down to rounded hills) — a mapwright-original "geological age" knob.

Regenerate them with python examples/gallery.py (SVGs always; PNGs when cairosvg is installed).

Install

pip install mapwright
# hand-drawn / themed atlas rendering (adds Pillow):
pip install "mapwright[atlas]"
# latest from git:
pip install git+https://github.com/sligara7/mapwright.git
# or, for local development:
pip install -e ".[dev]"

Quickstart

from mapwright import SeededRNG, RegionalTerrainGenerator, RegionalSVGRenderer, Marker

# Same seed -> same world, every time.
terrain = RegionalTerrainGenerator(SeededRNG(7)).generate(width=60, height=40)

markers = [Marker(name="Eldmoor", x=30, y=18, kind="settlement_city")]
svg = RegionalSVGRenderer().render(terrain, markers)
open("world.svg", "w").write(svg)

Shape the world with WorldMapConfig — or describe it and let an LLM fill the config:

from mapwright import WorldMapConfig, RegionalTerrainGenerator, SeededRNG

desert = WorldMapConfig.preset("desert")          # ready-made worlds...
custom = WorldMapConfig(continents=7, sea_level=0.55, temperature=-0.8)  # ...or tune
world  = RegionalTerrainGenerator(SeededRNG(1)).generate(60, 40, config=desert)

# Every field is a bounded scalar with a clear meaning, so it doubles as a schema
# a host app (or an LLM) can populate. from_dict clamps junk to valid ranges:
WorldMapConfig.from_dict({"temperature": 5, "continents": -3})  # -> safe, clamped

Presets: continent, pangaea, archipelago, islands, highlands, desert, arctic, tropical.

Terrain defaults to a tectonic-plate simulation (organic coasts + mountain ranges). For a controllable continent archetype, pass a template (Azgaar-style composed heightmap ops) — config still drives sea level, climate, and rivers on top of it:

from mapwright import RegionalTerrainGenerator, SeededRNG, WorldMapConfig, TERRAIN_TEMPLATES

print(list(TERRAIN_TEMPLATES))   # archipelago, volcano, peninsula, isthmus, atoll, continents
world = RegionalTerrainGenerator(SeededRNG(5)).generate(
    80, 58, WorldMapConfig(sea_level=0.55), template="archipelago")

Save and reload worlds (and dungeons) — JSON round-trips losslessly, so a reloaded world renders byte-identically:

from mapwright import RegionalTerrainGenerator, SeededRNG, TerrainResult

terrain = RegionalTerrainGenerator(SeededRNG(7)).generate(60, 40)
open("world.json", "w").write(terrain.to_json())          # ...later...
same = TerrainResult.from_json(open("world.json").read())  # bit-identical

to_dict/from_dict (and to_json/from_json) are available on TerrainResult, Dungeon, and Marker. Numpy rasters and full-precision floats are preserved.

Procedural place-names in several culture styles:

from mapwright import SeededRNG, NameGenerator

namer = NameGenerator(SeededRNG(7))
namer.settlement("nordic")    # -> 'Eirmundheim'
namer.settlement("elvish")    # -> 'Faelynnwood'
namer.region("dwarvish")      # -> 'The Korvald Reach'

Generate a dungeon and render it:

from mapwright import SeededRNG, DungeonGenerator, DungeonSVGRenderer

dungeon = DungeonGenerator(SeededRNG(3)).generate(48, 32)
svg = DungeonSVGRenderer().render(dungeon, labels=True)  # number the rooms
open("dungeon.svg", "w").write(svg)
print(dungeon.ascii())  # or eyeball it as text

Generate a town — an organic footprint split into named wards, each subdivided into building lots, threaded with streets, and optionally walled (try the port and citadel presets):

from mapwright import SeededRNG, SettlementGenerator, SettlementConfig, SettlementSVGRenderer

town    = SettlementGenerator(SeededRNG(7)).generate(90, 90)
port    = SettlementGenerator(SeededRNG(5)).generate(90, 90, SettlementConfig.preset("port"))
citadel = SettlementGenerator(SeededRNG(3)).generate(90, 90, SettlementConfig.preset("citadel"))
open("town.svg", "w").write(SettlementSVGRenderer().render(town))

Settlement presets: hamlet, village, town, city, port, citadel.

What's inside

Component What it does
SeededRNG One seed drives everything; .derive(label) yields independent, reproducible sub-streams (unifies stdlib + numpy).
NameGenerator Order-k character Markov names over hand-authored culture namebases; reproducible across processes.
RegionalTerrainGenerator Voronoi cells (Lloyd-relaxed) → tectonic-plate heightmap (organic coasts + mountain ranges at plate collisions; percentile sea level) → Planchon–Darboux depression fill → flux + hydraulic/creep erosion → rivers + inland lakes → latitude/elevation climate with rain-shadow → Whittaker biomes. Accepts a template= archetype or an elevation_hint= (caller-drawn macro shape).
compute_cell_polygons Reconstructs convex Voronoi polygons (half-plane clipping) for vector rendering.
RegionalSVGRenderer Shaded-relief (hillshade) SVG: biome polygons, coastline, rivers, roads, labelled markers. Takes a theme=.
Theme / THEMES A render palette + biome vocabulary; re-skins the same terrain — and its towns and dungeons — via one theme= (parchment / neon / dune / blueprint, or your own). The "Dominant Medium" layer.
environment_affordances / summarize_cells Neutral ecology helpers: biome + climate → affordance tags (scarce_water, predator, …); reduce a set of cells to a CellSummary (dominant biome, mean climate, hydrology, affordances). A host decides what tags mean mechanically.
AtlasRenderer / ArtPack Hand-drawn / themed PNG: stamps symbols from an external art pack (mountains, forests, hills, settlements, sea decorations) onto the terrain. mapwright ships no art — a pack is a skin. Needs pip install "mapwright[atlas]".
RegionalRoadGenerator Connects settlement sites with trade routes — an MST whose edges are A*-routed over the terrain (avoids sea, climbs/crosses rivers at a cost).
RegionGenerator Partitions land into named factions/territories: spread capitals seed a flood fill over the land graph (sea divides them); each Region is Markov-named.
DungeonGenerator BSP-partitioned rooms + minimum-spanning-tree corridors → rooms, corridor cells, and a walkable grid (with Dungeon.ascii()).
DungeonSVGRenderer Renders a Dungeon to SVG: walls, carved floor, room outlines, optional tile grid and per-room labels. Takes a theme=.
SettlementGenerator Self-contained town layout: an organic footprint divided into named Voronoi wards (market, docks, …), each subdivided into building lots, a street network (MST over ward adjacency + main roads from gates to the market), an optional defensive wall (towers + gate gaps, opened at the harbour when coastal), and optional coastline.
SettlementSVGRenderer Renders a Settlement to SVG: sea, footprint, kind-coloured wards, building lots, streets, wall with towers/gatehouses, labels. Takes a theme=.

Everything is neutral: RegionalTerrainGenerator returns a TerrainResult of TerrainCells (each with a Biome), and you decide how a Biome maps to your world.

Atlas rendering & art packs

RegionalSVGRenderer draws a clean shaded-relief map. For a hand-drawn (or neon, or scrap-metal, or any) look, AtlasRenderer stamps little symbol images — mountains, trees, hills, towns, sea monsters, a compass — placed exactly where the physics put them.

mapwright bundles no art. The renderer is the engine; the art is a separate art pack you point it at, so the same world can wear any style without re-generating anything:

from mapwright import SeededRNG, RegionalTerrainGenerator, ArtPack, AtlasRenderer, Marker

terrain = RegionalTerrainGenerator(SeededRNG(7)).generate(80, 56)
markers = [Marker("Eldmoor", 40, 28, kind="settlement_castle")]

pack = ArtPack.from_directory("path/to/my-art-pack")   # needs mapwright[atlas]
png = AtlasRenderer(pack, scale=12, seed=7).render(terrain, markers, land_age=0.3)
open("atlas.png", "wb").write(png)

An art pack is just a directory of transparent PNG symbols plus an optional manifest.json that maps mapwright's neutral concepts onto art slots:

{
  "name": "my-pack",
  "colors": {"parchment": "#ecdfbf", "water": "#b5cad1",
             "coast": "#463c2c", "label": "#2b2218"},
  "slots": {
    "mountain.young": {"files": ["mountains/sharp/*.png"], "width": 2.0, "anchor": "bottom"},
    "mountain.old":   {"files": ["mountains/eroded/*.png"]},
    "hill":           {"files": ["hills/*.png"]},
    "tree.pine":      {"files": ["trees/pine/*.png"]},
    "tree.deciduous": {"files": ["trees/leafy/*.png"]},
    "city.castle":    {"files": ["cities/castle*.png"]},
    "decoration.compass": {"files": ["compass/*.png"], "anchor": "center"}
  }
}

Slots the renderer asks for: terrain relief — mountain.young / mountain.mid / mountain.old (chosen by land_age), hill, tree.pine / tree.deciduous / tree.cactus (by climate), dune; settlements — city.castle / city.large / city.town / city.village (by marker kind); decorations — decoration.creature / decoration.ship / decoration.compass. A missing fine slot falls back to a coarser sibling (mountain.mid → any mountain.*), so partial packs still render. With no manifest.json, ArtPack.from_directory() auto-discovers slots from a conventional folder layout. Because packs are pure data, a host like an image-generation service can produce them on demand in any style — the generation stays the same; the pack is the skin.

Render themes

The vector RegionalSVGRenderer takes a Theme — a palette plus an optional biome vocabulary — so the same neutral terrain re-skins into wildly different worlds without regenerating anything. The neutral Biome enum never changes; a theme just decides how each biome looks and is named:

from mapwright import RegionalSVGRenderer, SettlementSVGRenderer, DungeonSVGRenderer, THEMES

svg  = RegionalSVGRenderer(theme="neon").render(terrain, markers, roads=roads)
town = SettlementSVGRenderer(theme="neon").render(settlement)   # same theme skins the town
dgn  = DungeonSVGRenderer(theme="blueprint").render(dungeon)    # …and the dungeon
# built-ins: "parchment" (default), "neon" (Tron/digital-grid), "dune" (sand), "blueprint"
THEMES["neon"].biome_label(Biome.OCEAN)   # -> "Void"  (the vocabulary layer)

All three renderers take the same theme=, so one theme skins the world map, its towns, and its dungeons together (a Theme carries nested SettlementPalette + DungeonPalette, importable from mapwright.themes for custom packs). A Theme is plain hex-string data (JSON-friendly), so a host — or the same image service that makes art packs — can author new ones. This is the "Dominant Medium" idea from mapwright's longer-term vision: a sand planet, a digital grid, and an irradiated waste are the same map wearing different skins. Pair a theme with a matching ArtPack for a full restyle of both the vector and hand-drawn renders.

Determinism

Every generator draws from a SeededRNG. The same seed (and parameters) reproduces an identical world — terrain, names, rivers, and SVG — across runs and across processes (the Markov chains are built in sorted order, so output never depends on PYTHONHASHSEED).

Performance

Pure Python + numpy, single-threaded. Typical map/town sizes generate in well under a second; examples/benchmark.py prints a table for your machine. Rough figures (numbers are machine-dependent):

Generator Size Time
Terrain 64×44 (≈470 cells) ~150 ms
Terrain 120×90 (1500 cells, capped) ~1.8 s
Dungeon 80×60 (≈50 rooms) ~9 ms
Settlement pop 9000 (50 wards, ~1100 lots) ~65 ms
Roads / regions on a 120×90 map a few ms

Two things worth knowing:

  • Terrain cell count is capped at 1500 (cell_area clamp in generate), which bounds the hydrology/climate/graph work — but the initial Voronoi rasterisation is per-pixel, so total time still grows roughly linearly with width × height on large maps. Raise cell_area (fewer, coarser cells) to trade detail for speed, e.g. generate(w, h, cell_area=12).
  • Dungeon corridor connection is a dense MST (~O(rooms³)), so dungeons with hundreds of rooms get slow — keep them modest or raise DungeonConfig.min_leaf for fewer, larger rooms.

API stability & contract

The public API is exactly the names exported in mapwright.__all__ — that's the contract. It's pinned by tests/test_api_contract.py (public surface, key signatures), so an accidental breaking change fails CI.

For the world parameters specifically, WorldMapConfig.json_schema() returns a JSON Schema (draft 2020-12) — the machine-readable contract a host app or LLM can validate/generate against, then feed through WorldMapConfig.from_dict() (which clamps to valid ranges). Schema and runtime clamping are generated from the same field spec, so they can't drift.

Versioning follows SemVer. While at 0.x the API may still change between minor versions; every change is recorded in CHANGELOG.md. Pin a tag or commit if you depend on it.

Development

python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
pytest

Credits & license

MIT licensed (see LICENSE). Algorithms were implemented clean-room from the publicly described techniques of Azgaar's Fantasy-Map-Generator (MIT) and Martin O'Leary / Ryan L. Guy's FantasyMapGenerator (Zlib); see NOTICE for details. The bundled name lists are original.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mapwright-0.19.0.tar.gz (4.0 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mapwright-0.19.0-py3-none-any.whl (82.6 kB view details)

Uploaded Python 3

File details

Details for the file mapwright-0.19.0.tar.gz.

File metadata

  • Download URL: mapwright-0.19.0.tar.gz
  • Upload date:
  • Size: 4.0 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for mapwright-0.19.0.tar.gz
Algorithm Hash digest
SHA256 006b4ec54033a31ac4eb00f34dc668387549441b3b9badf3bbd49121d07a554f
MD5 04bc5e0078c6a510721043724c8ccebd
BLAKE2b-256 2712a7dc99dc56f5babfc30094fe5250f69db117b9740847a9e3b504ae2325fb

See more details on using hashes here.

Provenance

The following attestation bundles were made for mapwright-0.19.0.tar.gz:

Publisher: publish.yml on sligara7/mapwright

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file mapwright-0.19.0-py3-none-any.whl.

File metadata

  • Download URL: mapwright-0.19.0-py3-none-any.whl
  • Upload date:
  • Size: 82.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for mapwright-0.19.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0671fc76cbd1de502519e54477a8f1eb66891cdf6d1954e2b0acde5fb4961616
MD5 2a6e62c84c48dc6ce97448f20afde716
BLAKE2b-256 8092703367888042f13f33be5bc51462bed7bf490c2c1757e36019f6b4694b18

See more details on using hashes here.

Provenance

The following attestation bundles were made for mapwright-0.19.0-py3-none-any.whl:

Publisher: publish.yml on sligara7/mapwright

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page