自宅 WSL2 で動かしている Factorio (Space Age) のヘッドレスサーバーについて、「今のマップを Web ブラウザでぐりぐり眺められたら楽しいよね」という思いつきから、定期的にマップ画像を生成して公開する仕組みを構築しました。最終的には Mapshot という Factorio mod + 外部 CLI ツール + Cloudflare Tunnel の組み合わせで、https://map1.welovefactorio.com から誰でも閲覧できるようになっています。

目次
何を作ったのか
cron が 5 分おきにスクリプトを叩き、ゲーム内時間が前回から 10 分以上進んでいたら新しいマップ画像を生成、Web で公開する。誰もサーバーに接続していない時は auto_pause で tick が進まないので、無駄なレンダリングは走らない。生成されたマップ画像は履歴として蓄積され、過去の任意の時点に戻ってマップを見ることもできます。

ツール選定:FactorioMaps か Mapshot か
定番は 2 つあります。
- FactorioMaps (L0laapk3/FactorioMaps): 歴史が長く、タイムラプス機能あり。ただし主に Factorio 1.1 向けで、2.0 + Space Age への対応は fork ベースで実験段階。
- Mapshot (Palats/mapshot): 比較的新しい。最新版 0.0.28 (5 ヶ月前) で公式に Factorio 2.0 をサポート。Linux ヘッドレスサーバー運用が想定されており、CLI が用意されている。
Space Age が前提だったので Mapshot を選択。後述しますが、これは正解でした。
Mapshot のしくみ
Mapshot は 3 つの部品で構成されています。
- Lua mod: ゲーム内で
game.take_screenshot()API を呼んでマップをタイル画像化する本体。 - Go 製 CLI: Factorio 本体をバックグラウンドで起動し、mod を仕込み、完了を検知して Factorio を終了させる司令塔。
- SPA フロントエンド: 生成されたタイル画像を Leaflet.js で表示する HTML/JS 一式。
Mapshot は 「マップ画像生成」と「配信」を分離した設計になっており、出力は完全に静的な HTML/JS/JPG です。任意の HTTP サーバーで配信できます (mapshot 自身に HTTP サーバー機能もあり)。

詰まりポイント (覚え書き)
1. ヘッドレスサーバーでは描画できない
当たり前といえば当たり前ですが、ヘッドレス版 Factorio は描画機能を持っていません。Mapshot のレンダリングは game.take_screenshot() という engine API を使うため、グラフィカル版 Factorio が必要です。WSL2 で X サーバーが無いので、xvfb-run で仮想ディスプレイを与えて起動します。
稼働中のサーバーには触らず、別途グラフィカル版を入れる構成にしました。レンダリング時は最新セーブ (saves/world.zip) を一時ディレクトリにコピーして、それをグラフィカル版が読み込みます。
2. 「Mod elevated-rails requires Space Age expansion」エラー
これが一番ハマりました。Factorio の anonymous DL ページから取れる「alpha」ビルドは Space Age を含まない「素のグラフィカル版」で、DLC 機能フラグ (rail-bridges, quality, freezing 等) が無効。elevated-rails mod を有効化するとこの DLC フラグが必要なため、起動が拒否されます。
解決策は 「expansion」ビルドをダウンロードすること。URL は次のような形式:
https://www.factorio.com/get-download/2.0.76/expansion/linux64?username=<u>&token=<token>
サイズは alpha が 1.4GB に対し expansion は 4.2GB。期待通り Space Age 一式 (Vulcanus, Fulgora, Gleba, Aquilo の各惑星アセット含む) が入っています。なお username と service-token は ~/factorio-server/server-settings.json に既に記載されているもの (普段ヘッドレスサーバーが認証に使っているもの) を流用できます。
3. script-output のパスの食い違い
これは小さいけど厄介でした。Mapshot CLI は --factorio_datadir/script-output/ を見て「レンダリング完了」を検知しますが、Factorio 本体は config.ini の write-data パス配下に script-output/ を作ります。デフォルト構成だと 2 つは別ディレクトリになり、CLI は永遠に完了を検知できず、ハング状態になります。
解決策は --factorio_scriptoutput オプションを明示的に指定すること。CLI と Factorio 本体で同じパスを見るように揃えます。
xvfb-run -a ./bin/mapshot render \
--factorio_binary ./factorio/bin/x64/factorio \
--factorio_datadir ./factorio/data \
--factorio_scriptoutput ./factorio/script-output \
--work_dir ./work \
--area all \
--surface _all_ \
--minjpgquality 50 \
./tmp/snapshot.zip
4. マップが「半分切れたように」見える
初期設定だと「プレイヤー建造物のあるチャンクのみ」が描画され、未開拓の地形タイルは省略されます。これだと序盤は本拠地のごく狭い範囲しか見えず、残りが黒く欠けたようになります。
解決策は --area all と --minjpgquality 50 の指定。前者で「生成済みの全チャンク」を対象にし、後者で「エンティティが無いタイルでも JPG を出力する」よう強制します。これでタイル数が一気に増え (例: zoom_4 で 3 → 182 タイル)、全域が見えるようになりました。
cron + ゲーム内時間ゲート
cron は単純に 5 分おきに発火させます。ただし毎回レンダリングを走らせると無駄なので、稼働中のサーバーから RCON で game.tick を取得し、前回の tick との差が「ゲーム内 10 分 (= 60 tick/秒 × 600 秒 = 36000 tick)」以上の時だけ実行するようにしました。
# RCON で game.tick を取得 (Source RCON プロトコルを 30 行 Python で自前実装)
CUR_TICK=$(python3 ./rcon.py 127.0.0.1 27015 "$RCON_PW" \
"/silent-command rcon.print(tostring(game.tick))" | tr -d '[:space:]')
LAST_TICK=$(cat .last-tick 2>/dev/null || echo 0)
DIFF=$((CUR_TICK - LAST_TICK))
[ "$DIFF" -lt 36000 ] && exit 0 # 10 ゲーム分未満ならスキップ
サーバー設定が auto_pause: true なので、誰もログインしていない夜間は tick が進まず、自動的にレンダリングも走らない。地味に嬉しい挙動です。
Cloudflare Tunnel での公開
もともと別用途 (glitchtip) で使っていた Cloudflare Tunnel に ingress を追加するだけ。welovefactorio.com は別ドメインだったため、cloudflared tunnel login を再実行してこのドメインの zone を許可する cert.pem を別ファイルとして用意しました。
# cert を zone 別に分けて管理
mv ~/.cloudflared/cert.pem ~/.cloudflared/cert-l4tp.pem
cloudflared tunnel login # ブラウザで welovefactorio.com を選択
mv ~/.cloudflared/cert.pem ~/.cloudflared/cert-welovefactorio.pem
# DNS route は --origincert で使い分け
cloudflared tunnel \
--origincert ~/.cloudflared/cert-welovefactorio.pem \
route dns <tunnel-id> map1.welovefactorio.com
そして ~/.cloudflared/config.yml に新 ingress を追記して cloudflared を再起動。同じ tunnel daemon で 2 ドメインを捌けます。
履歴機能:mapshot serve への切り替え
動作確認の段階では python3 -m http.server で「最新の 1 レンダリングだけ」を配信していました。ところが Mapshot は毎回のレンダリング結果を別ディレクトリ d-<hash>/ として堆積する設計になっており、過去の出力は実は全部残っています。
そこで配信を mapshot serve に切り替えました。これは script-output/ 配下を SPA で公開するモードで、複数のレンダリングを一覧化して切り替えられる UI が標準で付いてきます。

古い履歴は generate-map.sh 末尾でローテーションさせています。設定では「直近 10000 件まで」としているので、1 日 24 時間 144 件レンダリングしても 70 日弱は遡れる計算。容量上限は ~30GB です。
# レンダリング後に古い d-* を削除
for sdir in "$SCRIPT_OUTPUT/mapshot"/*/; do
ls -1dt "$sdir"d-*/ 2>/dev/null | tail -n +$((RETAIN_COUNT + 1)) | xargs -r rm -rf
done
mapshot serve は systemd で常駐
[Unit]
Description=Factorio Mapshot HTTP server
After=network.target
[Service]
Type=simple
User=yousan
WorkingDirectory=/home/yousan/games/factorio1/maps
ExecStart=/home/yousan/games/factorio1/maps/bin/mapshot serve --port 8765 \
--factorio_scriptoutput /home/yousan/games/factorio1/maps/factorio/script-output
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
完成形のディレクトリ構成
/home/yousan/games/factorio1/
├── server/ # ヘッドレスサーバー (本番)
│ ├── bin/x64/factorio
│ └── ...
├── saves/world.zip # 共有セーブ
├── config/rconpw # RCON パスワード
└── maps/ # マップ生成・配信用
├── factorio/ # グラフィカル版 (expansion ビルド)
│ └── script-output/mapshot/snapshot/
│ ├── d-<hash1>/ # 履歴 (古い)
│ ├── d-<hash2>/
│ └── d-<latest>/ # 最新
├── bin/mapshot # CLI バイナリ (Palats/mapshot 0.0.28)
├── work/ # mapshot 作業ディレクトリ
├── tmp/snapshot.zip # レンダリング用一時セーブ
├── generate-map.sh # cron エントリポイント
├── rcon.py # RCON クライアント
├── render.log
└── .last-tick # 前回レンダリング時の game.tick
所感
- Mapshot の出力が「完全に静的」なので、配信は何でもアリ。Cloudflare Pages にデプロイしても、GitHub Pages に push しても良い。今回はトラフィックが少ないので systemd で常駐させた
mapshot serveで十分。 - 「ゲーム内時間ゲート」は地味に効く。auto_pause と組み合わせると、誰も遊んでいない時間帯は完全に走らないので CPU・ディスクともにゼロコスト。
- 履歴が「ハッシュ付きディレクトリで自動的に堆積する」設計は、後から振り返るのに本当に便利。タイムラプス動画化したくなったら
ffmpegで zoom_0 のタイル群を時系列に連結すれば作れそう。 - Space Age expansion build を anonymous URL でダウンロードできる (Factorio アカウントの service-token があれば) ことを今回知った。便利。
参考
- Palats/mapshot — GitHub リポジトリ
- Mapshot – Factorio Mods — Factorio mod portal
- map1.welovefactorio.com — 公開中 (auto_pause で誰もいない時はマップが更新されないので注意)

コメント