My NAS rebuilds a game database every time the game gets patched
I registered unturnedlord.com in 2016 for my UnturnedLord servers. Those were PvP servers for Unturned (a free zombie survival game on Steam), running in the EU and the US from 2015 until about 2021, and the website sold VIP ranks through a WooCommerce shop. When the servers closed, the website stayed with the old host, and at some point it started returning a blank page. [CONFIRM: dates and details of the servers and shop]
This November, I decided to give the domain a second life. It’s now a fan reference site, UnturnedLord, with every item, vehicle, crafting recipe and map in the game. What makes it a home-lab project is how the data gets there: nobody types it in. My NAS downloads the game’s dedicated server from Steam, reads the game’s own asset files, and rebuilds the website whenever the game is patched.
In this post, I will show you how that pipeline works, from the daily timer down to the check that the live site shows the new version.
Why build it from the game files
Most Unturned item lists on the web were typed by hand at some point, and many of them never got updated after that. The game still gets patched, though, and the developer (Smartly Dressed Games) ships every item as a plain text .dat file inside the dedicated server. You don’t even need to own the game: the server is Steam app 1110390, and SteamCMD downloads it with an anonymous login.
This is what an item looks like on disk (the start of the Eaglefire, one of the rifles):
GUID b03d581a5c1a490f995f8deba57b0f17
Type Gun
Rarity Rare
Useable Gun
Slot Primary
ID 4
...
Caliber 1
Range 200
The server install has about 8,000 of these files. If a script can read them, it can generate the whole site, and the full item ID list is always the one from the current version of the game. So that’s what I built.
I built it together with Claude Code. I made the calls about what the site should have and how careful the pipeline should be, and Claude wrote most of the code, including the parser.
Here’s what’s involved:
- SteamCMD in Docker on my UGREEN NAS, for the game server and the Workshop maps.
- A TypeScript extractor that turns the
.datfiles into JSON. - Astro, which builds about 1,300 static pages from that JSON.
- A Cloudflare Worker with static assets for hosting (the same setup I use for this blog).
- A systemd user timer, Telegram and Uptime Kuma to run it every day and tell me when something goes wrong.
1. Check Steam every morning (and do almost nothing)
Every morning at 05:23, a systemd user timer starts update.sh on the NAS:
[Timer]
OnCalendar=*-*-* 05:23:00
Persistent=true
The first step updates the dedicated server with the official steamcmd/steamcmd Docker image. Nothing is installed on the NAS itself, and SteamCMD’s own files sit in a mounted folder, so they survive between runs:
docker run --rm -v "$dir:/data" -v "$UL_DATA_DIR/.steamcmd:/root/.local/share/Steam" \
steamcmd/steamcmd:latest +force_install_dir /data +login anonymous +app_update 1110390 +quit
The game also has 22 curated Workshop maps (community maps the developer features in the game), and I wanted those on the site too. The list of them is in the server’s own Status.json, so a newly curated map is picked up automatically.
Here I hit the first problem: with all the maps and the asset packs they need, that’s 25 Workshop items and about 8 GB. SteamCMD re-verifies every item you give it, so a day with no changes at all took 463 seconds. The fix was to ask Steam first. A small script calls Steam’s public GetPublishedFileDetails API (no key needed), compares each item’s manifest with the one I have installed, and only passes the changed items to SteamCMD. If the API doesn’t answer, it falls back to downloading everything, so an outage costs time, not freshness. A no-change morning now takes about 6 seconds.
2. Decide whether anything changed
Next, the script builds a fingerprint from three things:
- the game version from
Status.json(for example3.26.3.14); - the Steam build ID from the app manifest;
- a hash of the installed Workshop manifests.
If the fingerprint matches the last one I published, the script pings Uptime Kuma and exits. That’s what happens on most days.
I included the build ID because a patch can change files without changing the version number. The Workshop hash is there because a map author can update their map at any time, without a game patch.
3. Extract the data, and refuse to publish anything suspicious
When something did change, the script pulls the site’s repo and runs the extractor on the whole install. The extractor follows the game’s own text format, including its two syntaxes for recipes and spawn tables, and also reads the maps’ binary files for loot locations.
Before that, there’s one trick I like. The server settings on the site come from the Config.txt a fresh server writes on its first start, with every default for that version. To get it, the script starts a throwaway server container on a read-only mount of the install, with only two temp folders writable, waits for the file to appear (a few seconds) and removes the container. If that fails, I get a warning on Telegram and the site keeps the previous defaults.
The part I care about most comes next: the gates. A bad parse shouldn’t quietly turn into 1,300 broken pages, so the update stops if:
- any
.datfile failed to parse, or a recipe points at an item that doesn’t exist; - no command files were found (that would mean the install is incomplete);
- the item count dropped by more than 5%;
- the map count dropped at all (a failed Workshop download would otherwise delete map pages);
- any count went to zero.
Then the usual checks run: type checking, the Astro build, and a script that scans every built page for undefined, NaN, empty table cells and broken internal links.
If any gate fails, the script restores the repo, sends me a Telegram line starting with FAILED:, and publishes nothing. The next morning it tries again. For a patch that really does remove a lot of items, I can run it once by hand with --allow-drop.
4. Publish, and check that it really went live
If everything passes, the script:
- commits the
data/folder to the site’s GitHub repo, so every patch shows up as a readable git diff; - deploys with
wrangler deployto the Cloudflare Worker; - checks the live site;
- pings IndexNow with only the pages that changed;
- sends me a Telegram message with a link to the patch page.
Step 3 is there because I’ve learned not to trust a successful deploy. A 200 OK from wrangler doesn’t prove anyone sees the new data, so the script fetches the live home page and looks for the new version number:
curl -fsS -m 20 "$UL_LIVE_URL/" | grep -q "Data from Unturned $VERSION"
It tries six times, ten seconds apart. If the deploy itself fails, the script leaves a deploy-pending file behind, and the next morning’s run deploys again even if nothing else changed.
5. A page for every patch
This is my favourite part. Before the new data is committed, the extractor compares it with the previous version. Items and vehicles are matched by their GUID (so a renamed item is still the same item), and every changed value is listed, before and after. Each game update gets its own page under Unturned patch changes, with every item added, removed or changed.
That page writes itself, on the morning after the patch.
Testing it without waiting for a patch
The site went live on 10 November, and as I write this, the game hasn’t had a patch since. So the updates page still says “No update yet”. I didn’t want the first real patch to also be the first test, so there’s a simulate-patch.sh script. It copies the parts of the install the extractor reads (about 1.7 GB) to /tmp, bumps the patch version from 3.26.3.14 to 3.26.3.15, and changes one value in one file: the Eaglefire’s Player_Damage from 40 to 44. Then it runs the whole update as a dry run, in a throwaway clone of the repo, with no push, no deploy and no Telegram.
It passed every gate and built a /updates/3.26.3.15/ page with exactly one row: Eaglefire, player damage 40 → 44. A forced run on the real, unchanged install did the opposite, as it should: it extracted everything and logged “changed nothing the site reads”.
Making sure the timer doesn’t die quietly
On my UGREEN NAS, systemd user timers have a nasty habit: after a reboot, /home can mount after the user’s systemd has already started, and every timer ends up dead without an error. I wrote about the fix (a drop-in that makes systemd wait for the mount) in my UGREEN cron jobs guide. It covers every user timer, including this one.
A fix isn’t a guarantee, though, so this job also has an Uptime Kuma push monitor. Every successful run pings it, including the quiet “no change” ones. If 26 hours pass without a ping, Kuma sends me a Telegram alert.
That’s it! Every morning, my NAS checks Steam, and on the day a patch lands, the site rebuilds, checks itself and deploys without me.
Closing remarks
- The real test is still ahead. Everything above passed in a simulated patch, but the first real one will be the first real publish. I’ll find out then what I didn’t think of.
- It’s a fan site. “Unturned” is Smartly Dressed Games’ trademark, and the site says clearly that it isn’t affiliated with them. It only shows data read from the game files, and none of the game’s art, like the map images, goes on the site.
- The parser is my own. The developer released the game’s source code this year, which made it much easier to understand how the game reads its files. Its licence only allows non-commercial use, though, so the code was read for reference and nothing was copied.
- It’s cheap to run. The NAS was already on, SteamCMD and the server run in short-lived containers, and the site is static files on a Cloudflare Worker. The game data takes about 10 GB of disk, mostly the Workshop maps.
- The game files have more than just items. The same pipeline feeds a raid calculator that works out how many charges or rockets break any wall or door, using the damage numbers from the files.
If you run something similar for another game, or you know a game that ships its data this openly, let me know.