Static NZPlayers helper for managing player GameObjects and NZPlayer components in a multiplayer session. It clones either a prefab or the scene player, assigns ownership, network-spawns bodies, ensures the host gets an owned body, strips weapons from cloned bodies, manages which controller/camera/input belongs to which body, tracks movement timestamps, enforces a single viewmodel camera, and updates body appearance from the lobby selection.
using Sandbox;
using System.Linq;
namespace NZombies;
/// <summary>
/// ONE BODY PER CONNECTION.
///
/// ⛔ THE SCENE SHIPS EXACTLY ONE `Player Controller`, AND IT IS `NetworkMode.Snapshot`. So a
/// joining client receives a PROXY of the host's body and nothing of its own: it stands in the
/// map unable to move, watching a copy of somebody else. That is the "ghost" — not a missing
/// player, a borrowed one.
///
/// ⚠️ CLONED, NOT INSTANTIATED FROM A PREFAB. There is no player prefab in this project and
/// making one means editing the scene, which this project has a standing rule against doing from
/// code. `GameObject.Clone()` copies the object that is already there, components and all, so the
/// bodies cannot drift from the one a mapper actually sees in the editor.
///
/// ⚠️ AND THE CLONE HAPPENS ON THE HOST. `NetworkSpawn( connection )` is what hands ownership to
/// the joiner — "the owner will be the connection" — which is the difference between a body a
/// client can drive and one it can only watch.
/// </summary>
public static class NZPlayers
{
/// <summary>The prefab every body is cloned from once <see cref="FromPrefab"/> is on.</summary>
public const string PlayerPrefabPath = "prefabs/player.prefab";
/// <summary>
/// CLONE THE PREFAB INSTEAD OF THE BODY STANDING IN THE MAP.
///
/// ⛔ THIS IS THE ENGINE'S OWN PATTERN AND WE ARE THE ONES WHO DEVIATED. Facepunch's
/// documented spawn is `PlayerPrefab.Clone( spawn )` then `NetworkSpawn( connection )`, in
/// `INetworkListener.OnActive`. We clone a LIVE SCENE OBJECT — and a live object carries
/// runtime state that a prefab cannot:
///
/// RenderType = ShadowsOnly → every body invisible (fixed by asserting it back)
/// the `viewer` tag → every body invisible AGAIN (fixed by stripping it)
/// a viewmodel + its handler → floating arms, throwing update (fixed by null-guarding it)
///
/// Three separate bugs, three separate fixes, ONE cause. A prefab makes all three impossible
/// rather than survivable. See `SBOX_MULTIPLAYER.md` §9.1.
///
/// ⚠️ DEFAULT OFF, AND A SWITCH RATHER THAN A REPLACEMENT. The old path is the one that
/// currently gets through a whole game; this one has never run. Both are in the build so the
/// two can be compared in ONE session, on one map, instead of across two builds a test cycle
/// apart — which is how the last three "regressions" turned out to be different conditions
/// rather than different code.
///
/// ⚠️ IT IS A STATIC, SO IT SURVIVES HOTLOAD AND SURVIVES A PLAY SESSION. That has cost
/// this project more time than any other single thing (INSTRUCTIONS pattern 1) and cost it
/// again this afternoon (pattern 24). The mitigation is that **every spawn says out loud
/// which path it took** — a surviving static that announces itself cannot silently contaminate
/// a test.
/// </summary>
/// <remarks>
/// ✅ DEFAULT ON SINCE 2026-09-10, after a session where both players saw each other, wore
/// their own characters and carried working weapons. It was default-off while unproven; leaving
/// a proven fix behind a switch that has to be re-typed after every editor restart is a trap,
/// and the user hit it once already ("do i just start or do i run anything").
///
/// ⚠️ THE COMMAND STAYS, so the old path is one word away if something turns up.
/// </remarks>
public static bool FromPrefab { get; set; } = true;
static PrefabFile _prefab;
/// <summary>
/// `nz_prefab_players [0/1]` — switch the source of new bodies. No argument reports.
/// </summary>
[ConCmd( "nz_prefab_players" )]
public static void SetFromPrefab( int on = -1 )
{
if ( on >= 0 ) FromPrefab = on != 0;
Log.Info( $"[nz-net] new bodies come from {(FromPrefab ? $"THE PREFAB '{PlayerPrefabPath}'" : "the scene's own player object")}"
+ $"{(on < 0 ? " · nz_prefab_players 0/1 to change" : "")}" );
if ( !FromPrefab ) return;
// ⚠️ SAY NOW WHETHER THE ASSET IS EVEN THERE. Finding out at the spawn means finding out
// with a joiner already waiting for a body.
if ( ResourceLibrary.Get<PrefabFile>( PlayerPrefabPath ) is null )
Log.Warning( $"[nz-net] ⛔ '{PlayerPrefabPath}' DOES NOT EXIST — every spawn will fall "
+ "back to cloning the scene body." );
else
Log.Info( "[nz-net] the prefab is present and loadable" );
}
/// <summary>
/// A FRESH, UNOWNED BODY — from the prefab, or from the scene, whichever is switched on.
///
/// ⚠️ THE FALLBACK IS THE OLD PATH, NOT NULL. A missing or unloadable prefab must not mean
/// "this player gets no body"; it means "we are back where we were this morning", which is a
/// game that works.
///
/// ⚠️ CLONED AT THE TEMPLATE'S TRANSFORM either way, so `PlayerSpawner.PlaceAll` does
/// exactly the same job afterwards and this change alters WHERE THE COMPONENTS COME FROM and
/// nothing else.
/// </summary>
static GameObject NewBody( GameObject template )
{
if ( FromPrefab )
{
_prefab ??= ResourceLibrary.Get<PrefabFile>( PlayerPrefabPath );
if ( _prefab is not null )
{
var fresh = GameObject.Clone( _prefab, new CloneConfig
{
Transform = template.IsValid() ? template.WorldTransform : global::Transform.Zero,
StartEnabled = true,
} );
Log.Info( $"[nz-net] body cloned from THE PREFAB ({PlayerPrefabPath})" );
return fresh;
}
Log.Warning( $"[nz-net] ⛔ '{PlayerPrefabPath}' would not load — falling back to "
+ "cloning the scene body" );
}
if ( !template.IsValid() ) return null;
Log.Info( "[nz-net] body cloned from THE SCENE'S OWN PLAYER (the old path)" );
return template.Clone();
}
/// <summary>
/// Give this connection a body of its own. Host only; no-op for the host itself.
///
/// ⛔ THE HOST KEEPS THE SCENE'S OWN PLAYER. It is already there, already enabled by
/// `PlayerPresence`, and already the thing every single-player code path resolves to. Cloning
/// a second one for the host would leave two bodies on the machine that has the most code
/// assuming there is one.
/// </summary>
public static void SpawnFor( Connection channel )
{
if ( channel is null ) return;
if ( NZGame.IsClient ) return;
if ( channel == Connection.Local )
{
Log.Info( "[nz-net] host keeps the scene's own body" );
return;
}
var template = PlayerPresence.Find();
if ( !template.IsValid() )
{
Log.Warning( "[nz-net] no player object to copy — cannot give "
+ $"{channel.DisplayName} a body" );
return;
}
var go = NewBody( template );
if ( !go.IsValid() ) { Log.Warning( "[nz-net] could not make a body" ); return; }
go.Name = $"Player ({channel.DisplayName})";
// ⛔ WHOSE BODY THIS IS, WRITTEN DOWN BEFORE IT IS SPAWNED. This is the one moment the
// answer is known for certain — the `Connection` is right here — and a `[Property]` set
// before `NetworkSpawn` is serialised into the spawn, so it arrives on the client as part
// of the object rather than having to be derived there. Two attempts to derive it on the
// far end both failed; see `NZPlayer.OwningConnection`.
//
// ⚠️ BEFORE `NetworkSpawn`, NOT AFTER. Afterwards it is a local edit to a property that
// replicates to nobody, and the client would receive a body with no owner recorded —
// which is exactly the state that left it standing at the scene's default position with
// no body it would admit to owning.
var claim = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
if ( claim.IsValid() ) claim.OwningConnection = channel.Id.ToString();
Disarm( go );
// ⚠️ NOT SAVED WITH THE SCENE. These exist for the length of a session; a mapper opening
// the scene later should find the one body it ships with, not everyone who ever joined.
go.Flags |= GameObjectFlags.NotSaved;
// ⛔ ENABLED FOR THE SPAWN, WHATEVER THE MODE. A game is started FROM THE LOBBY, where
// `PlayerPresence` has every body switched off — so a clone made there is born disabled,
// and network-spawning a disabled object is not something this project can show works.
// The cost of being wrong about that is total: nobody receives the body, and both players
// stand in an empty map. Enabling first removes the question.
//
// ⚠️ AND NOTHING HAS TO PUT IT BACK. `PlayerPresence.Apply` owns my own body's enabled
// state and `RefreshBodies` owns everybody else's, both from the twice-a-second tick, so
// a body enabled here is switched off again within half a second if the mode says so.
go.Enabled = true;
go.NetworkSpawn( channel );
Log.Info( $"[nz-net] gave {channel.DisplayName} a body — owner={go.Network.OwnerId}" );
if ( !go.Network.Active )
Log.Warning( $"[nz-net] ⛔ {channel.DisplayName}'s body did NOT become a network "
+ "object — they will have no body at all. nz_see there will say so." );
// ⚠️ AND THE HOST CLAIMS ITS OWN, so every body in the scene has a named owner and
// "whose is this" stops being a question anybody has to interpret.
EnsureHostBody();
// ⛔ AND IT IS MOVED OFF THE HOST IMMEDIATELY. A clone starts exactly where its template
// stands, so without this every joiner arrives inside the host — which killed the client
// on contact and read as a spawn-point bug, because it was one. Placing everybody rather
// than just the newcomer is deliberate IN THE LOBBY: the spawn list is dealt as a hand, so
// adding a player changes where the others should be standing too.
//
// ⛔ BUT NOT ONCE A GAME IS ON — the newcomer alone (the co-op audit, 2026-09-27). Every join moved every body to the
// map's spawns, pulling the whole team out of basalt's boss arena. The newcomer goes into the arena if the fight is on
// (*"make the new player spawn in the boss arena"*), beside a standing teammate while the lava is out of its bed (the
// spawns lie under it), and to a spawn otherwise (`HexPlatforms.PlaceJoiner`).
if ( NZGame.Mode == GameMode.Survival )
{
if ( !HexPlatforms.PlaceJoiner( go ) )
PlayerSpawner.PlaceAll( only: b => b == go );
}
else PlayerSpawner.PlaceAll();
// ⚠️ THE NEW BODY WEARS THE CHARACTER ITS OWNER PICKED IN THE LOBBY, which the net table
// already knows — the clone would otherwise wear whatever the host is wearing.
RefreshBodies();
}
/// <summary>
/// GIVE THE HOST A BODY OF THE SAME KIND EVERYBODY ELSE HAS.
///
/// ⛔ THE SCENE'S `Player Controller` DOES NOT REPLICATE, AND THAT IS MEASURED, NOT INFERRED.
/// It is `NetworkMode.Snapshot` and nobody owns it — `TakeOwnership` was tried and does not
/// take — so no machine transmits its transform. `nz_bodies`, both ends, same moment:
///
/// HOST 'Player Controller' pos=2211,406,160 still=26s char='richtofen'
/// CLIENT 'Player Controller' pos=-60,1504,0 still=86s ⚠ NOT MOVING char=''
///
/// −60,1504,0 is the map's own default spawn: the client was watching the frozen instant the
/// join snapshot captured, and had been for the whole game.
///
/// ⚠️ SO THE FIX IS SYMMETRY, NOT ANOTHER SPECIAL CASE. A `NetworkSpawn( connection )`-ed
/// clone is the one arrangement already PROVEN to replicate here — the client's body does it
/// correctly in the same log (`still=9s`, position agreeing with its owner). The host now gets
/// one too, and every body in the game is the same kind of object with the same owner rule.
///
/// ⛔ AND THE SCENE'S ORIGINAL IS DESTROYED. Leaving it would keep a second, stale
/// `Player Controller` in every client's scene — the object that has caused a camera hijack, a
/// hidden player and a frozen body across three test rounds. Destroying a scene object at
/// runtime does not touch the `.scene` file; the next play starts from it unchanged.
///
/// ⚠️ HOST ONLY, AND ONLY WHILE NETWORKING IS ACTIVE. Single player keeps the scene's body
/// exactly as it always has — no clone, no destroy, nothing to go wrong.
///
/// ⚠️ IT MUST RUN BEFORE ANYBODY CONNECTS, which is why the tick calls it: a joining
/// client's scene IS the host's snapshot, so the swap has to have happened before the snapshot
/// is taken. `NZNetListener` ticks twice a second from the moment the game enters any mode.
/// A client that somehow joins inside that window is covered from the other side — see
/// `DropUnclaimed`.
/// </summary>
public static void EnsureHostBody()
{
if ( !Networking.IsActive || NZGame.IsClient ) return;
var me = Connection.Local;
if ( me is null ) return;
var mine = me.Id.ToString();
// ⚠️ ALREADY DONE IS THE COMMON CASE — this runs twice a second forever.
var bodies = PlayerSpawner.AllBodies();
if ( bodies.Any( b => OwnerOf( b ) == mine ) ) return;
// The scene's own body: the one nobody has claimed.
var original = bodies.FirstOrDefault( b => string.IsNullOrEmpty( OwnerOf( b ) ) );
if ( !original.IsValid() ) return;
var go = NewBody( original );
if ( !go.IsValid() ) return;
go.Name = $"Player ({me.DisplayName})";
go.Flags |= GameObjectFlags.NotSaved;
// ⚠️ WRITTEN BEFORE THE SPAWN, same as a joiner's body — a `[Property]` set beforehand
// is serialised into the spawn, and set afterwards replicates to nobody.
var np = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
if ( np.IsValid() ) np.OwningConnection = mine;
Disarm( go );
// ⛔ ENABLED FOR THE SPAWN, WHATEVER THE MODE. A game is started FROM THE LOBBY, where
// `PlayerPresence` has every body switched off — so a clone made there is born disabled,
// and network-spawning a disabled object is not something this project can show works.
// The cost of being wrong about that is total: nobody receives the body, and both players
// stand in an empty map. Enabling first removes the question.
//
// ⚠️ AND NOTHING HAS TO PUT IT BACK. `PlayerPresence.Apply` owns my own body's enabled
// state and `RefreshBodies` owns everybody else's, both from the twice-a-second tick, so
// a body enabled here is switched off again within half a second if the mode says so.
go.Enabled = true;
go.NetworkSpawn( me );
// ⛔ SAY WHETHER IT ACTUALLY TOOK, BEFORE DESTROYING THE ONLY ALTERNATIVE. The line
// below removes the scene body that clients would otherwise have received, so "the spawn
// silently did nothing" is the one outcome that must never pass unremarked.
if ( !go.Network.Active )
Log.Warning( "[nz-net] ⛔ the new host body did NOT become a network object — nobody "
+ "will receive it. Keeping the scene's original rather than leaving everyone "
+ "with nothing." );
// ⛔ THE CACHE POINTS AT AN OBJECT THAT IS ABOUT TO NOT EXIST. `PlayerPresence` holds the
// last body it resolved, and twenty call sites go through it.
PlayerPresence.Forget();
// ⚠️ AND ONLY IF THERE IS SOMETHING TO REPLACE IT WITH. A frozen mannequin every client
// can see is a bad outcome; no body at all is a worse one.
if ( go.Network.Active ) original.Destroy();
Log.Info( $"[nz-net] the host's body is now a spawned, owned one — {mine}"
+ " (the scene's unowned copy is gone; it never replicated)" );
}
// ⛔ `DropUnclaimed` WAS HERE AND IT DELETED THE ENTIRE GAME. It destroyed any body whose
// recorded owner read empty, on the theory that on a client such a body could only be the
// stale scene copy. It had **no exclusion for the client's own body** — so the first time
// that record was not readable, a client wiped every body it had, including its own:
//
// [nz-see] I am the CLIENT · my body ⛔ I HAVE NONE
// [nz-see] ⛔ THERE IS NO OTHER PLAYER'S BODY IN MY SCENE AT ALL.
//
// ⚠️ IT WAS NEVER WORTH ITS RISK. It existed for one rare case — a client joining in the
// half-second before the host swaps its own body — and the stale copy it was meant to catch
// is now handled properly anyway: `EnsureHostBody` destroys the scene original ON THE HOST,
// and that destroy replicates. A cleanup pass that can delete a player is not a cleanup pass.
//
// ⚠️ AND NOTHING ELSE MAY DESTROY A BODY ON A GUESS. `RemoveFor` destroys one, and it does
// so on a fact: the engine told us that connection left.
/// <summary>
/// Whose body is this, as the host wrote it down. "" when nobody has said.
///
/// ⚠️ THE ONE READER, so `Mine`, the character lookup and the disconnect sweep cannot
/// disagree about a body — which they did, because two of them asked the engine instead.
/// </summary>
public static string OwnerOf( GameObject go )
{
var np = go.IsValid() ? go.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) : null;
return np.IsValid() ? np.OwningConnection ?? "" : "";
}
/// <summary>
/// THE NAME TO SHOW A HUMAN FOR THIS PLAYER — their PROFILE name, never their character's.
/// "" when this machine cannot work out whose body it is.
///
/// ⛔ A PLAYER IS NOT THEIR CHARACTER, AND THREE PLACES HAD CONFLATED THEM. The revive
/// prompt read "Hold E - Revive Dempsey", which is the body's costume rather than the person
/// wearing it — and two people can pick the same character, at which point the prompt names
/// neither of them. Requested plainly: *"i dont want the player names to become the character
/// names, they keep their profile names"*.
///
/// ⚠️ THROUGH `OwnerOf`, NOT `Network.Owner`. `NZPlayer.OwningConnection` carries a screen
/// of comment about why the engine's own answer was wrong in both directions for these bodies,
/// and the Scoreboard's private copy of this lookup asked `Network.Owner` — so it printed the
/// object name for exactly the unowned host body that `OwningConnection` exists to describe.
/// One reader, the same one `Mine` and the disconnect sweep already use.
///
/// ⚠️ IT RETURNS "" RATHER THAN A FALLBACK, because the right fallback differs by caller:
/// the scoreboard wants the object's name in the cell, the revive prompt wants "your
/// teammate" in the sentence. A helper that picked one would be wrong in the other.
/// </summary>
public static string NameOf( NZPlayer p )
{
if ( !p.IsValid() ) return "";
var said = OwnerOf( p.GameObject );
if ( !string.IsNullOrEmpty( said ) )
{
foreach ( var c in Connection.All )
if ( c.Id.ToString() == said && !string.IsNullOrWhiteSpace( c.DisplayName ) )
return c.DisplayName;
}
// ⚠️ MY OWN BODY IS THE CASE THAT HAS NO OWNER RECORDED IN SINGLE PLAYER, where
// `Connection.All` is just me and nothing ever ran the claim path.
if ( PlayerPresence.Mine( p.GameObject )
&& !string.IsNullOrWhiteSpace( Connection.Local?.DisplayName ) )
return Connection.Local.DisplayName;
return "";
}
/// <summary>
/// Whose body is this id's, on this machine. Null when they have none here yet.
///
/// ⚠️ THE INVERSE OF `OwnerOf`, AND IT LIVES BESIDE IT ON PURPOSE. A relay that names a
/// player names them by connection id — that is the one handle every machine agrees on — and
/// every such relay then needs the body. Three of them had already grown their own sweep.
///
/// ⚠️ RETURNS THE COMPONENT, not the GameObject, because every caller wants the player.
/// </summary>
public static NZPlayer BodyOf( string ownerId )
{
if ( string.IsNullOrEmpty( ownerId ) ) return null;
foreach ( var go in PlayerSpawner.AllBodies() )
{
var np = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
if ( np.IsValid() && np.OwningConnection == ownerId ) return np;
}
return null;
}
/// <summary>
/// Give or take the things that make a body this machine's to drive.
///
/// ⛔ `UseCameraControls` IS THE ONE THAT BIT FIRST. A second controller that thinks it is
/// local does not announce itself — it just moves `Scene.Camera` to its own eye, and
/// whichever of the two ran last that frame wins. The player sees a plausible first-person
/// view of the wrong body and has no way to tell.
///
/// ⛔ AND `HideBodyInFirstPerson` IS WHY THE OTHER PLAYER WAS INVISIBLE. It is on in the
/// scene and the engine applies it to any body it considers LOCAL — which, on a client, the
/// host's unowned body was. So the controller hid it, exactly as it would hide your own body
/// in first person. Set from `mine` rather than forced false, so your own still disappears.
///
/// ⚠️ THE CONTROLLER STAYS ENABLED. It drives the animation and the renderer, which is how
/// somebody else's body looks like a person rather than a statue.
///
/// ⚠️ ALL FOUR FIELDS ARE IN THE GUARD. Checking one and setting four is how a field
/// silently stops being applied the moment anything else writes it.
/// </summary>
static void Control( GameObject go, bool mine )
{
var ctrl = go.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );
if ( !ctrl.IsValid() ) return;
// ⛔ SOMEBODY ELSE'S BODY IS ALWAYS DRAWN, AND THIS IS WHY IT WAS NOT.
//
// `HideBodyInFirstPerson` is true in the scene, so the engine sets YOUR OWN body renderer
// to `ShadowsOnly` while you are in first person — that is how you do not see your own
// torso. `RenderType` is a serialised property on the renderer, so **`Clone()` copies it**:
//
// host in first person → its renderer is ShadowsOnly
// EnsureHostBody clones the body → the clone is ShadowsOnly
// SpawnFor clones that for a joiner → every body in the session is ShadowsOnly
//
// ShadowsOnly renders shadows and nothing else, so every check passes — enabled, right
// model, right position, right scale — and nobody can see anybody. Hosting in THIRD person
// happened to clone a visible renderer, which is exactly the difference the user found.
//
// ⚠️ AND NOTHING PUT IT BACK, because the engine only writes `RenderType` for the body it
// considers yours. Setting `HideBodyInFirstPerson = false` on someone else's body stops it
// being hidden; it does not un-hide one that arrived that way.
//
// ⚠️ ONLY THE BODY RENDERER, NAMED THROUGH `PlayerController.Renderer`. Three previous
// attempts touched renderers found by searching and every one of them made bodies vanish;
// this one writes to exactly the renderer the controller itself points at, and to nothing
// else. Viewmodels are not touched.
//
// ⚠️ AND ONLY ON BODIES THAT ARE NOT MINE — my own is the engine's to hide and show, and
// fighting it would put my own torso in my face in first person.
if ( !mine && ctrl.Renderer.IsValid()
&& ctrl.Renderer.RenderType != ModelRenderer.ShadowRenderType.On )
{
ctrl.Renderer.RenderType = ModelRenderer.ShadowRenderType.On;
Log.Info( $"[nz-net] '{go.Name}' was ShadowsOnly — drawing it. A body cloned from a "
+ "first-person player inherits its hidden renderer." );
}
// ⛔ AND THE SAME STORY A SECOND TIME, THROUGH A TAG INSTEAD OF A RENDER TYPE.
//
// `Sandbox.PlayerController` hides your own body by tagging its renderer's object
// **`viewer`**, which the camera it drives excludes. The literal sits in the engine
// assembly between "Body" and the type name itself — it is the controller's, not ours, and
// nothing in this project writes it.
//
// ⚠️ A TAG IS PART OF A GameObject, SO `Clone()` COPIES IT, exactly as it copies
// `RenderType` above. The chain is identical and it starts in the same place:
//
// host in FIRST person → the engine tags its own 'Body' object `viewer`
// EnsureHostBody clones the body → the clone is tagged
// SpawnFor clones that for a joiner → EVERY body in the session is tagged
//
// ⚠️ AND NOTHING TAKES IT OFF AGAIN. The controller only manages the tag on the body it
// considers ITS OWN; a body that arrived already tagged is nobody's to untag. Which is
// exactly why fixing `RenderType` alone did not fix the symptom — two mechanisms hide a
// first-person body and the clone inherits both.
//
// ⚠️ THIS IS THE WHOLE OF "it matters whether third person was on when the server was
// made". Hosting in third person clones an UNTAGGED body, so everyone sees everyone;
// hosting in first person clones a tagged one, so nobody sees anybody. It was never the
// build.
//
// ⚠️ NOT MINE ONLY, and only the object the controller itself points at. My own body is
// the engine's to hide, and taking `viewer` off it would put my own torso in my face.
if ( !mine && ctrl.Renderer.IsValid() && ctrl.Renderer.GameObject.Tags.Has( "viewer" ) )
{
ctrl.Renderer.GameObject.Tags.Remove( "viewer" );
Log.Info( $"[nz-net] '{go.Name}' was tagged 'viewer' — untagged. A body cloned from a "
+ "first-person player inherits the tag that hides it." );
}
// ⛔ A PLAYER WHO IS OUT OF THE ROUND DOES NOT STEER, AND THIS POLL IS WHY IT HAS TO BE
// SAID HERE. `NZPlayer.ApplyOutOfRoundBody` takes their collider, gravity and body away
// every frame — but `UseInputControls` is one of the four fields THIS method asserts twice
// a second, so anything written elsewhere is put back within 500ms and reads as an
// intermittent fault.
//
// ⚠️ INPUT ONLY. The camera and the look controls stay with them, which is what makes it
// a spectator rather than a freeze: no body, no collision, cannot move, can still look
// around from where they fell until the round brings them back.
var sittingOut = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) is { IsOutOfRound: true };
// ⛔ NOR DOES ONE IN A PANZER'S CLAW (`PanzerGrab`, 2026-10-06 — no time limit, they must knife their way out): put back here, the
// legs would return for a frame and this method would log twice a second for as long as he holds them
// — nor one in the Mimic's tentacle (`MimicGrab`, 2026-10-07), the same hold
var held = go.Components.Get<PanzerGrab>( FindMode.EverythingInSelf ).IsValid()
|| go.Components.Get<MimicGrab>( FindMode.EverythingInSelf ).IsValid();
var steers = mine && !sittingOut && !held;
if ( ctrl.UseCameraControls == mine
&& ctrl.UseInputControls == steers
&& ctrl.UseLookControls == mine
&& ctrl.HideBodyInFirstPerson == mine ) return;
ctrl.UseCameraControls = mine;
ctrl.UseInputControls = steers;
ctrl.UseLookControls = mine;
ctrl.HideBodyInFirstPerson = mine;
Log.Info( $"[nz-net] '{go.Name}' is {(mine ? "MINE to drive" : "somebody else's — camera, look, input and first-person hiding taken off it")}" );
}
// ══ is that body actually moving? ═══════════════════════════════════════
/// <summary>Where each body was last seen, and when it last actually moved.</summary>
static readonly System.Collections.Generic.Dictionary<System.Guid, (Vector3 At, RealTimeSince Since)> _moved = new();
/// <summary>
/// Note whether a body has moved since the last check. Called from the twice-a-second tick.
///
/// ⛔ "I CANNOT SEE THE OTHER PLAYER" HAS TWO CAUSES THAT LOOK IDENTICAL FROM INSIDE THE
/// GAME: their body is hidden, or their body is somewhere else entirely because its position
/// never replicated and it is still standing where the join snapshot left it. The first is a
/// render flag; the second is a networking fault. Nothing on screen separates them — in both
/// cases you walk around an empty map.
///
/// ⚠️ A BODY THAT HAS NOT MOVED WHILE ITS OWNER IS PLAYING IS THE SECOND ONE. That is what
/// `nz_see` reports as link 4, and it needs no second machine to interpret.
/// </summary>
public static void NoteMovement()
{
foreach ( var go in PlayerSpawner.AllBodies() )
{
var here = go.WorldPosition;
if ( !_moved.TryGetValue( go.Id, out var last ) )
{
_moved[go.Id] = (here, 0f);
continue;
}
// ⚠️ A UNIT OF SLACK. A body resting on a slope creeps, and a jitter of a hundredth
// of an inch is not "moving" in the sense this question is asking.
if ( last.At.Distance( here ) > 1f )
_moved[go.Id] = (here, 0f);
}
}
/// <summary>How long since this body last moved, or -1 if it has never been seen.</summary>
public static float StillFor( GameObject go )
=> go.IsValid() && _moved.TryGetValue( go.Id, out var last ) ? (float)last.Since : -1f;
/// <summary>
/// Take every weapon off a freshly cloned body, BEFORE it is network-spawned.
///
/// ⛔ A CLONE COPIES THE RUNTIME WEAPON OBJECTS, AND `NetworkSpawn` THEN SENDS THE WHOLE
/// HIERARCHY. So the moment the host's body was cloned into a spawned one, every client
/// started receiving the HOST'S GUN as a real object in their scene — which SWB then drew as
/// a viewmodel: *"weird floating arms holding the gun that move around"*. `nz_bodies` had
/// already been reporting `weapon: Weapon` on somebody else's body and it read as normal.
///
/// ⚠️ WEAPONS ARE PER-MACHINE STATE IN THIS PROJECT. `NZPlayer.GiveWeapon` arms whichever
/// body is this machine's, locally, and `SERVER_SPLIT.md` files inventory under HOST for
/// later. Nothing about a gun is supposed to cross the wire yet — so a body must arrive BARE
/// and be armed where it lives.
///
/// ⚠️ AND A SECOND WEAPON IS WORSE THAN A VISIBLE ONE. A machine holding both a networked
/// copy and its own would have SWB running two `Weapon` components over one body, one of them
/// a proxy that skips every input branch — which is a very good way to be unable to shoot
/// while the knife, a separate component, keeps working.
///
/// ⚠️ BEFORE THE SPAWN, NOT AFTER. Afterwards the objects have already been serialised into
/// it and destroying them is a second message racing the first.
/// </summary>
static void Disarm( GameObject body )
{
var taken = 0;
// ⚠️ MATERIALISED WITH ToList FIRST — destroying as you enumerate the live component
// list is the classic way to miss half of them.
foreach ( var w in body.Components
.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants ).ToList() )
{
// ⚠️ NEVER THE BODY ITSELF. A weapon component is expected on a CHILD, but
// `EverythingInSelfAndDescendants` includes self — and destroying the root here
// would delete the player instead of disarming them.
if ( !w.IsValid() || w.GameObject == body ) continue;
w.GameObject.Destroy();
taken++;
}
foreach ( var w in body.Components
.GetAll<NZWeapon>( FindMode.EverythingInSelfAndDescendants ).ToList() )
{
if ( !w.IsValid() || !w.GameObject.IsValid() || w.GameObject == body ) continue;
w.GameObject.Destroy();
taken++;
}
if ( taken > 0 )
Log.Info( $"[nz-net] stripped {taken} weapon object(s) off the new body — it gets armed "
+ "where it lives, not over the wire" );
}
/// <summary>Take the body back when its owner leaves.</summary>
public static void RemoveFor( Connection channel )
{
if ( channel is null || NZGame.IsClient ) return;
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
// ⚠️ MATCHED ON THE OWNER ID, not the name. The name is for a human reading the hierarchy;
// two players called the same thing would otherwise take each other's bodies away.
var mine = PlayerSpawner.AllBodies( scene )
.FirstOrDefault( o => OwnerOf( o ) == channel.Id.ToString() );
if ( !mine.IsValid() ) return;
Log.Info( $"[nz-net] removing {channel.DisplayName}'s body" );
mine.Destroy();
}
/// <summary>
/// Make every body in the scene right: exactly one of them mine to drive, and everybody
/// else's present and wearing the character they picked.
///
/// ⛔ A COMPONENT PROPERTY DOES NOT REPLICATE UNLESS IT SAYS SO, AND NOT ONE IN THIS PROJECT
/// DOES — there is no `[Sync]` anywhere in it. `NZPlayer.CharacterId` and the model
/// `ApplyBody` writes into the renderer are therefore purely local: every machine sees its
/// own player as the character it picked and everybody else as whatever the scene shipped.
/// The lobby already replicates the choice through `NZNet.SetCharacter`, so the fix is to
/// read it back out and apply it here rather than to network the body.
///
/// ⛔ PROXIES ONLY, AND THAT GUARD IS LOAD-BEARING. Our own body is authored locally — by the
/// lobby, by `nz_character` — and the net table is empty in single player, so applying this to
/// our own body would clear the character we just picked the moment nothing was connected.
/// </summary>
/// <summary>
/// EXACTLY ONE VIEWMODEL CAMERA, AND IT HANGS OFF MY OWN BODY.
///
/// ⛔ MEASURED: `nz_vm_clear` on the host reported **"removed 2 viewmodel camera(s)"** while
/// `nz_vm_priority`, run seven seconds earlier, had listed only one. So there were two, and one
/// of them was invisible to a straight `GetAllComponents` sweep — disabled, or on a disabled
/// object.
///
/// ⚠️ A SECOND VIEWMODEL CAMERA DRAWS THE ARMS A SECOND TIME. Both are Priority 2 with
/// RenderTags { viewmodel, light }, so both composite the viewmodel layer, and the copy appears
/// displaced by the gap between them — which is exactly the reported "a copy at double my
/// distance from the host, playing my animations".
///
/// ⚠️ A SWEEP RATHER THAN ANOTHER GUARD. `Weapon.CreateModels` and `KnifeViewModel.Ensure` are
/// now both blocked on remote bodies, which stops NEW ones — but a camera made before that
/// guard existed, or by a path nobody has found yet, lives forever, because every creation site
/// checks `Owner.ViewModelCamera` and a stray one is not it. This runs twice a second, costs a
/// list walk, and cannot leave a duplicate standing.
///
/// ⚠️ DISABLED OBJECTS INCLUDED, which is the whole reason the earlier count disagreed.
/// </summary>
static void OneViewModelCamera()
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
var mine = PlayerPresence.Find();
if ( !mine.IsValid() ) return;
// ⛔ KEEP THE CAMERA MY OWN VIEWMODEL IS ACTUALLY USING. The first version of this kept
// "the one under my body, else the first in the list", and the first in the list turned out
// to be the DISABLED one — so it destroyed the live camera and the player was left with no
// hands at all. The user's report was exact: *"all arms are gone for the client, however
// this also removes its own hands."*
//
// ⚠️ THE HANDLER IS THE ONLY HONEST ANCHOR. `ViewModelHandler.Camera` is, by definition,
// the camera drawing that viewmodel. Anything derived from parenting or list order is a
// guess about which camera matters, and both guesses have now been wrong.
var keep = mine.Components
.GetAll<SWB.Base.ViewModelHandler>( FindMode.EverythingInSelfAndDescendants )
.Where( h => h.IsValid() && h.Camera.IsValid() )
.Select( h => h.Camera )
.ToHashSet();
var cams = scene.GetAllObjects( false )
.SelectMany( o => o.Components.GetAll<CameraComponent>( FindMode.EverythingInSelf ) )
.Where( c => c.IsValid() && c.RenderTags.Has( SWB.Shared.TagsHelper.ViewModel ) )
.ToList();
if ( cams.Count <= 1 ) return;
// ⛔ NEVER DESTROY THE LAST ONE. If no handler of mine claims a camera — the moment before
// a weapon deploys, or a frame after a rebuild — there is nothing to compare against, and
// removing anything then is how a player ends up blind. Do nothing and try again in half a
// second; a duplicate for one tick is survivable, no viewmodel is not.
if ( keep.Count == 0 )
{
Log.Info( $"[nz-net] {cams.Count} viewmodel cameras, but none of my viewmodels claims "
+ "one yet — leaving them alone this tick rather than risk taking the live one" );
return;
}
var gone = 0;
foreach ( var c in cams )
{
if ( keep.Contains( c ) ) continue;
c.GameObject.Destroy();
gone++;
}
if ( gone == 0 ) return;
Log.Info( $"[nz-net] destroyed {gone} duplicate viewmodel camera(s), kept the {keep.Count} "
+ "my own viewmodel is drawing through — each duplicate was drawing the arms again, "
+ "displaced by the gap between it and the real one" );
}
public static void RefreshBodies()
{
OneViewModelCamera();
foreach ( var go in PlayerSpawner.AllBodies() )
{
// ⛔ EXACTLY ONE CONTROLLER PER MACHINE MAY DRIVE THE CAMERA, AND UNTIL NOW TWO DID.
// The scene's `Player Controller` is unowned as far as the engine is concerned —
// `TakeOwnership` on a snapshot object is measured NOT to take — so on a client that
// body's `PlayerController` believed it was local and took the camera, the look and
// the input. The client was therefore looking out of the HOST'S body, frozen wherever
// the join snapshot left it, while its own body walked to the spawn point the host had
// dealt it. Both machines were right about where that player was; they were watching
// different bodies.
//
// ⚠️ THE CONTROLLER STAYS ENABLED. It drives the animation and the renderer, which
// is how somebody else's body looks like a person rather than a statue. Only the three
// things that make a body YOURS are taken away.
Control( go, PlayerPresence.Mine( go ) );
// ⛔ `PlayerPresence.Theirs`, NOT `!IsProxy`. An UNOWNED object — which the host's
// scene body was until `EnsureHostBody` replaced it — is not a proxy on anybody's machine, so
// the old test skipped the host's player on the client entirely: never enabled, never
// dressed, invisible. One predicate, and it is now the same one `PlayerPresence.Find`
// uses, so "mine" and "theirs" cannot disagree.
if ( !PlayerPresence.Theirs( go ) ) continue;
// ⛔ A BODY THAT ARRIVED DISABLED STAYS DISABLED UNTIL SOMEBODY ENABLES IT. A client
// joins during the LOBBY, where `PlayerPresence` has every body switched off, so the
// host's player reaches it as a disabled object — and the host enabling its own copy
// later is not something this machine is guaranteed to be told about. Whatever the
// engine does or does not replicate, the rule here is local and unambiguous: if
// bodies should exist in this mode, the other players' bodies exist.
//
// ⚠️ DRIVEN BY *MY* MODE, WHICH IS THE HONEST APPROXIMATION. Everyone leaves the
// lobby on one broadcast (`NZNet.GameStarting`), so "I am in the map" and "they are in
// the map" part company only for the moment in between.
var np = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
if ( go.Enabled != PlayerPresence.ShouldExist )
{
go.Enabled = PlayerPresence.ShouldExist;
Log.Info( $"[nz-net] '{go.Name}' {(go.Enabled ? "shown" : "hidden")}" );
// ⚠️ SHOWN AGAIN, DRESSED AGAIN (2026-10-05). A renderer coming back from disabled can put back its serialised
// model (the reason `PlayerPresence.Apply` re-applies MY body on every entry), so the check below re-applies this one.
if ( go.Enabled && np.IsValid() ) np.BodyLook = null;
}
if ( !np.IsValid() ) continue;
// ⛔ THE RECORDED OWNER, NOT `Network.OwnerId`. The host's body reads
// `owner=········ (UNOWNED)` on both machines, so this looked the character up under
// `Guid.Empty`, got "" back, and left the host wearing `citizen.vmdl` on every
// client while the lobby had correctly agreed they were Richtofen. Measured:
//
// [nz-bodies] 'Player Controller' said=58a5f8fb owner=········ (UNOWNED)
// char='' model=citizen.vmdl
//
// `said` was right there the whole time. Every "whose is this" question goes through
// the recorded value now, including this one.
// ⛔ THE OWNER IS THE KEY, NOT THE ANSWER. This briefly read `OwnerOf( go )` on its own
// and dressed every remote player in their own CONNECTION ID —
// `'Player (Ned)' is now wearing '62798f42-7ef2-47ec-8f05-3349101ebc49'` — which
// `PlayerCharacters.Find` then failed to resolve, leaving the default body. Caught by
// the first unattended two-machine run, seconds after it was introduced.
var want = NZNet.CharacterOf( OwnerOf( go ) );
// ⚠️ "" AND null ARE THE SAME STATE — no character, weapon default hands — and they
// arrive from different places, so comparing the raw strings would re-dress the body
// every call and log a change that never happened.
// ⚠️ AND ONCE EVEN WITH NO CHARACTER (2026-10-05): `BodyLook` is what this machine last dressed the body as, so a
// teammate who never picks anyone is put in their own s&box avatar instead of keeping the prefab's Citizen.
if ( (np.CharacterId ?? "") == want && np.BodyLook == want ) continue;
var changed = (np.CharacterId ?? "") != want;
np.CharacterId = string.IsNullOrEmpty( want ) ? null : want;
PlayerCharacters.ApplyBody( np );
if ( changed ) Log.Info( $"[nz-net] '{go.Name}' is now wearing '{want}'" );
}
}
}