georender: Symbolic Map Rendering for Fantasy and Fictional Worlds
How to turn GeoJSON into slippy-map tiles with a declarative ruleset engine — no QGIS, no PostGIS mandatory, no ceremony.
georender: Symbolic Map Rendering for Fantasy and Fictional Worlds
How to turn GeoJSON into slippy-map tiles with a declarative ruleset engine — no QGIS, no PostGIS mandatory, no ceremony.
There is a quiet assumption embedded in almost every GIS tool ever built: the data describes Earth. Coordinate reference systems, default projections, even the mental model behind “zoom level 10” — all of it silently assumes a planet you can board a flight to.
Fantasy worlds do not care about WGS84. Fictional planets have no EPSG code. And yet, if you want to build an interactive map for your homebrew setting, your sci-fi star cluster, or your historical reconstruction, you end up wrestling with tools designed for road networks and municipal boundaries.
georender is a small, opinionated service that cuts through this. It takes geospatial data in standard formats and renders it as PNG images — tiles, bounding-box crops, or ad hoc renders — using a declarative JSON ruleset you define. No assumptions about what the data means. No built-in symbology for motorways or administrative regions. Just geometry, filters, and symbols.
The Core Idea
georender is a rendering microservice, not a full GIS stack. Its job is exactly one thing: given a source of geospatial data and a set of rendering rules, produce a PNG.
The rendering pipeline has three parts:
Data sources. georender speaks three backends: local GeoJSON files, PostGIS databases, and remote Mapbox Vector Tiles (MVT). You declare which one to use in a small JSON map config, and the service handles the rest. This means you can start with a single .geojson file on disk and graduate to a full PostGIS database later, changing only the config.
Rulesets. A ruleset is a JSON file that describes how to draw features. Rules match on geometry type (Point, Polygon, LineString…) and on feature properties using a small filter language. Matched features are drawn with a symbolizer. Rules are applied in ascending z_index order, giving you explicit control over layering.
Output modes. The service exposes a REST API that serves slippy-map tiles at /{map}/{ruleset}/{z}/{x}/{y}.png, full bounding-box images, TileJSON descriptors, and a POST /render/{ruleset}.png endpoint for ad hoc GeoJSON bodies. Drop the tile URL into Leaflet or MapLibre and you have an interactive map.
Getting Started in Two Commands
docker pull ghcr.io/openfantasymap/georender:main
docker run -p 8000:8000 ghcr.io/openfantasymap/georender:main
The container ships with a demo map and ruleset. Try it immediately:
curl "http://localhost:8000/demo/demo/3/4/2.png" --output tile.png
curl "http://localhost:8000/demo/demo/image.png?width=1024&height=768" --output image.png
Open image.png. That is a rendered map of the included demo world.
Writing a Ruleset
This is where georender earns its keep. A ruleset is a plain JSON file:
{
"background": "#f0ece0",
"asset_collections": { "terrain": "terrain" },
"rules": [
{
"name": "ocean",
"z_index": 1,
"geometry": ["Polygon", "MultiPolygon"],
"filter": { "kind": "water" },
"symbolizer": { "type": "polygon_fill", "fill": "#9fd7ffcc" },
"edge_fade": { "distance_px": 8 }
},
{
"name": "forest",
"z_index": 2,
"geometry": ["Polygon"],
"filter": { "kind": "forest" },
"symbolizer": { "type": "polygon_pattern", "asset": "terrain.tree-floor" }
},
{
"name": "settlements",
"z_index": 10,
"geometry": ["Point"],
"filter": { "kind": { "in": ["city", "town", "village"] } },
"symbolizer": { "type": "icon", "asset": "terrain.settlement" }
}
]
}
A few things worth noting here.
The filter language is simple but sufficient. Equality, in, not_in, exists, gte, lte — enough to cover most classification schemes without becoming a query language.
edge_fade applies a distance-based alpha fade at polygon boundaries. For water bodies against land, this produces the soft coastal gradient you see on hand-drawn fantasy maps without any Photoshop work.
Pattern fills (polygon_pattern) tile an asset image across a polygon. Icons (icon) render a sprite at point locations. Line patterns (line_pattern) stroke along line geometries.
The Asset System
Assets are images — sprites, textures, icons — organized into named collections and defined in assets/assets.json. A ruleset references a collection by name; individual rules reference specific assets within it.
The interesting part is variant sets:
{
"collections": {
"terrain": {
"stone-floor": {
"kind": "variant_set",
"variants": [
{ "file": "stone_01.png", "weight": 4 },
{ "file": "stone_02.png", "weight": 2 },
{ "file": "stone_03.png", "weight": 1 }
],
"randomization": {
"rotation": [0, 90, 180, 270],
"flip_x": true
}
}
}
}
}
When a pattern fill uses stone-floor, georender picks among the variants according to their weights and applies rotation and flip transformations. Crucially, the selection is deterministic per position: given the same coordinates and the same asset definition, you always get the same variant. Tiles rendered independently, on different requests, or after a cache eviction will still stitch together correctly.
This solves a real problem. Naive random texture variation produces seams at tile boundaries. Deterministic position-based selection does not.
Connecting to a PostGIS Source
For worlds with more data than fits comfortably in a single GeoJSON file, or where you need to query by bounding box rather than load everything into memory, georender supports PostGIS.
Create a connections.json (never commit this — mount it at runtime):
{
"myworld": {
"dsn": "postgresql://user:password@host:5432/myworld"
}
}
Then declare a map source:
{
"name": "My World",
"url": "/myworld",
"mode": "postgis",
"connection": { "db": "myworld" },
"events": "locations",
"relatedLayers": ["regions", "roads"],
"base": { "zoom": 5, "lat": 0, "lng": 0 }
}
Mount it when running the container:
docker run \
-v $(pwd)/connections.json:/app/connections.json \
-v $(pwd)/maps:/app/maps \
-p 8000:8000 \
ghcr.io/openfantasymap/georender:main
The events field points to a timeline-aware layer — georender has built-in support for temporal filtering, so you can render the same world at different points in its history.
The Ad Hoc Render Endpoint
One of the most useful endpoints for development is POST /render/{ruleset}.png. It accepts a GeoJSON body and renders it immediately, without requiring a configured map source.
curl -X POST \
"http://localhost:8000/render/demo.png?width=800&height=600" \
-H "Content-Type: application/json" \
-d @my_features.geojson \
--output preview.png
This is useful for testing ruleset changes against specific features, for building preview pipelines in editors, or for generating images from dynamically assembled GeoJSON without registering a persistent map source.
Caching
Rendered tiles are cached on disk under cache/. The cache key includes the map slug, the source revision, the ruleset revision, and an internal renderer version constant. Edit a ruleset file and the affected cache entries are automatically invalidated on the next request — you do not need to flush anything manually.
If you want to force a full cache bust (say, after changing the asset images), bump RENDERER_REVISION in georender_service/app.py.
What georender Is Not
It is worth being explicit about scope.
georender is not a cartographic editor. It does not have a GUI, drag-and-drop styling, or point-and-click rule creation. If you want that, QGIS exists and is excellent.
It is not a vector tile server. It produces PNG output. If you need client-side vector rendering with dynamic style switching, look at a dedicated MVT server.
It is not a data pipeline. It does not ingest, transform, or normalize your geospatial data. It assumes your data is already correctly structured with the properties your rules expect to filter on.
What it is: a reproducible, container-native, API-first rendering service that treats your ruleset as code and your map as a versioned artifact. If that matches your use case — whether you are building a campaign map viewer, a world atlas for a published setting, a historical reconstruction tool, or a data visualization for fictional geography — georender is worth fifteen minutes of your time.
Running in Development
If you prefer not to use Docker:
git clone https://github.com/openfantasymap/georender
cd georender
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn georender_service.app:app --reload
The service starts on port 8000. The demo map is available immediately at [http://localhost:8000/demo/demo/image.png.](http://localhost:8000/demo/demo/image.png.)
License and Source
georender is Apache 2.0. Source at
If you build something with it — a world, a tool, an integration — the OpenFantasyMap project is interested in hearing about it.
메타데이터
- post_id
- adf5c4c8af43
- slug
- georender-symbolic-map-rendering-for-fantasy-and-fictional-worlds-adf5c4c8af43
- url
- https://medium.com/openfantasymap/georender-symbolic-map-rendering-for-fantasy-and-fictional-worlds-adf5c4c8af43
- canonical_url
- https://medium.com/openfantasymap/georender-symbolic-map-rendering-for-fantasy-and-fictional-worlds-adf5c4c8af43
- author_url
- https://medium.com/@ingmmo
- status
- ok
- fetched_at
- 2026-06-11 12:34:08