PlayerSpawner is a static utility that places player bodies at configured spawn points. It selects randomized spawn indices, handles host/client authority (host sends placements, owners perform them), defers placements that arrive before a body exists, and zeroes velocity on placement to avoid fall damage.
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// PLAYER/SPAWNING — puts players on the config's player spawn points.
///
/// The placed spawns were dead data until this existed: MapEditor wrote them,
/// the config saved and reloaded them, the markers drew them, and nothing ever
/// read the list to move anybody. A config could say where a game starts and
/// the game would ignore it.
///
/// One spawn is chosen at RANDOM per player, per game — the original picks from
/// the eligible set rather than using a fixed start, so two runs of the same
/// config don't open identically.
///
/// ⛔ WITH TWO MACHINES, MOVING A BODY IS NOT THE SAME AS MOVING AN OBJECT. A body owned by
/// somebody else is simulated on THEIR pc; writing its `WorldPosition` here lands a value their
/// next controller tick overwrites, and leaves no trace of having been ignored. So the host
/// DECIDES the placement and the OWNER PERFORMS it — see `MoveTo`. Everything above still holds;
/// only the last inch changed.
/// </summary>
public static class PlayerSpawner
{
/// <summary>
/// Move every player to a spawn point. No-op with nothing placed.
///
/// Returns how many were moved, so a caller (or a test) can tell "there were
/// no spawns" from "it worked".
///
/// ⛔ HOST ONLY WHEN NETWORKED. Two machines both shuffling the spawn list would deal
/// different hands and each move only itself — everybody would end up somewhere legal and
/// nobody would agree on who was where.
/// </summary>
/// <param name="only">
/// ⛔ WHICH PLAYERS, BECAUSE "ALL" WAS THE WRONG ANSWER FOR THE CALLER THAT NEEDED ONE.
/// `RoundManager.BringBackTheBledOut` used this to return the players who died last round — and
/// it teleported EVERYBODY to spawn, mid-game, every time anyone came back. User: *"when a
/// player respawns all players are teleported to the player spawns, this should not happen."*
///
/// ⚠️ NULL STILL MEANS EVERYONE, which is right for the four callers that start a game or
/// load a map. Only the respawn wanted a subset, and only the respawn passes one.
/// </param>
public static int PlaceAll( int forceIndex = -1, System.Func<GameObject, bool> only = null )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return 0;
if ( NZGame.IsClient )
{
// ⚠️ NOT A FAILURE, AND IT MUST NOT READ AS ONE. A client reaching here is the normal
// case — `RoundManager.StartGame` calls this and the client runs it too. The host is
// already sending the answer.
Log.Info( "[nz-spawn] client — the host decides where everyone stands" );
return 0;
}
var spawns = ActiveConfig.Current.PlayerSpawns;
if ( spawns.Count == 0 )
{
Log.Warning( "[nz] no player spawns placed — leaving players where "
+ "they are. nz_tool player_spawn, then nz_place." );
return 0;
}
var players = only is null
? AllBodies( scene )
: AllBodies( scene ).Where( only ).ToList();
if ( players.Count == 0 ) return 0;
// Shuffled indices, so with enough spawns two players never start inside
// each other. Falls back to repeats once the spawns run out rather than
// refusing to place anyone.
//
// ⚠️ forceIndex bypasses the shuffle. Not for gameplay — for TESTING.
// Anything measured relative to the player (a path probe, a placement
// offset) is only comparable across runs if the player starts in the
// same spot facing the same way, and a random spawn makes every reading
// a different experiment.
var order = forceIndex >= 0 && forceIndex < spawns.Count
? new List<int> { forceIndex }
: Shuffled( spawns.Count );
for ( int i = 0; i < players.Count; i++ )
{
var pick = order[i % order.Count];
var spawn = spawns[pick];
MoveTo( players[i], spawn.Position, spawn.Rotation );
Log.Info( $"[nz-spawn] '{players[i].Name}' to spawn #{pick} {spawn.Position}"
+ $" facing {spawn.Yaw:0}" );
}
return players.Count;
}
/// <summary>
/// EVERY player body in the scene, ENABLED OR NOT — one per connection.
///
/// ⛔ `GetAllComponents<NZPlayer>` DOES NOT SEE A DISABLED BODY, and here that is not an
/// edge case, it is the normal case. A game is started FROM THE LOBBY, where `PlayerPresence`
/// has the body disabled, and a joining player's body is a clone of a lobby body so it is
/// disabled too. Placing "every player" through the enabled-only lookup placed the host and
/// nobody else — which is exactly why two players opened a round standing inside each other.
/// `NZMap.PlacePlayers` and `PlayerStats.All` carry the same note; this is the fourth time it
/// has bitten.
/// </summary>
public static List<GameObject> AllBodies( Scene scene = null )
{
scene ??= Game.ActiveScene;
if ( !scene.IsValid() ) return new List<GameObject>();
return scene.GetAllObjects( false )
.Where( o => o.IsValid()
&& o.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) is not null )
.ToList();
}
/// <summary>
/// Put one body somewhere — directly if it is ours, by asking its owner if it is not.
///
/// ⛔ THE PROXY BRANCH IS THE WHOLE POINT. `Network.IsProxy` means "not being simulated on
/// the local pc": the owner's `PlayerController` runs every tick and writes the transform, so
/// a position written from here is gone within a frame. The teleport has to happen on the
/// machine that owns the body.
///
/// ⚠️ AND IT READS CORRECTLY SOLO. Nothing is networked in single player, so no body is a
/// proxy and every move takes the direct branch.
/// </summary>
public static void MoveTo( GameObject go, Vector3 position, Rotation rotation )
{
if ( !go.IsValid() ) return;
// ⚠️ `PlayerPresence.Mine`, NOT `!IsProxy` — one predicate for "whose body is this",
// shared with `Find` and `RefreshBodies`, so the three cannot disagree about a body that
// nobody has claimed.
if ( PlayerPresence.Mine( go ) )
{
PlaceLocal( go, position, rotation );
return;
}
// ⛔ ONLY THE HOST GETS TO SEND ONE. `PlaceAt` is a broadcast addressed by id, so a
// client that reached here would be teleporting the HOST — and `NZMap.PlacePlayers`
// runs on whichever machine loads a map, which `nz_map_load ... force` lets a client do.
// The guard is here rather than at the caller because this is the only door.
if ( NZGame.IsClient )
{
Log.Warning( $"[nz-spawn] refusing to move '{go.Name}' — a client does not place people" );
return;
}
NZNet.PlaceAt( go.Network.OwnerId, position, rotation );
}
/// <summary>
/// The actual teleport, on the machine that owns the body.
///
/// ⚠️ Moving the GameObject IS the teleport — PlayerController reads its position from the
/// transform. There is no Teleport method and Velocity is READ-ONLY (it is derived), so there
/// is nothing to zero; any fall speed carries over, which is fine for a spawn on the floor.
/// </summary>
public static void PlaceLocal( GameObject go, Vector3 position, Rotation rotation )
{
if ( !go.IsValid() ) return;
go.WorldPosition = position;
go.WorldRotation = rotation;
// Aim the VIEW too, not just the body. The controller keeps its own eye
// angles, so rotating the object alone leaves you standing in the right
// place looking the wrong way.
var cc = go.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );
if ( !cc.IsValid() ) return;
cc.EyeAngles = rotation.Angles();
// ⛔ AND THE VELOCITY, OR THE FIRST SPAWN OF A SESSION KILLS YOU. Reported as
// *"whenever i spawn the first time i just die"*, and the console had it exactly:
//
// [nz-spawn] 'Player Controller' to spawn #2 -1272.8,738.2,-160 facing 160
// [nz] round 1 starting in 1.0s
// [NZPlayer] hit for 736 — 0/150
// [nz] KILLED — no reviver available
//
// One hit for 736 against 150 health, in the same second as the placement, before round
// one and before a single zombie existed. The body accumulates downward velocity while
// the map builds — debris, barricades, the box, the ammo box and the trade table all get
// built in that window — and setting `WorldPosition` TELEPORTS IT WITHOUT CLEARING THAT.
// The engine controller then lands a body already travelling at fall speed and charges
// for the whole descent.
//
// ⚠️ IT IS THE FIRST SPAWN ONLY, WHICH IS WHY THIS SURVIVED THIS LONG. A player coming
// from the lobby has a body that has been standing still, so there is nothing to carry;
// only the session's very first placement happens while the body is still falling.
//
// ⛔ "FALL DAMAGE IS NOT IN THIS GAME" IS A STALE CLAIM — `Placeable.cs` says so in a
// comment and it is wrong. `Armor.cs` already reads `tags.Has( "fall" )` and its own ⛔
// admits the tag was "still unverified"; 736 points of it is the verification.
//
// ⚠️ THIS IS THE ONLY DOOR, host and client alike. `MoveTo` sends a remote player
// through `NZNet.PlaceAt`, which calls straight back into this method — so the fix does
// not need repeating anywhere, and a second copy would be the thing that drifts.
var body = cc.Body;
if ( !body.IsValid() ) return;
body.Velocity = Vector3.Zero;
body.AngularVelocity = Vector3.Zero;
}
// ══ placement that arrives too early ══════════════════════════════════════════════
/// <summary>
/// Where this machine has been told to stand, held until it has a body to stand with.
///
/// ⛔ THE MESSAGE ARRIVES BEFORE THE BODY IS UP, RELIABLY. The host places everyone inside
/// `StartGame`, and that is the same instant the client is still leaving the lobby with its
/// body disabled. Dropping the message would leave that player at the map's default start —
/// which is where the host is: the exact stacking this whole change exists to stop.
/// </summary>
static (Vector3 Position, Rotation Rotation)? _pending;
/// <summary>Remember a placement to apply when a body next comes up.</summary>
public static void Defer( Vector3 position, Rotation rotation )
=> _pending = (position, rotation);
/// <summary>
/// Apply a placement that arrived early. Called by `PlayerPresence` the moment it enables a
/// body — the first instant one can be moved and made to stay moved.
/// </summary>
public static void TakePending( GameObject go )
{
if ( _pending is null || !go.IsValid() ) return;
var (pos, rot) = _pending.Value;
_pending = null;
Log.Info( $"[nz-spawn] applying the placement that arrived early to {pos}" );
PlaceLocal( go, pos, rot );
}
/// <summary>Forget a held placement — a new game, or a map change.</summary>
public static void ClearPending() => _pending = null;
/// <summary>0..count-1 in random order.</summary>
static List<int> Shuffled( int count )
{
var list = Enumerable.Range( 0, count ).ToList();
for ( int i = list.Count - 1; i > 0; i-- )
{
int j = Game.Random.Int( 0, i );
(list[i], list[j]) = (list[j], list[i]);
}
return list;
}
/// <summary>
/// `nz_spawn_where` — where everybody is, and where the config says they should be.
///
/// ⚠️ RUN IT ON BOTH MACHINES. "We spawned on top of each other" is one symptom of two
/// different faults — the host dealt one spawn to two bodies, or it dealt two and one of them
/// never arrived — and only comparing the two machines separates them.
/// </summary>
[ConCmd( "nz_spawn_where" )]
public static void WhereCmd()
{
var spawns = ActiveConfig.Current?.PlayerSpawns ?? new List<SpawnPoint>();
Log.Info( $"[nz-spawn] {spawns.Count} player spawn(s) in '{ActiveConfig.Current?.Name}'"
+ $" · I am the {(NZGame.IsHost ? "HOST" : "CLIENT")}" );
for ( var i = 0; i < spawns.Count; i++ )
Log.Info( $"[nz-spawn] #{i} {spawns[i].Position} facing {spawns[i].Yaw:0}" );
foreach ( var go in AllBodies() )
Log.Info( $"[nz-spawn] '{go.Name}' at {go.WorldPosition}"
+ $" · enabled={go.Enabled} proxy={go.Network.IsProxy}" );
}
}