Everything you need to run a Warsow 2.1 race community in Docker:
server/— a Dockerized Warsow 2.1.2 dedicated race server running our fork of thehraceracemod (vendored atserver/racemod/), which POSTs every finished race straight to the stats API from the game module.web/— a Node.js + PostgreSQL web server that hosts the race database behind a REST API (including the authenticated/api/ingestendpoint) and a retro-modern stats website (world records, maps, player rankings — all live-queried, searchable and sortable, with typo-tolerant trigram search). A Live tab shows who is playing right now on every enrolled server with a query address (UDPgetstatus, polled server-side; enable per server withnode admin.js address), and player pages are shareable as/player/<id>with server-rendered Open Graph stats for Discord/social unfurls.discord/— a small service that posts new race records to a Discord webhook (it polls the stats API — no database access of its own).postgres(compose service,pgdatavolume) — the race database. The legacydata/db.sqliteremains only as the one-time migration source and a rollback artifact (see The database below).
racesow/
├── docker-compose.yml # postgres + web + discord services
├── data/
│ └── db.sqlite # LEGACY: migration source / rollback artifact only
├── server/ # Warsow dedicated race server (Docker)
│ ├── Dockerfile · entrypoint.sh · docker-compose.yml
│ ├── configs/ # server.cfg · mappool.txt
│ ├── racemod/ # vendored hrace mod fork (see racemod/UPSTREAM)
│ ├── topscores/ # ← mod-written records (bind mount, gitignored)
│ └── racelog/ # ← mod-written race-finish events (bind mount)
├── collector/ # legacy: /api/ingest shipper for stock (unmodified) servers
├── web/ # Node API + frontend
│ ├── server.js · db.js · migrate-sqlite-to-pg.js · Dockerfile
│ └── public/ # index.html · assets/{css,js,img}
└── discord/ # Discord record announcer
└── announcer.js · Dockerfile
Our racemod fork reports every finished (non-practice) race straight from the
game module (racelog.as → the RS_ApiReportRace native, on a background
thread) to the web service's /api/ingest (per-server bearer token), which
upserts best times into PostgreSQL, recomputes per-map ranks, and refreshes
the site's aggregates. A local server/racelog/events.log audit trail is
still written alongside. The Discord announcer then spots the new race ids on
its next poll of /api/records. (The standalone collector/ — which
tails racelog/topscores files and ships them to /api/ingest — remains
only for feeding the site from a stock, unmodified race server; see
AGENT.md.)
Note: the live datastore is PostgreSQL (the
postgrescompose service). A fresh deploy starts with an empty database; to seed it from the historical livesow snapshot, migrate the bundleddata/db.sqliteonce (see The database). That file (~66 MB) is stored in Git LFS — install git-lfs andgit lfs pullbefore migrating, or it is just a small pointer file.
Set a Postgres password first (compose requires it):
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env
docker compose up -d --build
# stats site: http://localhost:8080
# one-time: seed Postgres from the bundled livesow snapshot (optional)
docker compose run --rm web node migrate-sqlite-to-pg.js /data/db.sqliteSet your Discord webhook to enable announcements:
DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/…" docker compose up -d(Without a webhook the announcer runs in harmless dry-run/log mode.)
scripts/setup.sh walks through either deployment shape — it checks/installs
Docker, asks the right questions, and wires everything up:
scripts/setup.sh agent # game server only, pushing records to a remote
# stats site (asks for the ingest URL + token)
scripts/setup.sh full # website + Discord announcer + game server on one
# box, enrolled against each other automaticallyFor production boxes, systemd/install.sh [full|agent] then installs systemd
units on top: explicit boot ordering (web stack before game server),
systemctl status racesow-*, and a nightly pg_dump backup into
backups/db/ (racesow-db-backup.timer, 14-day retention).
To stand up a brand-new VM (typically a regional game-server agent) with no
SSH-in required, use the cloud-init path. scripts/cloud-init.sh runs as root on
first boot and does the whole job — installs Docker + deps, creates the
unprivileged racesow service user, clones the repo, provisions the deployment
(setup.sh --non-interactive), and enables the systemd units.
It reads its configuration from /etc/racesow/deploy.env (docker-.env style —
no quoting, nothing evaluated as code). The only values you must supply are
the API token (INGEST_URL + INGEST_TOKEN, from the central stats admin)
and, if the box joins a server network, the mesh (MIRROR_PEERS /
MIRROR_SECRET / MIRROR_TAG). Everything else — hostname, rcon password
(auto-generated), ports, the map mirror — is defaulted or generated.
Paste systemd/cloud-config.yaml into your
provider's user-data field (edit the two values first), or run it by hand on
any fresh Ubuntu/Debian box:
sudo RACESOW_MODE=agent \
INGEST_URL=https://stats.example.com/api/ingest \
INGEST_TOKEN=<your-64-hex-token> \
bash scripts/cloud-init.sh # or set them in /etc/racesow/deploy.envRACESOW_MODE=full provisions the website + Discord + game server instead. The
script is idempotent — re-running updates the checkout and re-provisions in
place. (For a private fork, set RACESOW_REPO to an authenticated clone URL.)
Public production deployment (Cloudflare-fronted): the live stats box (
racesow.org) runs behind Cloudflare and only accepts traffic through it — the origin enforces Cloudflare's client certificate (mTLS / Authenticated Origin Pulls), so a direct hit returns HTTP 400. The full setup — Cloudflare dashboard settings, the origin nginx (real-IP, rate limits, mTLS, the origin-pull CA file), TLS certs, the tuned Postgres/Redis services, the rolling-deploy procedure, and the secrets that must never be committed — is documented indeploy/README.md. Read it before standing up or redeploying the public origin.
The Warsow server is a separate, heavier image (it downloads the ~465 MB official distribution and builds the racemod):
cd server
docker compose up -d --build
# connect a Warsow 2.1 client to <host>:44400- Base Ubuntu 18.04 (matches the 2018 Warsow build's glibc).
- Downloads Warsow 2.1.2 from
warsow.net, strips non-Linux/client files. - Builds the vendored racemod fork (
server/racemod/, gametypehrace, forked fromDenMSC/wsw-race— seeserver/racemod/UPSTREAM) by zipping itssource/tree into a.pk3— Warsow's AngelScript VM compiles it at runtime, so there is no separate build step. Our fork addshrace/racelog.as, which appends every finished race toracelog/events.logfor the collector. - Env-driven config (hostname, slots, public/private, rotation) and a restart
loop mirroring the community
start.sh.
See server/README.md. The racemod is based on the work of
hettoo and DenMSC (hettoo/wsw-race,
DenMSC/wsw-race).
- Express server backed by PostgreSQL (
DATABASE_URL); it bootstraps and migrates the schema at startup and buildsUNLOGGEDaggregate tables (~1s), rebuilt after each ingest, for fast queries. - Flexible REST API — search, sort and paginate maps and players; drill into map
leaderboards (with world-record split times) and player profiles; plus an
authenticated
POST /api/ingest,GET /api/servers, andGET /api/records(the announcer's feed). Search ispg_trgmtrigram-indexed, so substring matches are fast and typo-tolerant (elchpafindsELchupa). - Vanilla-JS single-page frontend with a Warsow-inspired "going fast" theme;
renders Warsow
^0–^9colour-coded player names.
Beyond raw leaderboards it computes:
- Perfect run per map — the sum of the fastest recorded split for every segment (start→cp1→…→finish), stitched across players, shown against the WR.
- Attempts vs records — every race START is counted (an attempt) and every finished run (a finish), both separate from the number of ranked best-times (records).
- Tournaments — time-boxed competitions on a fixed map pool. Admins
schedule them at
/admin/tournaments(with overlap avoidance and optional recurrence); players take an entry code on the site and redeem it in-game with/tournament <code>, after which their runs on pool maps score for the tournament board and the normal leaderboard. A tournament owns no runs — it is a filter over the finish log, frozen into a snapshot when it ends, so it can never corrupt the real records. Top three take a profile trophy. Seedocs/tournaments-design.md. - Canonical players — variants of one person collapse to a single profile,
displayed as the last nick we've seen them use, so leaderboards aren't
fragmented across
^1Foo/^2Foo/Foo(1). Grouping is exact after stripping colour codes, lowercasing, and dropping a trailing(N)collision suffix — and nothing else, so nicks that differ by spacing or punctuation stay separate. A non-empty login keys the account instead of the nick; distinct logins never merge.
See web/README.md for the full API reference, and
AGENT.md for running a game server that feeds a central site.
- Polls the stats API (
GET /api/records) — it needs no database access of its own — and posts rich embeds to a Discord webhook (world records by default; configurable to podiums/top-N). - Detects new records by race id and baselines on first run, so it never floods the channel with history.
See discord/README.md.
The live store is PostgreSQL (the postgres compose service, pgdata
volume). Historical data was seeded from the livesow race API
(http://livesow.net/race/api/db.sqlite) via a one-time migration of the
bundled data/db.sqlite:
docker compose run --rm web node migrate-sqlite-to-pg.js /data/db.sqliteThe web service bootstraps and versions the schema on connect; the tables are:
| Table | Rows | Notes |
|---|---|---|
map |
4,757 | id, name |
player |
~12,000 | name carries ^0–^9 colour codes; simplified plain; canonical_id → representative row |
race |
~237,000 | best time per player/map/version; global_rank, version_rank; server_id, created_at (ingested rows) |
checkpoint |
~789,000 | split times (absolute ms; number is a spatial index) |
version |
4 | wsw 1.0 / 1.5 / 2.1 and warfork 2.1 |
run_tally |
— | finishes and attempts per player/map/version |
canonical |
— | name-group key → representative player id |
server |
— | enrolled game servers: name, token hash, status, last-seen |
All times are milliseconds. global_rank = 1 is a world record. Ingested
race ids come from a monotonic config.next_race_id counter so improved records
always get a strictly higher id (how the announcer detects them).
Backups: scripts/backup-db.sh runs pg_dump (custom format, gzipped)
into backups/db/; restore with pg_restore (see the script header).
- Warsow was discontinued;
warsow.netand the livesow snapshot are community-hosted and may change. The game server works fine for LAN / direct-connect regardless of master-server availability. - Set
POSTGRES_PASSWORD(in.env) beforedocker compose up; compose requires it. Use per-server ingest tokens (node admin.js enroll) — the optional sharedINGEST_TOKENdefault is a well-known placeholder, so set a real one or leave it empty before exposing port 8080.
GPL-2.0-or-later — see COPYING for the full text and AUTHORS for provenance.
The race gametype in server/racemod/ descends from the Warsow hrace
gametype ("Copyright (C) 2009-2010 Chasseur de bots", GPL-2.0-or-later) by way
of DenMSC/wsw-race branch racemod, and
is a derivative work under that licence. The rest of the project is
distributed under the same terms.
Warsow by the Warsow team · racemod by hettoo & DenMSC · race database & infrastructure by the livesow / Racenet community.