Player component that prevents players being violently ejected or pushed through walls by zombies or geometry, clamps horizontal velocities, handles containment (undoes frame-crossings through vertical surfaces), and applies fall damage based on downward speed.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// Stops a zombie punting the player across the room, stops the wall at the other end hurting
/// them for it, and stops the push putting them THROUGH the wall.
/// </summary>
///
/// ⛔ THE CRUSH IS THE THIRD JOB AND IT WAS THE ONE THAT SOFTLOCKED PEOPLE. Reported as
/// *"zombies can push me through a barricade/invisible wall — effectively softlocking the
/// player"*. Clamping the speed could never have fixed it, because the speed is not what moves
/// them: a keyframed capsule pressing a dynamic body against a static wall leaves the solver with
/// an overlap it MUST resolve, and once the player's centre passes the wall's midplane the
/// shortest way out is the far side. It happens at walking pace, which is why `Ceiling` never
/// saw it.
///
/// ⛔ ZOMBIES STAY SOLID, WHICH IS WHY THIS EXISTS AT ALL. The clean fix for "sent flying" is a
/// `zombie`/`player` row in the collision matrix set to Ignore — one line, no component, no
/// per-frame work. It was rejected on purpose: zombies would stop body-blocking, and being unable
/// to shoulder your way out of a corner is the tension the mode is built on. Keeping them solid
/// means keeping the shove, so the shove is clamped instead of prevented.
///
/// ⛔ THE SHOVE IS A PHYSICS ARTEFACT, NOT A DESIGNED FORCE. A zombie's capsule has no Rigidbody,
/// so it is KEYFRAMED — infinite mass as far as the solver is concerned. When one walks into the
/// player's dynamic body the only way out of the overlap is to move the player, and the speed it
/// leaves behind scales with how fast the zombie was going. That is why this got worse today:
/// pests now run up to 562 u/s.
///
/// ⚠️ GATED ON A ZOMBIE ACTUALLY BEING IN CONTACT, so nothing else that moves the player fast is
/// touched. The shrieker's launch, Oberon's pull and a PhD dive are all DELIBERATE and all survive
/// this untouched — a blanket speed cap would have quietly neutered three features while fixing
/// one bug.
public sealed class ShoveGuard : Component
{
/// <summary>
/// The fastest a zombie may leave the player travelling, horizontally.
/// </summary>
///
/// ⚠️ ABOVE A FULLY-AUGMENTED SPRINT (538) ON PURPOSE. This must never slow a player down
/// under their own power — it is a ceiling on being THROWN, not a speed limit. Anything at or
/// below what the player can produce themselves would make sprinting into a zombie feel like
/// running into treacle, which is a worse bug than the one being fixed.
public static float Ceiling { get; set; } = 560f;
/// <summary>How close a zombie has to be to count as touching.</summary>
///
/// ⚠️ GENEROUS. The capsule is 18 wide and the player's is about the same, so contact happens
/// somewhere near 40; 64 covers the frame where the solver has already pushed them apart and
/// the damage has not landed yet.
public static float ContactRange { get; set; } = 64f;
/// <summary>
/// The fastest the player may travel horizontally from ANY cause. No contact test.
/// </summary>
///
/// ⛔ GEOMETRY EJECTIONS ARE THE OTHER HALF, AND THE ZOMBIE GATE MISSED THEM ENTIRELY.
/// Reported as *"invisible walls or barricades... they usually send me flying and that's
/// probably what kills me"*. A static collider rebuilt while the player is inside it — a
/// barricade re-running `BuildCollider` on repair, a wall manager laying its boxes out again —
/// leaves the solver one way out of the overlap and it takes it hard. No zombie is involved, so
/// `Ceiling` never saw it.
///
/// ⚠️ 1000 IS ABOVE EVERYTHING THE GAME DELIBERATELY DOES, WHICH IS WHY IT CAN BE
/// UNCONDITIONAL. Measured rather than guessed: the shrieker's `PulseShove` is 420 horizontal
/// and ADDS to the player's own velocity, so a sprinting player shoved by one reaches about
/// 958. PhD's slam and the banana springboard are vertical only, and this clamps the HORIZONTAL
/// component alone, so neither is touched at any value.
///
/// ⚠️ IT IS A BACKSTOP, NOT A FIX. Whatever collider is ejecting the player still is; this
/// stops the ejection being fatal. The two clamps are counted separately so the next one says
/// whether a zombie or geometry did it.
public static float HardCeiling { get; set; } = 1000f;
/// <summary>Velocity at the start of this frame, before anything clamped it.</summary>
public Vector3 LastVelocity { get; private set; }
/// <summary>Times the zombie clamp has fired this session. `nz_shove`.</summary>
public static int Clamped { get; private set; }
/// <summary>Times the unconditional clamp has fired — i.e. geometry. `nz_shove`.</summary>
public static int HardClamped { get; private set; }
/// <summary>Fastest horizontal speed seen this session, for tuning the two ceilings.</summary>
public static float Peak { get; private set; }
/// <summary>Our own fall damage. Off leaves the player unable to be hurt by landing at all.</summary>
public static bool FallDamage { get; set; } = true;
/// <summary>
/// Downward speed at touchdown below which landing costs nothing.
/// </summary>
///
/// ⚠️ ABOVE A JUMP, AND THAT SETS THE FLOOR. `PlayerController.JumpSpeed` is 300, so a jump
/// lands at about 300 and anything at or under that must be free or the game would hurt you
/// for moving. 450 is roughly a 126-unit drop at s&box's gravity — call it two player heights
/// before the first point of damage.
public static float FallSafeSpeed { get; set; } = 450f;
/// <summary>Downward speed at which a landing costs a full health bar.</summary>
///
/// ⚠️ ABOUT A 900-UNIT DROP. Basalt's high walkway to its low one is ~516 units, which arrives
/// at roughly 908 and takes about 61% of the bar — survivable, and expensive enough that the
/// drop is a decision rather than a shortcut.
public static float FallLethalSpeed { get; set; } = 1200f;
/// <summary>Landings that have cost health this session. `nz_shove`.</summary>
public static int Fell { get; private set; }
// ── containment ──────────────────────────────────────────────────────────
/// <summary>
/// Undo any frame that took the player through a solid vertical surface. `nz_containment`.
/// </summary>
public static bool Containment { get; set; } = true;
/// <summary>
/// Height above the player's origin the containment ray runs at.
/// </summary>
///
/// ⚠️ CHEST HEIGHT, AND AWAY FROM BOTH ENDS OF THE CAPSULE. At the feet it would graze every
/// step and kerb; at the head it would graze door frames. 36 is inside the body on a standing
/// player and still inside it on a ducking one, which is what keeps a crouch-slide under a
/// pipe from reading as a wall.
public static float ProbeHeight { get; set; } = 36f;
/// <summary>
/// A move longer than this in one frame is not judged. It is a teleport, not a crossing.
/// </summary>
///
/// ⛔ WITHOUT IT THE TELEPORTER WOULD FIGHT THIS GUARD AND WIN ONCE PER FRAME. `Teleporter`
/// writes `WorldPosition` outright, as does a respawn, and the straight line from where the
/// player was to where they now are runs through half the map. 128 is well past what any frame
/// of ordinary movement covers — a 1000 u/s sprint at 60fps is 17 units, and even a 120ms
/// hitch at that speed is 120 — so nothing a player does under their own power reaches it.
public static float BigJump { get; set; } = 128f;
/// <summary>Crossings undone this session. `nz_shove`.</summary>
public static int Crossings { get; private set; }
Vector3 _clear;
bool _hasClear;
float _nextSay;
float _fallSpeed;
bool _wasOnGround = true;
/// <summary>The next landing costs nothing: set by a springboard's launch (<see cref="SpareNextLanding"/>).</summary>
bool _spareLanding;
/// <summary>
/// A springboard just threw this player up, so their next landing costs nothing (`SpringboardSpot.SafeLanding`).
///
/// ⚠️ IT ALSO STARTS THE FLIGHT CLEAN: the fall measured so far is dropped, and the player counts as already on the
/// ground this frame. A pad that throws them again as they touch down launches in the same frame as that landing, and
/// this way the landing neither charges the old flight nor spends the new flight's pardon on a zero-speed touch.
/// </summary>
public void SpareNextLanding()
{
_spareLanding = true;
_fallSpeed = 0f;
_wasOnGround = true;
}
/// <summary>A springboard just threw this player (`SpringboardSystem.Launch`), safe landing or not.</summary>
public void NoteLaunch() => _sinceLaunch = 0f;
/// <summary>Since the last springboard throw: an "ejection" inside <see cref="LaunchWindow"/> of one is the throw hitting something.</summary>
TimeSince _sinceLaunch = 999f;
const float LaunchWindow = 0.5f;
/// <summary>The guard on a player, or null.</summary>
public static ShoveGuard Of( NZPlayer player )
=> player.IsValid()
? player.Components.Get<ShoveGuard>( FindMode.EverythingInSelf )
: null;
/// <summary>
/// Turn the engine's impact damage off. We do our own, from downward speed alone.
/// </summary>
///
/// ⛔ s&box DOES IMPACT DAMAGE ITSELF, AND THAT IS WHAT WAS KILLING PEOPLE. `Rigidbody` ships
/// with `EnableImpactDamage` true and `MinImpactDamageSpeed` **500** — and a fully-augmented
/// sprint is 538. So running into a wall under your own power was already over the line before
/// anything shoved you, and being ejected by a collider was far over it.
///
/// ⛔ THE ENGINE'S VERSION CANNOT BE MADE DIRECTIONAL, WHICH IS THE WHOLE REASON IT GOES. It
/// reads an impact SPEED and knows nothing about which way the player was going, so any
/// threshold high enough to ignore a wall is also high enough to ignore most falls. An earlier
/// attempt tied `MinImpactDamageSpeed` to `HardCeiling` and kept it on; that traded one wrong
/// behaviour for a duller one. Off, and replaced by something that only looks down.
///
/// ⚠️ IN CODE RATHER THAN THE PREFAB, for the reason `NZPlayer.OnStart` gives about tagging:
/// it keeps the setting with the rest of the player's setup and survives the prefab being
/// rebuilt, which a value typed into the inspector does not.
void DisableEngineImpact()
{
var body = Components.Get<PlayerController>( FindMode.EverythingInSelf )?.Body;
if ( !body.IsValid() ) return;
body.EnableImpactDamage = false;
}
protected override void OnStart() => DisableEngineImpact();
protected override void OnUpdate()
{
// ⛔ THE MACHINE THAT OWNS THE BODY, AND ONLY IT. Every machine holds a copy of every
// player; clamping a proxy's velocity would fight the transform arriving from its owner
// and show up as a stutter on somebody else's screen.
if ( Networking.IsActive && !PlayerPresence.Mine( GameObject ) ) return;
var body = Components.Get<PlayerController>( FindMode.EverythingInSelf )?.Body;
if ( !body.IsValid() ) return;
var v = body.Velocity;
// ⚠️ RECORDED BEFORE THE CLAMP, because the impact test wants the speed the player was
// ACTUALLY carrying when they met the wall — which is the number this method is about to
// take away.
LastVelocity = v;
// ⚠️ BEFORE THE SPEED CLAMP, BECAUSE THE CLAMP RETURNS EARLY. Three of them, in fact —
// the common case is a player under every ceiling, and a containment check placed after
// them would run on almost no frames at all.
KeepInside( body );
// ⛔ ONLY THE DOWNWARD COMPONENT EVER BECOMES DAMAGE, which is the point of doing this
// ourselves. The engine's impact damage reads a speed and cannot tell a fall from a crash;
// this watches `-v.z` while airborne and keeps the WORST of it, so a landing is judged on
// how hard the player was actually coming down rather than on how fast they happened to be
// travelling when they touched something.
//
// ⚠️ THE PEAK, NOT THE LAST FRAME. Touchdown can be read a frame after the solver has
// already arrested the fall, and reading that frame would score a 900 u/s drop as nothing.
var c = Components.Get<PlayerController>( FindMode.EverythingInSelf );
var onGround = c.IsValid() && c.IsOnGround;
if ( !onGround )
{
if ( -v.z > _fallSpeed ) _fallSpeed = -v.z;
}
else
{
if ( !_wasOnGround ) Land( _fallSpeed );
_fallSpeed = 0f;
}
_wasOnGround = onGround;
var flat = v.WithZ( 0f );
var speed = flat.Length;
if ( speed > Peak ) Peak = speed;
if ( speed <= Ceiling ) return;
// ⛔ THE ZOMBIE CLAMP FIRST, BECAUSE IT IS THE TIGHTER ONE. A zombie in contact is held to
// `Ceiling`; anything else only has to stay under `HardCeiling`, which is loose enough that
// a shrieker can still throw a sprinting player across a room.
if ( ZombieInContact() )
{
body.Velocity = flat.Normal * Ceiling + Vector3.Up * v.z;
Clamped++;
return;
}
// ⛔ THE PLAYER'S OWN SLIDE IS NOT AN EJECTION (2026-10-03). Stamin-Up's Slide Boost with Banana Colada's coasting launches
// a slide at 1,001 u/s, one over `HardCeiling`, so every frame of every boosted slide was clamped and logged as "something
// rebuilt a collider": 12,902 of the round-88 game's 39,682 log lines, a warning nobody could act on. The slide says how
// fast it is going (`Slide.OwnSpeed`); up to that, with a margin for a downhill run, is the player's own doing. A real
// ejection is still clamped, to whichever of the two is higher, and still said out loud.
var limit = MathF.Max( HardCeiling, OwnSpeed() * OwnSpeedMargin );
if ( speed <= limit ) return;
// ⚠️ SAID OUT LOUD EVERY TIME, because this one is the symptom of a collider bug that is
// still there. A silent backstop would let the real cause go unnoticed for good.
// ⚠️ UNLESS A SPRINGBOARD JUST THREW THEM (2026-10-05). Then it is the throw: Defocus's pad #0 sends a player straight up
// at 2550 u/s, they are going sideways at 1387-1900 within 20 ms, and back on the pad to be thrown again within half a
// second, every time in every log tonight. Something above the pad turns the throw, and the old wording sent the reader
// looking for a collider bug that is not there. Clamped all the same.
Log.Warning( _sinceLaunch < LaunchWindow
? $"[nz-shove] thrown sideways at {speed:0} u/s {(float)_sinceLaunch * 1000f:0} ms after a springboard launch"
+ $" — clamped to {limit:0}; the throw hit something above the pad (check its placement)"
: $"[nz-shove] ejected at {speed:0} u/s with no zombie in contact"
+ $" — clamped to {limit:0}; something rebuilt a collider inside the player" );
body.Velocity = flat.Normal * limit + Vector3.Up * v.z;
HardClamped++;
}
/// <summary>How far over its own speed a slide may run before the guard calls it an ejection: slopes, and the solver's rounding.</summary>
const float OwnSpeedMargin = 1.1f;
Slide _slide;
/// <summary>The player's own horizontal speed right now (`Slide.OwnSpeed`): 0 when not sliding.</summary>
float OwnSpeed()
{
if ( !_slide.IsValid() ) _slide = Components.Get<Slide>( FindMode.EverythingInSelf );
return _slide.IsValid() ? _slide.OwnSpeed : 0f;
}
/// <summary>
/// Put the player back if the last frame took them through a wall.
/// </summary>
///
/// ⛔ A ZERO-RADIUS RAY BETWEEN TWO CONSECUTIVE POSITIONS, AND THE ZERO RADIUS IS THE WHOLE
/// TRICK. The player's CENTRE can never come closer to a face than the capsule's radius, so a
/// ray between two centres cannot touch a wall the player merely slid along, hugged, fell past
/// or rounded the corner of. The only way it hits one is if the centre actually went through
/// it — which is the bug, stated exactly. A swept hull would have had to be shrunk by guesswork
/// and would still have clipped every corner.
///
/// ⛔ VERTICAL SURFACES ONLY. A ray between two frames of a step-up or a ramp can graze the
/// floor it is climbing, and undoing that would make stairs unusable. Nothing in the report is
/// about falling through a floor, so a mostly-upright normal is ignored.
///
/// ⚠️ IT DOES NOT CARE HOW THE PLAYER GOT THERE. A crush that takes ten frames and a pop that
/// takes one both end with a wall between them and where they were, so both are caught — and
/// so is anything else that ever does this, including the collider-rebuild ejection the hard
/// ceiling only ever back-stopped.
///
/// ⚠️ THE ANCHOR IS ONE FRAME OLD, so putting them back is a correction of a few units and
/// does not read as a teleport. While a zombie keeps pressing, it simply happens again — the
/// player feels a wall that holds instead of a wall that gives way.
///
/// ⛔ NOCLIP IS EXEMPT AND HAS TO BE. Flying through geometry is the entire feature; the anchor
/// is dropped while it is on so the first frame after landing starts clean rather than
/// yanking the flier back to wherever they took off from.
void KeepInside( Rigidbody body )
{
if ( !Containment ) return;
if ( Components.Get<Noclip>( FindMode.EverythingInSelf )?.Flying ?? false )
{
_hasClear = false;
return;
}
var here = WorldPosition;
if ( !_hasClear )
{
_clear = here;
_hasClear = true;
return;
}
var moved = here.Distance( _clear );
if ( moved < 0.01f ) return;
if ( moved > BigJump )
{
_clear = here;
return;
}
var up = Vector3.Up * ProbeHeight;
// ⚠️ THE BULLET IGNORE LIST IS DELIBERATELY NOT USED. Invisible walls and barricades both
// carry `PassBullets`, and they are the two things this exists to detect — filtering on
// that list would have made the guard blind to exactly the surfaces in the report.
var tr = Scene.Trace.Ray( _clear + up, here + up )
.WithoutTags( "player", "zombie", "ragdoll", "trigger" )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
// ⚠️ THE ANCHOR ITSELF IS INSIDE SOMETHING — a collider was rebuilt around it. Do not
// advance it (the new spot is no better) and do not send the player back into it either;
// the hard ceiling and the solver will push them out, and the next clear frame re-anchors.
if ( tr.StartedSolid ) return;
if ( !tr.Hit || MathF.Abs( tr.Normal.z ) >= 0.5f )
{
_clear = here;
return;
}
WorldPosition = _clear;
// ⚠️ HORIZONTAL SPEED GOES, VERTICAL STAYS. Whatever was pushing them into the wall is
// still there; leaving the inward velocity would spend the next frame doing it again. The
// fall is not part of the crush, and taking it would cancel a drop the player is owed
// damage for.
body.Velocity = Vector3.Up * body.Velocity.z;
Crossings++;
// ⚠️ RATE-LIMITED, because a player held against a wall by a crowd triggers this on
// consecutive frames and a line per frame would bury everything else in the console.
if ( Time.Now >= _nextSay )
{
_nextSay = Time.Now + 1f;
Log.Warning( $"[nz-shove] put back {moved:0} units — crossed"
+ $" {(tr.GameObject.IsValid() ? tr.GameObject.Name : "a wall")}"
+ $" ({Crossings} this session)" );
}
}
/// <summary>
/// Charge for a landing, from the speed the player was coming down at.
/// </summary>
///
/// ⛔ THROUGH `OnDamage`, NOT `Apply`, AND THAT IS WHAT KEEPS PhD FLOPPER WORKING. The perk is
/// defined as "immune to anything that is not a zombie" and that test lives on the `OnDamage`
/// path; `Apply` is the route a zombie's swipe takes and deliberately bypasses it. Fall damage
/// sent through `Apply` would hurt a player wearing the one perk that exists to stop it.
///
/// ⚠️ THE ATTACKER IS THE PLAYER THEMSELVES, which is how the friendly-fire guard already
/// expects self-damage to arrive — `Health.OnDamage` excludes `attacker == GameObject` from
/// that check precisely so your own grenade and your own fall still land.
///
/// ⚠️ TAGGED `"fall"` so `Armor.Bypasses` lets it through. Plates are for being shot at; a
/// player should not walk off a roof and have their armour absorb it.
///
/// ⚠️ LINEAR BETWEEN THE TWO SPEEDS, and at least one point once it applies at all. A curve
/// would be a second thing to tune with no way to feel the difference, and a landing that
/// crosses the threshold and takes zero reads as the system being broken.
void Land( float down )
{
// ⚠️ A SPRINGBOARD'S PARDON IS SPENT BY THE FIRST LANDING, SOFT OR HARD. Kept past a soft one — thrown onto a ledge —
// it would forgive the next fall that had nothing to do with any springboard.
var spared = _spareLanding;
_spareLanding = false;
if ( !FallDamage || down <= FallSafeSpeed ) return;
if ( spared )
{
Log.Info( $"[nz-shove] landed at {down:0} u/s off a springboard — no damage (safe landing)" );
return;
}
var player = Components.Get<NZPlayer>( FindMode.EverythingInSelf );
if ( !player.IsValid() || player.IsDown ) return;
var hp = player.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( !hp.IsValid() ) return;
var span = MathF.Max( 1f, FallLethalSpeed - FallSafeSpeed );
var t = MathX.Clamp( (down - FallSafeSpeed) / span, 0f, 1f );
// ⚠️ OF THE MATCH'S MAX HEALTH (the lobby's Difficulty, 2026-10-05), so a lethal fall is lethal on any difficulty
var dmg = MathF.Max( 1f, t * Difficulty.MaxHealth );
var tags = new TagSet();
tags.Add( "fall" );
hp.OnDamage( new DamageInfo
{
Damage = dmg,
Attacker = GameObject,
Position = WorldPosition,
Tags = tags,
} );
Fell++;
Log.Info( $"[nz-shove] landed at {down:0} u/s — {dmg:0} damage"
+ $" ({FallSafeSpeed:0} free, {FallLethalSpeed:0} fatal)" );
}
/// <summary>
/// Is a living zombie close enough to be the thing that just moved us?
/// </summary>
///
/// ⚠️ ONLY ASKED WHEN THE PLAYER IS ALREADY OVER THE CEILING, which is almost never — so the
/// scene walk costs nothing in the normal case. Putting the distance test first would run it
/// every frame for every player against every zombie in the round.
///
/// ⚠️ CORPSES DO NOT COUNT. A ragdoll already ignores the player in the collision matrix, so
/// one lying nearby is not what pushed them and would only widen the window.
bool ZombieInContact()
{
var scene = Scene ?? Game.ActiveScene;
if ( !scene.IsValid() ) return false;
var reach = ContactRange * ContactRange;
var here = WorldPosition;
return scene.GetAllComponents<ZombieAI>()
.Any( z => z.IsValid()
&& z.State != ZombieState.Dead
&& z.WorldPosition.DistanceSquared( here ) <= reach );
}
/// <summary>
/// `nz_containment [0|1] [probe] [jump]` — the wall guard, and what it has caught.
/// </summary>
[ConCmd( "nz_containment" )]
public static void ContainmentCmd( int on = -1, float probe = 0f, float jump = 0f )
{
if ( on >= 0 ) Containment = on != 0;
if ( probe > 0f ) ProbeHeight = probe;
if ( jump > 0f ) BigJump = jump;
Log.Info( $"[nz-shove] containment {(Containment ? "on" : "OFF")}"
+ $" · probe {ProbeHeight:0} up · teleports above {BigJump:0} units"
+ $" · {Crossings} crossing(s) undone" );
}
/// <summary>
/// `nz_unstuck` — put the local player back somewhere the game knows about.
/// </summary>
///
/// ⛔ THE NAVMESH FIRST, NOT THE GUARD'S OWN ANCHOR. Somebody who has been outside the map for
/// a while has an anchor out there with them — it follows every clear frame, and out in the
/// void every frame is clear. The mesh is the only description of the playable world that
/// cannot have followed them out, and `GetClosestPoint` returns the nearest point ON it from
/// wherever they are, which is the definition of where they should be standing.
///
/// ⚠️ MANUAL, AND STAYING MANUAL. An automatic version needs a test for "out of bounds" that
/// is right every time — and a false positive teleports a player who was fine, mid-fight,
/// which is worse than the bug. Containment above is the automatic half; this is the hatch.
[ConCmd( "nz_unstuck" )]
public static void UnstuckCmd()
{
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Info( "[nz] unstuck: no local player" ); return; }
var scene = p.Scene ?? Game.ActiveScene;
var here = p.WorldPosition;
var g = Of( p );
var target = scene?.NavMesh?.GetClosestPoint( here );
if ( !target.HasValue && g.IsValid() && g._hasClear )
target = g._clear;
if ( !target.HasValue )
{
var spawns = ActiveConfig.Current?.PlayerSpawns;
if ( spawns is not null && spawns.Count > 0 )
target = spawns.OrderBy( sp => sp.Position.DistanceSquared( here ) )
.First().Position;
}
if ( !target.HasValue )
{
Log.Warning( "[nz] unstuck: no navmesh and no player spawns — nowhere to send you" );
return;
}
// ⚠️ A LITTLE ABOVE IT. A navmesh point sits ON the floor, and dropping a capsule exactly
// there starts it half inside the surface it is standing on.
var to = target.Value + Vector3.Up * 8f;
p.WorldPosition = to;
var body = p.Components.Get<PlayerController>( FindMode.EverythingInSelf )?.Body;
if ( body.IsValid() ) body.Velocity = Vector3.Zero;
// ⚠️ RE-ANCHORED, or the containment guard would read the move as a crossing on the very
// next frame and undo the rescue.
if ( g.IsValid() )
{
g._clear = to;
g._hasClear = true;
}
Log.Info( $"[nz] unstuck — moved {here.Distance( to ):0} units" );
}
/// <summary>`nz_shove` — the knobs and what they have caught.</summary>
[ConCmd( "nz_shove" )]
/// ⚠️ THE FALL NUMBERS ARE THE ONES WORTH MOVING, so they are arguments rather than a rebuild.
/// `free` and `fatal` are a feel pair — how far you may drop for nothing, and how far kills —
/// and neither can be settled by reading them. `nz_shove 0 0 450 1200` restores the defaults.
public static void Report( float ceiling = 0f, float hard = 0f,
float free = 0f, float fatal = 0f )
{
if ( ceiling > 0f ) Ceiling = ceiling;
if ( hard > 0f ) HardCeiling = hard;
if ( free > 0f ) FallSafeSpeed = free;
if ( fatal > 0f ) FallLethalSpeed = fatal;
var p = NZPlayer.Local;
var g = Of( p );
Log.Info( $"[nz-shove] ceiling {Ceiling:0} u/s · contact {ContactRange:0}"
+ " u/s" );
var body = p.IsValid()
? p.Components.Get<PlayerController>( FindMode.EverythingInSelf )?.Body
: null;
Log.Info( $"[nz-shove] hard ceiling {HardCeiling:0} u/s · peak seen {Peak:0} u/s"
+ (body.IsValid()
? $" · engine impact threshold {body.MinImpactDamageSpeed:0}"
+ $" (damage {(body.EnableImpactDamage ? "on" : "off")})"
: "") );
Log.Info( $"[nz-shove] fall damage {(FallDamage ? "on" : "OFF")}"
+ $" · free under {FallSafeSpeed:0} · fatal at {FallLethalSpeed:0}"
+ $" · {Fell} costly landing(s)" );
Log.Info( $"[nz-shove] containment {(Containment ? "on" : "OFF")}"
+ $" · {Crossings} crossing(s) undone · nz_unstuck if one ever gets through" );
Log.Info( $"[nz-shove] zombie clamps {Clamped} · geometry clamps {HardClamped}"
+ (g.IsValid() ? $" · now {g.LastVelocity.Length:0} u/s" : " · no guard on this player") );
// ⚠️ WHICH COUNTER MOVED IS THE DIAGNOSIS. Zombie clamps mean the horde is shoving;
// geometry clamps mean a collider was rebuilt inside the player, which is a different bug
// wearing the same symptom.
if ( HardClamped > 0 )
Log.Warning( "[nz-shove] geometry has ejected this player — a barricade or invisible"
+ " wall rebuilt its collider while they were standing in it" );
}
}