A fan-made companion PWA for wdgwars.pl (Watch Dogs Go Wars): battle map, turf watcher, raid planner and crew features — built for use on the road.
Not official. WDGWars and LOCOSP do not build, run or endorse this tool. It is built and operated by a single player from the community.
Live instance: https://warroom.mechanics-toolbox.org — sign-ups are capped while the tool is young. What it does and what it stores: https://warroom.mechanics-toolbox.org/about
- Battle map — your turf glowing gold, enemy gangs in their real colors, unclaimed and virgin cells, distance rings, GSM-mast density and the coverage brush as toggleable layers, full-screen with follow mode (GPS)
- Watcher — polls the wdgwars API every 5 minutes and reports ownership changes on your turf, with configurable scope (own cells / gang turf / anything near), front detection and web push ("raven post")
- Planner — easiest flips first: enemy cells with the smallest AP gap, free cells, and virgin land (cells nobody ever scanned), sorted by real GPS distance
- Loot tour — pick cells, get an auto-optimized route by car, bike or on foot (each with its own routing profile), waypoints snapped to actual ways (OpenStreetMap) and the real route drawn in-app (OSRM), in-app guidance, or export: Google Maps hand-off (10 stops), or a GPX/KML file with every stop plus the routed road line, for OsmAnd, Locus, Garmin, Komoot and Google Earth
- Coverage brush — opt-in GPS recording of the ground you actually covered while driving: every stamp carries your expected reception radius, the screen stays awake while recording, and an interrupted session resumes on its own
- Trends — the one thing the game itself cannot show, because its feed only ever reports the present: your own history over weeks, who gained and lost ground in the last 24 h / 48 h / 7 days, the gang table with movement, and who actually holds cells inside your turf. Charts are server-rendered SVG, so the page draws with JavaScript off. The per-player parts need wdgwars to name cell holders, which it stopped doing on 2026-10-28 — see Reading a cell
- Crew — friends and opt-in live position sharing (auto-expires, no history)
- EN/DE, installable as PWA, works on phone and desktop
Your wdgwars API key is the entry ticket. Mint a fresh one just for warroom in your wdgwars profile — then pulling it back later costs you nothing else. It is:
- validated once at sign-up (
/api/me), your wdgwars username becomes your login - stored encrypted at rest (Fernet/AES) — the master key lives in
data/master.key, outside the database - used read-only: every wdgwars call the running app makes is a
GET(/api/me,/api/team/me,/api/territories,/api/member-territories,/api/leaderboard,/api/me/cells). It never uploads in your name and never changes anything on your wdgwars account. The API client does carry anupload_csvPOST for the planned live uploader — nothing calls it today, and if that ships it will be opt-in and said out loud here first - instantly dead the moment you rotate your key in your wdgwars profile
Beyond wdgwars, this is what leaves the machine: your browser loads map
tiles from tile.openstreetmap.org (they see your viewport and your IP). The
server asks Overpass for way geometry per map cell, and OSRM for the route
in your chosen travel mode (car, bike or foot — separate engines at the same
provider) — that request carries the tour stops plus your own position as
the start point, resent live while in-app guidance runs. For the air traffic
layer the server also asks OpenSky (the whole world, on a schedule),
adsb.lol (the worldwide list of military aircraft) and adsb.fi, with adsb.lol
as the fallback (200 km around the map area being viewed) — public requests
that carry an area, never a user. Tapping the Google Maps
or Street View export hands those stop coordinates to Google. No analytics, no
trackers, no update check, no phone-home of any kind.
Between users of one instance the line runs like this: everything the Trends page shows about other players comes from the public game feed and the leaderboards — the same numbers wdgwars shows every player anyway. Anything derived from a key (your series, your events, your footprint, your position) is shown to whoever owns that key and to nobody else. And nothing marks who has an account here: the movement tables cover every player in the feed, so signing up never lifts anyone into a list that others are missing from.
Raw AP data (exact positions, BSSIDs, names) is aggregated into map cells on arrival and never stored. The coverage brush is the one deliberate exception: while you actively record, it stores your own GPS trail (point + reception radius) — visible to nobody but you, clearable in one tap, gone with your account. Deleting your account removes everything, immediately. Details: /about
Requirements: Docker with the compose plugin. Locally there's no build pipeline, no Node — the frontend is server-rendered Jinja2 with vendored Leaflet (CI does run a JS/CSS minify pass before publishing images to GHCR, see Docker image below, but nothing you need for local dev).
An example compose.yml is included, pulling the published image:
services:
warroom:
image: ghcr.io/taccooh/warroom:latest
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data:/app/data
environment:
- WARROOM_VAPID_SUB=mailto:you@example.comdocker compose up -d
# open http://localhost:8000To build from source instead, replace image: ... with build: . and run
docker compose up -d --build.
Put a TLS-terminating reverse proxy (Caddy, nginx, …) in front — the app itself
speaks plain HTTP on port 8000. Behind a plain http://192.168.x.y:8000 URL it
is the browser, not the app, that takes things away:
- GPS stops working.
navigator.geolocationonly runs in a secure context — HTTPS, orlocalhost. No server-side setting can lift that. - Push and PWA install stop working, for the same reason: service workers are secure-context only.
- Login does not stick. The session cookie is
Secure, so a plain-HTTP origin refuses to store it.
http://localhost:8000 does count as a secure context, so a desktop-only
setup on the same machine works fully. For a phone you need a real certificate.
Without owning a domain, the least painful route is
Tailscale Serve — an
HTTPS *.ts.net hostname, no open ports, no cert warnings:
tailscale serve --bg 8000Cloudflare Tunnel, or Caddy with a DNS-01 challenge, work just as well if you
do own a domain. For a throwaway test you can instead tell Chrome to treat the
origin as secure under chrome://flags/#unsafely-treat-insecure-origin-as-secure
(add http://192.168.x.y:8000, port included) — that weakens the browser for
that origin, so don't leave it on.
.github/workflows/docker-publish.yml publishes
multi-arch images (linux/amd64, linux/arm64, linux/arm/v7) to GHCR:
- every push to
masterthat touchesapp/,Dockerfile,requirements.txtor.github/workflows/docker-publish.yml→ghcr.io/taccooh/warroom:edge(latest master, unreleased) - every GitHub Release →
:latest,:X,:X.Yand the release tag itself
docker pull ghcr.io/taccooh/warroom:latest
# or pin a version: ghcr.io/taccooh/warroom:1.2, :1
# or track master: ghcr.io/taccooh/warroom:edgeOne line, from the directory holding your compose.yml:
docker compose pull && docker compose up -dThere is no migration step, by design. New tables and indexes are
CREATE … IF NOT EXISTS in db.SCHEMA, new columns are added by _add_col, and
anything derivable from data you already have is backfilled at startup — the
sample log added in 1.8.0 reconstructs itself from player_snap in 47 ms over
176k rows, and is idempotent, so a restart costs one pass and changes nothing.
New settings always ship with a working default; you only touch .env if you
want to change one.
Your database lives in the data/ volume and is never touched by an image
change. Downgrading is a docker compose pull away too, but a database written
by a newer version may carry columns the older one does not know about — those
are ignored, not removed.
The Dockerfile is a two-stage build: a builder stage with a C toolchain
(needed because cffi/httptools/uvloop have no prebuilt wheels for
linux/arm/v7, so pip compiles them from source on that platform) and a slim
runtime stage that only gets the installed packages and app/, not the
toolchain.
| Variable | Default | Purpose |
|---|---|---|
WARROOM_DATA |
./data |
Data directory (SQLite DB, master key, VAPID keys) |
WARROOM_MASTER_KEY |
(file) | Fernet master key; if unset, generated on first start at data/master.key |
WARROOM_MAX_USERS |
30 |
Sign-up cap; registration closes above this count |
WARROOM_TZ |
Europe/Berlin |
IANA timezone for displayed timestamps (DB stores UTC) |
WARROOM_POLL_WORKERS |
4 |
Concurrent per-user poll workers (caps simultaneous wdgwars API requests) |
WARROOM_VAPID_SUB |
(contact) | mailto: contact sent to push services (set your own when self-hosting) |
WARROOM_PLANNER_LIMIT |
25000 |
Max enemy cells handed to the planner (raise if the log says the cap fired) |
WARROOM_ROAD_DRIP |
600 |
Cells per cycle checked against Overpass for a drivable road point |
WARROOM_DRIP_WORKERS |
3 |
Parallel road-snap workers, each leading with a different Overpass mirror |
WARROOM_ARCHIVE_HOURS |
1 |
Hours between history samples; 0 disables the archive (HTTP API) |
WARROOM_ARCHIVE_KEEP_DAYS |
0 |
Retention for the archive in days; 0 keeps everything |
WARROOM_AIR_SECONDS |
960 (120 with OpenSky account) |
Seconds between worldwide OpenSky samples for the air traffic layer; 0 disables it |
WARROOM_OPENSKY_ID / _SECRET |
(none) | Optional free OpenSky API client; lifts the quota from 400 to 4000 credits/day |
WARROOM_AIR_DAILY_MAX |
420 |
Own cap on adsb.lol requests per UTC day (military list, and the 200 km queries when adsb.fi fails; their limit is 500/day per IP). adsb.fi, tried first for area queries, is paced at 1 request/s and has no daily cap |
data/ contains three things that belong together: warroom.sqlite (the
database), master.key (Fernet master key) and vapid.pem (push keys). A
database backup without master.key is worthless — the stored API keys can
never be decrypted again. Back up the whole data/ directory.
People build their own tooling on top of warroom. These four endpoints are the supported surface for that, and their response shape is meant to stay stable.
Do not build on /api/live. It is the frontend's private channel and ships
pre-rendered HTML fragments alongside the data — it changes whenever a template
does. Everything a script needs is below.
Create a read-only API token in the app (info tab → API tokens) and send it as a bearer token:
curl -H "Authorization: Bearer wr_xxxxxxxx" 'https://your-instance/api/state'This is the right way to authenticate a script. A token may read the endpoints below and nothing else — it cannot change your account, your position or your crew — it is revocable on its own, and only its SHA-256 hash is stored, so the plaintext exists once, on screen, at creation.
A normal session cookie works too, which is handy for a quick manual call:
curl -c jar -X POST https://your-instance/login \
-d 'username=YOURNAME' -d 'password=YOURPASSWORD'
curl -b jar 'https://your-instance/api/state'Do not put a session cookie in a script. It is full access and lives 60 days,
so a cron file or a committed config hands your whole account to whoever reads it.
Note also that a failed login returns 200 with the login page, not 401 — check
that a wr_session cookie was actually set.
Either way, all endpoints answer 401 without valid credentials.
Send a User-Agent of your own. warroom does not filter by user agent, but a
proxy in front of it may: the hosted instance sits behind Cloudflare, which turns
away Python's default (Python-urllib/3.x) with a 403 before the request ever
reaches warroom. Any identifier of your own passes — my-script/1.0 is enough,
there is no need to pose as curl. So a 403 comes from the proxy, a 401 from
warroom.
The examples below use a shell variable for the header:
AUTH="Authorization: Bearer wr_xxxxxxxx"| Endpoint | Returns |
|---|---|
GET /api/state |
Where you stand now: meta, counts, cells (every cell in your turf with its gang, strength and your own APs — and its holder where wdgwars still names one), planer, events |
GET /api/stats |
Your own measured history, one row per poll: APs, credits, gang rank and points |
GET /api/players |
Standings from the latest sample; ?q= searches by name, ?id= gives one player's current state and history |
GET /api/gangs |
Leaderboard from the latest sample; with ?name= the history of one gang |
GET /api/boards |
The game's own top-50 lists over time; ?board= selects one, ?id= follows one player on it |
GET /api/virgin |
Never-scanned cells in your turf with a reachable point each; ?mode=car|bike|foot — raw material for route planning |
since=YYYY-MM-DD HH:MM:SS (UTC) and limit= work on all history endpoints.
limit is clamped to 5000.
/api/virgin lists the cells in your turf nobody has ever scanned — what a route
planner needs. Narrow it with bbox=lat_min,lat_max,lng_min,lng_max; by default
only cells you can actually reach come back (roads_only=false also returns
unclassified ones).
Not everyone wardrives. mode=car (default), bike or foot decides what
counts as reachable: a car needs a road, a walker also takes footpaths, steps and
bridleways, a cyclist the subset they may ride. Cells a car cannot reach used to
vanish from every list — for a war-walker they are perfectly good targets. Without
mode= the account's own setting applies (planner tab → Getting around), so a
script sees the same world as the app. The tour is routed on the matching OSRM
profile too.
curl -H "$AUTH" '…/api/virgin?bbox=42.3691,42.3979,-71.2205,-71.1381'{"count": 1, "navigate_with": "rlat,rlng", "cells": [
{"cell_key":"2119_-3561","i":2119,"j":-3561,
"lat":42.38,"lng":-71.20, "rlat":42.381,"rlng":-71.201, "road":1}]}Route to rlat/rlng, not lat/lng. The latter is the cell centre, which
lands in a field, a forest or a lake often enough to ruin a drive — that is the
whole reason the snapped points exist. road is 1 when a drivable point is
known, 0 when the cell has none (water, woods), null when the background
snapper has not reached it yet; path then carries the OSM way type
(path, footway, bridleway, cycleway, steps) for cells only a walker or
cyclist can reach.
Three questions that look alike and are not:
my_aps— how many APs you have in that cell. Presence. You can have APs in a cell somebody else holds.held—trueonly when you personally hold the cell. This is the onestatuscannot answer.status: "mine"— your gang holds it, which usually is not you. Measured on the hosted instance in August 2026: of 31,687 gang cells, players held 16,260 themselves — barely half.
owner carries the holder's wdgwars id (also in the planner as o), so you can
see who to beat, not just which gang. meta.wdg_user_id is your own id in the
same space, if you want to compare yourself.
Since 2026-10-28 wdgwars no longer names cell holders. The territory feed
sends user id 0 instead; the only holder it still names is the account whose API
key made the request. warroom reads 0 as not disclosed and returns
owner: null (releases before v1.9.0 pass the 0 through — treat it the same).
Gang and strength still arrive, so status, count and gap work as before.
What changed:
ownerisnullon practically every cell, and thenamestable below shrinks with it.helddepends on whose key fetches the feed. warroom fetches it once per cycle, with the first account whose key works — the oldest one, in practice. On a single-user instance that is you, andheldis as good as ever. On a shared instance it is right for that one account only: everyone else getsheld: falseon every cell, which there means not disclosed, not not yours.
Names for those ids come with the response as names, a {id: username}
lookup table covering everyone appearing in cells and planer. It is one table
rather than a name per row on purpose: a turf of 12,000 cells has only a few
hundred distinct holders. Ids missing from it have no known name — show the number
instead. /api/live carries the same table, since holders change when cells flip.
How complete that table is depends on your instance, not on the software. Names come from two places:
- the member list of every gang a user of your instance belongs to — complete for those gangs, and it costs no extra request (the poller already fetches it)
- the leaderboards — global, but only their top slots, a few hundred players
So a gang nobody on your instance belongs to stays anonymous. On the hosted instance, spanning 22 gangs, that means 100 % coverage inside those gangs and 69 % of all players seen; within one turf it tends to run higher, because neighbours are more often in large gangs. A single-user instance sees its own gang plus the boards, and correspondingly more bare ids. Every new user from a gang not yet represented unlocks that gang's whole roster at once.
gap is how many more APs you need to take an enemy cell:
gap = max(0, count - my_aps + 1)
count is the holder's AP total there, the +1 because you need more than they
have. gap: 0 therefore means you are already ahead and it should flip — not that
it is impossible. gap: null means unknown, never zero: either it is not an
enemy cell, or wdgwars is hiding enemy strength (it did for all team cells during
the Lone Silverback event). Do not fall back to 0 there, or every fogged cell looks
like a free win.
?q= searches names, anywhere in the string, case-insensitively, and answers the
question the territory feed cannot: which gang is this player in.
curl -H "$AUTH" '…/api/players?q=nightowl'{"query":"nightowl","players":[{"player_id":4711,"username":"nightowl",
"gang":"Example Gang","gang_id":42,"cells":4233,"aps":327700}]}?id= adds a current block (gang, cells, APs as of the latest sample that
contains the player, dated by its ts) and a registration block next to the
history.
Since the feed stopped naming holders (see Reading a cell),
gang, cells and aps come back null from ?q= for everyone except the
account whose key fetches the feed. The search still resolves a name to its id,
and ?id= still returns what was last known — check current.ts for how old
that is.
Dating an account. wdgwars does not report when someone signed up, but ids are
handed out sequentially and every gang member list carries a joined_at. So if a
higher id was already in a gang at time T, this account must predate T:
"registration": {
"registered_before": "2026-05-27 14:36:33",
"joined_gang": "2026-05-28 09:11:02",
"anchor_below": {"player_id": 4708, "joined_at": "2026-05-26 23:33:31"},
"anchor_above": {"player_id": 4713, "joined_at": "2026-05-27 14:36:33"}
}registered_before is a hard bound. There is no equally hard lower bound — an
old account can join a gang at any time, so a date below only shows where id
allocation stood, not when that player started. The two anchors are returned so you
can judge how tight the bracket is: neighbouring ids a few hours apart pin it down,
a gap of hundreds does not. Accuracy grows with the number of gangs your instance
can see.
Names come from two places: the gang member lists (every player in a gang any user belongs to) and the leaderboards (their top 50). A player nobody has ever shared a gang or a top-50 slot with stays a bare id — an empty result means "not known here", not "does not exist". Someone known by name but absent from the snapshots is still returned, just without figures.
The wdgwars feed is a snapshot that overwrites itself — it tells you who holds a
cell right now, never how that changed. Since the poller fetches the global
feed every cycle anyway (every gang cell with its strength and, until 2026-10-28,
its holder), warroom samples it every WARROOM_ARCHIVE_HOURS into player_snap
and gang_snap. That turns "is this rival gaining on me?" into a query.
Five things worth knowing, because the numbers are easy to misread:
-
Per-player history ends on 2026-10-28 for almost everyone. That evening (UTC) wdgwars stopped naming cell holders in the feed — see Reading a cell. Since then
player_snaponly gains rows for the account whose key fetches the feed:/api/playerslists that one player, and every other history stops there. The gang series runs on, becausecellsandapsare counted per gang and need no holder — exceptplayers, which counted distinct holders and now reads 0 or 1. For individual players, the boards are the series that still moves. -
player_idis the wdgwars user id from the feed, not a warroom account. -
cellsis what a player controls. The feed names one owner per cell, so a player holding APs in a cell someone else controls is not counted there. The game's own cell ranking counts every cell a player has APs in and runs 2–4× higher. Both are valid measures of different things — don't compare them. -
Players without a gang are missing entirely. The feed carries gang territory only; every cell in it has a
gang_id. A strong solo player can be completely invisible here. -
The archive cannot be backfilled. Whatever is not sampled while the feed is in memory is gone; history starts the day you switch it on.
# Standings from the latest sample (since 2026-10-28: the feed account only)
curl -H "$AUTH" 'https://
846E
your-instance/api/players?limit=20'
# How one player developed over the last week
curl -H "$AUTH" 'https://your-instance/api/players?id=4711&since=2026-08-21 00:00:00'The territory feed hands out numeric ids and no names. The game's leaderboards do
both, so warroom samples them alongside and keeps a growing player_id → username
table. The gang member lists fill the same table — see the coverage note below.
Names then appear in /api/players and /api/boards; they are null for anyone
neither source has named.
Sampling the boards also covers the blind spot above: they are the only place a
gang-less player appears at all. Seven boards are kept — today, week,
all_time, cells, hunters, flock, arcade — each with the position and the
figure it ranks by (ranks_by in the response says which). all_time additionally
carries wifi and ble split out.
The response carries sampled_at (and board_sampled_at on the ?id= form): when
that board was last written. A board that drops out of the upstream feed — the
cells board does this now and then — stops being sampled and its standings age
with no other signal, so check this timestamp before trusting the numbers as
current. On the ?id= form it also tells a stale board apart from a player who left
the top 50: if board_sampled_at is fresh but the newest ts in history is old,
the player dropped out; if board_sampled_at itself is old, the whole board is
behind.
# Who leads all-time, with names
curl -H "$AUTH" 'https://your-instance/api/boards?board=all_time'
# One player's climb; gaps mean they were outside the top 50 that hour
curl -H "$AUTH" 'https://your-instance/api/boards?board=cells&id=4711'rank and points come from wdgwars, cells/aps/players are counted from
the feed — they measure different things and will disagree. A gang can rank high
on points while controlling fewer cells than the one below it.
/api/gangs also carries the gang's official ap_count and member_count. Those
are the real totals; aps and players beside them only cover what the feed
shows (controlled cells, owners seen) and are systematically lower. Both are
null outside the top 50 — that leaderboard is capped, the feed is not. Since
2026-10-28 players no longer counts anything useful (holders are not named any
more); member_count is the figure to use.
AGPL-3.0-or-later — © 2026 St4bleground st4bleground@proton.me
Why AGPL: warroom is a hosted tool that people trust with API keys. The AGPL's network clause means anyone who runs a modified version for others must publish their modifications — the same transparency this repo exists to provide.
See THIRD_PARTY_NOTICES.md — Leaflet (BSD-2-Clause, vendored), Germania One font (SIL OFL 1.1), OpenStreetMap data (ODbL), and for the air traffic layer aircraft data from adsb.fi, adsb.lol (ODbL) and the OpenSky Network, each under its own terms and credited on the About page. Splash and icon artwork was generated by the author (Stable Diffusion) and is covered by the repository license.
No build pipeline locally. Python 3.12, dependencies from requirements.txt:
python -m venv .venv && .venv/Scripts/pip install -r requirements.txt # or bin/ on Linux
.venv/Scripts/python -m uvicorn app.main:app --reloadUsing the devcontainer instead? Dependencies are
installed automatically on container creation (postCreateCommand), so you can skip
the venv/pip install step above and go straight to uvicorn app.main:app --reload.
This only applies inside the devcontainer — local development still needs the setup
above.
The SQLite schema migrates itself on startup (CREATE TABLE IF NOT EXISTS plus
additive column migrations). Issues and pull requests are welcome — for bigger
changes, open an issue first.
Note for forks: the /about page describes this operator's instance
(contact address, backup retention, sign-up cap). If you host your own, edit
app/templates/about.html and CONTACT_MAIL/WARROOM_VAPID_SUB to match your
setup.
st4bleground@proton.me — or ask openly on the WDGWars Discord.