← Technicals

Foundation series · Part 03 of 03

No Client, Still a World

A connectionless IsoPlayer could be rendered through vanilla packets, but running with zero humans required separating network transport from world relevance as well.

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.

AUTHORITATIVE STATEServer-owned IsoPlayernpc.connection == null

Observation branch

SERIALIZEPlayerPacket.set(npc)NetworkCharacterAI.needUpdate()
DELIVERsendToClientvanilla remote-player update
EXPECTEDZero outbound packetsno recipient, no error

Simulation branch

WORLD PRESENCEBounded NPC relevanceNpcWorldPresence
VANILLA UNIONServerMap.characterIn(...)human + NPC + server relevance
RESULTCells load, save and unload normallyPauseEmpty = false
Transport answers who can observe the NPC. World relevance answers whether the NPC has a world to inhabit.

NpcWorldPresence

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.