The multiplayer assumption
A normal Project Zomboid login makes several independent systems appear to be one object:
IsoPlayer
+ OnlineID
+ UdpConnection
+ Steam identity
+ ServerPlayerDB record
+ address-map membership
They normally enter and leave the server together, so it is easy to assume that an IsoPlayer cannot exist without its own client connection.
The decisive discovery was that IsoPlayer is the simulated entity while UdpConnection is a transport owner. They normally travel together, but they are not the same thing.
That distinction became the architecture of True MP NPC.
P0: creating the body
The first proof created a real survivor description and invoked the actual player constructor, conceptually equivalent to:
SurvivorDesc descriptor = new SurvivorDesc();
IsoPlayer npc = new IsoPlayer(
currentIsoCell,
descriptor,
spawnX,
spawnY,
spawnZ
);
The runtime marked the new player as remote and assigned its visible identity:
remote = true
OnlineID = leased ID
username = generated NPC identity
displayName = generated NPC name
Here, remote means that the object is represented as a multiplayer remote player. It does not create a network connection.
The connection invariant remained:
GameServer.getConnectionFromPlayer(npc) == null
Leasing an OnlineID
Multiplayer replication identifies players through a signed short OnlineID. Reusing an active value could cause one player to replace another in lookups or packet processing.
The first OnlineIdLeaseAllocator used a deliberately bounded high range:
30000 ... 32760
The allocator rejects an ID when it is already active, quarantined after uncertain cleanup, present in IDToPlayerMap, or otherwise reported as colliding.
If teardown cannot prove that the expected object was removed cleanly, its ID can be quarantined rather than returned immediately to circulation. The objective is not only uniqueness at spawn time. The runtime must also prevent incomplete cleanup from aliasing a future NPC.
Installing only the required identity
The new body is installed into the minimum authoritative structures required for a server-side player:
GameServer.IDToPlayerMap[onlineId] = npc
GameServer.Players contains npc exactly once
IsoCell/world membership contains npc exactly once
It is deliberately excluded from connection-owned structures:
IDToAddressMap does not contain onlineId
PlayerToAddressMap does not contain npc
playerToCoordsMap does not contain onlineId
local player collection does not contain npc
The server knows that this OnlineID represents this IsoPlayer, but it does not believe that this IsoPlayer owns a client connection.
Identity is checked by object reference
Before modifying IDToPlayerMap, the runtime takes a baseline using IdentityMapInvariant<Short, Object>.
It records the exact addition it expects. Later comparisons can detect a missing entry, a key pointing to the wrong instance, an unrelated baseline entry disappearing, an unexpected addition, or rollback failing to restore the baseline.
The comparison is based on Java object identity. An equivalent object is not the same object.
The active invariant requires:
npc is exactly an IsoPlayer
npc.getOnlineID() equals the leased ID
IDToPlayerMap[leased ID] is the same instance
cell membership count equals one
server-player membership count equals one
no connection registry contains the NPC
Violations fail with explicit states such as M2_IDENTITY_REGISTRY_INVARIANT, M2_CELL_MEMBERSHIP_INVARIANT, or M2_CONNECTION_OR_CLIENT_REGISTRY_INVARIANT.
Rollback is part of spawning
Every successful mutation immediately registers an exact inverse in RollbackStack:
lease OnlineID
-> release or quarantine lease
construct and attach IsoPlayer
-> remove exact world object
insert IDToPlayerMap entry
-> remove only if the key still points to this NPC
append GameServer.Players
-> remove this exact object instance
Rollback executes in reverse order. It is not allowed to remove whatever currently occupies an ID. If the map has changed unexpectedly, cleanup reports failure instead of deleting unrelated player state.
The lifecycle makes the process explicit:
WAITING_FOR_WORLD
WAITING_FOR_LOADED_SQUARE
READY
SPAWNING
ACTIVE_P0
DESPAWNING
CLEAN
Failure is also represented as state: FAILED_ROLLBACK_PENDING or FAILED_SAFE. A Java object existing in memory is not enough to call the NPC active.
P1: the method that changed the project
P0 proved that a genuine IsoPlayer could exist with a null connection, zero address-map membership, and no player-database identity.
But it did not yet prove that an unmodified client could see it.
The decisive Java method was effectively:
GameServer.sendPlayerConnected(
IsoPlayer representedPlayer,
UdpConnection receivingConnection
);
Those arguments have separate roles. The first identifies the player being represented. The second identifies the real client receiving that representation.
The receiving connection does not need to belong to the represented player.
The first breakthrough
Representation and transport enter the send path independently.
Represented entity
zombie.characters.IsoPlayerIDToPlayerMap[id] === npcReceiving transport
UdpConnection targettargetConnection != npc.connectionrepresentedConnectionRequired = falseconnectionlessNpc = trueFor the initial proof, PzNpcManager waited for one fully connected human client and selected a safe loaded square near that player. It then executed sendPlayerConnected(npc, realClientConnection).
Throughout the operation:
npc connection = null
represented connection required = false
address-map membership = 0
The human connection was only the delivery destination. It was not assigned to the NPC.
When an unmodified client rendered that Java-created body, the central architecture was proven.
No fake login, hidden Steam user, simulated socket, or headless Project Zomboid client was required.
The connection was never the player.
The first real breakthrough was discovering that the object being represented and the connection receiving that representation were independent roles.