Projects / Observability & Telemetry
Multi-Protocol Player Telemetry
A dependency-free Python collector that asks 12 game servers how many people are playing, over four binary protocols and four JSON APIs. It publishes counts-only JSON for a public page, richer detail for internal dashboards and Prometheus metrics for 90 days of history.
- Python
- Sockets
- Prometheus
- VictoriaMetrics
The problem
I host game servers for friends: Phantasy Star Online: Blue Burst on three instances, four Minecraft servers, four classic arena shooters, Palworld and a card game. The first question anyone asks is “is anyone on?”, and the honest answer used to be “let me log in and look”.
Uptime monitoring didn’t answer it. A port check says a server is up, not whether anyone is playing, and it doesn’t separate players from bots. I wanted one place that answers the question for every game, publicly and safely, and remembers the answer so I can see when people actually play.
Constraints
- Speak each game’s own language. There’s no common protocol: some games have JSON APIs, others only answer a 1990s-era UDP query, and one compresses its replies with a fixed Huffman tree.
- Publish counts, never people. The public page must not reveal player names, IPs or anything about the server.
- No new attack surface. The collector shouldn’t need database credentials or write access to any game, and nothing it serves internally should be reachable from the internet.
- Survive any one game misbehaving. A crashed or slow server must not stall or break the rest.
Architecture
One small Python process (standard library only: socket, struct, urllib, json) runs a probe for
every server every 30 seconds and writes three files. A separate nginx container serves them, and only
one is routed publicly.
| Output | Contents | Who can read it |
|---|---|---|
players.json |
Per game: state, players, slots. Counts only. | Public status page, this site’s live panel, a join guide |
details.json |
The above plus player names, current map, server build, accounts, uptime | Internal network only (admin dashboard) |
metrics.prom |
Prometheus gauges: players online, slots, open games, server up | Internal network only, scraped by VictoriaMetrics |
The split is enforced in code: probes return private data under a separate extra key, and the function
that writes the public file strips it. I verified the public output carries none of it before shipping.
Talking to each game
| Game | How it’s asked |
|---|---|
| Minecraft (4 servers) | The Server List Ping: a hand-built TCP handshake with VarInt framing, then a JSON status reply. The reply also reveals the modpack and loader, so dashboards show each server’s build with no configuration. |
| Quake III, OpenArena | The UDP getstatus query. Bots report a ping of 0, so they’re excluded from the count. |
| Doom (Zandronum) | Zandronum’s launcher protocol over UDP. Replies are Huffman-compressed with a fixed tree, so the collector rebuilds that tree from its published description and decodes the reply bit by bit. |
| Unreal Tournament 2004 | The Unreal 2 query protocol over UDP. Replies can span several packets, and an empty server sends no player packet at all, which has to read as “0”, not “down”. |
| PSO: Blue Burst (production, canary) | newserv’s HTTP API. |
| PSO: Blue Burst (legacy, Tethealla) | The account portal’s status endpoint, which already counts online players. Reusing it means the collector never needs credentials for the game’s database. |
| Palworld, card game | Their REST APIs. Palworld’s requires authentication, held in a git-ignored env file. |
Every probe has a 5-second timeout and runs inside its own error boundary. A server that doesn’t answer is reported as offline, and the rest of the run carries on.
Reliability
- Health check on freshness, not liveness. The container is healthy only if
players.jsonwas rewritten in the last three minutes, so a hung loop is caught even though the process is still running. - Atomic writes. Each file is written to a temp file and renamed, so readers never see half a file.
- Rate-limit aware. Zandronum refuses a second query from the same address within 10 seconds; the 30-second cycle stays well clear of that.
- Monitored from both sides. Uptime Kuma watches the collector, the internal web server and the public page as separate checks.
Results
- 12 servers across 9 game types report live player counts from one feed.
- 90 days of history in VictoriaMetrics, shown as a stacked 24-hour / 7-day chart on the admin dashboard, so I can see when friends actually play.
- No credentials exposed and no new public routes beyond one read-only, counts-only JSON file.
- Reused everywhere: the public status page, a “how to join” guide, the admin dashboard and this site’s live panel all read the same feed.
What I’d do next
- Alert on nobody playing for an unusual stretch, as a soft signal that something is wrong.
- Split the probes into small, separately testable modules with recorded protocol fixtures, so each decoder is checked against real captured replies in CI.