P2: a visible body was not enough
A stationary player-connected message could still have been a narrow protocol trick.
The next proof had to demonstrate that Project Zomboid's normal character replication could serialize the changing state of a connectionless IsoPlayer.
The PZ adapter resolved the relevant internal path:
IsoPlayer.getNetworkCharacterAI()
NetworkCharacterAI.needUpdate()
PlayerPacket constructor
PlayerPacket.set(IsoPlayer)
INetworkPacket.sendToClient()
A player update followed this structure:
Object networkAI = npc.getNetworkCharacterAI();
networkAI.needUpdate();
PlayerPacket packet = new PlayerPacket();
Object packetWriter = packet.set(npc);
packet.sendToClient(packetWriter, realClientConnection);
The exact internal writer types are build-specific, which is why the methods are resolved and verified through PzApi.
The authority flow matters more than the reflection syntax. The server mutates the real IsoPlayer, PlayerPacket reads that object, and a vanilla packet is delivered to real connections. The NPC never sends a packet. It has no socket and no UdpConnection.
Synchronizing network-facing state
The first movement implementation updated both the authoritative body and the remote-position state consumed by the multiplayer representation.
That included fields and operations around:
realX / realY / realZ
networkMoving
networkDirection
forward direction
setForceX() / setForceY() / setZ()
ensureOnTile()
setMovingSquareNow()
Before accepting a movement step, the runtime checked that the target square was loaded, had a floor, was not blocked, respected stair constraints, and had a clear line path. It also checked alignment between the authoritative and replicated remote positions.
PlayerPacket.set(npc) then serialized the actual player object. The runtime captured packet evidence such as serialization success, prediction type, and relevant boolean-variable state.
This distinguished “a packet send was attempted” from “the vanilla PlayerPacket path successfully represented the NPC.”
Why this is not a headless client
“Headless NPC” can sound like a hidden Project Zomboid client running without graphics. That is not the architecture.
A headless client would still require a client process, login lifecycle, UdpConnection, multiplayer identity, client relevance window, and client-to-server packet traffic.
True MP NPC has:
one dedicated-server process
one server-owned IsoPlayer
no NPC client process
no NPC login
no NPC UdpConnection
no NPC socket
A more exact term is a connectionless, server-owned IsoPlayer. The server does not pretend that a client exists. It directly owns the authoritative player-shaped entity.
Visibility and simulation are separate
P1 and P2 solved client representation. They did not yet solve an empty server.
With zero human connections there are no packet recipients, but that is not a problem. The server can continue simulating the body without sending its representation anywhere.
The real problem was world loading.
Project Zomboid normally keeps cells and chunks relevant around connected clients. A connectionless NPC has no client relevance window.
This produced the second major architectural distinction: network transport and world relevance are also separate systems.
Two independent continuations
Observation may stop at zero clients. Simulation does not.
npc.connection == nullObservation branch
NetworkCharacterAI.needUpdate()vanilla remote-player updateno recipient, no errorSimulation branch
NpcWorldPresencehuman + NPC + server relevancePauseEmpty = falseNpcWorldPresence
The later runtime introduced NpcWorldPresence. It contributes a bounded coordinate-based relevance lease through the existing vanilla ServerMap path.
The NPC is reported through the appropriate ServerMap.characterIn(...) evaluation point. It does not construct a second chunk map, use ClientServerMap, create a fake UdpConnection, disable other relevance sources, or force the entire world to remain loaded.
Only the bounded area required by the materialized NPC is contributed.
As the NPC travels, relevance moves with it. New world regions become available ahead, while old regions return to vanilla saving and unloading behind it.
Zero humans
The dedicated server uses PauseEmpty=false. This tells the normal server loop to continue when no humans are connected. It is not a fake-player workaround.
During a zero-player run:
NpcWorldPresence keeps a bounded area relevant
ServerMap loads the required cells and chunks
the Brain continues making decisions
the IsoPlayer continues moving physically
real world objects remain authoritative
vanilla persistence continues
outbound player-packet count remains zero
Zero packets is the correct result. There are no receiving connections. The absence of packet traffic does not imply the absence of simulation.
Persistence closes the loop
A durable NPC record stores recoverable identity and state separately from the live Java object.
On cold startup:
persistent record
-> safe spawn validation
-> OnlineID lease
-> IsoPlayer materialization
-> registry invariant validation
-> NpcWorldPresence activation
-> Brain resumes
The persistent record is not proof that a body can safely appear at its previous coordinates. If the square is unsafe, missing, or already occupied, materialization can fail closed or use a bounded deterministic nearby search. The corrected position is persisted before autonomy resumes.
The complete separation
The project ultimately separated four concerns that normal multiplayer login bundles together:
Simulation entity
-> IsoPlayer
Network identity
-> leased OnlineID and authoritative registries
Transport
-> real receiving UdpConnections, if any
World relevance
-> bounded NpcWorldPresence through ServerMap
A normal human player owns all four through one client lifecycle. A True MP NPC does not.
It owns the simulation entity and a server-side network identity. Real human connections are used only as outbound recipients. World relevance is supplied independently through ServerMap.
That is why the same NPC can appear to an unmodified client when humans are connected, continue physically when every human disconnects, send no packets when nobody can receive them, and remain the same durable survivor after restart.
The server did not need to become a fake client. It only needed to own the player, serialize it when observed, and keep enough of the world alive for it to exist.