A Cairo-based minimap replay renderer for World of Warships. Parses .wowsreplay files and produces MP4 timelapse videos showing ship movements, shell traces, torpedoes, capture points, health bars, consumables with charge tracking, chat, and team scores.
The project was developed on Wargaming's request for the community.
🤖 Add the bot to your Discord server — invoke
/renderand attach a.wowsreplay.
Looped GIF, downsampled to 12 fps and 960 px wide for repo size — real MP4 output runs at 20 fps and full HD.
- 16 composable rendering layers — map background, team rosters, capture points, smoke, weather, projectiles, aircraft, ships, health bars, consumables, player header, damage stats, ribbons, killfeed + chat, HUD, trails
- Ship positions with rotated class SVG icons (destroyer, cruiser, battleship, carrier, submarine, auxiliary)
- Team-colored ships (green = ally, red = enemy, white = self) with player names
- Division mate highlighting — gold yellow ship icons on minimap + team roster
- Clan battle support — clan tags displayed below score bar in each clan's color
- Shell traces colored by ammo type (AP = white, HE = orange, SAP = pink) with caliber-scaled line widths + torpedo tracks
- Capture zone circles with progress bars, team ownership, and Arms Race buff zones
- Per-ship health bars with repair party recoverable HP segment
- Smoke screen radius visualization with per-puff FIFO lifecycle
- Consumable icons + radar/hydro/hydrophone detection radius circles
- Aircraft layer with type-specific icons (fighters, bombers, torpedo planes, scouts, depth charges, skip bombers) resolved from GameParams
- Weather zone overlay
- HUD overlay with team scores, timer, TTW pills, 1-kill-swing indicator, match result, clan battle tags
- Team roster with kills, damage, HP bars, consumable icons with charge counts (white = ready, green = active, gray = cooldown), time-based consumable capacity tracking
- Self-player damage breakdown by weapon type (AP/HE/SAP/torp/fire/flood/secondary)
- Self-player header with ship silhouette HP bar
- Kill feed + chat messages — interleaved chronologically, team chat prefixed with [T]
- Ribbon counters (grouped, accumulating)
- Per-version gamedata cache — automatic version detection from replay, isolated cache per game version, concurrent-worker safe (no git checkout at render time)
- GameParams.data pickle caching — decoded once, cached with blake2b hash key
- Configurable speed, resolution, FPS, time range, and quality (with input validation)
- Direct FFmpeg pipe with async frame writer (~17ms/frame at 1080p)
- Discord bot —
/renderslash command with progress reporting, game type display, per-phase timing breakdown - Statistics button — on-demand post-battle stats board PNG covering every player in the match, built from the replay's post-battle results packet
- Docker support — multi-stage build with persistent gamedata cache volume
The renderer ships three layouts, exposed as presets on the Discord bot's
/render command and selectable via the layer list when using the Python API.
All layers, both side panels — team rosters left, self-player header / damage breakdown / ribbons / killfeed right.
Minimap only, no panels. Best for embedding or when you just want the tactical view without the HUD chrome.
Minimap + right panel (player header, damage stats, ribbons, killfeed). Drops the team rosters; keeps the recording-player narrative.
| Dependency | Version | Purpose |
|---|---|---|
| Python | >= 3.12 | Runtime |
| FFmpeg | any recent | Video encoding (must be on PATH) |
| Cairo | system lib | 2D vector graphics. pycairo bundles it in its wheel, but cairosvg needs a separate libcairo-2.dll/libcairo.so for SVG ship icons — see Installing Cairo (matters on Windows) |
| Git | any | Gamedata version cache (git archive extraction) |
Windows: pip install pycairo ships a prebuilt wheel with Cairo statically
bundled, so pycairo itself works out of the box. This is not enough on its
own — the renderer also uses cairosvg (via cairocffi) to rasterize the
ship-class SVG icons, and cairocffi loads a separate libcairo-2.dll at
runtime through ctypes. pycairo's statically-linked copy does not expose
that DLL. If it's missing, every SVG icon load fails silently, the renderer
falls back to the PNG marker icons in gui/battle_hud/markers/ship/, and
ships render rotated 90° (the PNG markers point east; the SVGs point north,
which is what the cr.rotate(yaw) math assumes).
Install the Cairo DLL stack (plus its dependency chain — pixman, freetype, fontconfig, libpng, glib…) with MSYS2 and put it on PATH:
winget install MSYS2.MSYS2
& C:\msys64\usr\bin\pacman -Sy --noconfirm --needed mingw-w64-x86_64-cairo
# Persist the DLL directory to your user PATH (takes effect in new shells):
[Environment]::SetEnvironmentVariable('Path',
[Environment]::GetEnvironmentVariable('Path','User') + ';C:\msys64\mingw64\bin', 'User')Append
C:\msys64\mingw64\binto PATH — do not prepend it. MSYS2 ships its ownpython.exein that directory, and putting it first would shadow your project interpreter (breakingav, the parser, etc.). Appended, your normalpython3still wins while the Cairo DLLs remain discoverable.
Verify with python -c "import cairosvg; cairosvg.svg2png(bytestring=b'<svg/>')" —
it should run without an OSError: no library called "cairo-2" was found.
Ubuntu/Debian: the system libcairo2 package provides the shared library
that cairocffi needs, so SVG icons work without extra steps:
sudo apt-get install libcairo2-dev pkg-config python3-devmacOS:
brew install cairo pkg-configWindows: Download from ffmpeg.org and add to PATH, or:
winget install FFmpegUbuntu/Debian:
sudo apt-get install ffmpegmacOS:
brew install ffmpegThe renderer needs game assets (minimaps, ship icons, entity definitions) from the gamedata submodule:
git clone --recurse-submodules https://github.com/toalba/wows-renderer.git
cd wows-rendererOr if already cloned:
git submodule update --init --recursiveGamedata access: the
wows-gamedatasubmodule clones from the publicwows-render-gamedatarepo, which carries the production-required subset of game assets and is kept in sync with each WoWs build via tagged releases. No extra setup needed beyondgit submodule update --init.
# Using uv (recommended)
uv venv
source .venv/bin/activate # Linux/macOS
# or: .venv\Scripts\activate # Windows
uv syncOr with plain pip:
python -m venv .venv
source .venv/bin/activate
pip install -e "."# Check FFmpeg is available
ffmpeg -version
# Quick test render (auto-detects game version, caches gamedata on first run)
python render_quick.py path/to/battle.wowsreplay output.mp4The simplest way to render a replay:
python render_quick.py path/to/battle.wowsreplay output.mp4This renders at 20x speed, 1080px resolution, 20 FPS with all layers enabled. On first run for a new game version, it builds a gamedata cache (~10s), subsequent renders are instant.
from pathlib import Path
from renderer.config import RenderConfig
from renderer.core import MinimapRenderer
from renderer.gamedata_cache import resolve_for_replay
from renderer.layers.map_bg import MapBackgroundLayer
from renderer.layers.ships import ShipLayer
from renderer.layers.trails import TrailLayer
from renderer.layers.projectiles import ProjectileLayer
from renderer.layers.capture_points import CapturePointLayer
from renderer.layers.health_bars import HealthBarLayer
from renderer.layers.consumables import ConsumableLayer
from renderer.layers.smoke import SmokeLayer
from renderer.layers.weather import WeatherLayer
from renderer.layers.aircraft import AircraftLayer
from renderer.layers.team_roster import TeamRosterLayer
from renderer.layers.right_panel import RightPanelLayer
from renderer.layers.hud import HudLayer
from wows_replay_parser import parse_replay
# Resolve gamedata version for this replay (builds cache if needed)
vgd = resolve_for_replay("battle.wowsreplay", Path("wows-gamedata"))
# Parse the replay with version-correct entity definitions
replay = parse_replay("battle.wowsreplay", str(vgd.entity_defs_path))
# Configure the renderer
config = RenderConfig(
gamedata_path=vgd.version_dir / "data",
versioned_gamedata=vgd,
speed=20.0, # 20x playback speed
fps=20, # 20 frames per second
minimap_size=1080, # 1080px square minimap
panel_width=420, # Side panel width
crf=23, # Video quality (lower = better, 0-51)
)
# Build the renderer with layers (order = draw order, first = bottom)
renderer = MinimapRenderer(config)
for layer in [
MapBackgroundLayer(),
TeamRosterLayer(),
CapturePointLayer(),
WeatherLayer(),
SmokeLayer(),
TrailLayer(),
ProjectileLayer(),
AircraftLayer(),
ShipLayer(),
HealthBarLayer(),
ConsumableLayer(),
RightPanelLayer(),
HudLayer(),
]:
renderer.add_layer(layer)
# Render to MP4
renderer.render(replay, Path("output.mp4"))Measure per-layer timing breakdown:
python profile_frames.py path/to/battle.wowsreplay /tmp/profile.mp4Outputs a table showing total time, per-frame ms, and percentage for each layer + encode phase.
All rendering parameters are controlled via RenderConfig. Invalid values raise ValueError at construction time.
| Parameter | Default | Description |
|---|---|---|
minimap_size |
760 | Minimap resolution in pixels (square) |
panel_width |
220 | Side panel width in pixels |
left_panel_width |
None | Override left panel width (None = use panel_width) |
right_panel_width |
None | Override right panel width (None = use panel_width) |
fps |
20 | Output video frame rate |
speed |
10.0 | Playback speed multiplier (10x = 20min match in 2min) |
start_time |
0.0 | Start rendering at this timestamp (0 = auto-detect battle start) |
end_time |
None | Stop rendering at this timestamp (None = end of match) |
codec |
libx264 | FFmpeg video codec |
crf |
23 | Constant rate factor / quality (0-51, 18-28 typical) |
trail_length |
30.0 | Ship movement trail duration in seconds |
team_colors |
green/red | RGBA tuples per team ID |
self_color |
white | RGBA tuple for the recording player's ship |
division_color |
gold yellow | RGBA tuple for division mate highlighting |
versioned_gamedata |
None | VersionedGamedata for version-specific data (set by resolve_for_replay) |
Total output resolution = left_panel + minimap_size + right_panel x minimap_size + hud_height.
Layers are composited bottom-to-top. Each layer is independent and optional.
| Layer | Description |
|---|---|
MapBackgroundLayer |
Water texture + minimap PNG + grid (pre-rendered static cache) |
TeamRosterLayer |
Left panel: both teams with names, kills, damage, HP bars, consumable charge tracking |
CapturePointLayer |
Cap circles with progress arcs, team colors, contested indicators, Arms Race buff zones |
WeatherLayer |
Weather zone radius circles |
SmokeLayer |
Smoke screen radius visualization with per-puff FIFO lifecycle |
ProjectileLayer |
Shell traces (AP/HE/SAP colored, caliber-scaled) + torpedo tracks |
AircraftLayer |
CV squadrons + airstrikes + consumable planes with type-specific icons |
ShipLayer |
Rotated ship class SVG icons, player names, team colors, spotted glow, division mate gold icons |
TrailLayer |
Fading ship movement trails |
HealthBarLayer |
Per-ship HP bars + repair party recoverable HP |
ConsumableLayer |
Consumable icons + radar/hydro/hydrophone detection radius circles |
RightPanelLayer |
Composite: player header + damage stats + ribbons + killfeed/chat |
HudLayer |
Score bar, timer, TTW pills, 1-kill-swing indicator, match result, clan battle tags |
The RightPanelLayer is a composite of four sub-layers:
- PlayerHeaderLayer — Self-player ship silhouette with HP bar + clan tag + name
- DamageStatsLayer — Damage dealt/spotting/potential breakdown by weapon type
- RibbonLayer — Recording player ribbon counters (grouped, accumulating)
- KillfeedLayer — Recent kills with frag icons + chat messages (interleaved chronologically)
from renderer.layers.base import Layer, RenderContext
class MyLayer(Layer):
def initialize(self, ctx: RenderContext) -> None:
"""Called once before rendering. Preload assets, cache data."""
super().initialize(ctx)
def render(self, cr, state, timestamp: float) -> None:
"""Draw onto the Cairo context for this frame."""
# cr = cairo.Context
# state = GameState (ships, battle, capture_points)
# Use self.ctx.world_to_pixel(x, z) for coordinate mapping
...The renderer automatically detects the game version from each replay and uses version-specific gamedata. This ensures replays from different game patches render correctly.
- Cache location:
~/.cache/wows-gamedata/v{build_id}/ - Population: Automatic on first render of a new version (extracts via
git archive, decodes GameParams.data, writes pickle cache) - Warm path: Single
pickle.load()— near-instant - Concurrent-safe: Multiple workers can render different version replays simultaneously (no git checkout, no locks)
- Bot startup: Pre-populates caches for all known version tags in the background
Override the cache directory with the GAMEDATA_CACHE_DIR environment variable.
The bot provides a /render slash command that accepts a .wowsreplay file and returns the rendered minimap video.
-
Create a
.envfile in the project root:DISCORD_TOKEN=your_bot_token_here -
Run the bot:
python -m bot.main # or: wows-bot
All config is via environment variables (or .env file):
| Variable | Default | Description |
|---|---|---|
DISCORD_TOKEN |
(required) | Bot token |
GAMEDATA_PATH |
wows-gamedata/data |
Path to game assets (fallback) |
GAMEDATA_REPO_PATH |
wows-gamedata |
Path to wows-gamedata git repo |
GAMEDATA_CACHE_DIR |
~/.cache/wows-gamedata |
Override cache directory |
MAX_WORKERS |
2 |
Concurrent render processes |
RENDER_MAX_TASKS_PER_CHILD |
(unset) | Recycle each worker after N renders (unset = no recycling) |
RENDER_TIMEOUT |
120 |
Max seconds per render |
COOLDOWN_SECONDS |
60 |
Per-user rate limit |
DUAL_COOLDOWN_SECONDS |
600 |
Per-user cooldown on /render_dual (it parses two replays and merges them) |
MAX_UPLOAD_MB |
50 |
Max replay file size |
AUTHORIZED_GUILD_IDS |
(empty) | Comma-separated guild IDs allowed to use /render_batch |
ENABLE_BUILD_URLS |
false |
Re-enable the ShipBuilder build-URL embed |
METRICS_ENABLED |
true |
Serve the Prometheus /metrics endpoint |
METRICS_PORT |
9108 |
Port for /metrics (container-internal only) |
API_TOKEN |
(unset) | Bearer token for the HTTP render API. Unset = API disabled. Min 16 chars |
API_PORT |
8080 |
Port for the render API (container-internal only) |
API_MAX_PENDING |
4 |
Queued + running API jobs before 429 |
API_RESULT_TTL |
3600 |
Seconds a finished API result stays downloadable |
CLOUDFLARE_TUNNEL_TOKEN |
(unset) | Token for the cloudflared sidecar (compose only) |
The bot renders replays in a ProcessPoolExecutor (separate processes for CPU-bound cairo work), reports progress to Discord in real-time, and includes game type, match duration, render time, and file size in the response. Detailed per-phase timing (resolve/parse/setup/render/encode/upload + per-layer init) is logged for performance monitoring.
Each render reply carries up to three buttons, each shown only when the underlying data is available for that replay:
- Show Builds — ShipBuilder links for every player's fitting.
- Download Chat — the match's chat log as a text attachment.
- Statistics — a post-battle stats board PNG covering every player in
the match, built from the replay's post-battle results packet. Not shown
for replays that end before that packet arrives. Honors the
anonymizeflag and the render's theme.
The /render command exposes the three layouts described in
Render modes above (full, map, playerdata)
via a slash-command choice, with full as the default.
The same render pool is reachable over HTTP, so replays can be rendered from
your own tooling instead of Discord. Set API_TOKEN to enable it; leave it
unset and no server starts.
Jobs are asynchronous. Cloudflare's edge aborts a proxied request that takes ~100 s to produce its first byte, and renders routinely take longer, so you submit a job, poll it, then download the artifact. Downloading is not time-limited — only time-to-first-byte is.
Every route except /healthz requires Authorization: Bearer $API_TOKEN.
| Endpoint | Purpose |
|---|---|
POST /v1/jobs |
Submit a render. Returns 202 {"job_id": "..."} |
GET /v1/jobs/{id} |
State, progress percent, error, result metadata |
GET /v1/jobs/{id}/result |
The finished .mp4 or .png |
GET /healthz |
Unauthenticated liveness |
POST /v1/jobs takes multipart/form-data:
| Field | Applies to | Default | Notes |
|---|---|---|---|
replay |
all | (required) | A .wowsreplay file |
replay_b |
render_dual |
(required there) | Second replay from the same match |
type |
— | render |
render, render_dual, or stats |
preset |
render |
full |
full, map, playerdata |
theme |
all | default |
default, brandon |
flags |
all | (none) | Comma-separated; only anonymize is recognised |
speed |
video jobs | 20 |
1–100× playback speed |
fps |
video jobs | 20 |
1–60 |
layout |
stats |
compact |
compact or detailed |
Options that do not apply to the chosen type are rejected with 400 rather
than ignored, so a misunderstanding surfaces immediately.
API=https://render-api.cb-tracker.eu
TOKEN=... # $API_TOKEN
# 1. Submit
JOB=$(curl -sS -X POST "$API/v1/jobs" \
-H "Authorization: Bearer $TOKEN" \
-F replay=@battle.wowsreplay \
-F type=render -F preset=full -F speed=20 | jq -r .job_id)
# 2. Poll until state is done or failed
curl -sS "$API/v1/jobs/$JOB" -H "Authorization: Bearer $TOKEN" | jq
# {"state":"running","progress":42,"status":"Rendering... 42%", ...}
# 3. Download
curl -sS -o battle.mp4 "$API/v1/jobs/$JOB/result" -H "Authorization: Bearer $TOKEN"Status codes: 400 invalid request · 401 bad or missing token · 404
unknown job (including one whose result has expired) · 409 result not ready,
or the job failed (the body carries the reason) · 413 upload over
MAX_UPLOAD_MB · 429 more than API_MAX_PENDING jobs queued or running
(honour Retry-After) · 500 internal error, details in the bot log only.
Results are deleted API_RESULT_TTL seconds after a job finishes, and the job
registry lives in memory: restarting the bot drops queued jobs and any
result not yet downloaded.
The API port is exposed to the compose network only — never published on the
host — so the tunnel plus the bearer token are the only way in. The bundled
cloudflared service connects out; nothing needs to be port-forwarded.
- Cloudflare Zero Trust → Networks → Tunnels → Create a tunnel, connector
type
cloudflared. Copy the token into.envasCLOUDFLARE_TUNNEL_TOKEN. - On that tunnel, Public Hostname → Add: pick a subdomain (e.g.
render-api) on your domain, servicehttp://bot:8080(botis the compose service name; useAPI_PORTif you changed it). - Generate a token —
openssl rand -hex 32— and set it asAPI_TOKENin.env. - The
cloudflaredservice sits behind a compose profile, so deployments that don't want a public endpoint aren't stuck with a container looping on an empty token. AddCOMPOSE_PROFILES=tunnelto.env(after which plaindocker compose up -dincludes it), or pass--profile tunneleach time. docker compose up -d --build, then verify:
docker compose ps # cloudflared is present
docker compose logs cloudflared | grep -i registered # connector is up
curl -sS https://render-api.example.com/healthz # {"status":"ok"}Because the mapping lives in the Cloudflare dashboard, changing the hostname later needs no redeploy. A Cloudflare Access policy in front of the hostname composes fine with the bearer token if you want a second layer.
# Make sure submodules are initialized
git submodule update --init --recursive
# Create .env with at minimum:
# DISCORD_TOKEN=your_bot_token_here
# Build and run with docker compose (SSH agent needed for parser dependency)
eval "$(ssh-agent -s)" && ssh-add ~/.ssh/id_ed25519
DOCKER_BUILDKIT=1 docker compose build --ssh default
docker compose up -d
# View logs
docker compose logs -fThe docker-compose setup includes:
wows-gamedatamounted read-only for git archive extraction.git/modules/wows-gamedatamounted for tag access in the containergamedata-cachenamed volume for persistent version caches across restartsprometheus+grafanasidecars for metrics (see below)
The image is a multi-stage build: builder stage installs dependencies with SSH forwarding, runtime stage is python:3.12-slim with ffmpeg, libcairo2, and git.
The bot exports Prometheus metrics on :9108/metrics — render throughput by
outcome, per-phase latency (resolve / parse / setup / render / encode /
upload), end-to-end duration, encoded frames, output size, per-layer init
cost, worker peak RSS, pool rebuilds, and event-loop lag. docker compose up
also starts a Prometheus scraper and a Grafana instance with a pre-provisioned
dashboard.
Grafana binds to loopback only, so reach it through an SSH tunnel:
ssh -L 3000:localhost:3000 <user>@<host>
# open http://localhost:3000 — set GRAFANA_ADMIN_PASSWORD in .env firstDisable with METRICS_ENABLED=false, or move the endpoint with
METRICS_PORT (keep monitoring/prometheus.yml in sync).
Metrics live entirely in bot/ — the renderer/ package has no
prometheus_client dependency, so the library, CLI, and tests are unaffected.
.wowsreplay file
|
| resolve_for_replay() # gamedata_cache.py — version detection + cache
| → extracts build ID from replay header
| → populates ~/.cache/wows-gamedata/v{build_id}/ if needed
| (git archive + GameParams.data decode + pickle cache)
v
parse_replay() # wows-replay-parser
| Decrypt → decompress → decode packets → build events + state tracker
v
ParsedReplay
| .iter_states(timestamps) # O(delta) incremental state queries
| .events # Typed events (shots, damage, deaths, chat, etc.)
v
MinimapRenderer
| For each frame:
| state = next(state_iter)
| for layer in layers:
| layer.render(cairo_context, state, t)
| pipe frame to FFmpeg (async background thread)
v
output.mp4 # h264, Discord/YouTube compatible
When a new WoWs patch releases, update the gamedata submodule:
cd wows-gamedata
git fetch --tags
cd ..
git submodule update --remote wows-gamedataThe renderer automatically detects the replay's game version and builds a version-specific cache on first render. No code changes needed — entity definitions are loaded dynamically from .def files, and GameParams data is decoded from GameParams.data.
Bug reports, patches, and layer ideas welcome. See
.github/CONTRIBUTING.md for dev setup and
.github/CODE_OF_CONDUCT.md for expectations.
Security issues: see SECURITY.md.
landaire/wows-toolkit— another community World of Warships replay tooling project (Rust).
Apache 2.0 — Copyright Wargaming.net



