The received wisdom in the Project Zomboid modding community is that you cannot test craft recipes without a human in front of the game: spawn the items, open the crafting menu, click, watch, restart the server when a script changes. For a mod with a couple of hundred recipes and a long chain of dependent states, that loop is brutal — and unreliable. A “works once then does nothing” bug looks identical to “the recipe is fine and I fat-fingered the inputs.”
While porting a fairly large mod to Build 42, that loop cost weeks. So we stopped doing it. This post is about the two tools that replaced it — a live scripting channel into a running dedicated server, and a way to perform real crafts against a synthetic server-side character with no game client, no connected player and no human input — and the classes of bug that surfaced once we could actually see what the engine does. It stays at the level of “here is what works and why,” not a copy-paste harness.
Part 1: a scripting channel into a headless server
The first problem is that a dedicated server fires almost no Lua events. Worth knowing before you waste time on the usual tricks:
Events.OnTickis fired only from the client’s in-game state. A dedicated server never fires it.GameTime’sEveryOneMinute/EveryTenMinutesdo not fire while zero players are connected. With nobody online, a headless server fires essentially no periodic Lua at all.
So the trigger has to come from outside the game entirely. The server process does read console commands from standard input, and one of those commands re-runs a single already-loaded Lua file. Attach the server’s stdin to a channel you control, keep a fixed one-line file as the trigger, and have that trigger execute a scratch script by absolute path. Write the scratch script, poke the trigger, read the result out of the server log. Round trip: a couple of seconds instead of a ninety-second restart. That single change was the biggest force multiplier in the whole effort.
The one trap that will bite you: the “reload this Lua file” console command removes the file from the loaded set permanently if the reloaded version fails to compile — every later reload then answers “unknown file” until a full restart. Whatever the console reloads must be a file that never changes. Run your actual experiment code through a mechanism that loads a file by absolute path instead; a syntax error there is just a log line, and it heals itself on the next attempt.
Part 2: performing real crafts with no client
The “you can’t headless-test crafts” claim isn’t baseless — there are two real walls:
- Asking the crafting logic to build a recipe list pulls in icon-texture generation, which needs an OpenGL context. On a headless JVM that aborts the whole process. Setting a single recipe touches no textures — and it is what the game itself calls server-side anyway.
- The consume-inputs path refuses to run for real on a client. Fine: run the whole thing on the server, which is where multiplayer crafting is authoritative in the first place.
From there it is: make a synthetic character (it works fine unconnected, its map square is never even loaded), max its skills so a skill requirement can’t be the reason a recipe is unavailable, teach it the recipe, hand the crafting logic a container list and the one recipe, ask whether it can perform, perform it — then replay exactly what the game’s timed action does next: collect the created outputs, run the recipe’s OnCreate, process the consumed and destroyed items.
Then snapshot the character’s inventory before and after and diff it — item counts, drainable charge, fluid litres, and summed food nutrition. That last one is the part people miss, and it is exactly the trap the next section is about: without tracking nutrition, a partial food consumption is indistinguishable from “the input was never touched.”
Two edge cases worth guarding: creating a synthetic survivor can throw if it runs before the name tables load (i.e. mid-worldgen), and the game world doesn’t exist at all until the server has fully started. And an OnCreate that tries to place an item on the ground fails for a character whose square isn’t loaded — inventory-only OnCreates are fine.
What it caught: the duplication bug class
Once you can diff a craft’s real effect, one mistake shows up over and over. In Build 42, a craftRecipe input line like item N [SomeItem] does not reliably mean “N whole items.” For a food item it means N/100 of a nutrition unit:
item 1 [SomeMash] # a food item with HungerChange -10
# -> consumes 1/100 of a nutrition unit; the container SURVIVES
The player keeps the ingredient and gets the product. Multiply that across every “refill a bottle from the still” recipe and you have an infinite-resource exploit built entirely out of individually-reasonable lines. The same shape hits fluid-container items (read as a source, never consumed) and drainable items (one unit of charge drained, the item stays). The fix is usually a single flag that forces whole-item consumption — but you can’t know you need it until you can measure that you don’t have it.
A sneakier variant: an input that is a drainable whose “empty” state hands back a container, feeding an output line that also produces a container. One vessel in, two vessels out — an infinite container printer, again from two lines that each look correct on their own.
What it caught: silent no-ops and dead recipes
The nutrition-unit rule cuts the other way too. A recipe that asked for item 45 of a sugar item was quietly demanding roughly one and a half full bags (a full bag is about 0.30 nutrition units). With one bag in the inventory, the recipe simply never appeared in the menu — no error, no greyed-out entry, nothing. Real time went into hunting a “bug” that was arithmetic.
Three recipes turned out to be completely dead on arrival: their fluid requirement had no valid source and never would. The intended path to that outcome was a right-click interaction the recipes didn’t reflect, so a player searching the crafting menu for the obvious option found one that could never fire. An automated sweep of every hand-craft recipe against a fully-stocked synthetic inventory turns “this never offers” into a one-line result instead of a debugging session.
What it still can’t do
The honest boundary: this exercises the server crafting path — the multiplayer-authoritative one — and nothing else. Not the crafting UI, not the client/server item round-trip, not timed-action interruption, not anything visual. A Lua OnCreate that adds an item to an inventory sends no sync packet to the client (the game’s own code has a “todo: handle syncing” comment sitting right there), so “the item is in the inventory server-side” and “the player can see it” are different claims. And a dedicated server never loads the translation table, so rendered tooltip and recipe-name strings still need one human pass.
Even with that boundary: a full sweep of the core recipes across every scenario now takes about a second, a whole-mod audit of a couple hundred recipes takes about three, and “does this change break anything” has an answer before the server finishes rebooting. After weeks of the manual loop, that is the difference between a port that stalls and one that ships.
