← Technicals

Foundation series · Part 01 of 03

Lua Ends Here

True MP NPC began where Project Zomboid's normal Lua modding surface ended: inside the exact Java server build, on the authoritative server thread.

Lua was not deep enough

Project Zomboid's normal modding layer is Lua. It is the correct tool for recipes, items, UI, events, timed actions, and gameplay behavior exposed through the public modding surface.

True MP NPC's first problem existed below that layer.

The objective was not to spawn an object that merely resembled a player. The dedicated server had to construct and own an actual zombie.characters.IsoPlayer.

That object needed to participate in the same authoritative systems as a multiplayer survivor:

IsoCell membership
OnlineID lookup
GameServer player registries
NetworkCharacterAI state
PlayerPacket serialization
world collision and movement
inventory, damage and death

At the same time, it had to remain outside systems owned by a connected human client:

UdpConnection
Steam login
connection-owned address maps
client coordinate maps
ServerPlayerDB identity

Lua can manipulate players already created through the normal game lifecycle. It does not expose a safe lifecycle for constructing this unusual combination: a real multiplayer-shaped player whose owner is the server itself.

The experiment therefore moved into Project Zomboid's Java server internals.

Getting onto the server thread

The first requirement was a deterministic execution point inside the dedicated server.

True MP NPC starts as a Java instrumentation agent through Premain-Class. The agent uses ASM to transform a narrowly bounded set of Project Zomboid classes before they are loaded.

The primary runtime hook is inserted into zombie.network.ServerMap.postupdate().

Immediately before the method returns, the transformer injects:

RuntimeBridge.onServerTick();

This gives PzNpcManager a call on the authoritative server thread. That thread already owns ServerMap, IsoCell, player registries, moving-object lists, and the world update lifecycle. Performing those mutations from an ordinary worker thread would create races against the game loop.

The bridge records the server thread identity and requires manager work to remain on that thread.

Authoritative entry path

From an exact game build to one safe server-thread call.

01 · INPUTExact Build 42.20.2 bytecodezombie/network/ServerMap
02 · CONTRACTHash, descriptor and instruction checksBytecodeContract.validateOriginal()
03 · TRANSFORMInject before postupdate returnsRuntimeBridge.onServerTick()
04 · AUTHORITYPzNpcManager runs on the server threadownerThreadId == currentThread
FAIL CLOSEDRefuse to arm the runtimeContractException
The hook is accepted only when the original and transformed classes satisfy the exact bytecode contract.

Shutdown is part of the contract

Creating a server-owned player is only safe if the server can also remove it deterministically.

The agent adds cleanup notifications to the normal and JVM shutdown paths:

ServerMap.preupdate()
    -> RuntimeBridge.onNormalShutdownRequest()

GameServer$1.run()
    -> RuntimeBridge.onJvmShutdownRequest()

The runtime can then stop the manager, despawn materialized NPCs, restore registries, and release identity leases before shutdown finishes.

A proof that can create an IsoPlayer but cannot prove exact cleanup is not an acceptable foundation.

An exact-JAR agent

Project Zomboid's internal Java classes are not a stable public mod API. A method with the same name in another build is not automatically compatible.

The transformer verifies:

target class name
original class hash
method name and descriptor
expected instruction structure
unique injection point
transformed bytecode validity

The original foundation targeted exact classes including zombie/network/ServerMap and zombie/network/GameServer$1.

If ServerMap.postupdate()V, a shutdown landmark, or another expected instruction is missing, the agent refuses to arm. It does not search for the nearest method and hope for the best.

After transformation, the generated class is checked again before it is returned to the JVM. Compatibility is proven against an exact server build.

Isolating version-sensitive access

The rest of the runtime does not perform arbitrary reflection throughout the codebase. Project Zomboid-specific access is concentrated behind PzNpcManager.PzApi.

This adapter resolves the constructors, fields, and methods required by the active build:

SurvivorDesc constructor
IsoPlayer constructor
IsoPlayer.setOnlineID()
GameServer.IDToPlayerMap
GameServer.IDToAddressMap
GameServer.PlayerToAddressMap
GameServer.getConnectionFromPlayer()
GameServer.sendPlayerConnected()
PlayerPacket.set()
INetworkPacket.sendToClient()

If one of these members is missing or incompatible, the adapter cannot arm.

This creates a boundary between version-specific Project Zomboid internals and version-independent lifecycle and policy.

The Java agent and PzApi did not yet create an NPC. They created something more fundamental: a verified route into the server's authoritative execution model.

Only then could the real experiment begin.

Lua was not rejected as a language. The problem had simply crossed beneath the boundary Lua was designed to expose.