⚠️ 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.
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:
Render themes — the same continent (same cells, rivers, roads, settlements),
re-skinned by swapping a Theme (palette + biome vocabulary). No regeneration:
![]() theme="neon" |
![]() theme="dune" |
![]() theme="blueprint" |
The same theme= drives the town and dungeon renderers too — one skin across all three:
![]() Settlement, theme="neon" |
![]() Dungeon, theme="blueprint" |
Tectonic worlds — pass tectonic=True and the base morphology comes from a
spherical plate-tectonics simulation (plates drift, collide into mountain belts,
subduct and rift) rather than noise. The result is drifted continents, island arcs,
ragged coasts and collision ranges — a realistic planet, not one rounded blob:
from mapwright import SeededRNG, RegionalTerrainGenerator, WorldMapConfig, PRESETS
cfg = WorldMapConfig(**PRESETS["world"])
world = RegionalTerrainGenerator(SeededRNG(7)).generate(240, 130, cfg, tectonic=True)land_age sets how far the plates have drifted (young → little, old → much);
continents sets the plate count. numpy-only and seed-deterministic.
Below: deterministic shaded-relief renders of each built-in preset (or a dungeon),
produced by examples/gallery.py:
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).
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]"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, clampedPresets: 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-identicalto_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 textGenerate 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))
# Let the town take the shape of real terrain: seat it on a stretch of a world.
from mapwright import RegionalTerrainGenerator, world_terrain_field
world = RegionalTerrainGenerator(SeededRNG(103)).generate(64, 44)
field = world_terrain_field(world, region=(43, 14, 16, 16)) # a 16×16 world patch
coastal_town = SettlementGenerator(SeededRNG(5)).generate(90, 90, terrain=field)Settlement presets: hamlet, village, town, city, port, citadel,
shantytown, metropolis, grid_city, fortress_town, pilgrimage_site,
mining_camp. The wealth (poor ⇄ rich) and era (ancient ⇄ modern) knobs drive
the shanty↔skyscraper axis — plot size, ward-kind mix, and block regularity. The
layout knob picks the street pattern: "organic" (winding ward-to-ward roads,
the default) or "grid" (a geometric street grid aligned to the town's long axis,
with grid-aligned lots). The purpose knob ("fortress", "religious",
"extraction", …) gives the town a central landmark the main roads focus on
and biases its ward mix toward what it's for.
| 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 |
Town layout: an organic (concave, lobed) footprint divided into named Voronoi wards (market, docks, …), each subdivided into building lots, a street network (layout="organic" → MST over ward adjacency + gate-to-hub roads; layout="grid" → a geometric street grid + grid-aligned lots), an optional defensive wall (towers + gate gaps, opened at the harbour when coastal), an optional purpose that places a central landmark (citadel/temple/mine/…) the main roads focus on, and optional coastline. Pass an optional terrain field (or world_terrain_field(world, region)) to make the footprint take the shape of its ground — hugging shores, fingering between lakes, spreading round on flats. |
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.
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:
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.
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.
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).
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_areaclamp ingenerate), which bounds the hydrology/climate/graph work — but the initial Voronoi rasterisation is per-pixel, so total time still grows roughly linearly withwidth × heighton large maps. Raisecell_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_leaffor fewer, larger rooms.
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.
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
pytestMIT 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.































{ "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"} } }