Self-hosted statistics platform for Battlefield 1942 / Battlefield Vietnam dedicated servers — a modern successor to select(bf). One PostgreSQL database, one Go API binary serving a zero-JavaScript dark-themed web UI, and one ingestion worker that parses the engine's per-round XML event logs.
New here? Follow the Guided setup — one copy-paste walkthrough, no choices to make. Otherwise jump to your case:
| I want to… | Go to |
|---|---|
| Get running fast — one Linux box + a BF1942 server → a stats site | Guided setup → |
| Run it with Docker / containers | Docker |
| Ship logs from a game server on another machine | Remote game server |
| Use a PostgreSQL I already run, or see all installer options | Quick start |
| Take it public (TLS, hardening) | Going live |
| Look up what I need or a config option | Requirements · Configuration |
- Full event ingestion — parses
ev_*.xml/.zxmlround logs into normalized events, round stats, games, and rounds. Every raw log is archived content-addressed (<sha256>__<original-name>), so the entire database can be rebuilt from the archive at any time with-reingest. - Player identity across nicknames — the engine logs a per-CD-key
playerKeyHashnext to each humancreatePlayer. Ingestion anchors players to that keyhash, records every nickname as an alias, and automatically merges older nickname-keyed rows when a keyhash reveals they belong to the same human. Two humans sharing a nickname stay separate players. - Vehicle kill attribution — see Weapon Attribution.
- Round outcomes & medals — victory type (tickets / all flags / objectives / time limit), per-round gold medals in six categories with a medal case on every profile, and per-round time-played tracking (hours played, score per minute).
- Web UI (server-rendered inline-SVG charts, no client-side dependencies):
/dashboard,/players,/player?id=profiles (performance charts, K/D donut, weapon/kit/vehicle breakdowns, victims & nemeses, aliases, medal case, time played, and a self-serve/player/export?id=&format=csv|jsonstats download)/leaderboardwith metric / map / period / min-rounds / bot filters/awards— gold/silver/bronze career medals plus single-round records/map?name=— kill-origin and death-zone density heatmaps from logged world positions, plus weapon/vehicle/player pressure/chat— searchable battlefield chat log/live— real-time server status (map, players, tickets) over GameSpy queries, one card per registered server/trends,/maps,/rounds,/weapons,/vehicles,/kits,/compare,/rivalry/clans,/clan?tag=— players grouped by[TAG]/-TAG-/=TAG=/{TAG}nickname convention, with a per-clan roster and frequent-opponent list/adminmanagement area behind/admin/login: named admin accounts with two roles (adminfor everything;moderatorfor dashboards, player moderation, and identity tools), server-side revocable sessions, and a full audit log of admin actions.ADMIN_TOKENbootstraps the first account and stays available as a break-glass credential. Inside: ingest health dashboard and job monitor, server registry with per-server ingest API keys, player moderation (hide from public pages, force bot/human), manual identity merge/split tools, and user management
- JSON API —
/api/players,/api/rounds,/api/weapons,/api/player-search,/api/events, and more;/healthzfor probes. /about,/privacy, and/terms— what the site is, what data it shows, how to request changes, and acceptable use / no-warranty terms.- SEO/crawling —
og:imageand Twitter card previews (needsPUBLIC_BASE_URL),/sitemap.xml, and/robots.txt(opts/admin,/api,/search, and/chatout of indexing, points crawlers at the sitemap). - Discord notifications — set
DISCORD_WEBHOOK_URLto post a round-complete summary (map, top frag, round medals) after each live round, and an online/offline message whenever a registered server's status query stops or resumes responding. Round-complete notifications are suppressed for anything older thanROUND_NOTIFY_WINDOW(default 20m), so backlog imports and-reingestruns don't spam the channel. - Monitoring —
spawnpoint-healthcheck.timerpolls/healthzand posts to the same Discord webhook after sustained failures; see Monitoring for install steps and why it's a first layer, not a replacement for real off-box uptime monitoring.
This is a Linux server application. It runs on the machine that hosts the stats site — that can be the same box as your BF1942 server or a separate one.
Hardware — modest. A 1 vCPU / 1 GB RAM VPS handles a couple of busy servers; 2 GB is comfortable. Disk is dominated by the raw-log archive plus the database — budget roughly 1–2 GB per active server per year, plus room for PostgreSQL. The site is fully server-rendered, so visitors only need a browser.
Operating system — a modern 64-bit Linux with systemd (Ubuntu 20.04+, Debian 11+, Rocky/Alma/RHEL 8+, or similar). macOS works for local development; the server itself isn't supported on Windows (use WSL2).
Software — the setup.sh installer expects these already on the host:
| Tool | Version | Notes |
|---|---|---|
| Go | 1.22+ | builds the binaries — https://go.dev/dl/ |
| PostgreSQL | 13+ | 16 recommended; setup.sh can install it for you |
psql (PostgreSQL client) |
any | e.g. apt install postgresql-client |
git, curl |
any | clone the repo, health-check the API |
Using the Docker path instead? Then you only need Docker Engine + Docker
Compose v2 — no Go, psql, or PostgreSQL on the host.
Network / ports
| Port | Component | Notes |
|---|---|---|
8080 (PORT) |
stats app | web UI + API — put TLS in front before exposing it publicly |
5432 |
PostgreSQL | keep bound to localhost or a private network |
game + query port (e.g. 14567 / 23000) |
your BF1942 server | only needed for the /live status page |
From your BF1942 server you also need DICE XML event logging enabled and a
readable mod logs directory — see
BF1942 Server Integration.
New here? The Guided setup walks through this step by step with checkpoints.
git clone https://github.com/hootmeow/spawnpoint
cd spawnpoint
sudo ./setup.shPrefer Docker? See Docker below for a Compose-based install
instead of setup.sh.
The interactive installer covers the common self-hosting shapes:
- Standalone — provisions a local PostgreSQL role + database (installs PostgreSQL via your package manager if needed).
- Existing database — point it at any PostgreSQL you already run; it
verifies the connection and applies only the missing schema migrations
(tracked in
schema_migrations, safe on databases created before the installer existed). - Log sources — auto-detects local BF1942/BFV installs (
*/mods/*/logs), or watches any drop directory that logs are rsync'd/FTP'd into, or sets up the web UI only. Multiple directories are supported. - Backlog import — one-shot import of historical
.xml/.zxmllogs before the watcher takes over (already-seen logs are deduplicated by content hash, so this is safe to repeat). - systemd — installs and starts
spawnpoint-apiandspawnpoint-workerunits running as a dedicatedspawnpointsystem user (or prints the manual run commands when run without root/systemd).
Re-running setup.sh is safe: current .env values become the defaults,
hand-added .env settings (HOST, SECURE_COOKIES, PUBLIC_BASE_URL, …)
are preserved, migrations are skipped when already applied, and units are
refreshed in place.
setup.sh gives you a working install. Before you expose it to the internet,
walk this checklist — most items are one line, and the
Production Runbook has the full detail.
- Strong admin token.
ADMIN_TOKENbootstraps the first admin account and remains a break-glass credential with full access. Use a long random value (openssl rand -hex 24—setup.shgenerates one), and once signed in, create a named admin account under Admin → Users — day-to-day work should happen on accounts (individually revocable, role-scoped, audited), not the shared token. - Put TLS in front. The app speaks plain HTTP and must never be exposed on
PORTdirectly. Terminate TLS with Nginx or Caddy and reverse-proxy to it; the Nginx + Cloudflare guide is a copy-paste walkthrough. Then setHOST=127.0.0.1so the app is only reachable through the proxy. - Set
SECURE_COOKIES=1once TLS is in front, so the admin session cookie is never sent in cleartext. (Leave it unset only while testing over plain HTTP — the app logs a warning to remind you.) - Lock down PostgreSQL — bound to localhost or a private address, never the public internet, using a least-privilege role.
- Set
PUBLIC_BASE_URL(e.g.https://stats.example.com) for correct link previews and/sitemap.xml, andPRIVACY_CONTACTfor the/privacytakedown contact. - Turn on backups — the
spawnpoint-backup.timerunit (Backups). - Watch health —
/healthzfor uptime probes, and thespawnpoint-healthcheck.timeras a first alerting layer (Monitoring).
Releases are tagged vX.Y.Z. To update a setup.sh-installed checkout to the
latest release:
sudo ./update.shThis fetches tags, checks out the newest one, rebuilds the binaries with the
version embedded, applies any new schema migrations (skipping ones already
recorded in schema_migrations), and restarts the systemd units. It refuses
to run over uncommitted local changes and prints a changelog of commits
between your current tag and the target before asking for confirmation.
Pin or roll back to a specific release:
./update.sh --tag v1.2.0Rolling back is safe because migrations here only add tables/views/indexes —
running older code against a newer schema doesn't break anything. Check the
running version at any time with spawnpoint-api -version or on the
/admin overview page.
The root Dockerfile builds three small images (api, worker, admin)
from one multi-stage build via --target. For a full stack — Postgres, the
one-shot migration runner, api, and worker — use the production Compose file
(requires Docker Compose v2, i.e. the docker compose CLI):
cd deployments/compose
cp compose.env.example .env # fill in a real POSTGRES_PASSWORD, ADMIN_TOKEN, etc
docker compose -f docker-compose.prod.yml up -d --buildMount your BF1942/BFV server's log directory at BF1942_LOG_DIR (set in
.env) so the worker container can watch it; its own content-addressed
archive lives in a separate named volume so it survives container rebuilds.
docker-compose.dev.yml in the same directory is a lighter dev-only stack
(go run, no worker service, no automatic migrations) for iterating on the
code itself.
To update a Docker deployment, pull/rebuild and re-run up -d --build — the
migrate service re-applies only new migrations before api/worker start,
same as update.sh does for a bare-metal install.
# 1. database + schema
createdb spawnpoint
psql "$DATABASE_URL" -c 'create extension if not exists pg_trgm; create extension if not exists pgcrypto;'
for f in migrations/*.sql; do psql "$DATABASE_URL" -f "$f"; done
# 2. build
go build -o bin/spawnpoint-api ./apps/api
go build -o bin/spawnpoint-worker ./apps/worker
# 3. run
set -a; . ./.env; set +a
bin/spawnpoint-api &
bin/spawnpoint-worker -watch -interval 15s /path/to/bf1942/mods/bf1942/logs| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
— | PostgreSQL connection URL (required) |
PORT |
8080 |
Web UI / API listen port |
HOST |
(unset = all interfaces) | Set to 127.0.0.1 when running behind a reverse proxy on the same host (see Nginx + Cloudflare) |
ARCHIVE_DIR |
data/raw-logs |
Content-addressed raw log archive |
SERVER_NAME |
BF1942 Server |
Game server display name |
SERVER_ADDRESS / SERVER_PORT |
127.0.0.1 / 14567 |
Game server metadata |
ADMIN_TOKEN |
(empty) | Break-glass admin credential: sign in with it at /admin/login (then create named accounts under Admin → Users), or send it as a bearer header for scripts. Use a long random value. With it unset, the admin area works only via existing admin accounts |
SECURE_COOKIES |
(unset) | Set to 1 when TLS terminates in front of the app so the admin session cookie is marked Secure. Leave unset only for local/plain-HTTP testing |
SESSION_KEY_FILE |
data/session.key |
Where the admin session-signing secret is persisted so sessions survive restarts. Must be writable by the service user |
MAX_LOG_FILE_BYTES |
104857600 |
Ingestion size cap per (decompressed) log |
MAX_LOG_EVENTS |
5000000 |
Max events parsed from a single log, bounding parser memory against a malicious or oversized log |
RATE_LIMIT_RPS |
20 |
Per-IP request rate limit (burst 2x; 0 disables) |
STALE_INGEST_MINUTES |
120 |
/healthz turns 503 and /admin warns when no ingest job for this long |
UPLOAD_URL / UPLOAD_API_KEY |
— | Worker-only: ship logs to a central API instead of a local DB |
PRIVACY_CONTACT |
— | Shown on /privacy for takedown/removal requests; a bare email renders as mailto:, anything else (URL, form) is used as-is |
DISCORD_WEBHOOK_URL |
— | Round-complete and server up/down notifications; unset disables both |
ROUND_NOTIFY_WINDOW |
20m |
Suppresses round-complete notifications for rounds older than this (backlog/reingest safety) |
SERVER_WATCH_INTERVAL |
60s |
Worker-only: how often -watch mode polls registered servers for up/down transitions |
PUBLIC_BASE_URL |
— | Site's public URL (e.g. https://stats.example.com, no trailing slash); enables og:image/twitter:image previews and absolute URLs in /sitemap.xml |
HEALTHCHECK_FAIL_THRESHOLD |
3 |
Consecutive /healthz failures before spawnpoint-healthcheck.timer posts a Discord alert (see Monitoring) |
bin/spawnpoint-worker -watch -interval 15s <dir>... # follow live logs
bin/spawnpoint-worker <dir-or-file>... # one-shot import
bin/spawnpoint-worker -watch \
-server-dir "Coop Bots@127.0.0.1:14567=/srv/bf1942/coop/logs" \
-server-dir "Infantry Only@127.0.0.1:14568=/srv/bf1942/inf/logs"
bin/spawnpoint-worker -reingest data/raw-logs # rebuild DB from archive
bin/spawnpoint-worker -parse-only <file> # parser dry run
bin/spawnpoint-worker -watch \
-upload-url https://stats.example.com/api/ingest/upload \
-upload-key bfps_... <dir>... # remote game serverUse repeatable -server-dir entries when multiple local servers write logs on
the same box. Each entry maps one log directory to a server row, so aggregate
pages can show either all servers or a single server without mixing bot-heavy
and human-only populations unintentionally.
-reingest purges the previously stored games, rounds, events, and stats for
each log (matched by content hash) and re-ingests it with the current parser —
use it after parser upgrades so improvements (vehicle attribution, identity
linking) apply to history. Players, aliases, and identities are preserved.
Each worker registers itself by SERVER_NAME; games, rounds, and events are
tagged with that server, and /leaderboard, /trends, /rounds, and
/awards grow a server filter as soon as more than one server has data.
Player identity (keyhash) is global, so the same player keeps one profile
across servers.
For a game server on another machine, don't point it at the database — create
the server in /admin/servers, generate an ingest API key, and run the worker
there in -upload-url mode (or set UPLOAD_URL / UPLOAD_API_KEY in its
env). It ships each completed log over HTTPS; the central API parses,
archives, and dedupes by content hash.
The remote operator doesn't need database, SSH, or admin access to your
site — only the ingest URL and the one API key you generated for their
instance. Send them
deployments/scripts/remote-worker-setup.sh:
it builds the worker, creates an unprivileged system user, checks the URL/key
before installing anything, and installs a hardened systemd service per game
server instance (handles multiple instances on one box). Manual setup with
the flags above still works if they'd rather run it by hand.
The DICE XML log only contains timestamps relative to round start; the only
absolute clock is the log file name (ev_<port>-<YYYYMMDD>_<HHMM>.xml).
Ingestion parses games.started_at from that name, and the archive keeps files
as <sha256>__<original-name> so both the content hash and the original name
survive re-ingestion.
The BF1942 engine logs weapon="(none)" on scoreEvent whenever a kill was not
made with a handheld weapon. The parser resolves these by tracking vehicle
occupancy per player slot (enterVehicle/exitVehicle/death/spawn) and
attributing weaponless kills to the vehicle or stationary gun the killer
occupied, with _PCO<n> position suffixes collapsed (e.g. Hanomag_MG42_PCO1
becomes Hanomag_MG42). Kills that cannot be attributed (explosions, log gaps)
keep an empty weapon and stay out of the weapon tables.
The worker is compatible with BFSRM/BFSMD-style managers and plain BF1942 Linux dedicated binaries. The server side only needs DICE XML event logging enabled and a writable mod log directory, such as:
/home/bf1942_user/bf1942/mods/bf1942/logs
See BFSRM and Linux Dedicated Server Stats Setup
for the exact manager/script steps to create the log directory, enable
game.serverEventLogging, and point the Spawnpoint worker at the XML files.
For database roles, backups, and hardened systemd templates, see the Production Runbook. For a step-by-step public-internet setup — Nginx terminating a Cloudflare Origin Certificate, locked down so the app is only reachable through it — see Nginx + Cloudflare Reverse Proxy.
apps/api/ HTTP API + server-rendered UI (theme, charts, pages)
apps/worker/ log watcher / importer / reingester
internal/parser/bflog/ Battlefield XML parser + vehicle-kill attribution
internal/storage/ PostgreSQL ingestion, identity linking, analytics
internal/domain/ normalized event types
migrations/ schema (applied by setup.sh/update.sh, tracked in schema_migrations)
fixtures/ parser fixtures
deployments/ production + dev docker-compose stacks, systemd templates, backups
docs/ architecture and production notes
Dockerfile multi-stage build for the api/worker/admin images
setup.sh interactive installer
update.sh updates an existing install to the latest (or a pinned) release
- Historical correctness before real-time features.
- Raw event storage before aggregate-only stats.
- Self-hostable deployment by default.
- Small, testable parser core (
go test ./...).
MIT. This is an unofficial fan project — not affiliated with or endorsed by EA or DICE.