factorio-map #
A zoomable, deep-zoom web map + timelapse of a running Factorio server (2.0 / Space Age). It renders every surface — Nauvis, the other planets and space platforms — into a tiled map you can pan and zoom in the browser, with train-station overlays, a timelapse slider and shareable deep links.
It runs headless with software OpenGL (Mesa llvmpipe) — no GPU required —
and ships each render to any S3-compatible object store.
Screenshots #
Full map with train-station overlays:

Zoomed in — factory detail (a train-station loading array):

Every surface, including the other planets (here Fulgora):

Features #
- Every surface — planets and space platforms, one tab each.
- Sharp, near-native detail — resolution is a single knob (
MAP_ZOOM). - Train-station overlays — station names, click to zoom in.
- Resource overlay — each discovered deposit as type + remaining amount (oil shown as well count); patches clustered per chunk.
- Rail-network highlight — the whole track layout drawn as a canvas layer, readable even zoomed out.
- Clean ground — pollution is cleared on the throwaway save copy, so the map isn't covered in haze.
- Timelapse — a slider scrubs through the last
KEEPrenders and keeps your current view (pan + zoom) across frames. - Shareable deep links — the URL carries the surface, game coordinates and zoom, so you can link someone to an exact spot.
- Toggles — stations / resources / rails layers independently.
- No GPU — software rendering under
Xvfb+ Mesallvmpipe. - Storage-agnostic — one tarball per render on any S3-compatible bucket.
- Container-native — example Kubernetes manifests in
deploy/.
How it works #
A scheduled job runs the renderer and produces one tarball per render:
- downloads the graphical Factorio client once (build
expansion/linux64, authenticated with a Factorio account token) and caches it on a volume; - reads the latest autosave + the server's mods (e.g. from the server's data volume, or copied out of the server container);
- renders each surface as a grid of screenshots under
Xvfb+llvmpipe. A singletake_screenshotmaxes out at 16384², so a large base is captured inCELL_PXcells at the target zoom and stitched later. Factorio buffers every queued screenshot in RAM until shutdown, so the render is split into batches (PER_BATCHcells per Factorio invocation) to bound memory. A first plan pass enumerates the grid and writes per-surface station metadata. Seeimage/mod/control.lua; - stitches each surface's cells into one Deep-Zoom pyramid with
pyvips (
image/stitch.py,arrayjoin→dzsave, streamed so the multi-gigapixel image is never materialised; missing/unexplored cells are filled black); - packs the whole render (all tile pyramids + station JSON) into one tarball
and uploads it, plus a small
index.jsontimelapse manifest. One object per render sidesteps per-object throughput limits on the object store. The lastKEEPrenders are retained.
Serving is decoupled from the object store: a sync sidecar (image/sync.sh)
polls index.json, downloads each new tarball and extracts it onto a local
volume, and nginx serves the tiles from disk. This means the map stays up even
if the object store is slow or unavailable, and the viewer never hits the store
per tile.
The frontend (image/web/index.html, OpenSeadragon)
adds surface tabs, the timelapse slider, station overlays and the deep-link/
coordinate features. Deep links look like
#s=<surface>&x=<gx>&y=<gy>&z=<zoom>&t=<render>, using Factorio game
coordinates so a link keeps working across renders.
Why grids + batches + one tarball #
Three hard limits shaped the design:
- a single
take_screenshotcaps at 16384² → for a big base that's only ~6 px/tile (blurry), so render a grid and stitch; - Factorio buffers all queued screenshots in RAM until shutdown → many 16384² cells at once OOMs → render in batches, one process per batch;
- object stores throttle per-object transfers and a detailed map is hundreds of thousands of tiles → ship one tarball per render and serve from a volume.
Two more gotchas worth knowing:
anti_aliasrenders at 2× then downscales — at 16384² the 32768² intermediate exceedsllvmpipe's texture limit, so AA is only used whenshot_res ≤ 8192;- the scratch dir must be a disk-backed volume, not a
medium: MemoryemptyDir — tmpfs counts against the container memory cgroup and OOM-kills the renderer.
Configuration #
Renderer (environment variables):
| Variable | Purpose |
|---|---|
FA_USERNAME, FA_TOKEN |
Factorio account, to download the graphical client |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
S3 credentials |
S3_ENDPOINT, S3_BUCKET, S3_PREFIX |
object store target |
MAP_ZOOM |
px/tile = 32·zoom (0.5=16, 0.75=24, 1.0=native 32). Sharper ≈ 4× the cells/tiles/time/storage per doubling |
PER_BATCH |
cells per Factorio invocation (peak RAM ≈ PER_BATCH·~1 GiB + game) |
CELL_PX |
screenshot cell size (default 16384) |
JPEG_Q |
tile JPEG quality (default 90) |
KEEP |
number of renders to retain |
RENDER_TIMEOUT |
per-batch cap |
The bucket only needs read/write on its own prefix — a scoped S3 policy:
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:ListBucket", "s3:GetBucketLocation"], "Resource": "arn:aws:s3:::YOUR_BUCKET" },
{ "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::YOUR_BUCKET/*" }
]
}
Deploy #
Example Kubernetes manifests are in deploy/: renderer.yaml (the
scheduled render CronJob, RBAC, cache volume) and serving.yaml (the sync
sidecar + nginx, serving volume, Service, Ingress). Adapt the image reference,
storage classes, namespace, secrets and ingress host to your cluster. They assume
the renderer can read the server's save + mods (the sample uses kubectl exec
against the server pod, so it grants pods/exec; mounting the save volume works
too).
Building the image #
cd image
docker buildx build --platform linux/amd64 \
-t YOUR_REGISTRY/factorio-map:latest --push .
The image bundles the render pipeline (render.sh, stitch.py,
mod/), the sync script (sync.sh) and the web frontend (web/).
Notes #
- Station labels strip Factorio rich-text tags (
[item=…]); icon-only names render as a dot without a label. - Occasionally a cell is lost to the shutdown flush (a black patch); a post-render pass could re-shoot only the missing cells.
- Render time is
llvmpipe-bound (no GPU); a GPU node would cut it substantially.
License #
MIT — see LICENSE.