ZombieAI component for NZombies, implementing AI, locomotion, animation, puppet (networked proxy) behavior, health syncing, variant application and boss/special component wiring. It contains tuning, diagnostics and many properties controlling attacks, movement and model setup.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// ZOMBIE/AI — the walker, ported from the GMod gamemode.
///
/// Source of truth: Docs/NZOMBIES_REFERENCE.md §9 (analysed from
/// entities\entities\nz_zombiebase_moo.lua, 8,258 lines).
///
/// The original has NO state machine — behaviour is a Lua coroutine plus ~15
/// boolean flags. That's a constraint of Lua, not a design goal, so this port
/// uses an explicit state enum instead. The BEHAVIOURS are preserved exactly;
/// only the plumbing differs.
///
/// ⚠️ NOT WIRED UP YET. No spawner, no scene references. Written to be ready.
///
/// ⚠️ The navigation calls in the NAV region are written against an API surface
/// I inferred from Sandbox.Engine.dll but could not compile-test. Expect that
/// region to need adjustment; the rest of the file does not depend on it.
/// </summary>
// ⚠️ PARTIAL SINCE 2026-09-28: the gore (heads and arms) lives in `ZombieAI.Gore.cs`, beside the private state it needs.
public sealed partial class ZombieAI : Component
{
/// <summary>
/// Did this damage come from an enemy, as opposed to the world?
///
/// ⛔ THE QUESTION TWO SYSTEMS NEEDED AND NEITHER COULD ASK. Armor was depleted by fall
/// damage, damage walls and easter-egg traps, and Vigor Rush's M4 Killstreak was wiped by
/// them — so a player could lose a 60-kill streak to a pit they walked into. Both are
/// meant to be about being HIT BY SOMETHING, and neither had a way to tell.
///
/// ⚠️ THE TAG, NOT THE COMPONENT, AS THE PRIMARY TEST. `ZombieAI` adds `"zombie"` to its
/// own GameObject on spawn, so the tag is the thing that is definitely present on anything
/// that thinks like a zombie — including specials and bosses, which are ZombieAI with a
/// variant rather than separate types. The component lookup is a fallback for the frame
/// before the tag is applied.
///
/// ⚠️ SEARCHES ANCESTORS. A claw hit reports the HITBOX or a bone proxy as the attacker,
/// not the root the tag sits on — testing the reported object alone answers "no" for a
/// real zombie hit, which is the failure that would make both fixes look like they had
/// simply broken armor and the streak instead.
///
/// ⚠️ NULL IS NOT AN ENEMY, deliberately. Scripted and area damage has no attacker, and
/// "no attacker" is exactly the case both callers want to exclude.
/// </summary>
/// <summary>
/// This zombie's health multiplier at <paramref name="round"/> — the variant's own, plus a
/// boss's per-round growth.
///
/// ⛔ BOSSES ONLY. A walker's ×1 growing by 1 a round would double him on his second round
/// and be 50× by round 50, on top of a curve that already compounds 15.8% a round. The whole
/// point of the boss term is that bosses are rare and their curve is separate.
///
/// ⚠️ CLAMPED AT ZERO ROUNDS ELAPSED, so a boss spawned before `FirstRound` — a map script,
/// an Easter egg, `nz_boss` — gets the authored multiplier rather than a negative one that
/// would make him weaker than a walker.
/// </summary>
float BossHealthMultiplier( int round )
{
var authored = Variant?.HealthMultiplier ?? 1f;
if ( Variant is null || !Variant.IsBoss ) return authored;
var cfg = ActiveConfig.Current?.Bosses;
if ( cfg is null || cfg.HealthPerRound <= 0f ) return authored;
// ⚠️ FROM THE MATCH'S FIRST BOSS ROUND (the lobby's Difficulty, 2026-10-05), the one the schedule uses
var elapsed = MathF.Max( 0f, round - Difficulty.BossFirst( cfg.FirstRound ) );
return authored + elapsed * cfg.HealthPerRound;
}
/// <summary>
/// The zombie a hit belongs to, or null. Walks up from a hitbox to the body.
///
/// ⛔ THE COMPONENT IS THE AUTHORITY, NOT THE TAG, and the difference matters to anything
/// that counts per zombie. Tags are on the CHILDREN too — `head` is its own tagged object,
/// which is why `BulletDecals.IsFlesh` has to walk ancestors at all — so a tag search answers
/// "which body PART" and a caller counting bodies would count limbs.
///
/// ⚠️ ONE RESOLVER FOR EVERY CALLER. The packed decal's per-body cap and Marked's streak
/// both need exactly this question answered the same way; two copies would be two chances to
/// disagree about what a zombie is.
/// </summary>
public static GameObject RootOf( GameObject go )
{
for ( var o = go; o.IsValid(); o = o.Parent )
if ( o.Components.Get<ZombieAI>( FindMode.EverythingInSelf ) is not null )
return o;
return null;
}
public static bool IsEnemyDamage( GameObject attacker )
{
if ( !attacker.IsValid() ) return false;
if ( attacker.Tags.Has( "zombie" ) ) return true;
return attacker.Components
.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors )
.IsValid();
}
// ── AI/CONFIG ────────────────────────────────────────────────────────────
[Property] public ZombieVariant Variant { get; set; }
/// <summary>Per-spawn health scale, MULTIPLIED WITH the variant's own rather
/// than replacing it. The variant says what the enemy is; this says how this
/// map's special rounds want to use it (MapConfig.Specials).
///
/// ⚠️ Must be set BEFORE the component is enabled — health is rolled once in
/// OnStart. ZombieCommands.SpawnAt takes it as an argument for that reason;
/// assigning it to a live component is the race that made the first hellhound
/// spawn as a walker. See INSTRUCTIONS.md pattern 11.</summary>
[Property] public float ExtraHealthMultiplier { get; set; } = 1f;
/// <summary>Per-spawn speed scale, multiplied with the variant's own.</summary>
[Property] public float ExtraSpeedMultiplier { get; set; } = 1f;
/// <summary>
/// One of basalt's altar defense's wave (`HexPlatforms.Defense.cs`): it goes for the ALTAR and never a player
/// (`GetTargetables`), its swing lands on the altar (`DoAttackDamage`), and it runs at the top speed tier.
///
/// ⚠️ SET BEFORE `Enabled`, LIKE THE MULTIPLIERS ABOVE — `ZombieCommands.SpawnAt` takes it as an argument — so it is in
/// place for `OnStart`'s speed roll and travels with the network spawn, and a puppet picks the same sprint clips.
/// </summary>
[Property] public bool AltarWave { get; set; }
/// <summary>
/// Clip sets that replace the variant's for this ONE zombie, while they are set.
/// </summary>
///
/// ⛔ BECAUSE A `.zvar` IS SHARED AND WRITTEN TO DISK. Oberon takes up a knife at half health
/// and every clip he owns changes — walk, idle, both swings. Editing the variant to do that
/// would change every other Oberon in the room and persist the change into the asset, so the
/// second phase of one boss would leak into the first phase of the next.
///
/// ⚠️ NULL MEANS "ASK THE VARIANT", not "no clips". Clearing an override hands the zombie
/// straight back to its asset, which is what a boss reverting to phase one needs.
///
/// ⚠️ SET THEM THROUGH `RepickAnimations()`, or nothing re-reads them — the same reason that
/// method exists for `ExtraSpeedMultiplier`.
public List<string> MovementOverride { get; set; }
/// <summary>See <see cref="MovementOverride"/>.</summary>
public List<string> AttackOverride { get; set; }
/// <summary>
/// This model's own climb clip, for a rig that does not have the walker's.
/// </summary>
///
/// ⛔ `WalkerTraverse.ClimbUp` IS A TABLE OF WALKER CLIP NAMES. A ported boss has none of them,
/// and an unknown sequence name is not an error — it is the bind pose (WEAPON_PORTING §7.5). So
/// a boss crossing a nav link would slide over it frozen, silently.
public string ClimbClipOverride { get; set; } = "";
/// <summary>
/// This model's own WINDOW climb, for a rig without the walker's mantles (2026-10-07, the Sizzler's
/// `nz_base_zombie_walk/run/sprint_win_trav_m_01`): played at x1, its own window pace — the walker's mantles are ~5 s clips run
/// at `VaultSpeed`. Empty, the walker's (`WalkerAnimations.MantleForSpeed`); a model with neither hops the sill (`_vaultHop`).
/// </summary>
public string VaultClipOverride { get; set; } = "";
/// <summary>
/// An absolute ground speed for THIS zombie, overriding everything. 0 = no override.
///
/// ⛔ SET AT RUNTIME BY A STATE CHANGE, where `ZombieVariant.FixedSpeed` is the authored value.
/// Brutus has two speeds - helmeted and bare - and only one of them can live in the asset, so the
/// other is written here when his helmet breaks.
///
/// ⚠️ IT WINS OVER THE VARIANT AND OVER EVERY MULTIPLIER, and needs `RepickAnimations()` to
/// take effect: the derivation only runs when animations are picked.
/// </summary>
[Property] public float SpeedOverride { get; set; } = 0f;
/// <summary>
/// The fastest this zombie's legs may cycle against their clip, when not 0 — over the match's `MaxAnimRate` (2.5) for a body
/// that moves faster than any of its clips (2026-10-06: the raging Director at five times his sprint). 0, the default, is
/// the match's cap; a property, so a hotload's zero means exactly that. Set on EVERY machine by whoever owns the reason
/// (the Director's body swap) — a client's puppet clamps its own legs.
/// </summary>
public float MaxAnimRateOverride { get; set; }
/// <summary>The cap the walk's playback rate is clamped to: <see cref="MaxAnimRateOverride"/> when set, else the match's.</summary>
float AnimRateCap => MaxAnimRateOverride > 0f ? MaxAnimRateOverride : ActiveConfig.Zombies.MaxAnimRate;
/// <summary>
/// The absolute speed in force, or 0 when this zombie rides the round curve.
/// </summary>
///
/// ⛔ ONE ANSWER, THREE READERS. `ApplyGroundSpeed` needs it for the speed, `RefreshTier` for
/// the animation tier, and `SpeedReport` for the diagnostic — and the first two disagreeing is
/// exactly the bug this was written to kill: Brutus travelled at 85 while animating from the tier
/// the ROUND picked, so he ran at walking pace on round 1.
public float EffectiveFixedSpeed
=> SpeedOverride > 0f ? SpeedOverride : (Variant?.FixedSpeed ?? 0f);
/// <summary>Decision rate. The original runs its behaviour coroutine at
/// 10 Hz via TimeOut(0.1) while physics/housekeeping stays per-tick
/// (moo:1341 vs moo:540). That split is most of the CPU budget — do not
/// move decisions to OnUpdate.</summary>
[Property] public float ThinkRate { get; set; } = 0.1f;
// ── AI/TUNING — all values from the original, reference doc §9.4 ─────────
// Halved from the GMod values (80 / 70 / 25, moo:47-51 and moo:4691).
// Those were tuned around an attack ANIMATION with real arm reach — the
// swing visibly covers the gap. With no animation, 105 units of effective
// reach just looks like hitting from thin air.
// Revisit once the attack animation is playing.
[Property] public float AttackRange { get; set; } = 40f;
/// <summary>
/// This one never swings at a player. Set by an enemy whose attack is something else.
///
/// ⛔ IT GATES THE PLAYER SWING ONLY, NOT `CanAttack`. Barricade and wall TEARING go through the
/// same `CanAttack` gate, so disabling melee wholesale would leave the zombie standing at a
/// boarded window forever — unable to hit you and unable to get in. The napalm zombie still has
/// to be able to tear its way through the map; it just does not punch.
/// </summary>
[Property] public bool MeleeDisabled { get; set; }
[Property] public float CrawlAttackRange { get; set; } = 35f;
[Property] public float AttackRangePadding { get; set; } = 12.5f;
/// <summary>
/// How far a swing REACHES, as a multiple of the range that triggers it.
///
/// ⛔ THE TRIGGER RANGE AND THE DAMAGE RANGE ARE DIFFERENT QUESTIONS and were
/// nearly the same number: a zombie committed at 52.5u and could only connect
/// out to 65u. A swing takes most of a second, so anyone who kept walking was
/// already past 65 by the time it landed — which is why zombies only hit a
/// player who chose to stand still.
///
/// Keeping the TRIGGER close is what makes them commit at a believable
/// distance; letting the REACH run long is what makes that commitment
/// dangerous. 2x gives ~105u of reach off a 52.5u trigger.
/// </summary>
[Property, Range( 1f, 4f )] public float AttackReachMultiplier { get; set; } = 2f;
/// <summary>
/// How far into the swing the damage lands, as a fraction of the clip ELAPSED. 0.1 — the
/// start, by request.
///
/// ⛔ THIS KNOB WAS INVERTED AGAINST ITS OWN DOCUMENTATION AND SURVIVED TWO TUNING PASSES.
/// `_actionDone` is a `TimeUntil`, so it reads as time REMAINING — and the test was
/// `_actionDone <= _actionLength * AttackDamagePoint`, which fires once ELAPSED reaches
/// `1 - AttackDamagePoint`. The number therefore meant "fraction of the clip LEFT when the hit
/// lands", the exact opposite of the summary line above it.
///
/// ⛔ SO THE PREVIOUS RETUNE DID THE OPPOSITE OF WHAT ITS COMMENT CLAIMED. That note read "WAS
/// 0.6 ... at 0.3 the hit lands early enough that stepping back is no longer a guaranteed
/// dodge" — but 0.6 landed the hit 40% in and 0.3 landed it **70%** in. The change made swings
/// LATER and easier to walk out of while recording itself as a fix. The player's report was
/// "damage is dealt at the end of the attack animation", which is exactly what 70% feels like.
///
/// ⚠️ THE COMPARISON NOW CONVERTS TO ELAPSED, so the field means what it says and a future
/// retune reads the obvious way: bigger is later.
///
/// ⚠️ NOT ZERO, AND THE OLD NOTE'S REASON STILL HOLDS. Damage on the literal first frame lands
/// before the arm has moved and reads as being hit by nothing at all. 0.1 is about 0.12s into a
/// 1.2s clip — the start for every practical purpose, with the swing visibly begun.
///
/// ⚠️ AND IT MAKES A COMMITTED SWING ESSENTIALLY UNDODGEABLE, which is the intended trade.
/// `DoAttackDamage` re-checks range at the moment of the hit, so landing at 70% gave the player
/// most of a second to leave; landing at 10% means the range that authorised the swing is still
/// true when it resolves. `AttackReachMultiplier`'s padding stops mattering much as a result.
/// </summary>
[Property, Range( 0.05f, 1f )] public float AttackDamagePoint { get; set; } = 0.1f;
/// <summary>
/// Playback multiplier for the swing — the whole thing, windup and recovery.
///
/// ⚠️ THE ONLY LEVER THAT SHORTENS REACTION TIME. AttackDamagePoint moves the
/// hit EARLIER WITHIN the swing, but the swing still takes as long as the clip
/// says; this compresses the clip itself, so the wind-up is shorter, the
/// recovery is shorter, and the next swing comes sooner (the cooldown is the
/// animation length).
///
/// At 1.8x a ~1.2s swing runs in ~0.67s with the hit at ~0.20s.
///
/// ⚠️ ATTACKS ONLY. PlayAction is shared with deaths and entrances, which
/// default to 1x — speeding those up would make every zombie in the map look
/// wrong to fix a combat problem.
///
/// ⚠️ Above ~2.5x the swing reads as a twitch rather than a strike, and the
/// audio cue stops arriving early enough to be a warning at all.
/// </summary>
[Property, Range( 0.5f, 3f )] public float AttackSpeed { get; set; } = 1.8f;
// ⛔ `VictimImmunityTime` USED TO LIVE HERE AND WAS NEVER READ BY ANYTHING. It was a 1:1 port
// of moo:4741 and it looked exactly like the live knob — a [Property], a sensible 0.5f, right
// beside three fields that do work. The window it claimed to own is the PLAYER's
// (NZPlayer.VictimImmunity -> Health.ImmunityAfterHit), for the reason DoAttackDamage gives:
// one zombie cannot own a window that the whole horde shares. Removed rather than documented
// in place, because a decoy knob that reads as live is worse than no knob at all — tuning it
// would have produced a confident report of a change that never happened.
[Property] public int AttacksBeforeIgnoringCover { get; set; } = 6; // moo:2649
// ── round-scaled attack pressure ─────────────────────────────────────────
// The three fields above are the ROUND-1 values. What actually reaches the animation and the
// range test is the field times its curve — see ZombieStats' CURVES/ATTACK PRESSURE block.
//
// ⚠️ READ, NOT STAMPED. These are computed at the point of use rather than written onto the
// zombie at spawn, so a live `nz_zombie_attack_scale` retune applies to zombies already in
// the map, and a zombie that survives into the next round scales with it.
/// <summary>The round the curves are read at. Never below 1.</summary>
private static int CurveRound => Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
/// <summary>Authored swing playback, scaled by the round.</summary>
/// <remarks>⚠️ AND THE MATCH'S ATTACK SPEED (the lobby's Difficulty, 2026-10-05).</remarks>
public float ScaledAttackSpeed => AttackSpeed * ZombieStats.AttackSpeedScale( CurveRound ) * Difficulty.ZombieAttackSpeed;
/// <summary>Authored swing reach multiplier, scaled by the round.</summary>
public float ScaledAttackReach => AttackReachMultiplier * ZombieStats.AttackReachScale( CurveRound );
/// <summary>Authored damage point, scaled by the round and held at the 0.05 floor the
/// property's own Range attribute states — the curve must not walk it past its own edit
/// bounds, or the value the inspector shows stops being the value the code uses.</summary>
public float ScaledAttackDamagePoint => Math.Clamp(
AttackDamagePoint * ZombieStats.AttackDamagePointScale( CurveRound ), 0.05f, 1f );
/// <summary>Stand-in for animation-driven attack pacing. Remove once the
/// attack sequence's duration can drive it.</summary>
[Property] public float AttackCooldownPlaceholder { get; set; } = 1.2f;
/// <summary>
/// What is actually on the renderer right now — for nz_pose.
///
/// ⚠️ Exists because "it stands up after the animation" has at least three
/// causes that look identical on screen and need different fixes:
///
/// state Dead, sequence = the DEATH clip, time ~0 -> the clock wrapped
/// state Dead, sequence = a WALK clip -> something swapped it
/// state Chasing -> not a corpse at all,
/// it is a live zombie
/// standing still
/// </summary>
public string PoseDebug
{
get
{
if ( !_renderer.IsValid() ) return "no renderer";
var seq = _renderer.Sequence;
string name = seq?.Name ?? "(none)";
bool isAction = !string.IsNullOrEmpty( _actionClip ) && name == _actionClip;
return $"{State,-9} seq '{name}' t={(seq?.TimeNormalized ?? -1f):0.00} "
+ $"rate={_renderer.PlaybackRate:0.00} action='{_actionClip ?? "-"}'"
+ $"{(isAction ? "" : " ⛔ SEQUENCE IS NOT THE ACTION CLIP")}"
+ $" settled={_corpseSettled} wrapped={_actionWrapped} "
+ $"ragdoll={_ragdollStarted} hold={_deathHoldTime:0.00}"
+ (_corpseSettled && _deathHoldTime <= 0f
? " ⛔ NO HOLD CAPTURED — the death clip was not on the renderer "
+ "when the corpse settled, so there is no pose to pin"
: "");
}
}
/// <summary>Diagnostics for nz_status — is the agent actually pathing?
///
/// ⛔ IT NAMES THE CLIP AND WHERE THE CLIP CAME FROM, because the fallback below it is SILENT.
/// `RefreshAnimations` takes `_tier?.MovementSequences` and, when that is null or empty, quietly
/// uses `WalkerAnimations` instead — no warning, because an unassigned variant is the normal
/// case. So a variant whose `SpeedTiers` failed to load looks exactly like one that never had
/// any, and the only symptom is a custom enemy walking with the stock walk. That cost an hour on
/// the napalm zombie, whose nine imported clips were on the model and simply never asked for.
/// </summary>
public string AgentDebug =>
_agent.IsValid()
? $"agent ok speed {MoveSpeed:0} ({WalkerAnimations.TierName( SpeedRating )} "
+ $"rating {SpeedRating:0}) vel {Velocity.Length:0}"
+ $" clip '{_walkSequence ?? "-"}'"
+ $" [{( _tier is null ? "WALKER TABLE — variant has no tier" : "variant tier '" + _tier.Name + "'" )}]"
// ⚠️ THE MULTIPLIER IS SHOWN SEPARATELY BECAUSE `MinMoveSpeed` HIDES IT. With no
// round running the base speed is 0 and the floor reports 55 whatever the multiplier
// is — so a per-zombie speed effect looks like it did nothing, when it is applied and
// simply has nothing to scale yet.
+ ( ExtraSpeedMultiplier.AlmostEqual( 1f ) ? "" : $" xspeed {ExtraSpeedMultiplier:0.00}" )
: "agent not created yet (lazy - made on first repath)";
/// <summary>Diagnostics for nz_anims — what is this zombie playing, and is
/// the clip advancing? A frozen Time with a valid Name means the sequence
/// resolved but playback is stalled, which is a different bug to a name
/// that never resolved at all.</summary>
/// <summary>
/// The sequence names this renderer will actually accept. Null until the
/// body exists. This — not the animation list — is what a clip name has to
/// be in to play, so it is the list any diagnostic should compare against.
/// </summary>
public IReadOnlyList<string> SequenceNames =>
_renderer.IsValid() ? _renderer.Sequence.SequenceNames : null;
/// <summary>Hitboxes on the loaded model. 0 means headshots can't register.</summary>
public int HitboxCount => _renderer.IsValid() && _renderer.Model is not null
? _renderer.Model.HitboxSet?.All?.Count ?? 0
: 0;
public string AnimDebug =>
_renderer.IsValid()
? $"seq '{_renderer.Sequence.Name}' t {_renderer.Sequence.Time:0.00}"
+ $" rate {_renderer.PlaybackRate:0.00} anims {_renderer.Model?.AnimationCount ?? 0}"
: "no renderer";
public Vector3 Velocity => _agent.IsValid() ? _agent.Velocity : Vector3.Zero;
// Collision is deliberately NARROWER than the hitbox: 18x18 body so hordes
// funnel through doorways without jamming, 64x64 surrounding bounds so
// they're easy to shoot (moo:322-324). Preserve both numbers.
[Property] public float BodyRadius { get; set; } = 9f;
[Property] public float BodyHeight { get; set; } = 72f;
/// <summary>
/// Radius of the SHOOTABLE capsule — wider than BodyRadius on purpose.
///
/// The original splits the two (moo:322-324): an 18x18 collision box so a
/// horde funnels through doorways without jamming, and 64x64 surrounding
/// bounds so they are easy to hit. BodyRadius (9) is the narrow one and
/// feeds the nav agent; this is the generous one and feeds the collider.
/// Movement comes from the NavMeshAgent, so a wide capsule costs us nothing
/// in pathing.
/// </summary>
[Property] public float HitRadius { get; set; } = 16f;
/// <summary>
/// How far below the top of the body the shootable capsule stops, leaving
/// the head to the model's head hitbox. See EnsureHitDetection — if the
/// capsule reaches the head it shadows that hitbox and headshots quietly
/// stop paying out. The walker's head is roughly 10 units tall, so 18
/// clears it with margin for the animation bobbing the head around.
/// </summary>
[Property] public float HeadClearance { get; set; } = 18f;
// ── AI/STATE ─────────────────────────────────────────────────────────────
public ZombieState State { get; private set; } = ZombieState.Idle;
public GameObject Target { get; private set; }
/// <summary>
/// A player a boss has told this zombie to go after, until <see cref="TargetLockUntil"/> (<see cref="LockTarget"/>,
/// 2026-10-07: the Panzerhund runs down whoever hurts it — the user: *"when attacked pursue the attacker"*). `AcquireTarget`
/// takes it over the nearest while it is still a candidate (`GetTargetables`: up, in the round, not hidden, no lure in
/// reach), so every drop in `Think` (a down, the gas, a menu) still drops it. THE HOST.
/// ⚠️ BOTH DEFAULT TO "NO LOCK", so a hotload into a live zombie leaves it unlocked.
/// </summary>
public GameObject TargetLock { get; private set; }
public float TargetLockUntil { get; private set; }
/// <summary>
/// Hold <paramref name="target"/> as this zombie's target for <paramref name="seconds"/> (null or 0 lets go). A new one is
/// taken at once while it chases or idles (`ForceRetarget`); mid-swing, mid-special or stunned at its next look, as that ends —
/// ⛔ `ForceRetarget` SETS Chasing, which would cut a special short. THE HOST.
/// </summary>
public void LockTarget( GameObject target, float seconds )
{
TargetLock = target.IsValid() && seconds > 0f ? target : null;
TargetLockUntil = TargetLock is null ? 0f : Time.Now + seconds;
if ( TargetLock is null || Target == TargetLock ) return;
if ( State is ZombieState.Chasing or ZombieState.Idle ) ForceRetarget();
else _untilRetarget = 0f;
}
// Health lives in its own component so zombies and players share one model
// and the engine's bullet path can reach it through IDamageable. ZombieAI
// owns the REACTION to being hit, not the number.
//
// Named _hp rather than a Health property on purpose: a property called
// Health next to a type called Health is exactly the kind of ambiguity
// that produces confusing compiler errors later.
private Health _hp;
// ⚠️ ON A CLIENT, THE HOST'S NUMBERS (`HealthNet`, 2026-10-06, for the health bars): a puppet's own `Health` takes nothing
// (`Health.Apply`), so read locally it sat at its spawn value for the zombie's whole life, and `ClassTech`'s "below a share
// of its health" read that too.
public float HealthNow => IsPuppet ? HealthNet : (_hp.IsValid() ? _hp.Current : 0f);
public float HealthMax => IsPuppet ? HealthMaxNet : (_hp.IsValid() ? _hp.Max : 0f);
/// <summary>
/// THIS ZOMBIE'S HEALTH FOR EVERY OTHER MACHINE, rounded up (2026-10-06, `ZombieHealthBars`). The host writes it on a change
/// (`PublishHealth`); a client reads it through <see cref="HealthNow"/>.
///
/// ⚠️ `SyncFlags.FromHost`: a zombie is nobody's (`SpawnAt`'s `NetworkSpawn()` has no owner), so the host is its one writer.
/// A whole number that moves only on a hit sends nothing in between.
/// </summary>
[Sync( SyncFlags.FromHost )] public int HealthNet { get; set; }
/// <summary>Its maximum, the same way.</summary>
[Sync( SyncFlags.FromHost )] public int HealthMaxNet { get; set; }
/// <summary>Publish the health for the other machines: compared first, so a zombie nobody hurts sends nothing. Host only.</summary>
void PublishHealth()
{
if ( !_hp.IsValid() ) return;
var max = (int)MathF.Ceiling( MathF.Max( 1f, _hp.Max ) );
var now = (int)MathF.Ceiling( MathF.Max( 0f, _hp.Current ) );
if ( HealthMaxNet != max ) HealthMaxNet = max;
if ( HealthNet != now ) HealthNet = now;
}
public bool IsCrawler { get; private set; }
/// <summary>How fast this zombie actually travels, units/sec — read out of
/// the locomotion clip it plays, then scaled by any speed status on it.
/// See ApplyGroundSpeed and TickStatusSpeed.
///
/// ⛔ COMPUTED, AND IT WAS A STORED `private set` UNTIL 2026-08-20. The only writer
/// was ApplyGroundSpeed, reached only from PickAnimations, which is called from
/// exactly one line in the spawn block — so it ran ONCE PER ZOMBIE, EVER. Anything
/// wanting to change a live zombie's speed had no way in short of re-clipping it,
/// which is why StatusEffects.SpeedScale sat dead for three statuses.
///
/// ⚠️ THE STATUS IS NOT FOLDED INTO `_clipGroundSpeed`, deliberately, for two
/// reasons. The floor would eat it — MinMoveSpeed is 55 and a round-1 walk clip is
/// 35.2, so 35.2 x 1.5 = 52.8 is still below the floor and a speed-up would do
/// literally nothing for the first nine rounds. And leaving `_clipGroundSpeed`
/// authored is what makes UpdateAnimation's playback rate climb: it divides real
/// velocity by the CLIP's speed, so a boosted zombie's legs visibly churn faster
/// instead of skating. That animation rate is the node's only free visual cue.</summary>
/// <summary>
/// This zombie's speed, after everything that slows it.
///
/// ⚠ TIMESLIP TONIC IS A THIRD FACTOR, NOT A FOURTH WRITE. M2's proximity aura, M3's
/// pits and M4's per-hit stacks all resolve into `_timeScale`, which multiplies here — so
/// they compose with status effects and with each other rather than one overwriting the
/// rest. M2 is specified as "half their CURRENT speed", which is precisely a product.
/// </summary>
public float MoveSpeed => _baseMoveSpeed * _statusSpeedScale * _timeScale;
/// <summary>
/// The slowest speed this navmesh agent will actually MOVE at. Measured, not chosen.
///
/// ⛔ BELOW ROUGHLY 35 u/s THE AGENT DOES NOT MOVE AT ALL — not slowly, not at all. Measured
/// with `nz_zspeed_track`, which compares real world displacement against the told speed:
///
/// told 54.1 u/s -> REAL 54 u/s ratio 1.00 ok
/// told 42.1 u/s -> reached the player and attacked
/// told 33.0 u/s -> REAL 0 u/s moved 0u in 6.34s
/// told 30.1 u/s -> REAL 0 u/s moved 0u in 6.26s
///
/// The cliff sits between 33 and 42. This is why `MinMoveSpeed` (55) exists at all — its own
/// comment says a clip with no authored speed "plods", which is this same dead zone found
/// from the other direction and worked around rather than measured.
///
/// ⚠ IT HAD NEVER BITTEN BEFORE BECAUSE NOTHING PRODUCED A PARTIAL SLOW. Every status effect
/// in the project is `SpeedScale` 0 (the web, timestop) or 1. Timeslip Tonic is the first
/// thing to ask for 50%, and it landed straight in the dead zone.
/// </summary>
public static float MinAgentSpeed { get; set; } = 42f;
/// <summary>
/// What to actually write to the agent: `MoveSpeed`, floored out of the dead zone.
///
/// ⛔ ZERO IS PRESERVED EXACTLY. A full stop — the web's `SpeedScale = 0`, Timeslip m5's
/// `timestop` — must still be a full stop, so only NON-zero speeds are lifted. Clamping
/// blindly would un-freeze every webbed zombie in the game.
///
/// ⚠ THIS MEANS A SLOW CANNOT BE STRONGER THAN ABOUT x0.75 AND STILL MOVE. 42/55 is the
/// hard limit of what this agent will do; anything below it is a stop whether or not it was
/// asked for. `nz_zspeed_why` prints when a slow is being floored so the gap between the
/// requested figure and the delivered one is never silent.
/// </summary>
public float AgentSpeed
=> MoveSpeed <= 0.5f ? 0f : MathF.Max( MoveSpeed, MinAgentSpeed );
/// <summary>
/// Speed factor from Timeslip Tonic, refreshed on the think tick beside the status one.
///
/// ⛔ CACHED FOR THE SAME REASON `_statusSpeedScale` IS, and its note applies verbatim:
/// `MoveSpeed` is read every frame by FaceMovement's turn rate, while
/// `TimeAugments.SpeedScaleFor` walks the player list AND the pit list. At MaxAlive 50 that
/// would be thousands of lookups a second for a number that can only change ten times a
/// second anyway.
/// </summary>
private float _timeScale = 1f;
/// <summary>
/// Hits taken from Timeslip M4 Chrono Rounds, up to `TimeAugments.MaxChronoStacks`. Any at all drops this zombie one
/// speed tier for good (`TimeAugments.SpeedScaleFor`); it was 10% each until 08-22, and nothing takes one away.
///
/// ⚠ ON THE ZOMBIE, because the slow belongs to the zombie and not to the shooter — two
/// players shooting the same zombie stack onto one counter, which is what "every shot a
/// zombie takes" says.
/// </summary>
public int ChronoStacks { get; set; }
/// <summary>MoveSpeed before any status — the clip's number after the floor.</summary>
private float _baseMoveSpeed;
/// <summary>
/// Speed factor from status effects, refreshed on the think tick.
///
/// ⛔ CACHED RATHER THAN READ PER ACCESS, because MoveSpeed is read every frame by
/// FaceMovement's turn rate — and StatusEffects.SpeedScaleOf is an
/// EverythingInSelfAndAncestors component lookup. At MaxAlive 50 that is ~3,000
/// ancestor walks a second for a number that can only change ten times a second
/// anyway. Refreshing it where the AI already thinks costs 50 lookups/s instead.
/// </summary>
private float _statusSpeedScale = 1f;
/// <summary>
/// The round's speed NUMBER (curve + jitter) — a tier selector, not a
/// velocity. Matched against the 0/36/71/155 thresholds to choose which
/// animation set to play. Round 1 is legitimately 0-35.
/// </summary>
public float SpeedRating { get; private set; }
/// <summary>
/// Push this zombie up to a speed rating, if it is not already there. True if it changed.
///
/// ⛔️ A FLOOR, NOT AN ASSIGNMENT, for the same reason ZombieVariant.MinSpeedRating is one: by
/// round 40 the curve already has zombies at SuperSprint, and "make the last few sprint" must
/// never be able to SLOW one down. Late rounds keep whatever they had.
///
/// ⚠️ THE TWO CALLS AFTER IT ARE THE POINT. SpeedRating alone is inert -- it is only a tier
/// SELECTOR, and the zombie's actual velocity comes back out of the chosen clip. Without
/// RefreshTier and PickAnimations the rating would read as Sprint while the walker carried on
/// playing its walk loop at ~46 u/s, which is the "the setting does nothing" bug in advance.
///
/// ⚠️ SAFE MID-LIFE. PickAnimations only chooses `_walkSequence` and applies its ground speed;
/// it does not force a clip to play, so a zombie mid-attack or mid-vault finishes what it is
/// doing and moves at the new speed afterwards.
/// </summary>
public bool RaiseSpeedRating( float rating )
{
if ( rating <= SpeedRating ) return false;
SpeedRating = rating;
RefreshTier();
PickAnimations();
return true;
}
private TimeSince _sinceThink;
private TimeSince _sinceRepath;
private TimeUntil _untilRetarget;
private TimeUntil _untilAttackReady;
private int _failedAttacks;
private ZombieSpeedTier _tier;
/// <summary>Every live zombie. The original's single biggest structural
/// perf win was maintaining caches like this instead of calling
/// FindByClass — reference doc §4.1.</summary>
public static readonly List<ZombieAI> All = new();
// ══ PUPPET MODE ─ a zombie somebody else is thinking for ═══════════════════════
/// <summary>
/// Is the host doing the thinking for this one?
///
/// ⛔ ZOMBIES USED TO EXIST ONLY ON THE MACHINE THAT SPAWNED THEM. `ZombieCommands.SpawnAt`
/// made a plain scene object and never network-spawned it, so a client stood in a fully built
/// map with no enemies in it at all — "the config objects are there but the rounds don't
/// exist". They are networked now, which means every other machine receives a PROXY: the same
/// object, with its transform replicated, and no business running an AI of its own.
///
/// ⚠️ TWO AIs FOR ONE BODY IS WORSE THAN NONE. Both would path, both would steer, and the
/// replicated transform would fight the local agent every frame — the zombie would jitter
/// between two opinions and neither machine's would be wrong on its own terms.
///
/// ⛔ IT ASKED `Network.IsProxy` AND THAT WAS THE WRONG QUESTION. `SpawnAt` calls
/// `NetworkSpawn()` with no owner, so a zombie belongs to NOBODY — and an unowned object is
/// not "being simulated somewhere else" in the sense `IsProxy` reports. Clients therefore ran
/// a complete AI on every zombie while the host's transform was also arriving for it, which
/// is exactly the two-opinions fight this mode exists to prevent: *"zombies are seen for both
/// but not synched"*.
///
/// ⚠️ THE RULE IS SIMPLER THAN OWNERSHIP AND WORTH STATING AS ITSELF: a zombie is never
/// owned by a player, so the host thinks and everyone else watches. `NZGame.IsClient` says
/// that in one term and cannot be wrong about what an unowned object means.
///
/// ⚠️ AND IT READS FALSE SOLO. `IsHost` is true whenever networking is inactive, so no
/// zombie is a puppet in single player and this whole path is dead code there.
/// </summary>
public bool IsPuppet => NZGame.IsClient;
/// <summary>Where the puppet was last frame — the only speed signal a proxy has.</summary>
private Vector3 _puppetLast;
/// <summary>
/// Until when this puppet holds its pose — a pratfall on the host, relayed (`NZNet.ZombieFreeze`, 2026-10-04). Without it a
/// watching machine showed a zombie tipped onto its face or back with its legs still running the walk cycle.
/// </summary>
private float _puppetFrozenUntil;
/// <summary>A watching machine's half of a pratfall: hold the pose for <paramref name="seconds"/>.</summary>
public void FreezeAsPuppet( float seconds )
{
if ( State == ZombieState.Dead ) return;
_puppetFrozenUntil = Time.Now + MathF.Max( 0f, seconds );
if ( _renderer.IsValid() ) _renderer.PlaybackRate = 0f;
}
/// <summary>
/// Start a zombie that arrived over the network.
///
/// ⛔ NO ENTRANCE. `BeginSpawn` roots the body and plays a climb-out clip whose whole
/// purpose is to cover the moment a zombie appears — but the host already played it, and the
/// transform arriving here is of a zombie that has finished climbing and is walking. Playing
/// it again would root a body that the network is simultaneously dragging across the floor.
/// </summary>
private void BeginPuppet()
{
State = ZombieState.Chasing;
_puppetLast = WorldPosition;
PlaySequence( _walkSequence );
}
/// <summary>
/// One frame of a zombie the host owns: move nothing, animate to match.
///
/// ⛔ SPEED IS MEASURED, NOT ASKED FOR. `Velocity` reads the `NavMeshAgent`, and a puppet
/// has no agent — creating one would be the second AI this mode exists to prevent. So the
/// legs are driven by how far the body ACTUALLY moved since last frame, which is the one
/// signal a replicated transform does carry. `nz_zspeed_why`'s own header makes the same
/// point from the other side: world position over elapsed time cannot disagree with anything.
/// </summary>
/// <summary>
/// Fall over, on a machine that is only watching. Called by `NZNet.ZombieDied`.
///
/// ⚠️ IT SETS `State` AS WELL AS PLAYING THE CLIP. `TickPuppet` keeps the walk loop
/// running otherwise, so the body would animate a stride on top of its own death.
/// </summary>
public void DieAsPuppet()
{
if ( State == ZombieState.Dead ) return;
State = ZombieState.Dead;
// ⛔ AND IT STOPS BEING SOLID, WHICH THIS NEVER DID. The host's `Die()` calls
// `StopBeingSolid()` before the clip starts — capsule collider destroyed, nav agent
// disabled, retagged from `zombie` to `ragdoll`. A puppet did none of it, so on every
// client a corpse kept its collider for the whole linger and players walked into dead
// zombies. User: *"they have the colisions when dead."*
StopBeingSolid();
// ⛔ AND ITS EYES GO OUT, as the host's do (`Die`)
ZombieEyes.Darken( GameObject );
// ⛔ AND ITS STATUSES GO, as the host's `Die` clears them (2026-10-04, the review). A timed status ran out on its own,
// but Bloodhound's mark is PERMANENT: every client kept a marked corpse's red outline, through walls, for the whole
// linger.
StatusEffects.ClearAll( GameObject );
var deathClips = Variant?.DeathSequences;
if ( deathClips is null || deathClips.Count == 0 ) deathClips = WalkerDeaths.Normal;
PlayAction( deathClips );
// ⚠️ THE SOUND TOO. A kill you cannot hear reads as a miss, and this is the one cue
// the host's own `Die` calls "feedback on the player's own shot".
// ⚠️ NOT SHARED, DELIBERATELY. `DieAsPuppet` plays the death cue on each client already,
// off `NZNet.ZombieDied` — relaying it here as well would give every client two.
NZSound.Play( ZombieVariant.Cue( Variant?.DeathSound, NZSound.ZombieDeath ), VoicePosition,
SoundGate.Feedback );
}
/// <summary>
/// Play a one-shot, on a machine that is only watching. Called by <see cref="NZNet.ZombieClip"/>.
///
/// ⚠️ IT MUST GO THROUGH THE ONE-SHOT MACHINERY, not `PlaySequence`. `ActionPlaying` is what
/// makes `TickPuppet` leave the renderer alone for the length of the clip; a plain sequence
/// change is overwritten by the walk loop on the very next frame.
///
/// ⚠️ AND NOT OVER A DEATH. A relayed swing can arrive a frame after the kill that stopped it.
/// </summary>
public void PlayClipAsPuppet( string clip, float rate )
{
if ( State == ZombieState.Dead ) return;
if ( string.IsNullOrEmpty( clip ) ) return;
PlayAction( new List<string> { clip }, rate );
}
private void TickPuppet()
{
// ⛔ A DEAD PUPPET RUNS THE FULL CORPSE TICK, RE-LANDED ALONE 2026-09-10.
//
// This was reverted once, because players stopped seeing each other in the same build. That
// regression has since been found and it was nothing to do with this: bodies were cloned
// from a LIVE scene object and inherited the engine's first-person hiding. The corpse fix
// was never implicated; it was only ever adjacent.
//
// ⚠️ WITHOUT IT THE DEATH CLIP WRAPS. The dead branch used to maintain the playback rate
// and return, so nothing ever stopped the clock — the corpse played its death, reached the
// end, and started again, for the whole linger. `TickCorpse` is what settles the clip at
// 98% and pins the pose. User: *"they keep looping the death animation until the ragdoll
// despawns."*
if ( State == ZombieState.Dead )
{
TickCorpse();
return;
}
var here = WorldPosition;
var travelled = (here - _puppetLast).WithZ( 0 ).Length;
_puppetLast = here;
if ( !_renderer.IsValid() ) return;
// ⚠️ A PRATFALL HOLDS THE POSE, as it does on the host (`FreezeAsPuppet`) — no walk cycle, and no footsteps, while it
// lies there.
if ( Time.Now < _puppetFrozenUntil )
{
_renderer.PlaybackRate = 0f;
return;
}
// ⚠️ A ONE-SHOT STILL OWNS THE RENDERER, same as the real tick — a death or attack
// clip relayed to a puppet must be allowed to finish rather than being overwritten by the
// walk loop on the next frame.
if ( ActionPlaying )
{
_renderer.PlaybackRate = _actionRate;
return;
}
PlaySequence( _walkSequence );
var speed = Time.Delta > 0f ? travelled / Time.Delta : 0f;
var authored = _clipGroundSpeed > 1f ? _clipGroundSpeed : MoveSpeed;
_renderer.PlaybackRate = authored > 1f
? Math.Clamp( speed / authored, 0.05f, AnimRateCap )
: 1f;
// ⛔ THE HORDE WAS SILENT ON EVERY SCREEN BUT THE HOST'S, AND NONE OF IT NEEDED THE
// NETWORK. Footsteps and idle groans are PRESENTATION — a puppet has the same model, the
// same clip and the same position, so it can make its own noise from its own animation
// with nothing sent at all. They were simply below the `return` that separates a puppet
// from a thinking zombie. User: *"a lot of sounds still missing, the zombie steps and
// voices."*
//
// ⚠️ THIS IS THE PATTERN INSTRUCTIONS.md ALREADY RECORDS — *"a puppet should run every
// line that is presentation, and only skip authority"*. Relaying each footstep would have
// been a message per foot per zombie per machine for something both already know.
//
// ⚠️ `_puppetSpeed` IS WHY THE SPRINT VOICE STILL SPLITS. `TickVoice` picks its cue off
// `Velocity`, which a puppet does not have — its motion arrives as a transform — so
// without this every hellhound on a client would use its walking voice.
_puppetSpeed = speed;
TickFootsteps();
TickVoice();
FollowVoices();
}
/// <summary>How fast a PUPPET is moving, measured from its transform. Zero on the host.</summary>
private float _puppetSpeed;
/// <summary>
/// Ground speed for choosing a voice, whichever kind of zombie this is.
///
/// ⚠️ A PUPPET HAS NO `Velocity`. Its position is replicated, not simulated, so the
/// rigidbody reads zero however fast it is crossing the map.
/// </summary>
private float VoiceSpeed => IsPuppet ? _puppetSpeed : Velocity.WithZ( 0 ).Length;
protected override void OnEnabled() => All.Add( this );
protected override void OnDisabled() => All.Remove( this );
/// <summary>Model to render. Until ZombieVariant assets exist, fall back to
/// the ported walker so a spawned zombie is actually visible.</summary>
[Property] public Model BodyModel { get; set; }
// No BaseWalkSpeed. moo:73 does declare ENT.WalkSpeed = 100, but the walker
// that actually ships overrides RunSpeed outright from the round curve
// (nz_zombie_walker_derriese:772) and then again from the clip's ground
// speed (moo:454), so the 100 never reaches a zombie. Adding it here made
// round-1 zombies sprint. Speed now comes from WalkerGroundSpeeds.
/// <summary>How hard they can change velocity. High on purpose — a zombie
/// that carries momentum reads as sliding on ice. GMod base: 500 accel,
/// 900 decel (moo:43-76); we use the higher value for both.</summary>
[Property] public float Acceleration { get; set; } = 900f;
/// <summary>
/// Turn rate in degrees/sec. The original scales it with speed —
/// MaxYawRate + speed * 0.85 (moo:454-464) — so faster zombies also turn
/// faster, and that relationship is kept.
///
/// 210 -> 180 degree turn in 0.86s (the original's value)
/// 720 -> 180 degree turn in 0.25s
/// 2100 -> 180 degree turn in 0.09s (snaps, looks robotic)
///
/// ⚠️ BACK TO THE ORIGINAL'S 210. This was raised to 720 because 210 "looked
/// sluggish" — but that judgement was made against the unitless-lerp bug
/// noted in FaceMovement, and once that was fixed to a real angular velocity
/// nobody re-tested the constant. 720 is two full rotations a second, and
/// combined with a path that only refreshes every ~1s (see RepathInterval)
/// it read as a zombie walking the wrong way and then snapping round.
///
/// A shambling corpse should be visibly slow to come about. Tune live with
/// `nz_zombie_turn`; this is game feel, so it is meant to be argued with.
/// </summary>
[Property, Range( 90f, 2000f )] public float MaxYawRate { get; set; } = 210f;
/// <summary>
/// Corrects which way the MODEL faces relative to its GameObject.
///
/// The walker needed import_rotation [0,0,90] in ModelDoc to stand upright,
/// which also changes where its bind pose points. Rather than bake a guess
/// into the converter, tune this live in the Inspector while playing —
/// try 90, -90 or 180 — then tell me the value and I'll bake it in.
/// </summary>
[Property, Range( -180f, 180f )]
public float ModelYawOffset { get; set; } = 0f;
/// <summary>The same correction about the other two axes. See `ZombieVariant.ModelPitchOffset`.</summary>
[Property, Range( -180f, 180f )]
public float ModelPitchOffset { get; set; } = 0f;
/// <summary>The same correction about the other two axes. See `ZombieVariant.ModelPitchOffset`.</summary>
[Property, Range( -180f, 180f )]
public float ModelRollOffset { get; set; } = 0f;
/// <summary>
/// The rig's own orientation correction, as one rotation.
/// </summary>
///
/// ⛔ ONE HELPER, READ BY EVERY FACING IN THIS CLASS. `FaceMovement` and the vault both build a
/// rotation from scratch, and the vault has already shipped once with the yaw offset missing —
/// the zombie climbed the barricade with its back to it, a clean 180 out. A second place that
/// composes this by hand is a second chance to leave one of the three axes behind.
///
/// ⚠️ IDENTICAL TO `Rotation.FromYaw( ModelYawOffset )` WHEN THE OTHER TWO ARE ZERO, which is
/// every model shipped so far — `Angles.ToRotation()` is `Rotation.From( pitch, yaw, roll )`.
/// <summary>
/// The most one connected swing can deal, in health; 0 for no cap. Oberon's: two-thirds of a player's base health
/// (`OberonBoss.HitCap`), pushed from his start and every frame after.
/// </summary>
///
/// ⛔ IN PLACE OF `LethalMelee`, WHICH KILLED OUTRIGHT — *"we cant have any attack insta kill like that"* (2026-09-27). His
/// swipe now hits as the round curve says, times his variant's multiplier, and never more than this.
///
/// ⚠️ A CAP, NOT A SMALLER `DamageMultiplier`, for the reason the flag was a flag: that multiplier is read by every special
/// he has — `OberonBoss.Blast` scales the leap, the hole and all thirty bombs by it — so tuning the swipe through it
/// would retune them all.
public float MaxHitDamage { get; set; }
/// <summary>
/// A multiplier on its swing for now, on top of the variant's (2026-10-07, the third batch of bosses): the Sawrunner's
/// enraged chainsaw (135 against 75). 1 normally; whoever raises it puts it back. Oberon's cap still holds.
/// </summary>
public float SwingScale { get; set; } = 1f;
/// <summary>
/// A forward lean laid on top of the facing, in degrees. 0 is upright.
/// </summary>
///
/// ⛔ IT IS NOT PART OF `ModelTurn`, AND MUST NOT BE FOLDED INTO IT. `ModelTurn` is
/// `Angles( pitch, yaw, roll )` — for Oberon 9 / 116 / -55 — and adding to its pitch
/// component rotates inside that composition, which with a roll of -55 tips him sideways as
/// much as forward. This is applied OUTSIDE, in the facing's own frame, so it is a clean lean
/// about the body's right axis whatever correction the rig needs.
///
/// ⚠️ WHOEVER SETS IT OWNS CLEARING IT. `FaceMovement` bails out when there is no direction
/// to face — a target that dies mid-move — and a lean left set at that moment would freeze
/// the body tipped over. `OberonBoss` writes the upright rotation itself when its move ends
/// rather than trusting the next frame to do it.
public float LeanPitch { get; set; }
public Rotation ModelTurn
=> new Angles( ModelPitchOffset, ModelYawOffset, ModelRollOffset ).ToRotation();
/// <summary>
/// Seconds for the desired facing to catch up to the steering direction.
///
/// Separate from MaxYawRate and they do different jobs: the rate limits how
/// fast the body CAN come about, this limits how abruptly the thing it is
/// aiming at can move. Without it a repath moves the target in one step and
/// the zombie turns at full rate to meet it — mechanically smooth, still
/// reads as a snap. 0 disables it.
/// </summary>
[Property, Range( 0f, 1f )] public float FaceSmoothing { get; set; } = 0.2f;
private Vector3 _faceDir;
/// <summary>
/// The DMX build, not the FBX one.
///
/// FBX is an interpreted format for ModelDoc — it guesses the unit scale and
/// the bone axis convention, and it gets the second wrong for a Blender rig,
/// mirroring the animation across the YZ plane so knees bend forwards. DMX
/// states both explicitly. See INSTRUCTIONS.md Step 3b.
///
/// walker_honorguard.vmdl (FBX) is still on disk but is NOT the good one.
/// </summary>
private const string DefaultBodyModel = "models/zombies/walker_honorguard_dmx.vmdl";
/// <summary>Take the variant's body dimensions, where it states any.
///
/// ⛔ A QUADRUPED WAS GETTING A STANDING HUMAN'S CAPSULE. BodyHeight 72 /
/// BodyRadius 9 around a hound whose bounds are 23 x 71 x 60 meant shots
/// passed through the visible dog and hit a thin column near its middle —
/// user: "cannot be hit by bullets". Null fields leave the walker's value, so
/// this is inert for every variant that does not set them.</summary>
private void ApplyVariantBody()
{
if ( Variant is null ) return;
// ⛔ BRUTUS'S HELMET IS ATTACHED HERE, because this is the one place that runs after the
// variant is known and before anything renders — which is what the block below needs too.
// The component reads `Health.Max` in its OnStart, so it must exist by then.
//
// ⚠️ KEYED ON THE MODEL, NOT ON `IsBoss`. A future boss with no helmet would otherwise
// silently inherit a damage table built around one, and the table is the harshest thing in
// the game — body damage at 15%. Bosses are not all Brutus.
if ( Variant.IsBoss
&& Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "brutus" ) ?? false)
&& !Components.Get<BrutusHelmet>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<BrutusHelmet>();
// ⛔ THE NAPALM ZOMBIE GETS ITS FIGHT THE SAME WAY, AND DELIBERATELY NOT VIA `IsBoss`. It is
// a special, not a boss, so the condition above cannot be widened to cover it — and its
// mechanics are no more expressible as a `ZombieBehaviour` enum entry than Brutus's helmet
// was. Keyed on the MODEL for the same reason the helmet is: a future suicide bomber with a
// different body should not silently inherit this one's blast radius.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "napalm" ) ?? false)
&& !Components.Get<NapalmZombie>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<NapalmZombie>();
// ⚠️ SAME KEYING AS THE NAPALM ABOVE, AND THE TWO CANNOT COLLIDE even though they share one
// source .mdl: the models are written out separately as `napalm.vmdl` and `shrieker.vmdl`,
// so the path test stays exact. If a future variant ever points both at one .vmdl this is
// the line that would quietly give a Shrieker a fuse.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "shrieker" ) ?? false)
&& !Components.Get<ShriekerZombie>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<ShriekerZombie>();
// ⚠️ OBERON, KEYED ON THE MODEL LIKE THE THREE ABOVE. His component owns the leap, the hole
// and the bomb barrage — timed events inside long clips, which a `.zvar` has no way to say —
// and the phase change at half health. The ordinary swing stays with `ZombieAI`, so this
// adds a moveset rather than replacing one.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "oberon" ) ?? false)
&& !Components.Get<OberonBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<OberonBoss>();
// ⚠️ SHREK, KEYED ON THE MODEL LIKE THE FOUR ABOVE. His body is Brutus's (`shrek.vmdl` carries Brutus's skeleton and
// clips), so his path must not say "brutus", or he would take the helmet. It says "shrek", and his component adds
// the summon.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "shrek" ) ?? false)
&& !Components.Get<ShrekBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<ShrekBoss>();
// ⚠️ THE MARGWA (2026-10-06), KEYED ON THE MODEL LIKE THE FIVE ABOVE: all five of its variants are `margwa.vmdl` in one
// skin or another. Its component owns the heads, the mouths, the damage table, the slam and the element's attack.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "margwa" ) ?? false)
&& !Components.Get<MargwaBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<MargwaBoss>();
// ⚠️ AVOGADRO (2026-10-06), KEYED ON THE MODEL LIKE THE SIX ABOVE. His component owns the dashes, the bolt, the shockwave,
// the knife's stun and his x0.1 damage table.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "avogadro" ) ?? false)
&& !Components.Get<AvogadroBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<AvogadroBoss>();
// ⚠️ THE ASTRONAUT (2026-10-06), KEYED ON THE MODEL LIKE THE SEVEN ABOVE. His component owns the grab and the headbutt
// (which replace the swing), his temper, and the blast he dies in.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "astronaut" ) ?? false)
&& !Components.Get<AstronautBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<AstronautBoss>();
// ⚠️ THE DIRECTOR (2026-10-06), KEYED ON THE MODEL LIKE THE EIGHT ABOVE. His component owns the rage, the roar and the
// slam, the buffs he screams onto the horde, his lamp, his x0.095 table and the perk his killer gets.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "director" ) ?? false)
&& !Components.Get<DirectorBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<DirectorBoss>();
// ⚠️ THE PANZER SOLDAT (2026-10-06), KEYED ON THE MODEL LIKE THE NINE ABOVE — the whole file name, so his claw's model
// (`panzer_claw.vmdl`) could never take it. His component owns the armour table, the flamethrower, the claw or taser.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/panzer.vmdl" ) ?? false)
&& !Components.Get<PanzerBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<PanzerBoss>();
// ⚠️ BRENNER (2026-10-07): the flamethrower, the walk/run pace, the enrage, the stagger and his x0.25 table. Keyed on the model like the others.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/brenner.vmdl" ) ?? false)
&& !Components.Get<BrennerBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<BrennerBoss>();
// ⚠️ THE PANZERHUND (2026-10-07), both bodies (`panzerhund.vmdl`, `panzerhund3.vmdl` — the key is their common prefix): the flame lunge, its spawn protection and its death blast. Keyed on the model like the others.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/panzerhund" ) ?? false)
&& !Components.Get<PanzerhundBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<PanzerhundBoss>();
// ⚠️ THE SAWRUNNER (2026-10-07): the sprints, the music and the saw's idle, his x0.5, the perks at his death. Keyed on the model like the others.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/sawrunner.vmdl" ) ?? false)
&& !Components.Get<SawrunnerBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<SawrunnerBoss>();
// ⚠️ ZABALLA (2026-10-07): the mine teleport, the masks, the pulse, his x0.75. Keyed on the model like the others.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/zaballa.vmdl" ) ?? false)
&& !Components.Get<ZaballaBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<ZaballaBoss>();
// ⚠️ THE MANGLER (2026-10-07): the arm cannon and its homing shot, the helmet/chest/cannon-arm table, the enrage, the death blast. Keyed on the model like the others.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/mangler.vmdl" ) ?? false)
&& !Components.Get<ManglerBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<ManglerBoss>();
// ⚠️ THE TESLA ZOMBIE (2026-10-07): the sonic scream (a daze, the walkers round him stunned and sprinting), his light, the standing swing. Keyed on the model like the others.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/tesla.vmdl" ) ?? false)
&& !Components.Get<TeslaBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<TeslaBoss>();
// ⚠️ THE PANZERMORDER (2026-10-07, `meatflower.vmdl`): the four bars and three knock-downs, the roar, the ground slam, the burst and the perks at its death. Keyed on the model like the others.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/meatflower.vmdl" ) ?? false)
&& !Components.Get<PanzermorderBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<PanzermorderBoss>();
// ⚠️ THE MEÚCHLER (2026-10-07), both bodies (`meuchler.vmdl`, `meuchler_elite.vmdl` — the key is their common prefix): the ambush crawl, the enrage, the shove, the later blow and his x0.085 from bullets. Keyed on the model like the others.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/meuchler" ) ?? false)
&& !Components.Get<MeuchlerBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<MeuchlerBoss>();
// ⚠️ THE MEGATON TRIO (2026-10-07): `megaton.vmdl`, `megaton_blaster.vmdl`, `megaton_bomber.vmdl` — one component, which tells them apart by the model: the blast, the bomb and its gas, the split into the two halves and their tear-apart, the sprints, x0.15 / x0.2. Keyed on the model like the others.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/megaton" ) ?? false)
&& !Components.Get<MegatonBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<MegatonBoss>();
// ⚠️ THE THRASHER (2026-10-07): the spore sacs (his table; bones folded away), the rage, the burrow, both meals, the shove. Keyed on the model like the others.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/thrasher.vmdl" ) ?? false)
&& !Components.Get<ThrasherBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<ThrasherBoss>();
// ⚠️ THE MIMIC (2026-10-07): the tentacle grab (held on the victim's own machine, `MimicGrab`), the spit, the rage, its x0.4/x0.75 table and its burst. Keyed on the model like the others.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/mimic.vmdl" ) ?? false)
&& !Components.Get<MimicBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<MimicBoss>();
// ⚠️ THE KRASNY SOLDAT (2026-10-07): the armour table, the flamethrower, the fire bomb, the jetpack lunge, the enrage. Keyed on the model like the others.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/krasny.vmdl" ) ?? false)
&& !Components.Get<KrasnyBoss>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<KrasnyBoss>();
// ⚠️ THE SIZZLER (2026-10-07, a special): the ignite and its sprint, the trail, the fire steps, the death blast, its own window traverse. Keyed on the model like the others.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/sizzler.vmdl" ) ?? false)
&& !Components.Get<SizzlerZombie>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<SizzlerZombie>();
// ⚠️ THE WÜSTLING (2026-10-07, a special): the charge and its slam, the weak spot on his back, the stun shake, the standing swing. Keyed on the model like the others.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/wustling.vmdl" ) ?? false)
&& !Components.Get<WustlingZombie>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<WustlingZombie>();
// ⚠️ THE BOMBER ZOMBIE (2026-10-07, a special): the bomb on its back and its harness, the fuse sprint, the kamikaze, the loose bomb, the treasure bomber. Keyed on the model like the others.
if ( Variant.Models.Count > 0
&& (Variant.Models[0].Model?.ResourcePath?.Contains( "zombies/bomber.vmdl" ) ?? false)
&& !Components.Get<BomberZombie>( FindMode.EverythingInSelf ).IsValid() )
Components.Create<BomberZombie>();
if ( Variant.BodyHeight.HasValue ) BodyHeight = Variant.BodyHeight.Value;
if ( Variant.BodyRadius.HasValue ) BodyRadius = Variant.BodyRadius.Value;
if ( Variant.AttackRange.HasValue ) AttackRange = Variant.AttackRange.Value;
if ( Variant.HitRadius.HasValue ) HitRadius = Variant.HitRadius.Value;
// ⛔ SCALE LANDS AFTER THE DIMENSIONS AND MULTIPLIES THEM, so the `.zvar` states the model's
// own numbers once and the scale is the only thing anyone edits. Pre-multiplied values in
// the asset would mean two numbers to keep in step and a silent mismatch when one moved.
//
// ⛔ `BodyRadius` IS ABSENT ON PURPOSE — see `ZombieVariant.ModelScale`. It is the nav
// agent's radius, not a body dimension, and widening it is what left Brutus standing
// perfectly still with a flawless speed chain.
ApplyModelScale( Variant.ModelScale );
// Not a body dimension, but it belongs to the model the same way the
// capsule does — and it has to land before anything renders.
if ( Variant.ModelYawOffset != 0f ) ModelYawOffset = Variant.ModelYawOffset;
if ( Variant.ModelPitchOffset != 0f ) ModelPitchOffset = Variant.ModelPitchOffset;
if ( Variant.ModelRollOffset != 0f ) ModelRollOffset = Variant.ModelRollOffset;
// ⛔ THE SPAWN ROTATION GETS THE RIG'S CORRECTION HERE, AND NOWHERE ELSE. The spawner writes
// `WorldRotation` from the spawn point before this component starts, so it cannot compose a
// correction it has not read yet — the first attempt at this lived in `SpawnBossAt` and
// multiplied by identity, which is a fix that looks right in a diff and does nothing.
// `OnStart` is the first moment the offsets exist.
//
// ⚠️ WITHOUT IT THE ENTRANCE PLAYS AT THE WRONG ANGLE and snaps when `FaceMovement` takes
// over — one invisible frame on a walker, six visible seconds on a boss with an entrance.
//
// ⚠️ IDEMPOTENT BY CONSTRUCTION: rebuilt from the YAW alone, so running twice cannot stack
// the correction. Spawn points are authored yaw-only; any pitch or roll on this transform at
// this moment is a correction being re-derived, not data worth keeping.
WorldRotation = Rotation.FromYaw( WorldRotation.Angles().yaw ) * ModelTurn;
// HeadClearance is subtracted from BodyHeight to cap the capsule. On a
// low body the walker's 18 would invert it, so clamp rather than expose
// another field nobody would know to set.
if ( HeadClearance > BodyHeight * 0.5f )
HeadClearance = BodyHeight * 0.25f;
}
/// <summary>One random entry from the variant's model list, or null when no
/// variant is assigned. The original rolls a {Model, Skin} pair per zombie
/// (moo:175-184) so a horde is not 30 copies of one body.
///
/// ⚠️ Skin IS applied since 2026-10-05 (`ApplySkin`); RandomiseBodygroups still is
/// not. The walker skins' models carry their GMod skin families as `skinN`
/// material groups, written by `Tools/walker_skin_port.py`.</summary>
private ZombieModelEntry PickModelEntry()
{
var list = Variant?.Models;
if ( list is null || list.Count == 0 ) return null;
// Skip entries with an empty Model slot rather than rendering nothing —
// a half-filled list in the asset editor is a typo, not an instruction.
var usable = list.Where( m => m?.Model is not null ).ToList();
if ( usable.Count == 0 ) return null;
return Game.Random.FromList( usable );
}
/// <summary>
/// The rolled entry's skin: its GMod skin family, which the port wrote as the model's `skinN` material group.
///
/// ⚠️ ON THE RENDERER'S OWN PROPERTY, NOT THE SCENE OBJECT. The host rolls the entry, and the renderer travels in the
/// zombie's network spawn (`ZombieCommands.SpawnAt` spawns it after `OnStart`), so every client draws the same skin. A
/// skin set on the scene object would stay on the host's screen.
///
/// ⚠️ A FAMILY THE MODEL HAS NO GROUP FOR IS SAID, NOT GUESSED: the zombie keeps skin 0.
/// </summary>
static void ApplySkin( SkinnedModelRenderer renderer, Model model, ZombieModelEntry entry )
{
if ( entry is null || entry.Skin <= 0 || model is null ) return;
var group = "skin" + entry.Skin;
if ( model.GetMaterialGroupIndex( group ) < 0 )
{
Log.Warning( $"[ZombieAI] {model.ResourcePath} has no material group '{group}' — skin 0 instead" );
return;
}
renderer.MaterialGroup = group;
}
/// <summary>Give the zombie something to look at. Uses SkinnedModelRenderer
/// because the walker is a rigged, animated model.</summary>
private void EnsureBody()
{
if ( Components.Get<SkinnedModelRenderer>() is not null ) return;
// ⛔ THE VARIANT'S MODEL LIST WINS, AND FOR A LONG TIME IT DIDN'T. Every
// OTHER field of a ZombieVariant was honoured — speed multiplier, health,
// damage, jitter, and the whole animation tier table — so a .zvar read as
// fully wired while this one line still hardcoded `BodyModel ?? walker`.
// The first variant ever authored (the hellhound) therefore spawned with
// dog SPEEDS and dog CLIPS on a WALKER body, which looks exactly like
// "the variant was ignored". If a zombie is wearing the wrong body, check
// here before you doubt the asset.
var entry = PickModelEntry();
var source = entry?.Model is not null ? entry.Model.ResourcePath : DefaultBodyModel;
var model = entry?.Model ?? BodyModel ?? Model.Load( DefaultBodyModel );
// Model.Load returns null on a bad path, but a compiled-but-broken vmdl
// comes back as the ERROR MODEL instead — which renders happily as a
// checkerboard and would otherwise look like "the model works, the
// animations don't". Check both or you misdiagnose the next problem.
if ( model is null || model.IsError )
{
Log.Warning( $"[ZombieAI] could not load {source} "
+ $"({(model is null ? "null" : "error model")}) — recompile it in ModelDoc" );
return;
}
var renderer = Components.Create<SkinnedModelRenderer>();
renderer.Model = model;
ApplySkin( renderer, model, entry );
EnsureHitDetection( renderer );
// Direct sequence playback requires the animgraph out of the way. The
// API docs are explicit that Sequence "allows playback of sequences
// directly, rather than using an animation graph. Requires disabled if
// the scene model has one." The walker has none (anim_graph_name = "")
// but setting it states the intent and costs nothing.
renderer.UseAnimGraph = false;
// Cross-fade between sequences instead of hard-cutting.
//
// This is the whole difference between our transitions and GMod's. The
// nZombies Lua plays attacks with SetSequence + ResetSequenceInfo +
// SetCycle(0) and waits out the length — functionally identical to
// PlayAction. What makes it look smooth is SOURCE 1, not the gamemode:
// C_BaseAnimating runs a sequence transitioner that keeps the outgoing
// sequence as a fading layer for ~0.2s on every change. s&box's direct
// Sequence playback has no equivalent, so a swap is a hard cut. This is
// the only blend control the non-animgraph path exposes.
//
// ⚠️ Not a full substitute for an AnimGraph, which is where real
// transition/layer blending lives (UseAnimGraph = true). If this alone
// isn't enough, the animgraph is the honest fix, not more C#.
renderer.Sequence.Blending = true;
_renderer = renderer;
ReportModelAnimations( model );
}
/// <summary>
/// Give the zombie something bullets can hit — per bone, plus a fallback.
///
/// ⚠️ Without this a zombie is invisible to weapons entirely — the bullet
/// trace passes straight through and nothing ever calls IDamageable. A
/// SkinnedModelRenderer draws a model; it does not make it solid.
///
/// TWO shapes, doing different jobs:
///
/// ModelHitboxes — exposes the model's own 46 per-bone hitboxes to
/// traces. These carry TAGS, and BulletTrace's docs say
/// it "traces against hitboxes", with MergeHitboxTags
/// folding the hit box's tags (its own example is
/// "head") into DamageInfo.Tags. That is precisely what
/// Health.IsHeadshot reads, so this component is the
/// entire reason a headshot can pay 100 instead of 50.
///
/// CapsuleCollider — a physical body and a guaranteed-hittable fallback,
/// so a zombie is never unshootable even if the hitbox
/// set fails to load.
///
/// ⚠️ The capsule STOPS BELOW THE NECK on purpose. A capsule spanning the
/// full body is wider than the head hitbox and sits in front of it, so the
/// trace would hit the capsule first and every headshot would silently
/// register as a body shot — the hitboxes would be installed and provably
/// inert. Ending it at HeadClearance below the top leaves the head to the
/// head hitbox alone. Torso hitboxes are wider than the capsule, so they
/// still win there; whichever is hit, the body pays the same.
/// </summary>
private void EnsureHitDetection( SkinnedModelRenderer renderer )
{
// Tag for the collision matrix. Physics filtering in s&box is tag-pair
// based, so nothing here filters on its own — these are the handles the
// matrix rules attach to:
//
// zombie x zombie -> ignore (they pass through each other)
// ragdoll x zombie -> ignore
// ragdoll x player -> ignore
// ragdoll x ragdoll -> ignore (corpses don't pile up on each other)
//
// Everything not listed keeps colliding, so both keep world collision
// and a LIVING zombie still blocks the player.
if ( !GameObject.Tags.Has( "zombie" ) )
GameObject.Tags.Add( "zombie" );
if ( Components.Get<ModelHitboxes>() is null )
{
var boxes = Components.Create<ModelHitboxes>();
boxes.Renderer = renderer;
boxes.Target = GameObject; // what the trace reports, so IDamageable is found
}
// SOLID TO THE PLAYER, NOT TO EACH OTHER — as in the original.
//
// ⚠️ I got this wrong once, so the reasoning is written down. The Lua's
// `collisiongroup = COLLISION_GROUP_WORLD -- This is what allows
// zombies to ignore each other` lines are util.TraceLine PARAMETERS
// (line-of-sight checks that shouldn't be blocked by the horde), NOT
// the entity's group. The entity is COLLISION_GROUP_INTERACTIVE_DEBRIS
// (moo:389), which in Source collides with everything EXCEPT other
// debris. So: zombie<->zombie off, zombie<->player ON.
//
// We get that split for free rather than needing a collision matrix. A
// CapsuleCollider with no Rigidbody is static/keyframed, so two of them
// never resolve against each other — and zombies move by NavMeshAgent,
// not physics, so nothing makes them dynamic. The player's controller
// sweeps against colliders, so the capsule DOES block the player.
//
// Spacing between zombies therefore comes entirely from Separation
// below, exactly as the original relies on steering.
if ( !SolidBody ) return;
if ( Components.Get<CapsuleCollider>() is not null ) return;
// ⛔ DIVIDED BY THE MODEL SCALE, AND WITHOUT THIS THE CAPSULE IS SCALED TWICE. Collider
// geometry is LOCAL to the GameObject, and `ApplyModelScale` has already scaled that object
// — so feeding it world-unit dimensions gives scale². At Brutus's 1.35 that is 1.82×, a
// capsule half again too big swallowing the head hitbox it is carefully shaped to avoid.
//
// ⚠️ THE FIELDS STAY IN WORLD UNITS, which is why the division is here rather than there.
// `AttackRange`, the `BodyHeight + 12` head position and the agent's height are all
// world-space readers; only this one builds local geometry.
var local = _modelScale <= 0f ? 1f : _modelScale;
var capsule = Components.Create<CapsuleCollider>();
capsule.Radius = HitRadius / local;
capsule.Start = Vector3.Up * (HitRadius / local);
capsule.End = Vector3.Up * MathF.Max( HitRadius / local + 1f,
(BodyHeight - HeadClearance) / local );
}
/// <summary>
/// Give the zombie a capsule that blocks the PLAYER (but not other
/// zombies — see EnsureHitDetection for why that split is free). Matches
/// the original's COLLISION_GROUP_INTERACTIVE_DEBRIS.
/// </summary>
[Property] public bool SolidBody { get; set; } = true;
/// <summary>
/// What <see cref="TickPhaseRunner"/> last wrote to the capsule, so it only writes on a
/// change. Null until the first reconcile.
/// </summary>
bool? _capsuleSolid;
/// <summary>
/// Stamin-Up's M3 "Phase Runner" — turn the blocking capsule off so the player walks
/// straight through.
///
/// ⛔ THE CAPSULE IS DISABLED, NOT DESTROYED. It is created once in EnsureHitDetection
/// and that method early-returns when one already exists, so destroying it would make
/// losing the augment permanent — the zombie would never get its body back.
///
/// ⛔ AND THE CAPSULE IS ALSO THE HIT FALLBACK, which is the one real cost here. Its own
/// doc calls it "a guaranteed-hittable fallback, so a zombie is never unshootable even
/// if the hitbox set fails to load". While phasing, that fallback is gone and shooting
/// depends entirely on `ModelHitboxes`. Those demonstrably work — headshots pay 100, so
/// the 46 per-bone boxes are live — but if a zombie variant ever ships with a broken
/// hitbox set it would be unshootable ONLY while someone owned M3, which is about the
/// worst bug report to receive. Written down rather than discovered.
///
/// ⚠️ WRITES ONLY ON A CHANGE. Assigning `Enabled` on a collider re-registers it with
/// the physics scene; doing that every frame for 35 zombies would be real work for no
/// effect. `_capsuleSolid` is the last-written value, not a re-read of the component, so
/// a hotload that nulls it simply forces one reconcile.
///
/// ⚠️ RESPECTS `SolidBody`. A variant authored non-solid stays non-solid — the augment
/// can only take solidity away, never grant it.
/// </summary>
void TickPhaseRunner()
{
var capsule = Components.Get<CapsuleCollider>( FindMode.EverythingInSelf );
if ( !capsule.IsValid() ) return;
var solid = SolidBody && !NZombies.StaminUpAugments.PhaseRunnerActive();
if ( _capsuleSolid == solid ) return;
_capsuleSolid = solid;
capsule.Enabled = solid;
}
/// <summary>
/// How hard zombies steer away from crowding each other.
///
/// The engine's own words for NavMeshAgent.Separation: "the separation
/// factor used to control how strongly agents avoid crowding each other" —
/// the direct equivalent of the original's per-zombie
/// loco:SetAvoidAllowed. Since nothing is solid, this is the ONLY thing
/// keeping a horde spread out.
/// </summary>
[Property, Range( 0f, 2f )] public float Separation { get; set; } = 1f;
private static bool _reportedAnims;
/// <summary>
/// Log what the compiled model ACTUALLY contains, once per session.
///
/// "No animation" has three possible causes — the FBX, the vmdl, or this C#
/// — and they're indistinguishable from in-game. This settles the model's
/// half of it: if the count is 0 the vmdl didn't compile the AnimationList,
/// and no amount of C# will help.
/// </summary>
private static void ReportModelAnimations( Model model )
{
if ( _reportedAnims ) return;
_reportedAnims = true;
Log.Info( $"[ZombieAI] {model.Name} — {model.AnimationCount} animations" );
// Enumerate by index rather than via SequenceNames: GetAnimationName is
// a documented (int) -> string, so there's no collection type to guess.
for ( int i = 0; i < model.AnimationCount; i++ )
Log.Info( $"[ZombieAI] [{i}] {model.GetAnimationName( i )}" );
if ( model.AnimationCount == 0 )
Log.Warning( "[ZombieAI] model has NO animations — the vmdl's "
+ "AnimationList is missing or failed to compile" );
ReportModelHitboxes( model );
}
/// <summary>
/// Log the model's hitbox set.
///
/// ⚠️ Worth the few lines: hitboxes fail SILENTLY. A missing HitboxSetList
/// doesn't error, doesn't warn, and doesn't stop the zombie being shootable
/// — the capsule still catches the bullet. It just means every hit is a body
/// hit forever, and the only symptom is that headshots never pay 100. This
/// turns that into a number you can read in the console.
/// </summary>
/// <summary>
/// Print every hitbox on a live zombie and the tags it carries.
/// `nz_hitbox_tags`.
///
/// ⛔ THE POINT IS TO STOP GUESSING TAG NAMES. Health.PartMultiplier matches
/// tags by string; a name it does not recognise silently scores 1.0, which
/// looks exactly like "the multiplier does not work". Run this once per
/// zombie model and make the lists in Health.cs match reality.
/// </summary>
[ConCmd( "nz_hitbox_tags" )]
public static void DumpHitboxTags()
{
var zombie = Game.ActiveScene?.GetAllComponents<ZombieAI>()?.FirstOrDefault( z => z.IsValid() );
if ( zombie is null ) { Log.Info( "[hitbox] no zombie in the scene" ); return; }
var model = zombie._renderer?.Model;
var boxes = model?.HitboxSet?.All;
if ( boxes is null || boxes.Count == 0 )
{
Log.Info( $"[hitbox] {model?.Name} has NO hitboxes — every hit is a body hit" );
return;
}
Log.Info( $"[hitbox] {model.Name} — {boxes.Count} hitboxes" );
foreach ( var b in boxes )
{
var tags = b.Tags?.TryGetAll();
Log.Info( $"[hitbox] bone={b.Bone.Name,-28} tags=[{(tags is null ? "" : string.Join( ", ", tags ))}]" );
}
}
private static void ReportModelHitboxes( Model model )
{
// HitboxSet is a class (so it can be null) and All is an
// IReadOnlyList<Box> — checked against the assembly metadata, not
// assumed, since '?.' would not even compile against a struct.
var count = model.HitboxSet?.All?.Count ?? 0;
if ( count == 0 )
{
Log.Warning( "[ZombieAI] model has NO hitboxes — headshots will "
+ "register as body hits. The vmdl's HitboxSetList is missing "
+ "or failed to compile." );
return;
}
Log.Info( $"[ZombieAI] {model.Name} — {count} hitboxes" );
}
private SkinnedModelRenderer _renderer;
private string _walkSequence;
/// <summary>
/// Movement clips to choose from, by name.
///
/// These are the four baked into the FBX (the QC calls them $animation
/// blocks, a_nz_walk_ad1..ad4); the movement model has 314 to choose from.
///
/// Listed by hand rather than read from the model so a typo or a failed
/// compile is visible in the Inspector. PickAnimations cross-checks them
/// against what the model really has and says so when they disagree.
/// </summary>
[Property] public List<string> WalkSequences { get; set; } = new()
{
"a_nz_walk_ad1", "a_nz_walk_ad2", "a_nz_walk_ad3", "a_nz_walk_ad4",
};
// ── AI/ANIMATION ─────────────────────────────────────────────────────────
/// <summary>
/// Pick this zombie's movement animation ONCE and keep it.
///
/// The original freezes a single random sequence per key for the zombie's
/// lifetime rather than re-rolling per frame (moo:6359-6392) — that's what
/// makes a horde look varied instead of synchronised.
/// </summary>
private void PickAnimations()
{
// A ZombieVariant asset wins if one is assigned and has clips for this
// tier. Otherwise fall back to the generated table, which groups the
// walker's 98 locomotion clips by the same speed thresholds — so a
// zombie's animation matches how fast it actually moves without anyone
// having to author a .zvar first.
// ⚠️ THE OVERRIDE WINS OVER THE ASSET, and only while something is holding it. See
// `MovementOverride` — this is how a boss changes phase without editing a shared file.
var candidates = MovementOverride is { Count: > 0 } mo ? mo : _tier?.MovementSequences;
if ( candidates is null || candidates.Count == 0 )
candidates = WalkerAnimations.ForSpeed( SpeedRating );
if ( candidates is null || candidates.Count == 0 )
candidates = WalkSequences;
// Tier median up front, so a zombie that finds no usable clip still
// walks instead of standing frozen at speed 0.
ApplyGroundSpeed( null );
if ( candidates is null || candidates.Count == 0 ) return;
// Keep only names the model actually has. Without this a stale name
// fails silently — the zombie renders in its bind pose and everything
// looks fine except nothing moves, which is exactly the symptom that
// sent us hunting through the FBX.
var usable = candidates.Where( ModelHasSequence ).ToList();
if ( usable.Count == 0 )
{
Log.Warning( $"[ZombieAI] none of {candidates.Count} sequence names "
+ "exist on the model — check the AnimationList names against "
+ $"nz_anims. Wanted: {string.Join( ", ", candidates )}" );
return;
}
_walkSequence = Game.Random.FromList( usable );
ApplyGroundSpeed( _walkSequence );
}
/// <summary>
/// Re-resolve movement speed from the clip this zombie already has.
///
/// ⚠️ FOR A LIVE RETUNE, and it deliberately does NOT re-pick the clip. `PickAnimations`
/// would hand the zombie a different animation mid-stride; this only re-reads the numbers
/// around the one it is already playing, so `nz_zspeed_scale` changes how fast the horde in
/// front of you moves without any of them visibly snapping to a new gait.
/// </summary>
public void RefreshSpeed() => ApplyGroundSpeed( _walkSequence );
/// <summary>
/// SPEED — take the movement speed from the clip we're playing.
///
/// local speed = self:GetSequenceGroundSpeed( self:GetSequence() ) * multiplier
/// self:SetRunSpeed( speed )
/// self.loco:SetDesiredSpeed( self:GetRunSpeed() ) moo:454-462
///
/// This is the whole reason a round-1 zombie moves at all. It also gives
/// free variety inside a tier — the walk clips range 35-60 u/s, so two
/// round-1 zombies genuinely travel at different speeds.
///
/// SpeedMultiplier lands here rather than on the rating because the
/// original's MovementSpeedMultiplier scales the animation-derived speed
/// (moo:455), i.e. it makes a zombie faster without moving it up a tier.
/// </summary>
/// <summary>
/// Slowest a zombie may travel, whatever its clip says.
///
/// The authored walks run 35-60 u/s and the slowest (`a_nz_walk_au12`, 35.2)
/// is about a fifth of player speed — accurate to the source and, next to a
/// moving player, close to stationary.
///
/// ⚠️ 0 DISABLES IT and restores the pure authored speed. Raising it trades
/// away some of the per-clip variety the ground-speed table exists to give,
/// so it should be as low as gets away with it.
///
/// ⛔ THIS CANNOT FIX A ZOMBIE THAT IS STUCK. It raises what the agent is
/// TOLD to do; a zombie sitting at velocity ~0 with a healthy MoveSpeed is a
/// pathing failure and will sit there just as still with the floor raised.
/// `nz_zspeed` separates the two.
/// </summary>
/// ⚠️ 70, NOT 55 — AND 70 IS NOT ARBITRARY. The round-1 walk clip is authored
/// at ~35 u/s and UpdateAnimation clamps playback to 2x, so 70 is the fastest
/// a round-1 zombie can travel with its legs still keeping up. 55 left them at
/// HALF a walking player's 110, which is why they could be strolled past.
/// Anything above 70 here buys speed with foot skate.
[Property, Range( 0f, 120f )] public float MinMoveSpeed { get; set; } = 55f;
/// <summary>
/// The clip's own authored ground speed, BEFORE the floor.
///
/// ⚠️ Kept separately because the playback rate has to divide by this, not by
/// MoveSpeed — see UpdateAnimation. Floor a 35 u/s clip up to 55 and divide
/// by 55 and the rate comes out 1.0, so the legs animate at their authored
/// pace while the body travels 1.6x faster: textbook foot skate.
/// </summary>
private float _clipGroundSpeed;
/// <summary>
/// Extra ground speed per round, in units/sec, on top of the animation tier.
///
/// ⛔ WITHOUT THIS, ZOMBIES NEVER GET FASTER IN ABSOLUTE TERMS. The round
/// curve (ZombieStats.SpeedForRound) is only a TIER SELECTOR — it picks which
/// animation set plays, and velocity comes from that clip's authored root
/// motion. So speed moved in three steps across forty rounds and sat at ~55
/// u/s for the first nine, against a player who WALKS at 110. Being twice a
/// zombie's speed without even sprinting is why they could only land a hit on
/// someone standing still: no AI or reach tuning can fix an arithmetic gap
/// that large, because the zombie simply never arrives.
///
/// 3 u/s per round reaches the player's walk speed around round 15 — up to
/// there you can walk away from one zombie (correct: the threat is the horde
/// and the corners), after it you have to actually run.
/// </summary>
/// ⚠️ DEFAULT 0 — MOVE SPEED WAS NOT THE PROBLEM. The gap it describes is
/// real (velocity comes from root motion, so the round curve never changed it)
/// but raising it was MY diagnosis, not the reported symptom, and it is not
/// what made zombies avoidable. Left as a lever behind `nz_zspeed_bonus`,
/// changing nothing until someone asks for it.
public static float RoundSpeedBonus { get; set; } = 0f;
/// <summary>
/// Put this zombie on the OUTSIDE of the window it is about to tear at.
/// </summary>
///
/// ⛔ THE MIRROR OF `Barricade.ResolveDrop`, AND FOR THE MIRRORED REASON. A kill at a window
/// drops its reward INSIDE, because a powerup behind intact boards is visible and unreachable.
/// A zombie arriving at one can end up on the player's side of the plane — the agent paths to
/// the nearest point on a mesh that runs through the opening — and then it is swinging from
/// inside the room it has not broken into, hitting the player through boards that are still up.
///
/// ⚠️ IT ONLY EVER MOVES ONE THAT IS ON THE WRONG SIDE. `FarSideOf` returns the position
/// unchanged once it is far enough out, so this costs a plane test on a zombie already standing
/// where it should be — which is nearly all of them, nearly all the time.
///
/// ⚠️ THE AGENT IS TOLD, NOT JUST OUTRUN. Writing `WorldPosition` alone leaves the agent
/// believing the body is where it steered it, and it walks back through the window on the next
/// think — the same trap `BeginArc` and the vault both document.
///
/// ⛔ ONLY IN A ZOMBIE'S FIRST SECOND IN PLAY (2026-09-29, `StandOutsideWindow`): *"a zombie that walks in front of a barricade,
/// gets pulled behind it instantly ... this makes them get stuck ... make it so this only affects zombies that have spawned
/// less than a second ago"*. `BlockingBarricade` is the NEAREST boarded window in reach, not one on the path, so a zombie
/// already inside walking past one counted as blocked, and this put it through the boards to the far side, stuck behind
/// them. It hit the Shrieker most: the fastest thing on the map, always running at somebody past a window.
/// ⚠️ THE TRADE: a zombie that reaches a window from outside after its first second and ends up on the room's side of the
/// plane is no longer put back out.
private void StandOutside( Barricade wall )
{
if ( !wall.IsValid() ) return;
if ( _sinceInPlay >= StandOutsideWindow ) return;
var to = wall.FarSideOf( WorldPosition );
if ( to.Distance( WorldPosition ) < 1f ) return;
WorldPosition = to;
GiveTransformToAgent();
}
static float? _standOutsideWindow;
/// <summary>
/// For how long after coming into play a zombie is still put on the outside of a window (`StandOutside`), in seconds. 1, by
/// the user's number. `nz_stand_outside 0` never does it; a large number is the old behaviour, always.
/// </summary>
public static float StandOutsideWindow { get => _standOutsideWindow ?? 1f; set => _standOutsideWindow = value; }
/// <summary>`nz_stand_outside [seconds]` — the window above, in seconds since a zombie came into play. Bare, what it is now.</summary>
[ConCmd( "nz_stand_outside" )]
public static void StandOutsideCmd( float seconds = -1f )
{
if ( seconds >= 0f ) StandOutsideWindow = seconds;
Log.Info( $"[nz-barricade] a zombie is put on the outside of a window only in its first {StandOutsideWindow:0.##}s in play"
+ (StandOutsideWindow <= 0f ? " — never, now" : "") + " · nz_stand_outside <seconds>" );
}
/// <summary>
/// The window this zombie is HELD outside of, off the navmesh, until its boards are down (`TickParked`). Set by `SpawnAt`
/// for a spawner at a window whose own side the navmesh does not reach (`Barricade.SpawnSideFor`); null for every other
/// zombie (the user, 2026-10-01: *"make it so they are moved to the barricade after spawning, on the side nearest to their
/// spawn point"*).
///
/// ⛔ HELD MEANS THE AGENT IS OFF (`Park`). It enforces the navmesh every frame, and the nearest mesh to a spawn closet with
/// none is the room behind the boards: on, it puts the zombie there, which is the bug. Every hand-over to the agent ends
/// the hold (`GiveTransformToAgent`).
/// </summary>
public Barricade ParkedAt { get; set; }
/// <summary>At the end of the entrance, for a zombie held at a window: the agent off, so nothing moves it off its stand.</summary>
private void Park()
{
var agent = Agent;
if ( agent.IsValid() )
{
agent.UpdatePosition = false;
agent.Enabled = false;
}
if ( DebugSpawn || Watching )
Log.Info( $"[nz-spawn] held outside window #{ParkedAt.Index} at {WorldPosition:0}, off the navmesh, until its boards are down" );
}
/// <summary>
/// A zombie held outside its window: tear it while it is boarded, then climb through it to the room side, where the agent
/// takes over (`TickVault` → `GiveTransformToAgent`). Nothing else — no path, no other window, no wandering.
///
/// ⚠️ THE AGENT IS SWITCHED BACK ON AT THE START OF THE CLIMB, transform still ours (`TakeTransform`), so it has settled by
/// the time the climb hands the body over.
/// </summary>
private void TickParked()
{
var w = ParkedAt;
if ( w.IsOpen )
{
var into = w.RoomSideFor( WorldPosition );
ParkedAt = null;
if ( _agent.IsValid() ) { _agent.UpdatePosition = false; _agent.Enabled = true; }
StartVault( w, into );
return;
}
if ( _untilRetarget <= 0 || !Target.IsValid() ) AcquireTarget();
// ⚠️ THE STAND IS INSIDE THE TEARING REACH (CrossOffset from the run), so `BlockingBarricade` finds the window from
// here, and `CanAttack` needs no sight of anyone while it does.
if ( BlockingBarricade() is not null && CanAttack() )
SetState( ZombieState.Attacking );
}
private void ApplyGroundSpeed( string clip )
{
// ⛔ THE VARIANT'S OWN NUMBER FIRST. WalkerGroundSpeeds is keyed by walker
// clip NAME; asked about `a_nz_dog_run` it returns 0, which floors
// MoveSpeed at MinMoveSpeed and clamps PlaybackRate to 0.05. Any variant
// with its own clips must state its own speed or it plods.
var tierSpeed = _tier?.GroundSpeed ?? 0f;
_clipGroundSpeed =
(tierSpeed > 0f
? tierSpeed
: WalkerGroundSpeeds.For( clip, WalkerAnimations.TierName( SpeedRating ) ))
* (Variant?.SpeedMultiplier ?? 1f)
* ExtraSpeedMultiplier;
// ⛔ AN ABSOLUTE SPEED SHORT-CIRCUITS THE WHOLE CHAIN, INCLUDING THE FLOOR. A boss must move
// the same on round 11 and round 71, and `MinMoveSpeed` would drag a deliberately slow one
// back up to a walker's pace — which is the opposite of heavy.
//
// ⚠️ THE RUNTIME OVERRIDE BEATS THE AUTHORED ONE, because a boss's state changes his speed
// and only one number can live in the asset. See `SpeedOverride`.
var fixedSpeed = EffectiveFixedSpeed;
// ⛔ THE TIER SCALE GOES HERE AND NOWHERE ELSE — on the MOVE speed, deliberately NOT folded
// into `_clipGroundSpeed` eight lines above. The animation rate is
// `clamp( velocity / _clipGroundSpeed, 0.05, MaxAnimRate )`, so scaling both together
// leaves that ratio at 1.0 and the zombie travels faster with its legs cycling at the
// authored pace — a skate. Scaling only this side makes the legs speed up to match,
// exactly the way `MinMoveSpeed` already does for the floored walk clips.
//
// ⚠️ AND IT IS INSIDE THE NON-FIXED BRANCH ONLY. A boss's `FixedSpeed`/`SpeedOverride` is
// an absolute: it bypasses the floor for the reason stated above, and it has to bypass
// this for the same one.
float tierScale = ZombieStats.TierSpeedScale( WalkerAnimations.TierName( SpeedRating ) );
// ⛔ THE VARIANT SCALE MULTIPLIES WHAT A NORMAL ZOMBIE WOULD DO, FLOOR INCLUDED, AND THAT
// ORDER IS THE POINT. `MinMoveSpeed` decides the speed for most of the early game — the
// walk tier's clips are nearly all floored up to it — so folding the scale in before the
// floor lets the floor swallow it, and a pest on round 3 comes out at exactly a walker's
// pace. Multiplying the settled figure makes "1.5x a normal zombie on this round" true on
// every round rather than only the ones where the clips happen to be fast enough.
var normal = MinMoveSpeed > 0f
? MathF.Max( _clipGroundSpeed * tierScale, MinMoveSpeed )
: _clipGroundSpeed * tierScale;
// ⚠️ THE BOSS BRANCH IS UNTOUCHED, for the reason stated above the fixed-speed read: an
// absolute speed is absolute, and a scale applied to it would be a second opinion.
// ⚠️ THE MATCH'S MOVE SPEED (the lobby's Difficulty, 2026-10-05), ON THE NON-FIXED SIDE ONLY for the reason above: a
// boss's absolute stays absolute. The move speed alone, as the tier scale, so the legs speed up to match.
_baseMoveSpeed = fixedSpeed > 0f
? fixedSpeed
: normal * MathF.Max( 0.05f, Variant?.MoveSpeedScale ?? 1f ) * Difficulty.ZombieMoveSpeed;
// ⛔ NO ROUND BONUS AND NO CLIP CAP HERE ANY MORE. Both were added when I
// mis-diagnosed "zombies are avoidable" as a speed problem, and the CAP is
// what put them in slow motion: `Min(MoveSpeed, clipGroundSpeed * 2)`
// drags MoveSpeed BELOW the MinMoveSpeed floor whenever a clip's authored
// ground speed is low — a 20 u/s clip capped a 55 u/s zombie to 40, and a
// 3 u/s one capped it to 6.
//
// ⚠️ The floor exists precisely to rescue slow clips; a cap derived from
// the same clip cancels it out. Keeping both meant the slowest animations
// decided the speed, which is the opposite of what MinMoveSpeed is for.
//
// The bonus lever survives on RoundSpeedBonus (default 0) for anyone who
// wants it, and is applied nowhere until it is asked for.
if ( _agent.IsValid() ) _agent.MaxSpeed = AgentSpeed;
}
// The model carries 272 sequences. Scanning them per candidate clip meant
// ~10,000 string compares per attack, per zombie — so the set is built once
// and shared.
//
// ⛔ ONCE PER MODEL, AND IT WAS ONCE PER *SWITCH OF* MODEL. This was a single static slot keyed
// on one model name, which was right when every zombie wore the walker. It is not any more: a
// Basalt round runs the walker, the Origins knight and templar, and Brutus at the same time,
// and every zombie asking about a DIFFERENT model than the last one threw the set away and
// rebuilt it — a fresh 285-string HashSet and a log line each time. The published client's log
// has 438 rebuilds of the knight and 438 of the templar in one eleven-minute game, rising with
// the round because later rounds spawn more of them, faster.
//
// ⚠️ THE SOURCE COUNT IS STORED, NOT READ BACK OFF THE SET. The set is case-insensitive, so a
// model that lists the same sequence twice in two cases builds a set SMALLER than its list —
// and the old `_sequenceSet.Count != names.Count` test would then have been true on every call,
// rebuilding forever. Comparing against the count the set was built FROM keeps the
// self-healing (a model that finishes streaming in still gets a rebuild) without that trap.
//
// ⚠️ LAZY, NOT A `static readonly` INITIALISER — INSTRUCTIONS pattern 1: static initialisers
// do not re-run on hotload.
private static Dictionary<string, (HashSet<string> Set, int Source)> _sequenceSets;
/// <summary>
/// Does the loaded model expose this sequence?
///
/// ⚠️ Reads Sequence.SequenceNames, NOT model.GetAnimationName. Animations
/// and sequences are DIFFERENT LISTS in Source 2 — an AnimFile adds an
/// animation, and what Sequence.Name accepts is a sequence. Asking the
/// animation list whether a sequence exists is the wrong question, and it
/// answers "yes" often enough to look like it works. SequenceNames is
/// literally the set of strings this renderer will accept.
/// </summary>
private bool ModelHasSequence( string name )
{
if ( !_renderer.IsValid() ) return false;
var model = _renderer.Model;
if ( model is null ) return false;
var names = _renderer.Sequence.SequenceNames;
if ( names is null || names.Count == 0 ) return false;
// ⚠️ Rebuild when the COUNT moves, not just when the model changes.
//
// This cache is static and was keyed on the model name alone, so it was
// built exactly once per session and never revisited. If it was ever
// built while the model was still streaming in, every zombie for the
// rest of the session saw a truncated list — and since the AnimationList
// is written locomotion-first, a truncated list contains the walk clips
// and nothing else. That is precisely the symptom "they walk, but never
// attack, flinch or play a death". Comparing the count is one int
// compare per lookup and makes the cache self-healing.
_sequenceSets ??= new( StringComparer.OrdinalIgnoreCase );
if ( !_sequenceSets.TryGetValue( model.Name, out var entry ) || entry.Source != names.Count )
{
entry = (new HashSet<string>( names, StringComparer.OrdinalIgnoreCase ), names.Count);
_sequenceSets[model.Name] = entry;
Log.Info( $"[ZombieAI] sequence set built: {names.Count} sequences "
+ $"on {model.Name} ({model.AnimationCount} animations)" );
}
return entry.Set.Contains( name );
}
private static readonly HashSet<string> _warnedLists = new();
/// <summary>
/// Complain once per clip list when nothing in it exists on the model.
///
/// This is the failure that produced "they only ever walk": PlayAction
/// returned 0, so the attack fell back to a timer, the flinch was skipped
/// and the death clip never played — the zombie just vanished. All three
/// look like three separate bugs and are one missing-name bug.
/// </summary>
private void WarnNoUsableClips( List<string> clips )
{
var key = clips.Count > 0 ? clips[0] : "empty";
if ( !_warnedLists.Add( key ) ) return;
var have = _renderer.IsValid()
? _renderer.Sequence.SequenceNames?.Count ?? 0 : 0;
Log.Warning( $"[ZombieAI] none of {clips.Count} clips exist on the "
+ $"model (it exposes {have} sequences). First wanted: "
+ $"'{key}'. Run nz_anims to compare." );
}
// ── AI/ONE-SHOT ACTIONS ──────────────────────────────────────────────────
// Attacks, deaths and flinches are one-shots: they play once, and the AI
// waits for them. In the original the behaviour coroutine literally blocks
// on the sequence (moo:4644), which is why "attack pacing is the animation
// length, not a tuned cooldown" (reference doc §9.7b).
private string _actionClip;
private TimeUntil _actionDone;
private float _actionLength;
private bool _swingDamaged;
/// <summary>
/// Was the CURRENT swing chosen from the standing set? Decides whether the
/// zombie plants or keeps closing — see TickAttack. Set in
/// AttackClipsForNow, which is where the standing-vs-moving call is made.
/// </summary>
private bool _standingAttack;
/// <summary>
/// Is a one-shot clip still playing?
///
/// Both halves are needed. IsFinished is the engine's own answer and beats
/// guessing from Duration, but it never becomes true for a LOOPING sequence
/// — one mislabelled clip would freeze that zombie mid-swing forever. The
/// timer is the backstop, so the worst case is a slightly early cut.
/// </summary>
private bool ActionPlaying
{
get
{
if ( string.IsNullOrEmpty( _actionClip ) ) return false;
if ( _actionWrapped ) return false;
if ( _actionDone <= 0f ) return false;
if ( _renderer.IsValid() && _renderer.Sequence.Name == _actionClip
&& _renderer.Sequence.IsFinished ) return false;
return true;
}
}
/// <summary>Highest TimeNormalized this one-shot has reached. Monotonic.</summary>
private float _actionProgress;
/// <summary>Set once the clip's time has jumped BACKWARDS — see TickOneShot.</summary>
private bool _actionWrapped;
/// <summary>
/// Progress of the current one-shot, never decreasing, 0..1.
///
/// Use this instead of `Sequence.TimeNormalized` anywhere the value drives
/// something that must not go backwards — the root-motion curve especially.
/// </summary>
private float ActionProgress => _actionProgress;
/// <summary>
/// Watch the one-shot's clock and catch it WRAPPING.
///
/// ⛔ THIS IS THE "IT PLAYS THE FIRST FRAME AGAIN AT THE END" BUG, and it is
/// one fault behind two symptoms:
///
/// DEATH the clip reaches its end, time wraps to ~0, and the corpse is
/// suddenly standing again — frame 0 of a death IS upright — and
/// the ragdoll then takes over from that pose.
/// SPAWN same wrap, and TickSpawn's completion test is
/// `progress >= 0.99`. A wrap means it never SEES 0.99, so the
/// entrance is not finished, and the clip visibly replays from the
/// start until the backstop timer fires seconds later.
///
/// The clips are all `looping = false` in the .vmdl — verified, all 220
/// one-shots — so this is the playback clock wrapping rather than the model
/// asking for a loop. Rather than fight over why, watch for time going
/// backwards and treat that as "finished", which is what it means.
///
/// ⚠️ Freezes PlaybackRate on the wrap so the pose stops advancing. The frame
/// that already wrapped cannot be un-drawn, but everything after it holds.
/// </summary>
private void TickOneShot()
{
if ( string.IsNullOrEmpty( _actionClip ) || !_renderer.IsValid() )
{
_actionProgress = 0f;
_actionWrapped = false;
return;
}
var seq = _renderer.Sequence;
if ( seq is null || seq.Name != _actionClip ) return;
float p = seq.TimeNormalized;
// A big backwards jump is a wrap. Small jitter is not — the threshold is
// wide enough that a stutter cannot trip it.
if ( !_actionWrapped && p < _actionProgress - 0.25f )
{
_actionWrapped = true;
_actionProgress = 1f;
_renderer.PlaybackRate = 0f;
return;
}
if ( !_actionWrapped && p > _actionProgress )
_actionProgress = p;
}
/// <summary>
/// Start a one-shot clip from a list. Returns its length in seconds, or 0
/// if nothing usable was found — callers fall back to a timer so a missing
/// clip degrades to the old behaviour rather than freezing the zombie.
/// </summary>
/// <summary>
/// Playback rate of the CURRENT one-shot. 1 for deaths and entrances.
///
/// ⚠️ A field, not a local, because UpdateAnimation force-sets PlaybackRate
/// every frame while an action plays — it has to force it to the SAME rate
/// or a sped-up swing snaps back to 1x on the very next frame.
/// </summary>
private float _actionRate = 1f;
/// <param name="relay">
/// Send this clip to every watching machine. TRUE for every one-shot that is played at its
/// final rate — which is all of them but one.
///
/// ⛔ `BeginLinkCross` PLAYS A CLIP PURELY TO MEASURE IT, then re-times it. Relaying from that
/// call sent every client the MEASURING rate of 1x and nothing ever corrected it, so a climb
/// the host ran at ~3x played on the client at the authored five seconds. The body arrives
/// across the barricade in 1.6s either way, because position replicates — so what a client saw
/// was a zombie sliding through the window under a near-frozen climb pose. User:
/// *"there's no mantle animation client side on barricades."*
///
/// ⚠️ IT IS THE ONLY CALLER THAT NEEDS THIS, and it is not free to leave as an accident: a
/// relay sent BEFORE the value it carries is finalised is wrong for every future caller that
/// copies the pattern, not just this one.
/// </param>
private float PlayAction( List<string> clips, float rate = 1f, bool relay = true )
{
if ( clips is null || clips.Count == 0 ) return 0f;
if ( !_renderer.IsValid() ) return 0f;
string pick = null;
for ( int tries = 0; tries < 4 && pick is null; tries++ )
{
var candidate = Game.Random.FromList( clips );
if ( ModelHasSequence( candidate ) ) pick = candidate;
}
// Four random draws can miss when only a handful of names in the list
// are present on the model, so scan before giving up. A one-shot that
// silently does nothing is the difference between a zombie dying on
// screen and one blinking out of existence.
if ( pick is null )
{
var usable = clips.Where( ModelHasSequence ).ToList();
if ( usable.Count > 0 ) pick = Game.Random.FromList( usable );
}
if ( pick is null )
{
WarnNoUsableClips( clips );
return 0f;
}
PlaySequence( pick, restart: true );
_actionRate = rate <= 0.05f ? 1f : rate;
_renderer.PlaybackRate = _actionRate;
float dur = _renderer.Sequence.Duration;
if ( dur <= 0.01f ) dur = 0.6f; // model didn't report one
// ⛔ DIVIDE BY THE RATE. Sequence.Duration is the clip's authored length
// in seconds AT 1x; every timer built on it — the damage point, the
// cooldown, ActionPlaying itself — counts REAL seconds. Left undivided,
// a 2x swing would animate in half the time and then stand frozen for
// the other half waiting for a timer that no longer matched it.
dur /= _actionRate;
_actionClip = pick;
_actionLength = dur;
_actionDone = dur;
// ⚠️ MUST reset, or the next one-shot inherits the previous clip's wrap
// flag and is treated as finished on its first frame — every attack after
// the first death would be skipped.
_actionProgress = 0f;
_actionWrapped = false;
// ⛔ AND EVERY WATCHING MACHINE PLAYS THE SAME CLIP. Here rather than at each caller,
// because every one-shot a zombie plays comes through this method — the swing, the board
// being torn off, the mantle through the window, pain. A relay per animation would have to
// be remembered at each site and would miss the next one added.
//
// ⚠️ NOT FOR A DEATH: `NZNet.ZombieDied` already relays that one, and it carries the
// state change with it. Both firing would restart the death clip a frame in.
if ( relay ) RelayClip();
return dur;
}
/// <summary>
/// Tell every watching machine what this zombie is playing, at the rate it is ACTUALLY playing
/// it. Called from <see cref="PlayAction"/>, and again by any caller that re-times afterwards.
///
/// ⚠️ READS THE FIELDS RATHER THAN TAKING ARGUMENTS, so it cannot be handed a rate that
/// disagrees with the renderer — which is the exact bug it exists to close.
///
/// ⚠️ NOT FOR A DEATH: `NZNet.ZombieDied` already relays that one and carries the state change
/// with it. Both firing would restart the death clip a frame in.
/// </summary>
private void RelayClip()
{
if ( !Networking.IsActive || !NZGame.IsHost ) return;
if ( State == ZombieState.Dead ) return;
if ( string.IsNullOrEmpty( _actionClip ) ) return;
NZNet.ZombieClip( GameObject.Id, _actionClip, _actionRate );
}
/// <summary>
/// The attack clips to swing with right now.
///
/// Speed picks the tier, but the TARGET's motion picks stand-vs-move within
/// it — verified from moo:4586-4591, where a nearly-stationary target in
/// close range forces StandAttackSequences regardless of how fast the
/// zombie is going. So a sprinting zombie still plants for a standing swing
/// at someone holding still.
/// </summary>
private List<string> AttackClipsForNow()
{
// ⛔ THE VARIANT'S ATTACK CLIPS WIN, AND USED NOT TO BE READ AT ALL.
// `AttackSequences` was declared on ZombieVariant and consumed NOWHERE,
// so a hellhound swung with the walker's 40 human clips — none of which
// exist on a dog skeleton. The console said so plainly: "none of 40 clips
// exist on the model. First wanted: 'nz_attack_stand_ad_1'".
//
// No stand/move split here: a variant states one attack set, and the
// dog has exactly one lunge. If a variant ever needs the split, give it
// its own field rather than guessing from clip names.
// ⚠️ SAME PRECEDENCE AS THE MOVEMENT SET ABOVE: the instance first, then the asset.
var fromVariant = AttackOverride is { Count: > 0 } ao ? ao : _tier?.AttackSequences;
if ( fromVariant is not null && fromVariant.Count > 0 )
{
_standingAttack = false;
return fromVariant;
}
if ( Target.IsValid() && TargetSpeed() < 15f
&& WalkerAnimations.AttackStand.Count > 0 )
{
_standingAttack = true;
return WalkerAnimations.AttackStand;
}
// A MOVING attack. The zombie keeps closing while it swings — see
// TickAttack. Recorded here because this is where the decision is
// actually made, and the original reads the same flag back
// (IsStandingAttack) to decide whether to keep approaching.
_standingAttack = false;
return WalkerAnimations.AttackForSpeed( SpeedRating );
}
/// <summary>Target's ground speed, 0 if it doesn't expose one.</summary>
private float TargetSpeed()
{
if ( !Target.IsValid() ) return 0f;
var pc = Target.Components.Get<PlayerController>();
return pc.IsValid() ? pc.Velocity.WithZ( 0 ).Length : 0f;
}
/// <summary>
/// Play a sequence by name.
///
/// <paramref name="restart"/> matters for one-shots: two swings in a row can
/// roll the SAME clip, and without rewinding Time the name is already set,
/// the early-out fires, and the second swing plays nothing — a zombie that
/// hits you from a frozen pose.
/// </summary>
/// <summary>
/// The sequence the model is playing this frame, or empty.
/// </summary>
///
/// ⚠️ READ-ONLY, AND IT EXISTS FOR EFFECTS. Oberon's claw trails are separate models whose own
/// clip has to be fired at a cycle of the swing that is playing — and the swing is chosen by
/// `PickAttack` out of a list, so nothing outside this class can otherwise know which of the two
/// it got. The alternative was alternating the effects and hoping.
public string CurrentClip => _renderer.IsValid() ? _renderer.Sequence.Name : "";
/// <summary>
/// How long the sequence being played actually lasts, in seconds at rate 1.
/// </summary>
///
/// ⛔ THE CLIP AS PLAYED IS THE AUTHORITY, NOT frames ÷ fps. An ability that times its damage
/// from a constant is timing it against the SMD it was measured from, and the DMX that reached
/// the engine may have been exported at another rate — the model itself is the only thing that
/// knows. `PlayAction` has always read this; abilities never did, and drifted by the difference.
public float CurrentClipDuration => _renderer.IsValid() ? _renderer.Sequence.Duration : 0f;
private void PlaySequence( string name, bool restart = false )
{
if ( string.IsNullOrEmpty( name ) ) return;
if ( !_renderer.IsValid() ) return;
if ( _renderer.Sequence.Name != name )
_renderer.Sequence.Name = name;
else if ( !restart )
return;
if ( restart ) _renderer.Sequence.Time = 0f;
}
// ── AI/SPAWN ─────────────────────────────────────────────────────────────
/// <summary>
/// Where this zombie was when it came into existence, before anything in
/// OnStart has run.
///
/// ⚠️ Captured because the object is arriving at BeginSpawn ~9.5 units HIGHER
/// than the spawner placed it, and nothing in the spawn path is supposed to
/// move it. Without this the move is invisible — every log so far printed
/// only the position AFTER it had already happened.
/// </summary>
private float _createdZ;
protected override void OnStart()
{
_createdZ = WorldPosition.z;
// ⚠️ FIRST, BEFORE EnsureBody AND THE AGENT. The capsule is derived from
// HitRadius/BodyHeight/HeadClearance and the NavMeshAgent from
// BodyHeight/BodyRadius, so overriding the fields here fixes the
// collider, the agent and melee reach in one place instead of three.
ApplyVariantBody();
EnsureBody();
// ⚠️ Brackets EnsureBody specifically. It creates the renderer, the
// capsule and the health component — none of which should move anything —
// so if the ~9.5 unit rise happens across THIS call, that narrows it to
// one function instead of "somewhere in startup".
if ( DebugSpawn && MathF.Abs( WorldPosition.z - _createdZ ) > 0.01f )
Log.Info( $"[nz-spawn] ⚠ EnsureBody MOVED the object: z {_createdZ:0.0}"
+ $" -> {WorldPosition.z:0.0}" );
// The round the wave loop is on. Everything scaled per-round — health,
// speed, damage — reads from here, so this one line is what makes the
// ported curves take effect. 1 when no round is running.
int round = RoundManager.Instance?.Round ?? 1;
if ( round < 1 ) round = 1;
_hp = Components.GetOrCreate<Health>();
_hp.ImmunityAfterHit = 0f; // zombies get no mercy window
// ⛔ A BOSS IS A MULTIPLE OF A NORMAL ZOMBIE, NOT ITS OWN CURVE. This was `round × 500 +
// players × 500`, ported from upstream — a second author for how tough a zombie is, which
// drifted from the walker curve the moment either moved. `HealthMultiplier` already
// multiplies `HealthForRound`, so a boss is one number: ×15.
// ⚠️ A BOSS'S MULTIPLIER CAN GROW WITH THE ROUND, AND BY DEFAULT DOES NOT.
// `BossSettings.HealthPerRound` is 0, so this resolves to the authored ×15 at every
// round. It was 1 for one revision, to fix bosses being identical past the old 60,000
// health cap; raising that cap to 1,000,000 fixed it at the source instead, and two
// growth curves multiplied gave a round-61 Brutus 433 million effective health. The
// knob and its arithmetic are in `BossSettings.HealthPerRound`.
// ⚠️ AND THE MATCH'S ZOMBIE HEALTH, OR ITS BOSS HEALTH FOR A BOSS (the lobby's Difficulty, 2026-10-05) — here at the spawn,
// not in `HealthForRound`, whose other readers (the knife, the chain explosions) must not grow with it.
_hp.Reset( ZombieStats.HealthForRound( round )
* BossHealthMultiplier( round )
* ExtraHealthMultiplier
* Difficulty.HealthFor( Variant ) );
// React to damage here; the component only owns the number.
// Latch the melee flag as damage lands — OnKilled fires straight after
// OnDamaged, so reading it here keeps it valid for the kill award.
_hp.OnDamaged = ( amount, headshot ) =>
{
_lastHitWasMelee = _hp.LastHitWasMelee;
// ⚠️ LATCHED HERE FOR THE SAME REASON as the melee flag: OnKilled fires
// straight after OnDamaged, so this is the last moment the killer is
// knowable. Reading _hp.LastAttacker inside Die() would work today and
// break the moment anything damages a corpse.
_lastAttacker = _hp.LastAttacker;
// ⚠️ AND THE POINTS VERDICT, LATCHED FOR THE SAME REASON AS THE OTHER TWO — this is
// the last moment the hit that caused it is knowable.
_lastHitPaid = _hp.LastHitPays;
OnHurt( headshot );
// ⚠️ AND WHAT IT TORE OFF, if anything — a head or an arm (`ZombieAI.Gore.cs`). After the points, as in the original
// (`OnInjured` runs after the damage is taken).
GoreOnHurt( amount );
};
// ⛔ A PUPPET NEVER DIES OF ITSELF — only by the host's `NZNet.ZombieDied` (`DieAsPuppet`). Its health takes nothing on a
// client (`Health.Apply`), and this is the second lock on the same door (the co-op audit, 2026-09-27).
_hp.OnKilled = headshot => { if ( !IsPuppet ) Die( headshot ); };
// ⚠️ SEEDED TRUE. Anything that damages a zombie without going through the bullet path —
// a knife, a trap, a Nuke — never sets it, and all of those must pay normally.
_lastHitPaid = true;
// Speed rating = round curve + per-zombie jitter. NO base is added.
//
// SetRunSpeed( WeightedRandom(speeds) + math.random(0,35) )
// nz_zombie_walker_derriese:772
//
// ⚠️ This is NOT a velocity — it is the number matched against the
// animation Threshold table (0/36/71/155) to pick a tier. The zombie's
// actual speed comes back out of the chosen clip in PickAnimations.
// That is why round 1 rates 0 and still shambles: the walk clips carry
// it forward at ~46 u/s on their own.
//
// Adding a 100 base here (as this did) shifted every zombie about two
// tiers up — round-1 zombies sprinted, and the top tier arrived by
// round 6-15 instead of round 31-40.
//
// The jitter width (35) is sized to the first tier gap (36) on purpose
// — see ZombieVariant.SpeedTiers.
int jitter = Variant?.SpeedJitter ?? 35;
SpeedRating = ZombieStats.SpeedForRound( round ) + Game.Random.Int( 0, jitter );
// ⚠️ A FLOOR, NOT AN ASSIGNMENT. Late rounds should still be able to push
// a hound above its own minimum; what this stops is round 1 rolling the
// walk tier for something that has no walk in its repertoire.
if ( Variant?.MinSpeedRating is int floor && SpeedRating < floor )
SpeedRating = floor;
// ⛔ MISERY IS A FLOOR HERE TOO, applied AFTER the variant's own. Everything spawned
// while the device is on arrives at the top tier; `RefreshTier` and `PickAnimations`
// below then do the work that makes the rating mean anything, which is why this sits
// above them rather than being another RaiseSpeedRating call afterwards.
// …and so is basalt's altar defense, for its wave: *"really fast zombies"*.
if ( (MiseryDevice.Running || AltarWave) && SpeedRating < WalkerAnimations.SuperSprintRating )
SpeedRating = WalkerAnimations.SuperSprintRating;
// …and basalt's boss fight, for what comes with the beast: each phase faster (`HexPlatforms.FightSpeedFloor`) — never a
// boss, whose speed is its own
if ( HexPlatforms.FightSpeedFloor is float fightFloor && !(Variant?.IsBoss ?? false) && SpeedRating < fightFloor )
SpeedRating = fightFloor;
RefreshTier();
PickAnimations();
// ⚠️ EVERYTHING ABOVE STILL RUNS ON A PUPPET, and it has to: it is what builds the
// renderer, loads the variant's model and picks the walk clip. A proxy that skipped
// startup would be an invisible zombie, which is the bug this is fixing.
if ( IsPuppet )
{
BeginPuppet();
return;
}
BeginSpawn();
}
// ── SPAWNING ─────────────────────────────────────────────────────────────
/// <summary>Spawn Z at the moment the entrance began — the reference the
/// riser diagnostics measure against.</summary>
private float _spawnBaseZ;
/// <summary>Where the zombie stood when the entrance began. Root motion is
/// applied as an offset from here rather than accumulated, so it cannot
/// drift.</summary>
private Vector3 _spawnOrigin;
private Rotation _spawnRotation;
/// <summary>
/// Where the zombie must STAND when the entrance finishes — the placed
/// spawn point, before the sink was applied.
///
/// ⚠️ The object stays sunk for the whole entrance while the bones carry the
/// body up. The moment the walk loop takes over the bones return to neutral,
/// so unless the object is restored here the zombie drops straight back into
/// the ground it just climbed out of.
/// </summary>
private Vector3 _spawnAnchor;
/// <summary>
/// Drive the transform from the baked curve while the entrance plays.
///
/// ⚠️ REQUIRED — the compiled animation is IN-PLACE. Measured directly:
/// with this off, bone 0's world Z sits at a constant 1105.6 for the entire
/// clip, identical to the GameObject. The root never moves; only child bones
/// pose. So nothing carries the climb unless we do.
///
/// This is exactly why the original needs PlaySequenceAndMove (moo:6985):
/// Source extracts root motion into sequence movement and leaves the pose
/// in place, so the entity must be moved explicitly. Our pipeline lands in
/// the same state.
///
/// ⚠️ Turning this off does NOT simply disable the climb — combined with the
/// spawn sink it plays the whole entrance 48u underground and then snaps to
/// the floor on handover, which reads as "static, then suddenly walking".
/// </summary>
public static bool ApplyRootMotion { get; set; } = true;
/// <summary>
/// Scales the baked curve. 1 = exactly what the animation authored.
///
/// Exposed because the climb has to END level with the floor: too little
/// and the zombie surfaces waist-deep, too much and it pops into the air.
/// </summary>
public static float RootMotionScale { get; set; } = 1f;
/// <summary>
/// Extra yaw applied to the curve before it is added to the world position.
///
/// The model's forward axis and the GameObject's need not agree — ZombieAI
/// already carries a ModelYawOffset for exactly that reason — and a curve
/// rotated the wrong way sends a riser sideways out of its hole.
/// </summary>
public static float RootMotionYaw { get; set; }
private TimeSince _sinceSpawnStart;
private TimeSince _sinceSpawnLog;
/// <summary>
/// Since this zombie came into play: its entrance over and the agent handed the body — or at once, for one with no entrance
/// (a hound, a Shrieker). ⚠️ NOT `_sinceSpawnStart`, which starts with the ENTRANCE: a walker's climb out of the ground runs
/// about two seconds, so a one-second window counted from there would close before it had taken a step.
/// </summary>
private TimeSince _sinceInPlay;
/// <summary>Log a line per tick while the entrance plays. Off by default —
/// a wave of 24 would bury the console.</summary>
public static bool DebugSpawn { get; set; }
/// <summary>
/// Drop the 10Hz throttle on that log — one line per FRAME.
///
/// ⚠️ At 120fps the throttle is one line per twelve frames, which is more
/// than enough to miss an event lasting two or three. Anything suspected of
/// being a single-frame artefact has to be looked at with this on.
/// </summary>
public static bool DebugSpawnEveryFrame { get; set; }
/// <summary>Restrict real spawns to WalkerAnimations.SpawnVerified. ON by
/// default: ship what has been measured, not what merely loads. Toggle with
/// nz_spawn_verified 0 to put all twenty back in rotation.</summary>
public static bool VerifiedSpawnOnly { get; set; } = true;
/// <summary>Per-tick lines as well as the verdict. Off during a sweep — 20
/// clips × ~40 ticks would bury the results the sweep exists to show.</summary>
public static bool DebugSpawnVerboseTicks { get; set; } = true;
/// <summary>
/// Force every entrance to use one named clip. Empty = pick at random.
///
/// There are 20 risers and they do not fail the same way — a ground climb
/// and a ceiling drop are different problems. Diagnosing a random one per
/// spawn means never seeing the same fault twice.
/// </summary>
public static string ForcedSpawnClip { get; set; } = "";
/// <summary>
/// Playback rate for entrance clips. Below 1 is slow motion.
///
/// A riser runs 1.7-3.8s, which at screenshot cadence is a handful of
/// frames — too few to see where the motion goes wrong. Slowing it turns
/// the same capture burst into a usable flipbook.
/// </summary>
public static float SpawnPlaybackRate { get; set; } = 1f;
/// <summary>
/// Play an entrance animation, or skip straight to chasing when there is
/// none.
///
/// ⚠️ THE 20 RISER CLIPS HAVE NEVER PLAYED. They compile into the model and
/// resolve by name, but nothing ever started one — OnStart went straight to
/// Chasing. This is the first thing that plays them.
///
/// ⚠️ Known bad: the root-Z conversion loses the clips' vertical travel, so
/// a riser plays its climb at standing height instead of coming up out of
/// the ground. Wiring it anyway makes the fault visible and measurable,
/// which is what the diagnostics below are for.
/// </summary>
/// <summary>
/// Master switch for spawn dirt, across every zombie.
///
/// ⛔ A STATIC AS WELL AS THE PER-ZOMBIE PROPERTY, BECAUSE THE PROPERTY ALONE CANNOT BE TURNED
/// OFF FROM ANYWHERE. `SpawnDirtEnabled` is per-instance and zombies are created at runtime, so
/// there is no inspector to untick and no prefab to edit — turning the effect off would have
/// meant editing a default and rebuilding, with no way to compare on against off in one session.
///
/// ⚠️ OFF BY DEFAULT NOW, on request. The per-zombie property is left ON so a single zombie can
/// still be given dirt deliberately; this gates it globally rather than overwriting the intent.
///
/// ⚠️ NULLABLE-BACKED — a static's default does not reach a running editor, since hotload copies
/// statics forward by name and skips initialisers. The vertical gate reported ON for an entire
/// session after being set to false, which is the trap this avoids.
/// </summary>
public static bool SpawnDirtOn
{
get => _spawnDirtOn ??= false;
set => _spawnDirtOn = value;
}
static bool? _spawnDirtOn;
/// <summary>Dirt kicked up by a ground entrance, as the original does.</summary>
[Property] public bool SpawnDirtEnabled { get; set; } = true;
/// <summary>Clods per burst. ⚠️ Multiplied by however many zombies spawn at
/// once — this is a per-wave cost, not a per-zombie one.</summary>
[Property, Range( 0, 40 )] public int SpawnDirtCount { get; set; } = 14;
/// <summary>
/// Does this entrance come UP THROUGH SOIL?
///
/// ⚠️ Matched on the clip name, which is not something to be proud of, but
/// the alternative is tagging 20 clips by hand and the naming is completely
/// regular: `ceiling` drops in from above, `wall` comes through masonry, and
/// the elevator riser has an explicit from_ceiling / from_floor pair. Anything
/// that is not one of those is a ground riser.
///
/// Getting it wrong is cosmetic in one direction (no dirt where there should
/// be) and silly in the other (soil raining out of a ceiling), so it fails
/// toward NO dirt on anything it does not recognise as ground.
/// </summary>
public static bool ClipIsGroundEntrance( string clip )
{
if ( string.IsNullOrWhiteSpace( clip ) ) return false;
var c = clip.ToLowerInvariant();
if ( c.Contains( "ceiling" ) ) return false;
if ( c.Contains( "wall" ) ) return false;
return c.Contains( "ground" ) || c.Contains( "riser" )
|| c.Contains( "spawn" ) || c.Contains( "ent_" );
}
private void BeginSpawn()
{
// ⛔ THE ANCHOR IS WHERE THE NAV AGENT WILL STAND THE ZOMBIE, NOT WHERE
// THE OBJECT HAPPENS TO BE.
//
// This is the "drops back underground before it walks" bug, and it was
// never the animation. Traced frame by frame (nz_handover):
//
// 417 z=1090.5 seq='nz_ent_ground_02' state=Chasing agent=-
// 418 z=1090.5 seq='a_nz_walk_au7' state=Chasing agent=on
// 419 z=1090.0 dz=-0.4 agent=on
//
// The agent comes online one frame after the handover and then eases the
// body down ~10 units over about a second, decelerating as it goes. It is
// not teleporting anything back to the start of the curve — it is pulling
// the zombie to ITS ground, because the entrance finished 10 units above
// it. Everything downstream (the curve's start offset, the landing, the
// verifier's "feet vs floor") is measured from this one value, so it is
// the only place worth fixing.
//
// ⚠️ Explains "does not always happen" honestly: the gap is however far
// the spawn point sits above the navmesh at that spot, which varies with
// where the zombie was placed.
// ⛔ THE GEOMETRY FLOOR, NOT THE NAVMESH. Measured on this map:
//
// navmesh 1087.6 geometry 1080.3 gap 7.2
//
// The baked navmesh floats ~7 units above the floor you can see, and
// `NavMesh.GetClosestPoint` returns the navmesh. Anchoring there ends the
// entrance seven units in the air; the agent then eases the body down to
// the real floor over about a second, which is the drop being reported.
//
// ⚠️ The agent lands on the GEOMETRY, not on the navmesh — confirmed in a
// separate trace where it settled at 1080.5 with the navmesh at 1090.5.
// So the floor is the honest target for both.
//
// ⚠️ And this is where the previous attempt went wrong: it anchored to
// `GetClosestPoint` and reported `gap 0.0`, which looked like a clean
// result. It was only measuring the navmesh against itself.
var floorTrace = Scene.Trace
.Ray( WorldPosition + Vector3.Up * 128f, WorldPosition - Vector3.Up * 4096f )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
// ⛔ THE NAVMESH IS THE ONLY AUTHORITY ON WHERE A ZOMBIE STANDS, because
// the agent enforces it EVERY FRAME for the rest of the zombie's life
// (NavMeshAgent.UpdatePosition). Anything the entrance lands on that is
// not the navmesh gets corrected the moment the agent appears — and that
// correction IS the drop being reported. The whole chain has to agree:
//
// nz_spawn snaps to navmesh -> entrance lands on navmesh
// -> agent holds navmesh
//
// ⚠️ I ANCHORED THIS TO A DOWNWARD TRACE AND IT WAS WRONG. A trace from
// 128 up hits the first thing on the way down, which is not necessarily
// the floor the zombie stands on — measured across runs it disagreed with
// the navmesh by +9.1, +7.2, +4.0 and then −3.0. A reference that changes
// SIGN between spawns cannot be the thing everything else is aligned to.
// ⛔ ON ITS OWN LEVEL (NavGround), NOT THE NEAREST MESH IN ANY DIRECTION — that put a zombie whose spawner stood
// under a platform up on the platform at the end of its entrance (2026-10-01).
// ⛔ AND NOT AT ALL FOR ONE HELD AT A WINDOW (`ParkedAt`): its stand is off the mesh on purpose, and the nearest mesh is
// the room behind the boards.
var ground = ParkedAt.IsValid() ? WorldPosition : NavGround( Scene, WorldPosition );
// Trace kept as a DIAGNOSTIC only — it is how we see the two disagreeing,
// which is worth knowing (a navmesh baked far off the floor is a real map
// problem) without letting it steer anything.
if ( DebugSpawn || Watching )
{
// ⛔ FOUR HEIGHTS, AND THEY DISAGREE. Chasing this bug has repeatedly
// meant reasoning about "the floor" as if it were one number. It is
// not, and the previous log only printed two of them:
//
// spawned where the spawner put the object
// object where it actually is now — these differ by ~9.5 and
// NOTHING in the spawn path is supposed to move it
// navmesh what GetClosestPoint says, which the agent will enforce
// geometry what a downward trace hits — the visible floor
//
// Whichever pair disagrees is the bug; printing one pair at a time is
// how four theories all looked plausible.
// ⚠️ Reuses floorTrace — a second trace here would be a second
// measurement of the same thing, free to disagree with the one that
// actually gets used.
var navZ = Scene.NavMesh?.GetClosestPoint( WorldPosition )?.z
?? WorldPosition.z;
Log.Info( $"[nz-spawn] heights — spawned {_createdZ:0.0}"
+ $" object {WorldPosition.z:0.0}"
+ $" navmesh {navZ:0.0}"
+ $" geometry {(floorTrace.Hit ? floorTrace.HitPosition.z : float.NaN):0.0}"
+ $" -> ANCHORED AT {ground.z:0.0}"
+ $" | navmesh-vs-geometry {(floorTrace.Hit ? navZ - floorTrace.HitPosition.z : float.NaN):0.0}"
+ (floorTrace.Hit ? "" : " ⚠ NO FLOOR HIT — fell back to the navmesh") );
}
_spawnBaseZ = ground.z;
_spawnOrigin = ground;
_spawnAnchor = ground;
_spawnRotation = WorldRotation;
// Own the transform for the duration of the entrance.
TakeTransform();
_sinceSpawnStart = 0f;
_sinceSpawnLog = 0f;
_spawnSamples = 0;
var clips = _tier?.SpawnSequences;
var fromVariant = clips is not null && clips.Count > 0;
// ⛔️ A VARIANT THAT NAMES NO ENTRANCE HAS NONE, and must not be handed the walker's.
//
// Not every zombie climbs out of the ground. A hellhound has no emerge animation at all --
// its model ships run/trot/walk/attack/jump/deaths and an idle, and nothing else -- so the
// honest answer for one is "no entrance", not "borrow a clip". Without this the fallback
// below hands it WalkerAnimations.Spawn, none of which exist on a dog skeleton, and the
// only reason it works is that PlayAction then fails and drops through to the bail. Making
// a behaviour depend on a lookup failing is how it breaks the day the lookup succeeds.
//
// ⚠️ THE FORCED CLIP STILL WINS, so nz_spawn_clip can drive an entrance onto anything for
// testing -- that is the whole point of the command.
var noEntrance = !fromVariant && Variant is not null
&& string.IsNullOrWhiteSpace( ForcedSpawnClip );
if ( !fromVariant ) clips = WalkerAnimations.Spawn;
// ⚠️ APPLIED AFTER THE TIER LOOKUP, NOT INSTEAD OF IT. Trimming
// WalkerAnimations.Spawn alone would silently leave unverified entrances
// in play for any tier that sets them, so this is the gate every WALKER
// spawn passes through.
//
// ⛔ BUT IT MUST NOT OVERWRITE A VARIANT'S OWN CLIPS. It used to, and the
// list it forced is walker-skeleton-only: a hellhound asked for
// `nz_dog_idle`, got handed `nz_ent_ground_02`, and logged "none of 1
// clips exist on the model". The verified list is a statement about which
// WALKER entrances are safe — it says nothing about a dog, and a variant
// that names its own entrance has already made that call.
if ( !fromVariant && VerifiedSpawnOnly && WalkerAnimations.SpawnVerified.Count > 0 )
clips = WalkerAnimations.SpawnVerified;
// A forced clip is a list of one — same code path, so nothing about the
// diagnosis differs from a real spawn. Deliberately checked LAST so
// nz_spawn_clip can still reach an unverified entrance for testing.
if ( !string.IsNullOrWhiteSpace( ForcedSpawnClip ) )
clips = new List<string> { ForcedSpawnClip };
var length = noEntrance ? 0f : PlayAction( clips );
if ( length <= 0f )
{
// No usable entrance — behave exactly as before rather than freezing.
//
// ⚠️ MUST HAND THE TRANSFORM BACK. TakeTransform() ran above, so bailing
// out here without this leaves UpdatePosition off forever and the zombie
// never moves — a far worse bug than the one being fixed.
//
// ⚠️ THE SPAWN SOUND STILL PLAYS. It used to sit past this bail, so anything with no
// entrance arrived in silence — which for a hellhound meant losing nz.hound.spawn, the
// one warning you get that a dog is behind you. A zombie that spawned should sound
// like it spawned whether or not it had an animation to do it with.
NZSound.PlayShared( ZombieVariant.Cue( Variant?.SpawnSound, NZSound.ZombieSpawn ),
_spawnAnchor, SoundGate.Voice );
if ( ParkedAt.IsValid() ) Park();
else GiveTransformToAgent();
State = ZombieState.Chasing;
_sinceInPlay = 0f;
ArmSpawnHold();
return;
}
// ⚠️ SAID OUT LOUD FOR A BOSS, AND ONLY FOR A BOSS. "The spawn animation does not exist" is
// not a question this code could answer from outside: the bail above is silent, a clip that
// plays subtly looks the same as one that never started, and forty walkers logging their
// entrance every round would drown the console. One line, once, for the one kind of spawn
// anybody watches.
if ( Variant?.IsBoss ?? false )
Log.Info( $"[nz-spawn] {Variant.ResourceName} entrance '{_actionClip}' {length:0.00}s"
+ $" (rate {_actionRate:0.##})" );
State = ZombieState.Spawning;
if ( WatchArmed && _watched is null )
{
WatchArmed = false;
_watched = this;
_watchTick = 0;
_watchLastZ = WorldPosition.z;
Log.Info( $"[nz-watch] === entrance '{_actionClip}' len={_actionLength:0.00}s "
+ $"rate={SpawnPlaybackRate:0.00} rootmotion={ApplyRootMotion} "
+ $"hasCurve={WalkerRootMotion.Has( _actionClip )} ===" );
}
// ⚠️ Played at the SPAWN ANCHOR, not at WorldPosition. By the time this
// runs the object has been sunk to the start of the root-motion curve —
// up to 95 units underground — and a sound emitted from down there is
// occluded by the floor the zombie is about to climb through.
NZSound.Play( ZombieVariant.Cue( Variant?.SpawnSound, NZSound.ZombieSpawn ),
_spawnAnchor, SoundGate.Voice );
// ⚠️ AT THE ANCHOR, for the same reason as the sound — the object has
// already been sunk to the start of the root-motion curve, so emitting at
// WorldPosition buries the dirt up to 95 units under the floor it is
// supposed to be bursting through.
//
// ⚠️ GROUND RISERS ONLY. A ceiling drop and a wall emerge are the same
// system and want nothing to do with soil.
// ⚠️ BOTH GATES. The static is the global off switch; the property stays per-zombie.
if ( SpawnDirtOn && SpawnDirtEnabled && ClipIsGroundEntrance( _actionClip ) )
SpawnDirt.Burst( Scene, _spawnAnchor, SpawnDirtCount );
// Where the entrance BEGINS, so that replaying the curve ENDS it on the
// spawn point:
//
// riser net +48 -> starts 48 BELOW the floor, surfaces level
// ceiling net -372 -> starts 372 ABOVE the floor, lands level
//
// ⚠️ Only meaningful together with ApplyRootMotion. On its own it just
// parks the whole animation underground — the two are one mechanism,
// not two independent options.
if ( EndAtSpawnPoint && WalkerRootMotion.Has( _actionClip ) )
{
var end = WalkerRootMotion.Sample( _actionClip, 1f ) * RootMotionScale;
var rot = _spawnRotation * Rotation.FromYaw( RootMotionYaw );
_spawnOrigin -= rot * end;
WorldPosition = _spawnOrigin;
}
if ( DebugSpawn )
Log.Info( $"[nz-spawn] start '{_actionClip}' {length:0.00}s"
+ $" floor Z {_spawnBaseZ:0.0} -> begins at {_spawnOrigin.z:0.0}"
+ $" (curve net z {WalkerRootMotion.Sample( _actionClip, 1f ).z:+0.0;-0.0;0})" );
}
/// <summary>
/// Place the entrance so it FINISHES on the spawn point.
///
/// Off means the animation starts there instead — useful only for
/// diagnosing a curve, since it leaves risers finishing in mid-air.
/// </summary>
public static bool EndAtSpawnPoint { get; set; } = true;
/// <summary>
/// Hold still while the entrance plays, and report what the root is doing.
///
/// The numbers are the point: a riser that works climbs, so world Z should
/// RISE across the clip. If dz stays ~0 the vertical motion was lost in
/// conversion, which is the root-Z bug rather than a wiring fault.
/// </summary>
/// <summary>
/// World Z of a bone. The entrance diagnostic's real measurement — the
/// GameObject is parked below ground on purpose, so only this can tell
/// whether the model is actually climbing.
/// </summary>
/// <summary>Which bone counts as "the body" for entrance diagnostics —
/// something up the spine, not the root.
///
/// ⚠️ 5 IS NOT THE SPINE. nz_bones says index 5 on this skeleton is
/// 'j_ball_le', the ball of the left foot — so every trace that called it
/// "body" was reading a foot, which is why it sat 30 units below the root
/// and looked wrong. Traces now resolve bones BY NAME below; this stays only
/// because it is settable from the console.</summary>
public static int BodyBoneIndex { get; set; } = 5;
/// <summary>
/// Bone index by name, or -1. Cached per zombie — the lookup is a linear scan
/// over 64 bones and the trace runs every tick.
///
/// ⚠️ ALWAYS RESOLVE BY NAME. Indices were guessed three times and were wrong
/// three times; the skeleton is CoD-style ('j_mainroot' is the PELVIS, at
/// +34 from the object origin, not a floor-level root).
/// </summary>
private int BoneIndex( string name )
{
if ( _boneIndices.TryGetValue( name, out var cached ) ) return cached;
var bones = _renderer.IsValid() ? _renderer.Model?.Bones?.AllBones : null;
var index = -1;
if ( bones is not null )
foreach ( var b in bones )
if ( string.Equals( b.Name, name, StringComparison.OrdinalIgnoreCase ) )
{
index = b.Index;
break;
}
_boneIndices[name] = index;
return index;
}
private readonly Dictionary<string, int> _boneIndices = new();
/// <summary>
/// A bone's position RELATIVE TO THE OBJECT — what the pose is doing, with
/// our own root-motion slide subtracted out.
///
/// ⚠️ THIS IS THE ONLY READING THAT SEPARATES AN ANIMATION FROM A SLIDE. In
/// world space a moving object and a moving pose look identical, which is
/// how a static skeleton being dragged upward got mistaken for a climb.
/// </summary>
/// ⛔ SUBTRACTS LAST FRAME'S POSITION, NOT THIS FRAME'S. BoneWorldTransforms
/// is a cache evaluated with the object transform as it was on the PREVIOUS
/// frame. Subtracting the current position therefore mixes the object's own
/// movement into what is supposed to be a pure pose reading — and during an
/// entrance the object moves every single frame.
///
/// ⚠️ This cost an entire wrong fix. The first tick of a riser reported
/// `pelvis +103`, which reads unmistakably as a standing pose; it was the
/// stale cache from before BeginSpawn teleported the object 64 units down,
/// minus the new position. 103 was the teleport distance, not a stance. I
/// built a hide-the-model fix on it, which made the flash real instead of
/// imagined and put a −620 dip in the verifier.
///
/// The lag is documented two hundred lines down as the reason the feet
/// verifier discards its first sample. Same cache, same lag, already known.
private Vector3 BoneLocal( string name )
{
var index = BoneIndex( name );
if ( index < 0 || !_renderer.IsValid() ) return Vector3.Zero;
var bones = _renderer.BoneWorldTransforms;
if ( index >= bones.Length ) return Vector3.Zero;
return bones[index].Position - (_bonesEvaluatedAt ?? WorldPosition);
}
/// <summary>
/// The object position the bone cache was last evaluated with — i.e. where
/// the object was at the END of the previous frame.
///
/// Null until the first frame completes, where falling back to the current
/// position is correct: nothing has moved yet.
/// </summary>
private Vector3? _bonesEvaluatedAt;
/// <summary>
/// Lowest point of the posed skeleton, in world Z.
///
/// ⚠️ The MINIMUM over every bone, not a named foot. It needs no knowledge
/// of the skeleton's naming or indices, it is correct for a crawler and a
/// riser alike, and it is the number that actually answers the question a
/// screenshot cannot: is any part of this body below the floor, and does it
/// end resting on it.
/// </summary>
private float LowestBoneZ()
{
if ( !_renderer.IsValid() ) return float.NaN;
var bones = _renderer.BoneWorldTransforms;
if ( bones.Length == 0 ) return float.NaN;
var lowest = float.MaxValue;
for ( int i = 0; i < bones.Length; i++ )
lowest = MathF.Min( lowest, bones[i].Position.z );
return lowest;
}
// Entrance verification, sampled across the whole clip.
private float _feetStart, _feetEnd, _feetMin, _feetMax;
/// <summary>Ticks sampled since the entrance began. Exists only so the FIRST
/// one can be thrown away — see the note in TickSpawn.</summary>
private int _spawnSamples;
/// <summary>
/// How far the lowest bone sits above the object origin when the zombie is
/// simply standing on the ground — measured with nz_bones, not guessed:
/// 'j_ball_le' reads +3.0, because the ball joint is inside the foot rather
/// than on its sole.
///
/// ⚠️ EVERY LANDING IS JUDGED AGAINST THIS, NOT AGAINST ZERO. Zero is the
/// object's origin, and a zombie resting there has its feet 3 units up; the
/// original check took that constant as error.
/// </summary>
public const float RestingFeetOffset = 3f;
/// <summary>
/// ⚠️ Reads BoneWorldTransforms, NOT GetBoneObject.
///
/// GetBoneObject only returns a GameObject for bones that have one created —
/// most do not. It returned invalid for every bone here, my fallback quietly
/// substituted the renderer's own position, and two different bones both
/// reported the object's Z. I then read that as "the root is static", which
/// was never a measurement at all. BoneWorldTransforms is the cached
/// skeleton read and always has real data.
///
/// Returns float.NaN when unavailable, so a missing value is obvious in the
/// log rather than silently plausible.
/// </summary>
private float BoneWorldZ( int index )
{
if ( !_renderer.IsValid() ) return float.NaN;
// ⚠️ A ReadOnlySpan<Transform>, not a list — no null check, and Length
// rather than Count.
var bones = _renderer.BoneWorldTransforms;
if ( index < 0 || index >= bones.Length ) return float.NaN;
return bones[index].Position.z;
}
// ── ENTRANCE WATCH ───────────────────────────────────────────────────────
//
// ⛔ BUILT AFTER THREE WRONG GUESSES. The "zombie drops back underground
// before it walks" bug has now survived a wrap fix, a nav-agent fix and a
// completion-threshold fix, every one of them reasoned from the source and
// every one of them wrong. The common failure is that the interesting moment
// — the HANDOVER — is the one thing no existing diagnostic covers:
// nz_spawn_debug logs inside TickSpawn, and TickSpawn stops being called the
// instant the entrance ends.
//
// So this deliberately keeps logging PAST the transition, and prints who
// moved the object rather than only where it ended up.
//
// Watches ONE zombie and then disarms, because a wave of 24 risers at 60fps
// is not something anyone can read.
/// <summary>Arm the next entrance for a full trace: nz_spawn_watch.</summary>
public static bool WatchArmed { get; set; }
/// <summary>
/// Arm the watch, clearing any previous subject.
///
/// ⚠️ The clear matters: if the watched zombie is destroyed mid-trace (shot,
/// or the round ends) nothing runs to release it, and a stale `_watched`
/// silently blocks every later arm — a diagnostic that quietly stops working
/// is worse than none, since you would trust the empty log.
/// </summary>
public static void ArmWatch()
{
_watched = null;
WatchArmed = true;
}
/// <summary>
/// Cancel the watch — both a pending arm and a trace already running.
///
/// ⚠️ THERE WAS NO WAY TO TURN THIS OFF, which is a bad property for anything
/// that writes a line per frame. `nz_spawn_debug 0` does not silence it: the
/// watch is gated on its own subject, not on DebugSpawn, so the obvious "stop
/// the spawn logging" command leaves it running and the console keeps filling.
///
/// ⚠️ An arm also SURVIVES until something spawns. Arm it, walk away, and the
/// next entrance minutes later still gets traced.
/// </summary>
public static void DisarmWatch()
{
WatchArmed = false;
_watched = null;
}
private static ZombieAI _watched;
private TimeUntil _watchUntil;
private int _watchTick;
private float _watchLastZ;
/// <summary>
/// The Z we last WROTE to the transform, and whether we have written one.
///
/// ⛔ THE MEASUREMENT FOR "SOMETHING ELSE IS MOVING IT". Every diagnostic so
/// far has printed where the zombie IS, which cannot distinguish our own
/// write from someone else's. Comparing the position at the START of our
/// update against what we wrote at the END of the previous one isolates it:
/// any difference happened BETWEEN our frames, so it was not us.
///
/// A non-zero drift names the culprit by elimination — the nav agent is the
/// only other thing that touches this transform.
/// </summary>
private float _writtenZ;
private bool _hasWritten;
/// <summary>Seconds to keep logging AFTER the entrance hands over.</summary>
public static float WatchAfter { get; set; } = 1.5f;
private bool Watching => _watched == this;
private void WatchLog( string phase )
{
if ( !Watching ) return;
var seq = _renderer.IsValid() ? _renderer.Sequence : null;
float z = WorldPosition.z;
float dz = z - _watchLastZ;
_watchLastZ = z;
// Difference between where we LEFT it last frame and where it is now,
// before we touch it. Non-zero => something else wrote the transform.
float drift = _hasWritten ? z - _writtenZ : 0f;
Log.Info( $"[nz-watch] {_watchTick,4} {phase,-9} "
+ $"z={z,8:0.0} dz={dz,7:0.0} drift={drift,6:0.00} "
+ $"dt={Time.Delta * 1000f,5:0.0}ms floor={_spawnBaseZ,7:0.0} "
+ $"anchor={_spawnAnchor.z,7:0.0} "
+ $"seq='{(seq?.Name ?? "-")}' raw={(seq?.TimeNormalized ?? -1f):0.000} "
+ $"prog={_actionProgress:0.000} wrap={(_actionWrapped ? "Y" : "n")} "
+ $"rate={(_renderer.IsValid() ? _renderer.PlaybackRate : -1f):0.00} "
+ $"state={State} agent={(_agent.IsValid() ? (_agent.Enabled ? "on" : "off") : "-")}" );
_watchTick++;
}
/// <summary>Called from OnUpdate so the trace survives the handover.</summary>
private void TickWatch()
{
if ( !Watching ) return;
if ( State == ZombieState.Spawning ) return; // TickSpawn logs that half
WatchLog( "after" );
if ( _watchUntil <= 0f )
{
Log.Info( "[nz-watch] --- end of trace ---" );
_watched = null;
}
}
private void TickSpawn()
{
if ( _agent.IsValid() ) _agent.MaxSpeed = 0f;
// Set every tick rather than once — cheap, and it survives anything else
// that touches the renderer between frames.
if ( _renderer.IsValid() )
_renderer.PlaybackRate = SpawnPlaybackRate;
var seq = _renderer.IsValid() ? _renderer.Sequence : null;
// ── ROOT MOTION ──────────────────────────────────────────────────────
//
// Position is SET from the curve, never accumulated. An absolute offset
// from the spawn origin cannot drift; adding per-frame deltas would,
// every time a frame is missed or the rate changes.
//
// ⚠️ RUNS BEFORE THE VERIFICATION SAMPLE, not after. Sampling first read
// the bones against LAST frame's position, which put a constant one-frame
// lag into every measurement.
if ( ApplyRootMotion && seq is not null && WalkerRootMotion.Has( _actionClip ) )
{
// ⚠️ ActionProgress, NOT seq.TimeNormalized. The clock wraps at the
// end of the clip (see TickOneShot), and feeding a wrapped 0 into the
// curve teleports the zombie back to the start of its own hole —
// which is the pop reported as "it plays the first frame again".
var local = WalkerRootMotion.Sample( _actionClip, ActionProgress )
* RootMotionScale;
// The curve is in MODEL space, so it has to be rotated into the
// world by the facing the zombie spawned with — otherwise every
// riser climbs along the same world axis regardless of which way it
// is pointing.
var rot = _spawnRotation * Rotation.FromYaw( RootMotionYaw );
WorldPosition = _spawnOrigin + rot * local;
_writtenZ = WorldPosition.z;
_hasWritten = true;
}
WatchLog( "climb" );
// ── VERIFICATION ─────────────────────────────────────────────────────
//
// Sampled every tick, not just at the ends: a clip that starts and
// finishes correctly can still pop above the floor in the middle, and
// that is exactly the fault a screenshot is worst at catching.
//
// ⚠️ THE FIRST TICK IS DISCARDED. BoneWorldTransforms is the skeleton as
// the animation system last EVALUATED it — on the tick an entrance
// begins that is still the pre-spawn standing pose, taken before the
// object was moved to the start of the curve. That stale frame made
// 'elevator_from_ceiling' report its feet on the floor at t=0 when they
// were 126 units above it, and failed the clip for it.
// ⚠️ STAMPED AFTER EVERY WRITE TO THE TRANSFORM THIS FRAME. The engine
// evaluates the skeleton against whatever the transform ends up as, so
// this is the position next frame's BoneWorldTransforms will correspond
// to. Recording it earlier would reintroduce exactly the offset this is
// here to remove.
_bonesEvaluatedAt = WorldPosition;
_spawnSamples++;
var feet = LowestBoneZ();
if ( _spawnSamples > 1 && !float.IsNaN( feet ) )
{
if ( _spawnSamples == 2 ) { _feetStart = feet; _feetMin = feet; _feetMax = feet; }
_feetMin = MathF.Min( _feetMin, feet );
_feetMax = MathF.Max( _feetMax, feet );
_feetEnd = feet;
}
if ( DebugSpawn && DebugSpawnVerboseTicks
&& (DebugSpawnEveryFrame || _sinceSpawnLog > 0.1f) )
{
_sinceSpawnLog = 0f;
// ⚠️ LOCAL, NOT WORLD, and full XYZ rather than Z alone.
//
// World Z cannot answer the question: our root-motion code moves the
// object, so the whole skeleton's world position changes whether or
// not a single joint is animating. Subtracting the object leaves the
// POSE — if these numbers change, the clip is playing; if they sit
// still while the object climbs, we are dragging a statue up a hole.
// XYZ because a clip can be wrong in the horizontal too, and a
// vertical-only reading calls a sideways drift correct.
var pelvis = BoneLocal( "j_mainroot" );
var head = BoneLocal( "j_head" );
var footL = BoneLocal( "j_ball_le" );
var footR = BoneLocal( "j_ball_ri" );
Log.Info( $"[nz-spawn] t {(float)_sinceSpawnStart:0.00}s"
+ $" '{seq?.Name}' {seq?.TimeNormalized:0.00}"
+ $" obj z {WorldPosition.z:0.0} (dz {WorldPosition.z - _spawnBaseZ:+0.0;-0.0;0.0})"
+ $" | pose pelvis {pelvis.x:+0;-0;0},{pelvis.y:+0;-0;0},{pelvis.z:+0;-0;0}"
+ $" head {head.x:+0;-0;0},{head.y:+0;-0;0},{head.z:+0;-0;0}"
+ $" footL {footL.x:+0;-0;0},{footL.y:+0;-0;0},{footL.z:+0;-0;0}"
+ $" footR {footR.x:+0;-0;0},{footR.y:+0;-0;0},{footR.z:+0;-0;0}"
+ $" | pitch {WorldRotation.Pitch():0} roll {WorldRotation.Roll():0}" );
}
// ⚠️ Completion is measured by the SEQUENCE'S OWN PROGRESS, not by
// ActionPlaying. ActionPlaying expires on a real-time timer taken from
// the clip's unslowed length, so at 0.25x it fired four times too early
// and cut the entrance off at 25% — the animation was fine, the timer
// was wrong. TimeNormalized does not care about playback rate.
// ⚠️ Monotonic, so a wrapped clock reads as 1.0 (finished) rather than
// falling back to ~0 and holding the entrance open until the backstop.
var progress = seq is null ? 1f : ActionProgress;
// Backstop for a clip that loops and never reaches 1.
//
// ⚠️ DERIVED FROM THE CLIP'S OWN LENGTH. A flat 8s was shorter than the
// longest entrance we ship — 'nz_spawn_ground_v1' runs 12.67s, so the
// backstop fired at 63% and dropped the zombie into Chasing while it was
// still 28 units underground, mid-climb. A fixed number cannot be right
// for a set of clips ranging from 1.25s to 12.7s.
var length = seq?.Duration ?? 8f;
var backstop = (length + 2f) / MathF.Max( 0.05f, SpawnPlaybackRate );
// ⛔ A WRAP ENDS THE ENTRANCE. This is "the zombie drops back underground
// just before it walks", and the 0.99 threshold is the whole problem.
//
// If the clock wraps to 0 without any tick ever OBSERVING >= 0.99 — and
// at 60fps on a 2s clip a tick covers ~0.008, so landing in the last 1%
// is luck — then progress freezes at whatever maximum it did see, ~0.97,
// and this test never passes. The entrance then stays open for the whole
// BACKSTOP, another two-plus seconds, with TickOneShot having frozen the
// renderer on the wrapped frame: frame 0 of a riser, which is the body
// underground. So it sits buried, then walks.
//
// ⚠️ THAT LUCK IS THE "does not always happen". Whether a tick lands in
// the final 1% of the clip is a frame-timing coincidence.
//
// Two changes: treat a wrap as finished, and drop the threshold to 0.98
// so the common case completes BEFORE the wrap rather than racing it.
if ( progress < 0.98f && !_actionWrapped && _sinceSpawnStart < backstop )
return;
// ⚠️ DO NOT HOLD EXTRA TICKS HERE TO "SETTLE" THE POSE. Tried, measured,
// reverted: once the entrance finishes, ActionPlaying has already expired
// on its own real-time timer and UpdateAnimation has swapped the renderer
// to the ~1s walk loop. Waiting for another completion therefore waits for
// the WALK to cycle, and meanwhile the root-motion block above is feeding
// the walk's TimeNormalized into the ENTRANCE curve — which drags the
// zombie back down its own hole. Every short clip gained a flat +2.02s and
// landed 16 to 50 units off. The last sample being one frame shy of 1.0 is
// far cheaper than that.
if ( Watching )
{
string why = _actionWrapped ? "WRAPPED"
: progress >= 0.98f ? "progress>=0.98"
: "BACKSTOP (clip never reported finishing)";
Log.Info( $"[nz-watch] === entrance ended: {why} "
+ $"(prog={progress:0.000}, elapsed={_sinceSpawnStart:0.00}s) — "
+ "now watching the handover ===" );
_watchUntil = WatchAfter;
}
if ( DebugSpawn ) ReportEntrance();
// Land exactly on the spawn point. With root motion on the curve has
// already brought it here, so this is a guarantee rather than a move —
// it pins the last frame against float error and against a clip whose
// curve does not quite return to its authored end.
WorldPosition = _spawnAnchor;
// The last write the entrance makes. Anything that moves the zombie from
// here on is somebody else, and the drift column will say so.
_writtenZ = WorldPosition.z;
_hasWritten = true;
// ⚠️ AFTER the anchor pin above and BEFORE anything else runs, so the
// agent's first frame of ownership starts from the landed position.
// ⚠️ A ZOMBIE HELD AT A WINDOW (`ParkedAt`) KEEPS ITS STAND: no agent until it climbs in.
if ( ParkedAt.IsValid() ) Park();
else GiveTransformToAgent();
_actionClip = null;
State = ZombieState.Chasing;
_sinceInPlay = 0f;
ArmSpawnHold();
// Hand the renderer back at normal speed, or the zombie walks in slow
// motion for the rest of its life.
if ( _renderer.IsValid() ) _renderer.PlaybackRate = 1f;
if ( _agent.IsValid() ) _agent.MaxSpeed = AgentSpeed;
}
/// <summary>
/// The navmesh at a point ON ITS OWN LEVEL: the mesh within a step of its height, else the mesh below it, else (as before)
/// the nearest mesh anywhere as long as it is not overhead, else the point itself.
///
/// ⛔ `NavMesh.GetClosestPoint( point )` IS THE NEAREST MESH IN ANY DIRECTION, UP INCLUDED. The spawn snapped with it and the
/// entrance anchored to it, so a zombie whose spawner stood under a platform, a walkway or a stair came up ON the platform,
/// or climbed out of the ground and was then lifted there (the user, 2026-10-01: "sometimes an enemy spawns and either spawns on the platform above, or spawns in the correct place but is moved above").
///
/// ⚠️ THE BBOX OVERLOAD SEARCHES FROM THE BOX'S CENTRE, Size/2 EITHER WAY (engine IL: `BBox.Center`, `BBox.Size / 2`). So a
/// box a step tall around the point cannot reach a level overhead, and the second box reaches well down and still only a
/// step up. 64 sideways keeps a point just past a ledge's edge (a Helldonkey summoned beside Shrek) on the ledge.
/// </summary>
public static Vector3 NavGround( Scene scene, Vector3 at )
{
var nav = scene?.NavMesh;
if ( nav is null ) return at;
const float side = 64f, step = 24f, down = 192f;
var level = nav.GetClosestPoint( new BBox( at - new Vector3( side, side, step ), at + new Vector3( side, side, step ) ) );
if ( level.HasValue ) return level.Value;
var below = nav.GetClosestPoint( new BBox( at - new Vector3( side, side, down ), at + new Vector3( side, side, step ) ) );
if ( below.HasValue ) return below.Value;
// ⚠️ NOTHING ON THIS LEVEL OR BELOW: the old answer, but never overhead. A spawner off the mesh (a zombie room the
// navmesh does not reach) still finds the mesh nearby, as it always did.
var any = nav.GetClosestPoint( at );
return any.HasValue && any.Value.z <= at.z + step ? any.Value : at;
}
/// <summary>How long a zombie is held at its spawn height once it comes into play. `nz_spawn_hold [seconds]`, 0 for off.</summary>
public static float SpawnHoldSeconds { get; set; } = 1f;
/// <summary>How far over its spawn height a held zombie may rise (a kerb, uneven ground) before it is put back.</summary>
const float SpawnHoldRise = 16f;
private float _spawnHoldZ;
private bool _spawnHoldArmed;
/// <summary>At the spawn hand-over (the two places `_sinceInPlay` restarts): remember the height it came into play at.</summary>
private void ArmSpawnHold()
{
_spawnHoldZ = WorldPosition.z;
_spawnHoldArmed = true;
}
/// <summary>
/// ⛔ FOR ITS FIRST SECOND IN PLAY A ZOMBIE STAYS AT ITS SPAWN HEIGHT (the user, 2026-10-01: "we need to make sure they
/// remain at their spawn height for a second"). Whatever lifts it — the agent settling onto a mesh overhead, a push out
/// of a crate — it is put back onto the navmesh of its own level, THROUGH THE AGENT, so the agent holds it there too.
/// Writing only the transform would be overwritten by the agent's next frame.
///
/// ⚠️ ONLY UPWARD. A zombie stepping off a ledge it spawned on is falling, which is not this bug, and pinning it to the
/// ledge's height would leave it standing on air.
/// ⚠️ ARMED ONLY BY A SPAWN, never by a vault or a link crossing (they hand the transform over too, through
/// GiveTransformToAgent), so a zombie climbing through a window is never pulled back down.
/// </summary>
private void TickSpawnHold()
{
if ( !_spawnHoldArmed ) return;
if ( State == ZombieState.Dead || _sinceInPlay >= SpawnHoldSeconds ) { _spawnHoldArmed = false; return; }
// ⚠️ NOT A ZOMBIE HELD AT A WINDOW (`ParkedAt`): nothing lifts it, its agent is off, and `NavGround` from its stand is
// the room behind the boards
if ( State == ZombieState.Spawning || ParkedAt is not null || WorldPosition.z <= _spawnHoldZ + SpawnHoldRise ) return;
var was = WorldPosition.z;
var back = NavGround( Scene, WorldPosition.WithZ( _spawnHoldZ ) );
WorldPosition = back;
if ( _agent.IsValid() ) _agent.SetAgentPosition( back );
if ( DebugSpawn || Watching )
Log.Info( $"[nz-spawn] held at its spawn height: lifted to {was:0.0}, put back to {back.z:0.0}"
+ $" ({_sinceInPlay:0.00}s into play, held for {SpawnHoldSeconds:0.##}s)" );
}
/// <summary>`nz_spawn_hold [seconds]` — how long a new zombie is kept at its spawn height; bare, it reports.</summary>
[ConCmd( "nz_spawn_hold" )]
public static void SpawnHoldCmd( float seconds = -1f )
{
if ( seconds >= 0f ) SpawnHoldSeconds = seconds;
Log.Info( $"[nz-spawn] new zombies are held at their spawn height for {SpawnHoldSeconds:0.##}s"
+ $" once in play (lifted more than {SpawnHoldRise:0} units, they are put back)"
+ ( seconds < 0f ? " — nz_spawn_hold <seconds> to change, 0 for off" : "" ) );
}
/// <summary>
/// Take exclusive ownership of the transform for the entrance.
///
/// ⛔ THE HANDOVER HAS NEVER HAD A PROTOCOL, AND THAT IS THE WHOLE BUG. Two
/// systems drive this transform — the entrance curve and the nav agent — and
/// until now the switch between them was "whoever wrote last". The agent wins
/// by default, because `NavMeshAgent.UpdatePosition` is ON and the engine docs
/// are explicit: "Set the Position of the GameObject to the agent position
/// every frame."
///
/// So the agent is CREATED here, at the start, where its self-initialisation
/// and its rate-limited ground trace can settle during the 3.5s climb — with
/// UpdatePosition OFF so none of that settling touches the body. Creating it
/// lazily at the handover instead is what made the jump land exactly between
/// the entrance ending and the walk starting.
/// </summary>
private void TakeTransform()
{
var agent = Agent;
if ( !agent.IsValid() ) return;
agent.UpdatePosition = false;
agent.SetAgentPosition( WorldPosition );
}
/// <summary>
/// Hand the transform to the agent, in this order, once the entrance is done.
///
/// ⚠️ ORDER MATTERS AND IS THE POINT: tell the agent where the body is, THEN
/// let it start writing. Reversed — or with the position set before the
/// component has initialised, as the getter does — the agent writes its own
/// uninitialised guess first and drags the body there. Measured at −222.87 and
/// −247.03 in two separate traces.
/// </summary>
private void GiveTransformToAgent()
{
var agent = Agent;
if ( !agent.IsValid() ) return;
// ⛔ A HAND-OVER ENDS ANY HOLD AT A WINDOW (`ParkedAt`): the agent back on, whatever hands the body over — a relocation,
// its window gone — so no living zombie is left with its agent off.
// ⚠️ NEVER ON A CORPSE: death switches the agent off (`StopBeingSolid`), and a vault or a leap ended by death still
// hands the body over
if ( ParkedAt is not null )
{
ParkedAt = null;
if ( State != ZombieState.Dead && !agent.Enabled ) agent.Enabled = true;
}
// ⛔ NEVER UP ONTO A LEVEL OVERHEAD (2026-10-05). The agent puts the body on the nearest navmesh, up included, and in a
// spawn closet the navmesh doesn't reach that is the closet's roof, 96 units up: Defocus's zombies stood on those roofs
// all night. When the only mesh near is that far above, the body is not handed over. At a window it is held on its own
// side until it climbs through, as a new zombie there would be; anywhere else it stays put, and the stuck check moves it.
if ( State != ZombieState.Dead && MeshOnlyOverhead( out var above ) )
{
var w = Variant?.IgnoresBarricades != true ? Barricade.WindowFor( WorldPosition ) : null;
Vector3? stand = null;
var onMesh = false;
if ( w.IsValid() ) stand = w.SpawnSideFor( WorldPosition, out onMesh );
if ( stand is not Vector3 at )
{
agent.UpdatePosition = false;
Log.Warning( $"[nz-spawn] {GameObject.Name} at {WorldPosition:0}: the only navmesh near is {above:0} units up,"
+ " and no window to hold it at — not handed to the agent; the stuck check will move it" );
return;
}
WorldPosition = at;
if ( !onMesh )
{
ParkedAt = w;
Park();
Log.Info( $"[nz-spawn] {GameObject.Name}: the only navmesh near was {above:0} units up — held at window #{w.Index}"
+ $" at {at:0} instead, until its boards are down" );
return;
}
}
agent.SetAgentPosition( WorldPosition );
agent.UpdatePosition = true;
if ( DebugSpawn || Watching )
Log.Info( $"[nz-spawn] handover: agent placed at {WorldPosition.z:0.0}"
+ $", agent reports {agent.AgentPosition.z:0.0}"
+ " — UpdatePosition on from here" );
}
/// <summary>How far above the body the nearest navmesh may be before handing the body to the agent counts as a lift (`GiveTransformToAgent`).</summary>
const float MaxAgentLift = 48f;
/// <summary>Is the nearest navmesh to the body more than <see cref="MaxAgentLift"/> above it? `above` is how far.</summary>
private bool MeshOnlyOverhead( out float above )
{
above = 0f;
var nav = Scene?.NavMesh;
if ( nav is null || !nav.IsEnabled ) return false;
var near = nav.GetClosestPoint( WorldPosition );
if ( !near.HasValue ) return false;
above = near.Value.z - WorldPosition.z;
return above > MaxAgentLift;
}
/// <summary>
/// Did this entrance actually look like one? Three checks, all measured off
/// the lowest bone rather than judged by eye.
///
/// BURIED — the body starts below the floor. Otherwise it is not an
/// entrance, it is a zombie appearing and standing up.
/// LANDS — it finishes resting ON the floor, not sunk and not hovering.
/// NO POP — it never rises meaningfully above the floor mid-clip, which
/// is the "floating" fault a single screenshot cannot rule out.
///
/// A ceiling drop legitimately starts ABOVE the floor, so BURIED is reported
/// rather than failed for those — the curve's sign says which is expected.
/// </summary>
private void ReportEntrance()
{
var floor = _spawnBaseZ;
var start = _feetStart - floor;
var end = _feetEnd - floor;
var peak = _feetMax - floor;
var dip = _feetMin - floor;
var descends = WalkerRootMotion.Sample( _actionClip, 1f ).z < 0f;
// Measured against the model's RESTING offset, not against zero — a
// zombie standing on the ground reads +3, so a window centred on 0 was
// scoring a correct landing as 3 units of error before the clip even
// started.
var lands = MathF.Abs( end - RestingFeetOffset ) <= 8f;
var buried = descends ? start > 8f : start < -8f;
// ⚠️ THE STRAY-DIRECTION CHECK INVERTS WITH THE CLIP. A riser must never
// pop ABOVE the floor; a ceiling drop starts 126u up, so peak height is
// correct for it and the fault to catch is sinking BELOW the floor.
// Applying one rule to both failed every ceiling clip for doing its job.
//
// 24 is the nav agent's own step height — the tallest thing a zombie
// walks over without leaving the ground, so nothing below it can read as
// floating. At 12 the check was failing climb-outs for the plant-and-push
// beat that every one of them has.
var stray = descends ? dip >= -24f : peak <= 24f + RestingFeetOffset;
var strayLabel = descends ? "never sinks below floor" : "never pops above floor";
var verdict = lands && buried && stray ? "PASS" : "FAIL";
Log.Info( $"[nz-spawn] {verdict} '{_actionClip}' "
+ $"({(descends ? "drop" : "riser")}) after {(float)_sinceSpawnStart:0.00}s" );
Log.Info( $"[nz-spawn] feet vs floor — start {start:+0.0;-0.0;0}"
+ $" end {end:+0.0;-0.0;0} peak {peak:+0.0;-0.0;0} dip {dip:+0.0;-0.0;0}" );
Log.Info( $"[nz-spawn] {(buried ? "ok " : "FAIL")} starts {(descends ? "above" : "below")} floor"
+ $" {(lands ? "ok " : "FAIL")} lands on floor"
+ $" {(stray ? "ok " : "FAIL")} {strayLabel}" );
}
/// <summary>Pick the animation set for our tier. One-shot: the original
/// freezes a single random sequence per key for the zombie's lifetime
/// rather than re-rolling per frame (moo:6359-6392).</summary>
/// <summary>
/// Pick the animation tier this zombie belongs in.
///
/// ⛔ AN ABSOLUTE SPEED PICKS ITS OWN TIER, AND WITHOUT THIS BRUTUS WALKED. `SpeedRating` is the
/// ROUND's number — `SpeedForRound( round ) + jitter` — and it is unrelated to a fixed speed. A
/// Brutus travelling at 85 therefore animated from whatever tier round 1 rated (the walk one),
/// so he crossed the map at a run's pace playing a walk cycle. The speed and the clip that
/// illustrates it have to be answered by the same number.
/// </summary>
private void RefreshTier()
{
var fixedSpeed = EffectiveFixedSpeed;
_tier = Variant?.TierForSpeed( fixedSpeed > 0f ? fixedSpeed : SpeedRating );
}
// ── AI/VERTICAL GATE ─────────────────────────────────────────────────────
/// <summary>
/// Is the vertical gate active at all?
///
/// ⛔ A SWITCH BECAUSE THE GATE IS A SUSPECT, NOT BECAUSE IT IS OPTIONAL. Its own remarks below
/// admit it is a PROXY for "is a vertical detour necessary" and that it refuses genuinely-needed
/// links when the two points happen to sit at similar heights. That is a hypothesis about
/// observed sticking, and a hypothesis needs an off switch to be tested rather than argued.
///
/// ⚠️ TURNING IT OFF IS NOT FREE OF CONSEQUENCE, which is the other reason it is a switch and not
/// a deletion. The gate exists because link cost used to be 1.0 — links were FREE, so zombies
/// took every ledge on the map. With cost real (jump_up x6, drop_down x2) the pathfinder should
/// price them correctly on its own, but that is exactly what has to be measured before the gate
/// is removed for good. `nz_zvert 0`, then `nz_zpath`.
///
/// ⛔ DEFAULTS OFF NOW, WHICH IS THE ACTUAL BEHAVIOUR CHANGE. Leaving it on would mean the proxy
/// still overrules the pathfinder, and "zombies do not understand when to use the jump and drop
/// links" is precisely the proxy overruling the pathfinder: a route over a ledge that costs less
/// than walking round is refused because the player happens to be at a similar height. With
/// jump_up at x6 and drop_down at x2 the pathfinder already declines links that are not worth
/// it, and it declines them by MEASURING the alternative instead of guessing from height.
///
/// ⚠️ `nz_zvert 1` puts it straight back. If zombies start taking ledges they should not, that
/// is a COST problem, not a reason to restore the gate — raise jump_up with `nz_nav_cost` first
/// and see whether it settles, because the gate's failure mode (a zombie frozen with no route
/// at all) is worse than the cost's (a zombie taking a scenic route).
/// </summary>
/// ⛔ NULLABLE-BACKED BECAUSE CHANGING A STATIC'S DEFAULT DOES NOT REACH A RUNNING EDITOR.
/// Hotload copies statics forward BY NAME and does not re-run initialisers, so flipping this from
/// `= true` to `= false` left the old `true` in place: `nz_zvert` reported ON while every file on
/// disk said off, and a whole session would have played with the gate still active. The backing
/// field below is a NEW name, so hotload finds nothing to copy and the getter supplies the real
/// default. Same reason SoundGate.Policies is written this way.
public static bool VerticalGateEnabled
{
get => _verticalGateOn ??= false;
set => _verticalGateOn = value;
}
static bool? _verticalGateOn;
/// <summary>Height difference at which a vertical link becomes available.
/// A Source storey is ~128u, so 120 reads as "a floor apart".</summary>
public static float VerticalEnable { get; set; } = 120f;
/// <summary>And the height at which it is taken away again.
///
/// ⛔ DELIBERATELY LOWER THAN VerticalEnable — this is HYSTERESIS, not a
/// second guess at the same number. With one threshold a zombie hovering at
/// the boundary flips its filter every think, repaths every think, and
/// visibly dithers at the top of a ledge instead of committing. Enable at
/// 120, keep until 70.</summary>
public static float VerticalRelease { get; set; } = 70f;
/// <summary>What the gate currently allows. -1 down, 0 neither, +1 up.</summary>
public int VerticalMode { get; private set; }
/// <summary>True while crossing a link, so the gate leaves the filter alone.
///
/// ⚠️ Set by the traversal when that lands — NavMeshLink exposes
/// LinkEntered/LinkExited for exactly this. Until then it stays false and the
/// gate can in principle re-evaluate mid-crossing; hysteresis makes that rare
/// rather than impossible.</summary>
public bool OnLink { get; set; }
/// <summary>
/// VERTICAL GATE — decide whether this zombie may use jump and drop links.
///
/// The rule, from the design discussion:
///
/// • within the deadband of the target's height -> NEITHER
/// • target is BELOW -> drops only
/// • target is ABOVE -> jumps only
///
/// ⚠️ DIRECTIONAL, not just "am I on a different floor". A plain
/// far-from-target test would let a zombie BELOW the player take a drop link
/// and get further away. Allowing only the direction that closes the gap is
/// the same cost and cannot do that.
///
/// ⛔ THIS IS A PROXY FOR "IS IT NECESSARY", NOT A MEASUREMENT OF IT. Two
/// points at the same height on opposite sides of a wall may genuinely need a
/// vertical detour, and this refuses it. The honest version compares path
/// length with links allowed against forbidden — two queries per zombie per
/// repath, ~100/sec at MaxAlive, and it oscillates near ties. Height is cheap,
/// predictable and authorable; when it is wrong, the fix is to place the link
/// somewhere the rule agrees with, or to give that link its own always-on
/// flag.
///
/// ⚠️ Assigns the agent's list ONLY WHEN THE MODE CHANGES. Writing it every
/// think allocates for every zombie alive, and the hysteresis above is what
/// makes changes rare.
/// </summary>
private void TickVerticalGate()
{
if ( !_agent.IsValid() ) return;
// ⚠️ Never mid-crossing. Changing the filter while a zombie is halfway up
// a wall is how one ends up stuck there.
if ( OnLink ) return;
// ⛔ OFF MEANS OFF, INCLUDING WHAT IT ALREADY DID. Returning early without
// clearing would leave whatever the gate last assigned in place forever —
// the zombie stays blocked and the switch reads as having done nothing,
// which is the worst possible outcome for a diagnostic control.
if ( !VerticalGateEnabled )
{
ClearVerticalGate();
return;
}
var want = VerticalMode;
if ( !Target.IsValid() )
{
want = 0;
}
else
{
var dz = Target.WorldPosition.z - WorldPosition.z;
var mag = MathF.Abs( dz );
// Already committed to a direction: keep it until we are clearly back
// on the level, and only for the direction we committed to.
if ( VerticalMode != 0 )
{
if ( mag < VerticalRelease ) want = 0;
else want = dz > 0f ? 1 : -1;
}
else if ( mag >= VerticalEnable )
{
want = dz > 0f ? 1 : -1;
}
}
if ( want == VerticalMode ) return;
VerticalMode = want;
// Forbid the direction that does NOT close the gap, and both when level.
//
// ⚠️ A HashSet of the RESOURCES, not of paths — ForbiddenAreas is typed
// on NavMeshAreaDefinition. A missing resource is skipped rather than
// added as null, which would forbid nothing and read as the gate failing.
var forbidden = new HashSet<Sandbox.Engine.Resources.NavMeshAreaDefinition>();
if ( want <= 0 && NavLinkManager.Area( NavLinkManager.JumpArea ) is { } jump )
forbidden.Add( jump );
if ( want >= 0 && NavLinkManager.Area( NavLinkManager.DropArea ) is { } drop )
forbidden.Add( drop );
_agent.ForbiddenAreas = forbidden;
}
/// <summary>
/// Drop the gate's area filter entirely, so every link is available again.
///
/// ⚠️ RETURNS WHETHER IT CHANGED ANYTHING, so `nz_zvert` can report a real count instead of the
/// number of zombies it looped over — "cleared 14" when 14 were already clear is a lie that makes
/// the next measurement untrustworthy.
///
/// ⚠️ An EMPTY SET, not null. ForbiddenAreas' own documentation reads "if empty, no areas are
/// forbidden", so empty is the documented way to say yes to everything.
/// </summary>
public bool ClearVerticalGate()
{
if ( !_agent.IsValid() ) return false;
if ( VerticalMode == 0 && (_agent.ForbiddenAreas?.Count ?? 0) == 0 ) return false;
VerticalMode = 0;
_agent.ForbiddenAreas = new HashSet<Sandbox.Engine.Resources.NavMeshAreaDefinition>();
return true;
}
// ── AI/THINK ─────────────────────────────────────────────────────────────
protected override void OnUpdate()
{
// ⚠️ THE OUTER SCOPE. Contains every inner zombie.* scope plus whatever is NOT scoped, so
// `zombie.update` minus the sum of the inners is the work still unaccounted for — which is
// how the next unmeasured cost gets found instead of hidden.
using var _scope = CpuScope.Measure( "zombie.update" );
// ⛔ ABOVE THE PUPPET RETURN, ALONE AMONG THESE, AND DELIBERATELY. Every other tick below
// DECIDES something, which a proxy must not do. This one decides nothing — it reconciles
// this machine's copy of the capsule against this machine's own answer to "should zombies
// be solid for the player sitting here". On a client every zombie is a puppet, so behind
// the return Stamin-Up M3 Phase Runner simply never ran: the client walked into bodies the
// augment had already turned off.
TickPhaseRunner();
// ⚠️ ABOVE THE PUPPET RETURN TOO: a headless zombie spurts from the neck on every screen, and the host drops it after
// the last spurt (`ZombieAI.Gore.cs`). Presentation everywhere, the one decision host-only inside.
TickGore();
// ⛔ A PUPPET ANIMATES AND DOES NOTHING ELSE. Everything below decides where this
// zombie should be, and on a proxy that decision has already been made on another
// machine and is arriving as a transform. See IsPuppet.
if ( IsPuppet )
{
TickPuppet();
return;
}
// ⚠️ THE HEALTH, FOR THE OTHER MACHINES' BARS (2026-10-06, `HealthNet`): every frame, sent only on a change
PublishHealth();
// ⚠️ Advances the entrance-verification sweep from HERE, not from
// RoundManager. That component is created on demand and simply is not
// in the scene unless a round has been started — so the sweep ran its
// first clip and then silently stalled forever. A zombie always exists
// mid-sweep (the previous one is still chasing), so this ticks.
ZombieCommands.VerifyTick();
TickOneShot();
TickWatch();
TickSpawnHold();
// Per-tick work only. Decisions live in Think() at 10 Hz.
//
// A dead zombie still ticks its animation: the death clip has to play
// to completion before the object goes away.
if ( State == ZombieState.Dead )
{
TickCorpse();
return;
}
// ⚠️ BEFORE Think(). A spawning zombie must not path, steer or face a
// target — an entrance that slides across the floor toward the player
// is worse than no entrance at all. It owns the whole tick.
// ⚠️ UpdateAnimation is NOT called while spawning — the entrance clip
// owns the renderer outright.
//
// UpdateAnimation bails out only while ActionPlaying is true, and that
// is a REAL-TIME timer taken from the clip's unslowed length. Slow the
// entrance down and the timer expires long before the animation does,
// at which point UpdateAnimation cheerfully replaced the riser with the
// walk loop mid-climb. The symptom was a zombie standing and walking on
// the spot while the log still said Spawning.
if ( State == ZombieState.Spawning )
{
TickSpawn();
if ( ZombieCommands.Debug ) DrawDebug();
return;
}
// ⛔ THE VAULT RUNS EVERY FRAME, AHEAD OF THINK. Think is gated to
// ThinkRate (0.1s = 10/sec) and rendering runs at 60+, so a lerp driven
// from in there redraws the SAME position for six frames and then jumps —
// which is the shaking. A scripted crossing has to be sampled at frame
// rate or it is a slideshow.
if ( TickLinkCross() )
{
UpdateAnimation();
TraceFrame();
return;
}
if ( TickVault() )
{
UpdateAnimation();
TraceFrame();
return;
}
// ⚠️ BESIDE THE VAULT AND FOR THE SAME REASONS — frame rate, not Think rate, and it owns
// the body while it runs. A boss's leap is a scripted crossing like any other.
if ( TickArc() )
{
UpdateAnimation();
TraceFrame();
return;
}
// ⛔ SCOPED SO THE PERF LOG CAN ATTRIBUTE THE COST. A survival log proved the CPU cost is
// linear at ~0.25ms per zombie with no superlinear term, and proved it was not GC, not sound
// count, not spawns and not a think-cadence burst — but it could not say WHICH of these calls
// it is, and reading them narrowed nothing. Each is now its own scope; see CpuScope.
//
// ⚠️ THE `using` COSTS NOTHING WHEN COLLECTION IS OFF — Scope's constructor tests one bool
// and stores no timestamp. Measured by nz_cpu_overhead rather than asserted.
if ( _sinceThink >= ThinkRate )
{
_sinceThink = 0;
using ( CpuScope.Measure( "zombie.think" ) )
Think();
}
using ( CpuScope.Measure( "zombie.watchdog" ) ) TickTraversalWatchdog();
using ( CpuScope.Measure( "zombie.antistuck" ) ) TickAntiStuck();
// ⚠️ THE SECOND WATCHDOG, in the same scope's neighbourhood because it is the same job for
// the case `TickAntiStuck` deliberately exempts. See `TickIdleRecovery`.
using ( CpuScope.Measure( "zombie.antistuck" ) ) TickIdleRecovery();
// ⚠️ AND THE THIRD, for a hazard this file already documents but never guarded.
using ( CpuScope.Measure( "zombie.antistuck" ) ) TickSpeedGuard();
using ( CpuScope.Measure( "zombie.vertgate" ) ) TickVerticalGate();
using ( CpuScope.Measure( "zombie.face" ) ) FaceMovement();
using ( CpuScope.Measure( "zombie.separation" ) ) UpdateSeparation();
using ( CpuScope.Measure( "zombie.anim" ) ) UpdateAnimation();
TraceFrame();
using ( CpuScope.Measure( "zombie.footsteps" ) ) TickFootsteps();
using ( CpuScope.Measure( "zombie.voice" ) ) TickVoice();
using ( CpuScope.Measure( "zombie.followvoices" ) ) FollowVoices();
if ( ZombieCommands.Debug ) DrawDebug();
}
private float _lastStepTime;
private string _stepClip;
/// <summary>
/// Footsteps, fired from the clip's own authored event times.
///
/// ⚠️ NOT A SPEED-SCALED TIMER, and that is the whole point. The obvious
/// implementation — a metronome whose interval falls as the zombie speeds up
/// — is not what the original does and does not sound like it. GMod fires
/// footsteps from animation events baked into each clip
/// (nz_zombiebase_moo.lua:1225), and their spacing is deliberately uneven:
/// nz_walk_ad1 steps at frames 7, 33, 46, 67, 91, 114 — gaps of 26, 13, 21,
/// 24, 23. That short-long limp IS the zombie gait. An even rhythm makes the
/// horde march in step.
///
/// Driven off TimeNormalized, so the timings survive the playback-rate
/// scaling that UpdateAnimation applies to match ground speed — and a zombie
/// that stops moving stops stepping for free, because its playback rate goes
/// to nearly zero and it never crosses another marker.
/// </summary>
private void TickFootsteps()
{
if ( !_renderer.IsValid() ) return;
var seq = _renderer.Sequence;
if ( seq is null ) return;
var clip = seq.Name;
var t = seq.TimeNormalized;
// A clip change resets the cursor rather than firing everything between
// the old position and the new one — otherwise every animation swap
// dumps a burst of steps at once.
if ( clip != _stepClip )
{
_stepClip = clip;
_lastStepTime = t;
return;
}
if ( !WalkerFootsteps.Has( clip ) ) { _lastStepTime = t; return; }
var steps = WalkerFootsteps.For( clip );
// A looping cycle wraps past 1.0 back to 0. Handled as two spans so the
// steps near the end of the loop are not skipped every single lap.
if ( t >= _lastStepTime )
{
FireSteps( steps, _lastStepTime, t );
}
else
{
FireSteps( steps, _lastStepTime, 1f );
FireSteps( steps, -0.001f, t );
}
_lastStepTime = t;
}
private void FireSteps( WalkerFootsteps.Step[] steps, float from, float to )
{
foreach ( var s in steps )
{
if ( s.Time <= from || s.Time > to ) continue;
// ⚠️ At the FEET, not VoicePosition. A footstep is the one cue that
// genuinely comes from the floor, and it is emitted where the foot
// lands — so unlike a voice it must NOT be followed afterwards.
NZSound.PlayAmbient(
s.Heavy
? ZombieVariant.Cue( Variant?.StepRunSound,
ZombieVariant.Cue( Variant?.StepSound, NZSound.ZombieStepRun ) )
: ZombieVariant.Cue( Variant?.StepSound, NZSound.ZombieStep ),
WorldPosition, SoundGate.Step );
// ⛔ HERE, NOT ON A TIMER, so the shake is locked to the foot actually landing. The step
// times are the original's own animation events, so a heavy zombie thumps in the rhythm
// he is animated at rather than to a metronome that drifts against it.
//
// ⚠️ ZERO FOR EVERY ORDINARY ZOMBIE — see `ZombieVariant.StepShake`. A horde of walkers
// firing this would be a permanent tremor.
if ( (Variant?.StepShake ?? 0f) > 0f )
CameraShake.Punch( WorldPosition,
Variant.StepShake * (s.Heavy ? 1f : 0.6f),
Variant.StepShakeRange );
}
}
private TimeUntil _nextVoice = 0f;
/// <summary>
/// Every voice this zombie currently has in the air, so their emitters can be
/// dragged along with it.
///
/// ⚠️ A LIST, NOT TWO SLOTS. With one field per cue, the next groan
/// overwrites the last one still playing and that sound is orphaned — it
/// stops being moved and stays where it started while the zombie walks off.
/// Measured: drift held at 1 unit until the zombie's own voice timer fired
/// again at t=3.0s, then ran to 121 units. Cues overlap, so the container
/// has to as well.
/// </summary>
private readonly List<SoundHandle> _voices = new();
/// <summary>
/// Where this zombie's voice comes from — the head, not the object origin.
///
/// ⚠️ The origin is at the FEET. At the 1400-unit falloff a 55-unit offset is
/// nothing, but a zombie swinging at you is 80 units away, and at that range
/// emitting from the floor puts the growl 35° below where the thing actually
/// is. `j_head` measures +55 to +59 local on this skeleton (nz_bones).
/// </summary>
private Vector3 VoicePosition => WorldPosition + Vector3.Up * 55f;
/// <summary>Hand this zombie a voice handle to carry. Public so a diagnostic
/// can play something and have it followed exactly like a real cue.</summary>
/// <summary>
/// How much further than authored a zombie can be heard.
///
/// ⛔ SET ON THE HANDLE, NOT ON THE SoundEvent. The asset is SHARED — every zombie plays the same
/// cue — so writing Distance on the event would change it globally, permanently, and for anything
/// else that happens to use that cue. `CreateBulletImpact` already does exactly that with
/// `sound.Distance = 10000` and it is a bug waiting to be noticed. SoundHandle.Distance is
/// per-playback, which is what this needs.
///
/// ⚠️ NULLABLE-BACKED, because a static's default does not survive a hotload — the trap that had
/// the vertical gate reporting ON for a whole session after being set to false.
/// </summary>
public static float VoiceRangeScale
{
get => _voiceRangeScale ??= 4f;
set => _voiceRangeScale = value;
}
static float? _voiceRangeScale;
/// <summary>
/// Widen a zombie sound's audible radius, then track it.
///
/// ⛔ APPLIED HERE BECAUSE EVERY ZOMBIE VOICE ALREADY FUNNELS THROUGH TrackVoice. Scaling at each
/// of the five call sites would mean the next sound added is the one that gets forgotten — the
/// same "enumerated somewhere else" shape the barricade PassBullets note argues against.
///
/// ⚠️ MULTIPLIES WHAT THE ASSET AUTHORED rather than assigning a flat number, so a cue
/// deliberately made quiet stays relatively quiet. A zero or negative authored distance is left
/// alone: it means "not spatialised", and multiplying it would still be zero while implying the
/// setting had been applied.
/// </summary>
public void TrackVoice( SoundHandle handle )
{
if ( handle.IsValid() && VoiceRangeScale > 0f && handle.Distance > 0f )
handle.Distance *= VoiceRangeScale;
if ( handle.IsValid() ) _voices.Add( handle );
}
/// <summary>
/// How many of this zombie's voices are still playing.
///
/// ⚠️ EXPOSED FOR THE PERF LOG, and the number it answers is a real suspicion: there is a
/// per-zombie cooldown on ambient voices but NO GLOBAL CAP, and FollowVoices repositions every
/// live emitter every frame. So the cost scales with zombies x voices each, and "the sound
/// overlaps itself and lags" was a reasonable read of that. Summed across ZombieAI.All it is the
/// only way to find out whether it is true.
/// </summary>
public int VoiceCount => _voices.Count;
/// <summary>Zombie voices playing across the whole scene.</summary>
public static int TotalVoices
{
get
{
int n = 0;
foreach ( var z in All ) if ( z.IsValid() ) n += z._voices.Count;
return n;
}
}
/// <summary>
/// Keep playing voices glued to the zombie.
///
/// ⚠️ Sound.Play( cue, position ) CREATES A STATIC EMITTER. It samples the
/// position once and leaves the sound where it was born, so a groan started
/// mid-stride stays behind while the zombie walks out from under it — about
/// 80 units on a walk, 250 on a sprint. Nothing about the SoundEvent fixes
/// that; the emitter has to be moved by hand, every frame, for as long as it
/// is playing.
///
/// Deliberately NOT applied to the spawn or death cues: the spawn is anchored
/// above its hole on purpose, and a corpse is not going anywhere.
/// </summary>
private void FollowVoices()
{
if ( _voices.Count == 0 ) return;
var at = VoicePosition;
// Backwards so removal does not skip entries.
for ( int i = _voices.Count - 1; i >= 0; i-- )
{
var h = _voices[i];
if ( !h.IsValid() || h.IsStopped )
{
_voices.RemoveAt( i );
continue;
}
h.Position = at;
// Written back in case SoundHandle is a value type — assigning
// through the copy would otherwise move nothing, and the bug would
// look exactly like the orphaning this list was added to fix.
_voices[i] = h;
}
}
/// <summary>
/// Idle groaning, and the faster snarl when sprinting.
///
/// ⚠️ THE INTERVAL IS PER ZOMBIE AND RANDOMISED. A shared timer, or a fixed
/// one, makes eighty zombies groan in lockstep — which reads as a single
/// looping sound effect rather than a crowd. The random spread is what turns
/// the same six samples into a horde.
///
/// Goes through the ambient budget, so at high round counts most of these are
/// dropped on purpose and the ones that survive are the ones near the player.
/// </summary>
private void TickVoice()
{
// ⚠️ A HEADLESS ZOMBIE GROANS NO MORE (the original's idle sounds ask `!self:IsDecapitated()`, `ZombieAI.Gore.cs`)
if ( _headless ) return;
if ( _nextVoice > 0f ) return;
var sprinting = VoiceSpeed > 180f;
var cue = sprinting
? ZombieVariant.Cue( Variant?.SprintSound,
ZombieVariant.Cue( Variant?.IdleSound, NZSound.ZombieSprint ) )
: ZombieVariant.Cue( Variant?.IdleSound, NZSound.ZombieIdle );
// ⚠️ CLOSE RANGE OVERRIDES BOTH. The hellhound set splits its voice into
// 12 `move` clips and 4 `close` ones, and that split is the original's
// actual warning that one is on you — losing it makes a pack sound
// uniform no matter how near it gets. Walkers set no CloseSound and are
// unaffected.
if ( !string.IsNullOrWhiteSpace( Variant?.CloseSound ) && Target.IsValid() )
{
var range = Variant.CloseSoundRange;
if ( Target.WorldPosition.DistanceSquared( WorldPosition ) < range * range )
cue = Variant.CloseSound;
}
// Reset either way: a dropped cue must not retry next frame, or a horde
// over budget would hammer the check eighty times a tick.
TrackVoice( NZSound.PlayAmbient( cue, VoicePosition, SoundGate.Voice ) );
// ⚠️ THE VARIANT'S OWN GAP WINS AND IGNORES THE SPEED SPLIT ENTIRELY — a boss that is always
// above the sprint threshold would otherwise always take the short branch. See
// `ZombieVariant.VoiceIntervalMin`.
var vMin = Variant?.VoiceIntervalMin ?? 0f;
var vMax = Variant?.VoiceIntervalMax ?? 0f;
_nextVoice = vMin > 0f
? Game.Random.Float( vMin, MathF.Max( vMin, vMax ) )
: sprinting
? Game.Random.Float( 1.2f, 2.6f )
: Game.Random.Float( 2.5f, 6f );
}
/// <summary>Drive the played sequence from state. Crude for now — one walk
/// clip — but it puts the plumbing in place for the speed-tier system.</summary>
private void UpdateAnimation()
{
if ( !_renderer.IsValid() ) return;
// ⛔ A SPECIAL OWNS THE RENDERER, AND UNTIL NOW NOTHING SAID SO. `PlaySpecial` is a second
// one-shot mechanism — it sets `_specialClip` and holds the state, where `PlayAction` sets
// `_actionClip` — and only the second was checked here. So every ability clip in the game
// was set by `PlaySpecial` and then overwritten by the walk clip on the very next frame:
// the effect landed on time, the model kept walking, and the two are indistinguishable from
// outside unless you know which clip you expected.
//
// ⚠️ IT COST A ROUND TRIP AS "the leap works, but you did not use the leap animation" — the
// arc was visibly moving him while his legs cycled a walk. Every other special had the same
// fault and no movement to make it obvious.
//
// ⚠️ RATE 1, LIKE AN ACTION. The ability was started at 1 and its timings are real seconds;
// scaling it by how fast the body happens to be moving would re-time every event in it.
if ( State == ZombieState.Special && !string.IsNullOrEmpty( _specialClip ) )
{
// ⚠️ THE SPECIAL'S OWN RATE, 1 unless it asked for another (`PlaySpecial`'s `rate`)
_renderer.PlaybackRate = _specialRate > 0.01f ? _specialRate : 1f;
return;
}
// A one-shot owns the renderer until it finishes. Playback rate is
// forced to 1 so an attack or death plays at its authored speed rather
// than being scaled by how fast the zombie was moving.
if ( ActionPlaying )
{
_renderer.PlaybackRate = _actionRate;
return;
}
_actionClip = null;
PlaySequence( _walkSequence );
// Match playback to actual speed so the feet don't skate. The original
// derives movement speed FROM the animation (moo:454); we're doing the
// inverse until the animgraph exists, which is the pragmatic version of
// the same intent.
//
// ⚠️ AGAINST THE CLIP'S AUTHORED SPEED, NOT AGAINST MoveSpeed. Those were
// the same number until MinMoveSpeed existed. They are not any more: a
// zombie whose clip carries 35 u/s but is floored to 55 is travelling
// 1.6x faster than its legs are animating, and dividing by MoveSpeed
// would give a rate of 1.0 and hide that — the feet would skate exactly
// as far as the floor lifted it.
// ⛔ DURING A WALK CROSSING THE AGENT IS STOPPED, SO `Velocity` IS ~0. The body is being
// moved by the lerp in TickLinkCross, not by the agent, and reading the agent here clamped
// the walk clip to 0.05x — a zombie sliding across a step with its legs almost frozen. Use
// the speed the crossing was timed at, which is the speed the body is genuinely travelling.
// ⚠️ AND WHILE A BODY WITHOUT THE CLIP IS CARRIED — a clip-less climb or drop, or a hop through a window — for the same
// reason: the agent is stopped, and the body moves at the pace it was timed to.
float speed = _crossing && (_crossWalk || _crossNoClip) && _crossWalkSpeed > 1f
? _crossWalkSpeed
: _vaulting is not null && _vaultHopSpeed > 1f
? _vaultHopSpeed
: Velocity.WithZ( 0 ).Length;
float authored = _clipGroundSpeed > 1f ? _clipGroundSpeed : MoveSpeed;
_renderer.PlaybackRate = authored > 1f
? Math.Clamp( speed / authored, 0.05f, AnimRateCap )
: 1f;
}
/// <summary>
/// AI/FACING — turn toward where we're going, per-tick.
///
/// The original drives yaw itself rather than letting the locomotion lerp
/// it, and scales the rate with speed so faster zombies turn faster:
/// loco:SetMaxYawRate( MaxYawRate + speed * 0.85 ) moo:454-464
///
/// Without this they drift and slide, because the agent's own rotation
/// smoothing is tuned for something with momentum.
/// </summary>
private void FaceMovement()
{
// ⛔ A ZOMBIE ON ITS FACE DOES NOT TURN TO LOOK AT YOU. This runs every tick and falls back
// to facing the target when stationary, so without this it would rewrite the rotation the
// pratfall just set — every frame — and the body would stand straight back up while still
// rooted.
if ( _pratfall ) return;
// Prefer actual travel direction; fall back to the target so they still
// face you while stationary in melee.
var dir = Velocity.WithZ( 0 );
if ( dir.Length < 1f && Target.IsValid() )
dir = (Target.WorldPosition - WorldPosition).WithZ( 0 );
if ( dir.Length < 0.1f ) return;
// ⚠️ SMOOTH THE TARGET DIRECTION, NOT JUST THE TURN RATE.
//
// Capping angular velocity makes a turn smooth; it does not stop the
// thing being turned TOWARD from jumping. Two sources make it jump:
//
// the path RepathInterval is 0.5 + 0.05*count seconds at close
// range, so at ten zombies the route — and with it the
// agent's heading — only refreshes once a SECOND, then
// moves in one step.
// crowding Separation shoves the agent sideways frame to frame, so
// raw velocity direction jitters even walking straight.
//
// A low-pass on the direction turns both into a continuous drift. It is
// framerate-independent: the same time constant regardless of dt.
dir = dir.Normal;
if ( _faceDir.Length < 0.01f )
_faceDir = dir; // first frame: adopt, don't ease
else
{
float blend = 1f - MathF.Exp( -Time.Delta / MathF.Max( 0.01f, FaceSmoothing ) );
_faceDir = Vector3.Lerp( _faceDir, dir, blend ).Normal;
}
// ⚠️ THE LEAN GOES BETWEEN THE FACING AND THE RIG CORRECTION, so it pitches about the
// body's own right axis rather than about whatever axis `ModelTurn` happens to leave
// pointing sideways. See `LeanPitch`.
//
// ⚠️ AND THE TURN-RATE LERP BELOW EASES IT FOR FREE. `wanted` moves as the lean ramps,
// and the body chases it at `rate` degrees a second, so a lean that snaps in the caller
// still arrives smoothly on screen.
var wanted = Rotation.LookAt( _faceDir, Vector3.Up )
* Rotation.FromPitch( LeanPitch )
* ModelTurn;
// Turn at a real angular velocity in degrees/sec.
//
// The previous version lerped by Time.Delta * (rate / 180), which is
// not an angular velocity at all - the number was meaningless and the
// turn rate drifted with framerate and with how far it had to turn.
//
// Now: work out the actual angle remaining, move at most
// (rate * dt) degrees of it this frame, and snap when close enough.
float rate = MaxYawRate + MoveSpeed * 0.85f;
float remaining = Rotation.Difference( WorldRotation, wanted ).Angle();
if ( remaining <= 0.5f )
{
WorldRotation = wanted;
return;
}
float step = rate * Time.Delta;
WorldRotation = step >= remaining
? wanted
: Rotation.Lerp( WorldRotation, wanted, step / remaining );
}
/// <summary>
/// Per-FRAME position/facing dump for the nearest zombie. `nz_ztrace 1`.
///
/// ⛔ PER FRAME, NOT PER THINK — that distinction is the entire point. Think
/// runs at ThinkRate (0.1s, so 10/sec) while rendering runs at 60+, so
/// anything Think moves is drawn at the SAME PLACE for six frames and then
/// jumps. Sampling this on the think tick would show a perfectly smooth line
/// and prove nothing; the judder only exists between the samples.
///
/// ⚠️ Nearest zombie ONLY. A horde tracing at 60 lines a second per zombie is
/// not a log, it is a denial of service on the console.
/// </summary>
private void TraceFrame()
{
if ( !ZombieCommands.Trace ) return;
if ( this != ZombieCommands.TraceSubject() ) return;
var v = Velocity.WithZ( 0 );
var f = WorldRotation.Forward.WithZ( 0 );
// The three things that can disagree during a vault: where we ARE, where
// we are FACING, and where we are MOVING. Printed together because the
// bug is always one of them contradicting the other two.
Log.Info( $"[ztrace] {State,-9} pos {WorldPosition.x,7:0.0},{WorldPosition.y,7:0.0},{WorldPosition.z,6:0.0}"
+ $" yaw {f.EulerAngles.yaw,6:0.0}"
+ $" vel {v.Length,5:0.0} @ {(v.Length > 0.1f ? v.EulerAngles.yaw : 0f),6:0.0}"
+ $" anim {(ActionPlaying ? _actionClip : "-"),-28}"
+ $" vault {(_vaulting is null ? "-" : $"{(1f - (float)_actionDone / MathF.Max( 0.01f, _actionLength )) * 100:0}%")}" );
}
/// <summary>AI/DEBUG — state, target line and distance over each zombie.
/// You can't read the code, so this is how you see whether it works.
/// Toggle with `nz_zdebug 1`.</summary>
private void DrawDebug()
{
var head = WorldPosition + Vector3.Up * (BodyHeight + 12f);
var colour = State switch
{
ZombieState.Chasing => Color.Yellow,
ZombieState.Attacking => Color.Red,
ZombieState.Stunned => Color.Blue,
_ => Color.Gray,
};
var label = $"{State} hp{HealthNow:0}";
if ( Target.IsValid() )
{
float d = Vector3.DistanceBetween( WorldPosition, Target.WorldPosition );
label += $" d{d:0}";
Gizmo.Draw.Color = colour;
Gizmo.Draw.Line( WorldPosition + Vector3.Up * 40f,
Target.WorldPosition + Vector3.Up * 40f );
}
Gizmo.Draw.Color = colour;
Gizmo.Draw.ScreenText( label, Gizmo.Camera.ToScreen( head ), size: 12 );
}
/// <summary>
/// Pick up a change in speed status and push it to the agent.
///
/// ⛔ THE PUSH IS WHY THIS IS NOT JUST A CACHE REFRESH. Nothing re-reads MoveSpeed
/// on a schedule: Repath does, but RepathInterval is 0.5+0.05*count scaled by 0.5 —
/// 0.25 s with one zombie and 1.1 s with a full horde of 35 — and TickAttack's
/// branch only runs while attacking. Writing MaxSpeed here bounds the delay to one
/// think tick (0.1 s), which is the difference between a status that lands when you
/// shoot and one that lands a second later for no visible reason.
///
/// ⚠️ ONLY ON A CHANGE. The comparison is what keeps this off the agent every
/// tick for the 99% of zombies carrying no status at all.
/// </summary>
private void TickStatusSpeed()
{
// ⛔ NOT WHILE AN ABILITY OR A PRATFALL HOLDS IT (2026-10-04). `Special` owns the agent — stopped, speed 0 — and the move
// order re-issued below would set a zombie lying on its back from a banana slip or a Shockwave walking again the
// moment a status landed or ran out. The change is left for the first think after it ends: the caches below are not
// written, so it is still a change then.
if ( State == ZombieState.Special ) return;
var scale = StatusEffects.SpeedScaleOf( GameObject );
var time = TimeAugments.SpeedScaleFor( this );
// ⚠ BOTH FACTORS ARE COMPARED BEFORE EITHER IS WRITTEN. The early-out exists so the
// agent is not written every tick; testing only the status factor would have made a
// change in the Timeslip factor invisible until something else happened to move the
// status one — §4, an early-out gating everything below it.
if ( MathF.Abs( scale - _statusSpeedScale ) < 0.001f
&& MathF.Abs( time - _timeScale ) < 0.001f ) return;
_statusSpeedScale = scale;
_timeScale = time;
if ( !_agent.IsValid() ) return;
_agent.MaxSpeed = AgentSpeed;
// ⛔ THE MOVE ORDER MUST BE RE-ISSUED, AND THIS IS THE BUG THAT MADE SLOWED ZOMBIES
// STOP DEAD. Writing `MaxSpeed` on its own cancels the agent's current path — measured:
// a zombie walking at 55 crossed into Timeslip's aura, the factor changed from 1 to 0.5,
// this line ran, and the zombie stopped at 152u with a 160u aura radius. It never moved
// again while `Chasing`, `MaxSpeed` correct at 27.5 and velocity 0.
//
// ⚠ `Repath` IS WHY THIS WAS INVISIBLE UNTIL NOW. That method writes `MaxSpeed` AND
// follows it with `MoveTo`, so every other speed change in this file happened to repair
// itself. This was the only site that wrote the field alone — and nothing wrote a partial
// slow before Timeslip, because every status effect to date is SpeedScale 0 or 1.
//
// ⚠ ONLY WHEN THERE IS SOMEWHERE TO GO. `Repath` returns immediately without a target,
// and a zombie with no target is the idle-drift path's business, not this one's.
if ( Target.IsValid() ) Repath();
}
/// <summary>
/// How many times Think, TickChase, and TickChase-past-the-vault-guard have run.
/// For `nz_target`.
///
/// ⛔ THREE COUNTERS, NOT ONE, because "the zombie never retargets" has three stalls
/// stacked on top of each other — OnUpdate returning before Think, the state switch not
/// dispatching, and TickChase's `_vaulting` guard bailing — and all three present as an
/// untouched retarget timer. Reading the code narrowed it to "all three look correct",
/// which is exactly where reasoning stops paying and §7 applies.
///
/// ⚠️ Cheap on purpose: three int increments on a 10 Hz method. Kept permanently — the
/// next time a zombie freezes, this is the first reading to take.
/// </summary>
public int ThinkCount { get; private set; }
public int ChaseCount { get; private set; }
public int ChasePastVault { get; private set; }
private void Think()
{
ThinkCount++;
TickStatusSpeed();
// ⛔ A TARGET THAT HAS GONE DOWN IS DROPPED AT ONCE, NOT AT THE NEXT RETARGET.
// The candidate list already excludes downed players, but retargeting runs on a
// 3-15s cadence — so a zombie already locked on carried on eating the body for up
// to fifteen seconds after it fell, which is most of a bleedout and reads exactly
// like the filter not working.
//
// ⛔ AND IT BELONGS HERE, NOT IN TickChase. `Think` dispatches Chasing and Idle to
// TickChase, but a zombie tearing boards at a window is in **Attacking**, which
// goes to TickAttack — so in TickChase the check never ran for exactly the zombies
// the bug was about. `TickAttack` could not catch it either: it drops a target only
// when the target is INVALID, and a downed player is perfectly valid — on the floor,
// not destroyed. So the zombie tore boards forever and never looked up when the
// player got back on their feet.
//
// ⛔ THE OLD TARGET IS PUT BACK IF NOBODY ELSE IS UP. Clearing it unconditionally
// left every zombie targetless the moment a solo player went down, and they all
// walked to the nearest window and stayed there — including after the revive.
//
// ⚠️ Re-acquires immediately rather than waiting: dropping the target alone would
// leave the zombie standing over the body doing nothing, which looks just as broken
// as attacking it.
if ( Target.IsValid()
&& Target.Components.Get<NZPlayer>() is { IsDown: true } victim )
{
var downed = Target;
Target = null;
AcquireTarget();
// ⛔ BUT A BLED-OUT BODY IS NEVER PUT BACK, AND THAT IS THE BUG THIS FIXES. The
// fallback above exists so a solo player going down does not leave every zombie
// standing at a window — a downed player is on the floor, in the world, and being
// eaten is the right outcome. A player who has BLED OUT is a different state
// entirely: `ApplyOutOfRoundBody` has switched off their renderer, collider, gravity
// and motion, so the body is out of the world in every sense except that the
// GameObject still exists.
//
// ⚠️ AND `IsValid()` DOES NOT NOTICE THAT. A disabled GameObject is still valid, so
// `TickAttack`'s `if ( !Target.IsValid() )` guard passed and the horde swarmed an
// invisible, untouchable body and swung at it forever. User: *"when the player bleeds
// out the zombies break."*
//
// ⚠️ NULL IS THE HONEST ANSWER when everybody is out. Idling at a window is what the
// zombies did before a target existed; mobbing a ghost is not a better failure.
if ( !Target.IsValid() && !victim.IsOutOfRound )
Target = downed;
}
// ⛔ AND A TARGET THE HORDE MAY NO LONGER SEE IS DROPPED HERE TOO, IN EVERY STATE (2026-10-05, with the Arsenal and
// Wunderfizz menus). `TickChase` drops one for a chasing zombie, but a zombie mid-swing is in `TickAttack`, which holds a
// target until it is INVALID, so the swing that was starting as a player opened a menu still landed. NEVER PUT BACK, unlike
// a downed one: being hidden is the point, so with nobody else to go for the zombie does what it does with no target.
if ( Target.IsValid()
&& Target.Components.Get<NZPlayer>() is { IsUntargetable: true } )
{
Target = null;
AcquireTarget();
}
switch ( State )
{
case ZombieState.Chasing: TickChase(); break;
case ZombieState.Attacking: TickAttack(); break;
case ZombieState.Stunned: break; // held by the stun timer
case ZombieState.Special: TickSpecial(); break;
case ZombieState.Idle: TickChase(); break;
}
}
private void TickChase()
{
ChaseCount++;
// ⚠️ The vault itself is driven from OnUpdate at frame rate, not here —
// see the comment there. Think only DECIDES to start one.
if ( _vaulting is not null ) return;
ChasePastVault++;
// ⛔ HELD OUTSIDE ITS WINDOW (`ParkedAt`): tear it, then climb through it, and nothing else.
// ⚠️ ITS WINDOW GONE (the barricades rebuilt in Creative): the agent takes it, as it would anywhere
if ( ParkedAt is not null )
{
if ( !ParkedAt.IsValid() ) GiveTransformToAgent();
else
{
TickParked();
return;
}
}
// ⛔ AND THE SAME FOR THE GAS, BECAUSE THE FILTER ALONE IS NOT ENOUGH.
// `AcquireTarget` sets a retarget cadence of `Clamp(dist/200, 3, 15)` seconds, so
// a zombie already mid-chase would keep coming for up to FIFTEEN seconds after
// its target stepped into the cloud. The gas lasts twelve. Without this line the
// augment would appear not to work at all in the case it exists for — being
// chased.
//
// ⚠️ RE-ACQUIRES IMMEDIATELY, matching the downed-player block above and for the
// same reason: dropping the target alone leaves the zombie standing still, which
// looks as broken as ignoring the gas would.
if ( Target.IsValid()
&& Target.Components.Get<NZPlayer>() is { IsUntargetable: true } )
{
Target = null;
AcquireTarget();
}
// ⛔ AND THE SAME FOR THE BANANA STAND, FOR THE SAME REASON THE GAS NEEDS IT. The candidate
// filter alone only bites on the next ACQUIRE, and the cadence is 3-15 seconds — so a
// zombie mid-chase that walks into a stand's radius would keep coming for you for up to
// fifteen more seconds. `BananaStand.PullNearby` covers the zombies in range at the moment
// the stand LANDS; this covers every one that arrives afterwards, which is most of a horde
// being led somewhere.
//
// ⚠️ ONLY WHEN THE CURRENT TARGET IS A PLAYER. A zombie already on a stand must not
// re-acquire every tick — `AcquireTarget` resets the cadence, so doing that to the whole
// horde standing around one lure would put the retarget scan back on a per-frame budget,
// which is the cost the 3-15s cadence exists to avoid.
if ( Target.IsValid()
&& Target.Components.Get<NZPlayer>().IsValid()
&& (BananaStand.Luring( WorldPosition ) || ReanimatorHuman.Luring( WorldPosition )) )
{
Target = null;
AcquireTarget();
}
if ( _untilRetarget <= 0 )
AcquireTarget();
if ( !Target.IsValid() )
{
// Original: if nothing is targetable and we're off-screen, despawn
// and recycle rather than let one zombie stall the round
// (OnNoTarget, moo:3185-3205).
OnNoTarget();
return;
}
float dist = Vector3.DistanceBetween( WorldPosition, Target.WorldPosition );
// An OPEN barricade between us and the target gets climbed rather than
// walked through. Checked before the boarded case: a barricade cannot be
// both, and this is the cheaper test.
if ( _vaulting is null && VaultableBarricade() is Barricade open )
{
StartVault( open );
return;
}
// ⚠️ A BOARDED BARRICADE IS CHECKED FIRST, and on its own reach rather than
// the player's. A zombie stopped at a window is nowhere near the player —
// gating this on `dist` would mean it only ever tore boards while the
// player stood right behind them.
if ( BlockingBarricade() is { } boarded && CanAttack() )
{
StandOutside( boarded );
SetState( ZombieState.Attacking );
return;
}
// ⚠️ A BANANA COLADA WALL STOPS THEM THE WAY A BOARDED WINDOW DOES, and is checked right
// after it for that reason — same rule, same shape, one after the other. Nothing physically
// blocks either: the zombie stops because the AI stops, which is what makes "they must hit
// it to get through" true without giving a decal a collider the nav mesh would ignore.
if ( BlockingWall() is not null && CanAttack() )
{
SetState( ZombieState.Attacking );
return;
}
if ( dist <= EffectiveAttackRange() && CanAttack() && !MeleeDisabled )
{
SetState( ZombieState.Attacking );
return;
}
if ( _sinceRepath >= RepathInterval( dist ) )
{
_sinceRepath = 0;
Repath();
}
}
// ── AI/TARGETING ─────────────────────────────────────────────────────────
// Nearest valid target by pure distance. NO line-of-sight, NO aggro
// accumulation. This is faithful to CoD Zombies — they always know where
// you are — and is a deliberate design decision, not an oversight
// (moo:4474-4507, reference doc §9.3).
/// <summary>
/// How close a zombie must be to start tearing. Generous — it stops at the
/// barricade rather than walking into it, and the navmesh does not know the
/// barricade exists, so it will happily path straight through.
/// </summary>
/// <summary>
/// How close to a window's RUN a zombie must be before it will stop and tear it. 55.
///
/// ⛔ IT WAS 38 AND THE AGENT CANNOT GET THAT CLOSE, WHICH IS THE WHOLE BUG. Measured over
/// a 1173-row session trace on cs_cruise (`nz_watch`, watch_002.log): 142 zombies stalled while
/// CHASING, and 107 of them — 75% — were standing at a barricade with `InReach` returning
/// false. Their distance to the run:
///
/// 38u 9 ← the floor is EXACTLY the old threshold
/// 39u 8
/// 40u 18
/// 41u 20
/// 42u 29 ← the mode
/// 43u 3
/// 45u+ 20
///
/// Nothing below 38 ever failed. The nav agent stops about four units outside the gate,
/// every time, so the zombie arrives at an intact window, `BlockingBarricade()` returns null,
/// it never enters Attacking, and it stands there until the anti-stuck relocates it — which
/// fired 91 times in that session. Reported as *"zombies nas janelas tao kinda fucked as vezes"*.
///
/// ⚠️ 55 RATHER THAN 42, EVEN THOUGH 42 IS THE MODE. A threshold set AT the cluster catches
/// half of it — 42 recovers 59 of the 107, 46 recovers 92, 55 recovers 97.
///
/// ⛔ AND 55 IS WHERE THE DATA ITSELF BREAKS, WHICH IS WHY IT IS NOT 60 OR 80. Sorted, the
/// failures above 46 read:
///
/// 47.1 47.6 48.3 51.2 53.8 | 56.8 60.2 69.4 77.5 86.8 95.4 132.8 244.4 255.6 257.7
///
/// There is a clean gap after 53.8. Everything to the left is the same population as the main
/// cluster — an agent parked just outside the gate on a slightly wider frame. Everything to the
/// right trails off to a quarter of a map away, where the nearest barricade is simply the
/// nearest OBJECT and has nothing to do with why that zombie stopped. Chasing those with reach
/// would be fitting a threshold to noise.
///
/// ⛔ AND THE CEILING IS NOT ARBITRARY EITHER: `BlockingBarricade` HAS NO DIRECTION TEST. It
/// takes the nearest in-reach unopened barricade whether or not it lies between the zombie and
/// its target — `VaultableBarricade` checks "genuinely between us and the target" and this does
/// not. So every unit of reach is also a unit of "stops to tear a window it was only walking
/// past". At 55 a zombie's body edge is about 39u from the run, which still reads as reaching
/// the frame; much beyond that and it would be tearing boards from across a corridor.
///
/// ⚠️ IT IS SHARED WITH `BlockingWall`, which passes the same value to
/// `Placeable.WallBlocking`. That is defensible — both answer "how close before I stop and
/// break this" — but it does mean this number widens two gates, not one.
///
/// ⛔ THE VERTICAL HALF WAS NOT THE PROBLEM AND IS UNTOUCHED. `InReach` also tests
/// `|z - runZ| <= VerticalReach (64)`, and on a multi-deck ship that was the obvious suspect —
/// stall heights in the same log span -184 to +168. Exactly ONE of the 142 failed vertically.
/// The measurement is the only reason that theory was dropped instead of acted on.
///
/// ⚠️ IT DOES NOT FIX THE OTHER 35. Those had `InReach` TRUE and 30 of them were at a window
/// with NO planks left — an open window they would not cross, which is the traversal latches
/// (`ReCrossDelay` 2s, `ReCrossPatience` 6s) and not this gate at all. Their stall durations
/// cluster under those two numbers, so most are the design working slowly rather than failing.
/// </summary>
[Property] public float BarricadeReach { get; set; } = 55f;
// ── vaulting an open barricade ───────────────────────────────────────────
/// <summary>The barricade being climbed, or null.</summary>
// ── AI/LINK CROSSING ─────────────────────────────────────────────────────
//
// ⚠️ A SEPARATE PATH FROM THE BARRICADE VAULT, NOT A GENERALISATION OF IT.
// Folding both into one course-crossing routine is the tidier code and was
// the plan — but the vault is working, subtle (it took a transform-ownership
// bug and a lift-stacking bug to get right) and could not be re-tested at the
// time this was written. The duplication is a few lines; breaking barricade
// vaulting to save them is not a trade worth making. Merge them once link
// crossings are proven in play.
/// <summary>Playback multiplier for a CLIMB. Above 1 is faster, and the whole
/// crossing shortens with it — the body is lerped over the CLIP's duration, so
/// speeding the clip speeds the traversal by the same factor.
///
/// ⚠️ MUCH FASTER THAN THE DROP, AND THAT IS FROM WATCHING IT. A climb is an
/// effort — the body should snap up and be done — while a fall has weight and
/// wants the time. One shared number could not express that, which is why
/// there are now two.</summary>
public static float CrossSpeedUp { get; set; } = 5f;
/// <summary>Climb speed in UNITS PER SECOND. 0 falls back to clip timing.
///
/// ⛔ THE CROSSING USED TO TAKE THE CLIP'S TIME — A CONSTANT DURATION, NOT A
/// CONSTANT SPEED. A 48u hop and a 300u climb both finished in the same
/// fraction of a second, so the tall one moved six times faster. Duration is
/// now distance / speed, so a longer crossing simply takes longer.</summary>
public static float CrossUnitsUp { get; set; } = 450f;
/// <summary>Drop speed in units per second.
///
/// ⚠️ SLOWER THAN THE CLIMB (300 vs 450), which is the opposite of what physics
/// would give you — a fall accelerates. It is a readability choice, arrived at
/// by watching: a climb is an effort that should look decisive, while a drop is
/// the moment the player needs to see coming.</summary>
public static float CrossUnitsDown { get; set; } = 300f;
/// <summary>Playback multiplier for a DROP.</summary>
public static float CrossSpeedDown { get; set; } = 3.5f;
/// <summary>
/// How sharply a crossing goes UP-THEN-ACROSS instead of straight.
///
/// ⛔ A STRAIGHT LERP IS THE WRONG SHAPE FOR A LEDGE. Interpolating both axes
/// together walks the zombie diagonally THROUGH the wall it is climbing — it
/// leaves the floor immediately and arrives at the lip having passed inside the
/// solid. What a climb actually looks like is: rise up the FACE first, then
/// move across onto the top.
///
/// So the two axes get their own curve of the same `t`:
///
/// climb vertical t^(1/Bias) fast early — up the face
/// horizontal t^Bias slow early — across at the top
/// drop mirrored out first, fall after
///
/// ⚠️ 1 gives the old straight line, so this is a shape knob and not a switch.
/// Higher is squarer. `nz_cross_bias` tunes it live.
/// </summary>
public static float CrossBias { get; set; } = 3.5f;
/// <summary>How long this crossing should take, from distance and speed.
/// 0 means "use the clip's own length", the old behaviour.</summary>
private float _crossDuration;
/// <summary>Time spent crossing so far. Drives `t` when a duration is set.</summary>
private TimeSince _crossSince;
private bool _crossing;
private Vector3 _crossFrom;
private Vector3 _crossTo;
private float _crossLift;
/// <summary>This crossing is a WALK — see <see cref="NavLinkSpot.Walk"/>. No action clip, the
/// zombie's own speed, a straight lerp.</summary>
private bool _crossWalk;
/// <summary>Ground speed the walk crossing is running at, so the locomotion clip can be paced
/// from it. The agent is stopped during a crossing, so `Velocity` is ~0 and
/// <see cref="UpdateAnimation"/> would otherwise clamp the walk to a near-freeze.</summary>
private float _crossWalkSpeed;
/// <summary>
/// A climb or a drop with no clip for it, carried on the clock as a walk is. ⛔ Without this the shared end condition
/// (`!ActionPlaying`) finished it on its first frame and set the body down at the far end: a teleport up every ledge for a
/// body without the clips — the pest has none (2026-09-27).
/// </summary>
private bool _crossNoClip;
/// <summary>True while crossing a nav link — the vertical gate reads this.</summary>
public bool CrossingLink => _crossing;
/// <summary>
/// Begin a scripted crossing of a nav link.
///
/// Called from NavLinkManager when the agent enters a link. Same shape as
/// StartVault: stop steering, take the transform, play a clip, and lerp the
/// body across for as long as the clip runs.
/// </summary>
public void BeginLinkCross( Vector3 from, Vector3 to, bool walk = false )
{
if ( _crossing ) return;
if ( State == ZombieState.Dead ) return;
// ⛔ THE COOLDOWN, AND THIS LINE IS THE ONE THAT WAS MISSING FOR THE LEDGES. `_crossing` above
// clears the moment a crossing ends, so it prevented overlapping traversals and nothing else
// — a zombie left standing on an endpoint could re-enter the same link on the very next
// frame. See TraverseCooldown.
//
// ⚠️ Cancels rather than ignores, so the agent is not left parked in a link it will not be
// carried through. See RefuseTraversal.
// ⛔ NEITHER THE GATE NOR THE MARK APPLIES TO A WALK LINK, and that is the flag's whole
// point — see NavLinkSpot.Walk. The cooldown is a one-second guard against re-entering the
// link you just left, which on two low steps a second apart IS the delay being removed.
if ( !walk )
{
if ( !TraverseReady ) { RefuseTraversal(); return; }
MarkTraversed();
}
_crossing = true;
_crossWalk = walk;
_crossNoClip = false;
OnLink = true;
_crossFrom = from;
_crossTo = to;
var rise = to.z - from.z;
StopMoving();
// ⛔ TAKE THE TRANSFORM. Writing WorldPosition while the agent still owns
// it is what made the barricade vault LOOP — the agent never learned the
// body moved and steered back. See StartVault for the full trace.
TakeTransform();
// ⛔ NO ADDED ARC, IN EITHER DIRECTION. A sine lift on top of the lerp puts
// the peak of the jump ABOVE and BEFORE the destination, so the zombie
// sails over the ledge and drops back onto it. Interpolating straight from
// A to B makes the highest point of the climb the point you clicked, which
// is what the marker promises. The clip already carries the body's own arc;
// this only has to carry the ORIGIN, and the origin should travel straight.
_crossLift = 0f;
// Distance / speed, so a longer jump takes proportionally longer.
var dist = from.Distance( to );
// ⛔ A WALK IS TIMED FROM THE ZOMBIE, NOT FROM THE LINK. `CrossUnitsUp`/`Down` are the speeds
// a staged jump travels at and they are nothing like a walk pace, so using them here would
// make the body slide across at a rate its legs are not moving at — which is the skate
// `UpdateAnimation` exists to avoid everywhere else.
var ups = _crossWalk
? MathF.Max( MinMoveSpeed, MoveSpeed )
: (rise > 8f ? CrossUnitsUp : CrossUnitsDown);
_crossWalkSpeed = _crossWalk ? ups : 0f;
_crossDuration = ups > 1f ? dist / ups : 0f;
_crossSince = 0f;
// ⛔ NO CLIP AT ALL ON A WALK, AND THEREFORE NOTHING TO MEASURE OR RE-TIME. Leaving
// `_actionClip` null is what keeps `ActionPlaying` false, which is what lets
// `UpdateAnimation` go on driving the locomotion sequence for the whole crossing. Playing a
// clip and then trying to blend back out of it would be a second animation problem invented
// to solve the first.
if ( _crossWalk )
{
// ⚠️ A zero duration would end the crossing on the frame it started and leave the body
// at A. Fall back to something short rather than to the clip-length path, which does
// not exist here.
if ( _crossDuration <= 0.01f ) _crossDuration = MathF.Max( 0.05f, dist / 60f );
return;
}
// ⚠️ Started at rate 1 to MEASURE it, then re-timed. PlayAction returns the
// clip's length at the rate given and there is no way to ask for a length
// without starting it, so the rate that fits the duration can only be
// worked out afterwards.
// ⚠️ `relay: false` — this call is a MEASUREMENT. See PlayAction's `relay` parameter for
// what sending it did. The relay goes out below, once the rate is final.
float baseLen = rise > 8f
? PlayAction( string.IsNullOrWhiteSpace( ClimbClipOverride )
? WalkerTraverse.ClimbForHeight( rise )
: new List<string> { ClimbClipOverride }, 1f, relay: false )
: PlayAction( WalkerTraverse.DropDown, 1f, relay: false );
if ( _crossDuration > 0.01f && baseLen > 0.01f )
{
// ⚠️ The CLIP is stretched to the crossing, not the crossing to the
// clip. That is the point: the body's speed is what is being held
// constant and the animation follows it.
_actionRate = baseLen / _crossDuration;
_actionLength = _crossDuration;
_actionDone = _crossDuration;
// ⛔ AND THE RENDERER, which is where _actionRate is actually applied
// (PlayAction sets both). Setting the field alone re-times the logic
// and leaves the animation playing at its old speed.
if ( _renderer.IsValid() ) _renderer.PlaybackRate = _actionRate;
}
else if ( baseLen <= 0.01f && _crossDuration > 0.01f )
{
// ⛔ NO CLIP FOR THE CLIMB OR THE DROP: carried on the clock with the run playing on (`_crossNoClip`)
_crossNoClip = true;
_crossWalkSpeed = dist / _crossDuration;
}
else
{
_crossDuration = 0f;
}
// ⛔ AFTER THE RE-TIME, AND OUTSIDE THE IF. Both branches leave a clip playing — the else
// simply leaves it at the 1x it was measured at — so both have something to send. Relaying
// inside the success branch would have left the fallback silent on every client.
if ( baseLen > 0.01f ) RelayClip();
}
/// <summary>
/// Move the body along the link while the clip runs. Mirrors TickVault.
///
/// ⚠️ Returns TRUE while it owns the frame, so OnUpdate skips the normal
/// steering — a zombie halfway up a wall must not be repathed.
/// </summary>
private bool TickLinkCross()
{
if ( !_crossing ) return false;
// ⚠️ ENDS ON WHICHEVER FINISHES FIRST. The clip is re-timed to the duration
// so they should agree, but a clip ending early must not strand the body in
// mid-air, and a duration running out must not wait on a clip.
bool timeUp = _crossDuration > 0.01f && _crossSince >= _crossDuration;
// ⛔ A WALK ENDS ON THE CLOCK ALONE. There is no action clip, so `ActionPlaying` is false
// from the first frame and the shared condition would finish the crossing before the body
// had moved — landing it on B instantly, which is the teleport this flag is supposed to
// avoid.
if ( (_crossWalk || _crossNoClip ? timeUp : (!ActionPlaying || timeUp)) )
{
_crossing = false;
_crossWalk = false;
_crossNoClip = false;
_crossWalkSpeed = 0f;
OnLink = false;
_crossDuration = 0f;
// ⚠️ Land exactly on the far end before handing back. A body left a
// few units short is a body the agent has to walk backwards to reach,
// which reads as a stumble at the top of every climb.
WorldPosition = _crossTo;
GiveTransformToAgent();
// ⚠️ AFTER the transform is handed back, so the agent is released at the position it is
// actually going to resume from rather than the one it entered at.
CompleteTraversal();
return false;
}
float t = _crossDuration > 0.01f
? ((float)_crossSince / _crossDuration).Clamp( 0f, 1f )
: _actionLength > 0.01f
? (1f - (float)_actionDone / _actionLength).Clamp( 0f, 1f )
: 1f;
// ⚠️ TWO CURVES FROM ONE `t`, so the two axes still finish together — the
// body lands exactly on the far point at t=1 whatever the bias is.
//
// ⛔ BIAS 1 ON A WALK, WHICH COLLAPSES BOTH CURVES TO THE STRAIGHT LERP. The lead-and-trail
// split is what shapes a JUMP — vertical first going up, horizontal first coming down — and
// applied to a walk it reads as the body surging and then coasting over a step it should
// have crossed at a constant pace.
float bias = _crossWalk ? 1f : MathF.Max( 1f, CrossBias );
bool up = _crossTo.z > _crossFrom.z;
// Climb: vertical leads, horizontal trails. Drop: the mirror — step out
// over the edge first, then fall, which is what a fall actually looks like.
float vT = up ? MathF.Pow( t, 1f / bias ) : MathF.Pow( t, bias );
float hT = up ? MathF.Pow( t, bias ) : MathF.Pow( t, 1f / bias );
var flat = Vector3.Lerp( _crossFrom.WithZ( 0 ), _crossTo.WithZ( 0 ), hT );
float lift = MathF.Sin( t * MathF.PI ) * _crossLift;
WorldPosition = flat.WithZ( MathX.Lerp( _crossFrom.z, _crossTo.z, vT ) + lift );
// ⛔ FACING IS LEFT ALONE, DELIBERATELY — this used to aim along the link.
// The link is a straight line between two clicked points and a zombie
// almost never arrives along it, so snapping to it spun the body on the
// spot at the start of every crossing. Whatever direction it was walking
// when it reached the link is the direction the animation should play in,
// and since TickLinkCross owns the frame nothing else overwrites it.
//
// ⚠️ The vault does aim along its course, and should: a barricade crossing
// is a fixed run through a window frame, where the course IS the approach.
return true;
}
private Barricade _vaulting;
private Vector3 _vaultFrom;
private Vector3 _vaultTo;
/// <summary>A vault with no clip: how long its hop takes. 0 when a clip carries it (`StartVault`).</summary>
private float _vaultHop;
/// <summary>How fast a hop carries the body — the run clip is paced to it (`UpdateAnimation`).</summary>
private float _vaultHopSpeed;
private TimeSince _vaultSince;
/// <summary>The quickest and the slowest a clip-less hop through a window may be, in seconds.</summary>
private const float HopMin = 0.25f, HopMax = 0.9f;
/// <summary>How high a clip-less hop carries the body, as a fraction of the sill — over it, not through it.</summary>
private const float HopLift = 1.1f;
/// <summary>The variants already said to hop, so the log says it once each rather than per window.</summary>
private static readonly HashSet<string> _hopSaid = new();
/// <summary>How far past the run the vault lands them. Enough to clear it.</summary>
[Property] public float VaultCrossDistance { get; set; } = 70f;
/// <summary>
/// How long after crossing a barricade before this zombie may cross one again.
///
/// ⛔ THIS REPLACES WHAT THE OLD SIDE TEST WAS SECRETLY DOING. Its comment said the test was
/// there so "a zombie that has just landed is still within reach and would vault straight back
/// over it, forever" — so the ping-pong guard and the should-I-cross decision were the same
/// piece of code. That is why fixing one broke the other. They are now separate: the cooldown
/// stops the loop, and the pathfinder decides whether to cross.
/// </summary>
[Property] public float ReCrossDelay { get; set; } = 2f;
/// <summary>
/// How far from a barricade's run this zombie must get before it may cross THAT one again.
///
/// ⛔ A TIME COOLDOWN ALONE WAS NOT ENOUGH, WHICH IS WHY THIS EXISTS. Two seconds elapses while
/// the zombie is still standing on the landing point 34u from the run, so the moment it expired
/// the crossing was eligible again and the loop resumed — a slower loop, not a fixed one. The
/// question is not "has enough time passed", it is "have I actually gone somewhere", and only
/// distance answers that.
///
/// ⚠️ Must exceed CrossOffset (34) or it is satisfied at the landing point and does nothing.
/// 90 means the zombie has genuinely committed to the far side.
///
/// ⚠️ PER BARRICADE, NOT GLOBAL. A zombie that comes through one window and immediately meets a
/// different one should be free to cross it — that is a route, not a loop.
/// </summary>
[Property] public float ReCrossDistance { get; set; } = 90f;
private TimeUntil _mayCrossAgain;
/// <summary>The barricade just crossed, refused until ReCrossDistance away from it.</summary>
private Barricade _lastCrossed;
/// <summary>
/// How long the distance guard may refuse the same barricade before giving up on it.
///
/// ⛔ THE ANSWER TO "STILL OCCASIONALLY STUCK BEHIND THE BARRICADE". ReCrossDistance made
/// distance the only escape, so any space smaller than it was a trap — see MayCross. This bounds
/// the guard in time so it can slow a loop but never create a deadlock.
/// </summary>
[Property] public float ReCrossPatience { get; set; } = 6f;
/// <summary>When the last crossing happened, for ReCrossPatience.</summary>
private TimeSince _crossedAt;
/// <summary>
/// How long after ANY traversal before this zombie may traverse anything again.
///
/// ⛔ COVERS WHAT THE BARRICADE GUARDS CANNOT: THE JUMP AND DROP LINKS. ReCrossDistance is keyed
/// to a specific Barricade, so it does nothing for a NavLinkManager link — and BeginLinkCross
/// only ever checked `_crossing`, which clears the instant a crossing ends. A zombie standing on
/// a link endpoint the moment its climb finished was immediately eligible to climb again, with
/// nothing anywhere in the class to stop it. Every authored ledge had this and it was never the
/// barricade's fault.
///
/// ⚠️ SHORT ON PURPOSE, AND THIS IS THE REAL TRADE. A long cooldown would also block LEGITIMATE
/// chains — a staircase of two jump links, or a window that opens onto a ledge — and a zombie
/// frozen at the bottom of a climb it is allowed to make is the same failure as one frozen by the
/// vertical gate. One second is longer than any single traversal and shorter than the walk
/// between two separate authored links. Raise it with `nz_ztraverse` if loops survive, but watch
/// for zombies stalling at chained links when you do.
///
/// ⚠️ NULLABLE-BACKED. Changing a static's default does not reach a running editor — hotload
/// copies statics forward by name and skips initialisers. See VerticalGateEnabled.
/// </summary>
public static float TraverseCooldown
{
get => _traverseCooldown ??= 1f;
set => _traverseCooldown = value;
}
static float? _traverseCooldown;
private TimeUntil _mayTraverseAgain;
/// <summary>
/// Has the traversal cooldown expired?
///
/// ⚠️ Zero disables it entirely, so a measurement without it needs no code change.
/// </summary>
private bool TraverseReady => TraverseCooldown <= 0f || _mayTraverseAgain;
/// <summary>Start the cooldown. Called by every path that moves a zombie across something.</summary>
private void MarkTraversed() => _mayTraverseAgain = TraverseCooldown;
// ── anti-stuck ───────────────────────────────────────────────────────────
/// <summary>
/// How long a zombie may fail to move before it is relocated to a spawn.
///
/// ⛔ THE SAFETY NET THIS CLASS HAS NEVER HAD, and its absence is documented right here in
/// MinMoveSpeed's own remarks: "a zombie sitting at velocity ~0 with a healthy MoveSpeed is a
/// pathing failure and will sit there just as still with the floor raised". Every stuck cause
/// found so far — a side test that refused the only route, a landing point inside a wall, an
/// agent parked in a link nothing released, a distance guard that could not be satisfied in a
/// small room — was permanent for that zombie's life, because nothing ever checked.
///
/// ⚠️ IT DOES NOT REPLACE FIXING THE CAUSE. Every relocation logs, for the same reason the
/// traversal watchdog does: a net that silently catches everything turns a reproducible bug into
/// an occasional teleport that is far harder to find. If this fires often, the log says where.
/// </summary>
[Property] public float StuckTimeout { get; set; } = 5f;
/// <summary>
/// How far a zombie must travel to count as having moved.
///
/// ⛔ NOT ZERO, AND NOT VELOCITY. A wedged zombie is rarely perfectly still — agent avoidance
/// shoves it a few units back and forth against whatever is holding it, so its velocity is
/// non-zero the whole time it is going nowhere. Displacement from an anchor is what distinguishes
/// "moving" from "struggling"; 24 units is under a stride and well over the shuffle.
/// </summary>
[Property] public float StuckRadius { get; set; } = 24f;
/// <summary>Global off switch, for measuring without it.</summary>
public static bool AntiStuckEnabled
{
get => _antiStuckOn ??= true;
set => _antiStuckOn = value;
}
static bool? _antiStuckOn;
/// <summary>How many relocations have happened, for the report.</summary>
public static int UnstuckCount { get; set; }
private Vector3 _stuckAnchor;
private TimeSince _stuckSince;
/// <summary>
/// Is this zombie in a state where standing still is NORMAL?
///
/// ⛔ THE EXCLUSIONS MATTER MORE THAN THE DETECTION. A zombie tearing boards off a barricade is
/// motionless on purpose and is doing exactly what it should — relocating it would break the one
/// mechanic barricades exist for. So is one playing an entrance, one mid-vault, one stunned, and
/// one with nothing to chase.
/// </summary>
private bool StillnessIsExpected()
{
// ⛔ IDLE AND NO-TARGET STILL RETURN TRUE HERE, AND THAT IS NOT AN OVERSIGHT — BUT IT WAS
// THE WHOLE BUG UNTIL `TickIdleRecovery` EXISTED. These two lines switch the anti-stuck
// watchdog off in exactly the two states a broken zombie ends up in: `OnNoTarget` sets
// `State = Idle` and leaves `Target` null, so a zombie that lost its target walked to the
// nearest spawn, arrived, and stood there — with the one system that would have rescued it
// told that standing still was correct. User: *"the zombies keep walking towards random
// spots where a player has been in the past and staying there."*
//
// ⚠️ THE EXEMPTION STAYS AND A SECOND WATCHDOG COVERS IT, because `Unstick` is the wrong
// medicine for an idle zombie: it relocates to a spawn, and an idle zombie is ALREADY at a
// spawn. Moving it to another one it has no reason to be at just restarts the same wait. The
// recovery for "nobody to chase" is to LOOK AGAIN and then go somewhere different — see
// `TickIdleRecovery`.
if ( State != ZombieState.Chasing ) return true;
if ( _crossing || _vaulting is not null || OnLink ) return true;
if ( !Target.IsValid() ) return true;
// Tearing boards or swinging at a player — both hold position deliberately.
if ( BlockingBarricade() is not null ) return true;
if ( BlockingWall() is not null ) return true;
return false;
}
/// <summary>How long an idle zombie may stand still before it is made to look again. 3s.</summary>
[Property] public float IdlePatience { get; set; } = 3f;
/// <summary>Times a zombie was shaken out of a permanent idle. Reported by `nz_zwhy`.</summary>
public static int IdleRecoveries;
private TimeSince _idleStill;
private Vector3 _idleAnchor;
/// <summary>
/// Shake a zombie out of standing at a wander point forever.
///
/// ⛔ `OnNoTarget` PICKS THE NEAREST SPAWN AND ONLY RE-PICKS ON ARRIVAL — so on arriving it
/// chooses the point it is already standing on, and never moves again. Two faults compound: the
/// wander point is sticky, and `StillnessIsExpected` exempts idle zombies from the anti-stuck
/// watchdog. This is the watchdog for that case.
///
/// ⚠️ IT FORCES A LOOK BEFORE IT MOVES ANYTHING. Nine times in ten the reason a zombie is idle
/// is that every player was momentarily filtered out — downed, in gas, mid-revive — and the
/// answer is to ask again against a FRESH candidate list, which `ForceRetarget` guarantees by
/// invalidating the per-frame cache first.
///
/// ⚠️ AND IF THERE IS GENUINELY NOBODY, IT GOES SOMEWHERE ELSE rather than standing. A horde
/// drifting between spawns during a wipe reads as alive; a horde frozen mid-map reads as broken,
/// and is indistinguishable from the bug this exists to catch.
/// </summary>
private void TickIdleRecovery()
{
// ⚠️ CHASING WITH A LIVE TARGET IS NOT THIS METHOD'S BUSINESS — `TickAntiStuck` owns that
// case and relocating on top of it would fight it.
if ( State != ZombieState.Idle && Target.IsValid() )
{
_idleStill = 0f;
_idleAnchor = WorldPosition;
return;
}
if ( WorldPosition.Distance( _idleAnchor ) > StuckRadius )
{
_idleAnchor = WorldPosition;
_idleStill = 0f;
return;
}
if ( _idleStill < MathF.Max( 0.5f, IdlePatience ) ) return;
_idleStill = 0f;
_idleAnchor = WorldPosition;
ForceRetarget();
if ( Target.IsValid() ) return;
// ⚠️ A DIFFERENT POINT, CHOSEN AT RANDOM, rather than the nearest — "nearest" is precisely
// what glued it here. Zero means the picker had nothing to offer and is left alone.
var next = RandomSpawnAwayFrom( WorldPosition );
if ( next == Vector3.Zero ) return;
_wanderTo = next;
IdleRecoveries++;
if ( !Agent.IsValid() ) return;
Agent.MaxSpeed = AgentSpeed;
Agent.MoveTo( _wanderTo );
}
/// <summary>Times a chasing zombie was found frozen at MaxSpeed 0 and released. `nz_zwhy`.</summary>
public static int SpeedRescues;
/// <summary>
/// Put the speed back when a zombie that should be moving has been left at zero.
///
/// ⛔ `SpeedReport()` ALREADY STATES THE HAZARD AND NOTHING ACTED ON IT: *"Nine places in this
/// file write that field and one of them writes ZERO — so the two can disagree, and when they
/// do, no amount of reading the slow arithmetic will explain a stopped zombie."* Twelve writers
/// now. `TickSpawn`, `TickCorpse` and the stun path all set 0 legitimately; the failure is any
/// path that sets 0 and then hands the zombie back to the chase without restoring it.
///
/// ⚠️ IT ONLY EVER RAISES, AND ONLY WHEN CHASING A LIVE TARGET. A zero is CORRECT while
/// spawning, dead, stunned, crossing a link or mid-vault — so those are exactly the states this
/// refuses to touch. Anything looser would fight the three systems that set zero on purpose.
///
/// ⚠️ AND IT COUNTS, because a silent self-repair is a bug that never gets fixed. If
/// `nz_zwhy` shows this climbing, something is leaking a zero and the counter is the evidence.
/// </summary>
private void TickSpeedGuard()
{
if ( State != ZombieState.Chasing ) return;
if ( !Target.IsValid() ) return;
if ( _crossing || _vaulting is not null || OnLink ) return;
if ( !_agent.IsValid() ) return;
if ( _agent.MaxSpeed > 0.01f ) return;
if ( AgentSpeed <= 0.01f ) return;
_agent.MaxSpeed = AgentSpeed;
SpeedRescues++;
}
/// <summary>
/// How far off the navmesh a player may be and still be chaseable. 96u.
///
/// ⚠️ GENEROUS ON PURPOSE. The mesh is inset from walls by `AgentRadius` and sits below a
/// standing player's origin, so a perfectly normal player is already tens of units from the
/// nearest mesh point. This is here to catch a body in the air or inside geometry, not to
/// second-guess ordinary standing.
/// </summary>
public static float ReachableSlack { get => _reachSlack ?? 96f; set => _reachSlack = value; }
static float? _reachSlack;
/// <summary>Players rejected as unreachable, for `nz_zwhy`.</summary>
public static int UnreachableSkips;
/// <summary>Is there navmesh close enough to this point for anything to path to it.</summary>
public static bool Reachable( Vector3 at )
{
var nav = Game.ActiveScene?.NavMesh;
// ⚠️ NO MESH, NO OPINION — see the note at the call site.
if ( nav is null || !nav.IsEnabled ) return true;
var on = nav.GetClosestPoint( at );
if ( !on.HasValue ) { UnreachableSkips++; return false; }
if ( at.Distance( on.Value ) <= MathF.Max( 1f, ReachableSlack ) ) return true;
UnreachableSkips++;
return false;
}
/// <summary>An eligible spawn that is NOT the one we are standing on, or Zero if there is none.</summary>
private Vector3 RandomSpawnAwayFrom( Vector3 here )
{
var round = RoundManager.Instance?.Round ?? 1;
var far = RoundManager.EligibleSpawnsFor( round )
.Where( sp => sp.Position.Distance( here ) > MathF.Max( 1f, WanderArriveDistance ) * 2f )
.ToList();
return far.Count == 0 ? Vector3.Zero : far[Game.Random.Int( 0, far.Count - 1 )].Position;
}
/// <summary>
/// Relocate a zombie that has stopped making progress.
///
/// ⚠️ RELOCATES RATHER THAN DESTROYS AND RESPAWNS. The wave's bookkeeping — Remaining, WaveTotal,
/// the round-clear test — counts what has spawned and what is alive, and destroying a zombie
/// mid-wave to create another would have to be threaded through all of it correctly. Moving the
/// same zombie changes none of those numbers, which is why it cannot get them wrong.
/// </summary>
private void TickAntiStuck()
{
if ( !AntiStuckEnabled || StuckTimeout <= 0f ) return;
if ( StillnessIsExpected() )
{
_stuckAnchor = WorldPosition;
_stuckSince = 0f;
return;
}
if ( WorldPosition.Distance( _stuckAnchor ) > StuckRadius )
{
_stuckAnchor = WorldPosition;
_stuckSince = 0f;
return;
}
if ( _stuckSince < StuckTimeout ) return;
Unstick();
}
/// <summary>Put this zombie back at a spawn point. Public so a command can force it.</summary>
public bool Unstick()
{
var rm = RoundManager.Instance;
var spot = rm.IsValid() ? rm.PickWaveSpawn() : null;
// ⚠️ Reset the clock even on failure, or a zombie stuck somewhere with no usable spawn
// point retries every single frame and floods the console with the same warning.
_stuckAnchor = WorldPosition;
_stuckSince = 0f;
if ( spot is null )
{
Log.Warning( $"[nz] {GameObject.Name} is stuck at {WorldPosition:0} but there is no"
+ " usable spawn to move it to — check the links (nz_spawns)" );
return false;
}
var from = WorldPosition;
// ⛔ WHERE A NEW ZOMBIE FROM THAT SPAWNER WOULD STAND, NOT ON THE SPAWNER ITSELF (2026-10-05): its window's own side,
// held there if the mesh doesn't reach it (`ZombieCommands.SpawnStand`, which `SpawnAt` uses). The bare position, handed
// to the agent, put the zombie on its closet's roof wherever the closet has no navmesh: on Defocus nearly every
// relocation ended on a roof, stuck, and was relocated again, 717 times in one night.
var to = ZombieCommands.SpawnStand( Scene, spot.Position, Variant, atWindow: true, out var park );
// ⛔ THE TRANSFORM HANDSHAKE, exactly as the vault and the link crossing do it. Writing
// WorldPosition while the agent still owns the transform is what made the vault LOOP — the
// agent never learns the body moved and steers back to where it thinks it is.
TakeTransform();
WorldPosition = to;
WorldRotation = spot.Rotation;
if ( park.IsValid() )
{
ParkedAt = park;
Park();
}
else GiveTransformToAgent();
// ⚠️ Clear the crossing latches too. A zombie relocated while a barricade was still latched
// would arrive at the far side of the map refusing the first window it met.
_lastCrossed = null;
_mayCrossAgain = 0f;
_mayTraverseAgain = 0f;
// ⚠️ And force a fresh path — the route it was holding was to somewhere it no longer is.
ForceRetarget();
UnstuckCount++;
Log.Warning( $"[nz] {GameObject.Name} had not moved in {StuckTimeout:0.#}s at {from:0}"
+ $" — relocated to {to:0}{( park.IsValid() ? $", held at window #{park.Index}" : "" )}."
+ " That is a PATHING BUG, not a fix (nz_zpath)." );
return true;
}
/// <summary>
/// How long a zombie may sit in a link traversal it is not actually performing.
///
/// ⛔ A WATCHDOG BECAUSE THE FAILURE MODE IS PERMANENT AND SILENT. AutoTraverseLinks is off, so
/// the agent parks in a link and waits for this class to release it. Every path that declines a
/// crossing has to cancel, and one that forgot — the barricade's board check — produced zombies
/// frozen at a window for the rest of the round with no error anywhere. That is too fragile a
/// contract to leave unguarded: this catches the next one instead of shipping it.
///
/// ⚠️ NOT A FIX FOR THE CAUSE, AND MUST NOT BE READ AS ONE. It logs every time it fires, because
/// a watchdog that quietly papers over a missed release turns a reproducible freeze into an
/// occasional stutter that is far harder to find. If this warns, something upstream is failing
/// to cancel and that is the bug.
/// </summary>
[Property] public float StuckTraversalTimeout { get; set; } = 2f;
private TimeSince _inTraversal;
private bool _wasTraversing;
/// <summary>
/// Release an agent parked in a link that nothing is carrying it across.
///
/// ⚠️ `_crossing` AND `_vaulting` BOTH CHECKED. Either one being active means a crossing really
/// is in progress and the clock must not run — a slow vault is not a stuck one.
/// </summary>
private void TickTraversalWatchdog()
{
if ( !_agent.IsValid() ) return;
var traversing = _agent.IsTraversingLink;
if ( !traversing )
{
_wasTraversing = false;
return;
}
if ( !_wasTraversing )
{
_wasTraversing = true;
_inTraversal = 0f;
return;
}
// A crossing genuinely under way resets the clock rather than tripping it.
if ( _crossing || _vaulting is not null )
{
_inTraversal = 0f;
return;
}
if ( _inTraversal < StuckTraversalTimeout ) return;
Log.Warning( $"[nz] {GameObject.Name} sat in a link traversal for"
+ $" {StuckTraversalTimeout:0.#}s without crossing — releasing it."
+ " Something declined a crossing without cancelling it (nz_zpath)." );
_agent.Stop();
_wasTraversing = false;
}
/// <summary>
/// Tell the agent a crossing we performed has finished.
///
/// ⛔ REQUIRED BY AutoTraverseLinks = false. The agent parks itself in a traversal when an
/// agent enters a link and waits to be released; without this it waits forever and the zombie
/// never walks again. Every completion path calls it.
///
/// ⚠️ GUARDED ON IsTraversingLink so it is safe to call from a completion that was reached
/// without a link being involved at all — the barricade fallback vault, for one, which fires on
/// proximity and not from a link.
/// </summary>
private void CompleteTraversal()
{
if ( !_agent.IsValid() ) return;
if ( !_agent.IsTraversingLink ) return;
_agent.CompleteLinkTraversal();
}
/// <summary>
/// Back out of a traversal we are NOT going to perform.
///
/// ⛔ CompleteLinkTraversal WOULD BE EXACTLY WRONG HERE — it finishes the crossing, which is the
/// thing being refused. Stopping cancels the navigation instead, and the think loop's own repath
/// picks the zombie up again on its next cadence.
///
/// ⚠️ THIS IS WHAT MAKES A BOARDED WINDOW HOLD. A zombie refused at a barricade stops, ends up
/// standing inside BarricadeReach of the run, and BlockingBarricade then puts it into tearing —
/// which is the behaviour that was being skipped entirely while the engine auto-traversed.
/// </summary>
private void RefuseTraversal()
{
if ( !_agent.IsValid() ) return;
if ( !_agent.IsTraversingLink ) return;
_agent.Stop();
}
/// <summary>
/// May this zombie cross this particular barricade right now?
///
/// ⚠️ Clears the latch once the distance is met, so the check stops costing anything and a
/// barricade does not stay blacklisted for the zombie's whole life.
/// </summary>
private bool MayCross( Barricade b )
{
if ( !TraverseReady ) return false;
if ( !_mayCrossAgain ) return false;
if ( !_lastCrossed.IsValid() ) return true;
if ( _lastCrossed != b ) return true;
if ( _lastCrossed.DistanceToRun( WorldPosition ) >= ReCrossDistance )
{
_lastCrossed = null;
return true;
}
// ⛔ THE PATIENCE ESCAPE, AND WITHOUT IT THIS GUARD COULD DEADLOCK FOREVER. Distance was the
// ONLY way out: a zombie that crossed into a vent or a small room, and then needed to cross
// back, had to first get 90 units from the run — which in a space smaller than that is
// GEOMETRICALLY IMPOSSIBLE. It stood at the window refusing its own only route for the rest
// of its life, and the traversal watchdog never fired because nothing was parked in a link;
// the refusal was happening cleanly and repeatedly, exactly as written.
//
// ⚠️ A DEADLOCK IS WORSE THAN THE PING-PONG THIS GUARD EXISTS TO STOP, which is why the
// timeout is the tie-breaker rather than the distance. Six seconds is far longer than a
// ping-pong cycle (which was sub-second) and far shorter than a round, so a genuine loop is
// still suppressed while a trapped zombie always gets out.
if ( _crossedAt >= ReCrossPatience )
{
_lastCrossed = null;
return true;
}
return false;
}
/// <summary>
/// An OPEN barricade close enough to climb that is genuinely between us and the target.
///
/// ⛔ THE FALLBACK, NOT THE MECHANISM. Barricade.BuildNavLink puts the window in the navmesh as a
/// priced edge, so the PATHFINDER now decides when crossing is worth it and fires LinkEntered.
/// This remains for the case where a crossing could not be published — no mesh under a landing
/// point, a snap that folded back through the wall — so a barricade whose link failed is still
/// passable instead of being a wall.
///
/// ⛔ A SEGMENT TEST, NOT THE OLD INFINITE-LINE ONE. The previous version took the 2D cross
/// product of the run against both positions, which splits the WHOLE WORLD along the line
/// through RunA and RunB — so a vent whose player was anywhere but straight ahead read as "same
/// side" and no crossing was ever offered. Asking whether the segment ME→TARGET actually
/// intersects the segment RUNA→RUNB asks the question that was meant all along: is this
/// barricade between us.
/// </summary>
private Barricade VaultableBarricade()
{
if ( !Target.IsValid() ) return null;
foreach ( var b in Barricade.All )
{
if ( !b.IsValid() || !b.IsOpen ) continue;
if ( !MayCross( b ) ) continue;
// ⚠️ A barricade with a working link is the pathfinder's business. Offering it here too
// would let a zombie cross without the link ever being traversed, which is how the two
// mechanisms would start disagreeing about where it ended up.
if ( b.NavLinked ) continue;
// ⚠️ VERTICAL BAND TOO — see Barricade.InReach. Flattened alone, a zombie on the
// floor below reads a boarded window overhead as being right in front of it.
if ( !b.InReach( WorldPosition, BarricadeReach ) ) continue;
if ( Crosses( WorldPosition, Target.WorldPosition, b.RunA, b.RunB ) )
return b;
}
return null;
}
/// <summary>
/// Do the 2D segments p→p2 and q→q2 intersect?
///
/// ⚠️ Flattened, like every other barricade measurement — a zombie on the floor and a run at
/// the sill are not at the same height and never were.
/// </summary>
private static bool Crosses( Vector3 p, Vector3 p2, Vector3 q, Vector3 q2 )
{
float d1 = Cross( q, q2, p );
float d2 = Cross( q, q2, p2 );
float d3 = Cross( p, p2, q );
float d4 = Cross( p, p2, q2 );
// Straddling on both counts. Touching endpoints read as 0 and do NOT count — a zombie
// standing exactly on the run is mid-crossing, and sending it over again is the loop.
return d1 * d2 < 0f && d3 * d4 < 0f;
}
/// <summary>2D cross product of (b-a) against (c-a). Sign is which side c falls on.</summary>
private static float Cross( Vector3 a, Vector3 b, Vector3 c )
{
var ab = (b - a).WithZ( 0 );
var ac = (c - a).WithZ( 0 );
return ab.x * ac.y - ab.y * ac.x;
}
/// <summary>
/// The pathfinder routed us through a barricade crossing. Take it.
///
/// ⛔ CALLED FROM Barricade's OWN LINK, which is what makes crossing a route the pathfinder can
/// choose rather than something that happens when a zombie blunders close enough. The barricade
/// has already checked its boards are down before calling this.
/// </summary>
public void BeginBarricadeCross( Barricade b )
{
// ⛔ EVERY REFUSAL HAS TO CANCEL THE TRAVERSAL, not just decline to animate it. With
// AutoTraverseLinks off the agent is already parked in the link by the time this runs, so a
// bare `return` leaves it stuck there — and with AutoTraverseLinks on, which is how this
// shipped, a bare return let the engine carry it through a boarded window.
// ⚠️ `!b.IsOpen` IS IN THIS CONDITION NOW rather than in the barricade's handler, so a boarded
// window refuses through the same path as every other refusal and the agent is actually
// released. The tear logic then owns the zombie: it is standing inside BarricadeReach of the
// run, so BlockingBarricade finds it and it starts pulling boards off.
if ( !b.IsValid() || !b.IsOpen || _vaulting is not null || !MayCross( b ) )
{
RefuseTraversal();
return;
}
StartVault( b );
}
/// <summary>
/// Start climbing. The clip is chosen by SPEED, and its name carries the
/// obstacle height it was authored for — see WalkerAnimations.MantleForSpeed.
/// </summary>
/// <param name="to">Where it lands, when the caller knows: a zombie held at its window climbs to that window's room side
/// (`TickParked`).</param>
private void StartVault( Barricade b, Vector3? to = null )
{
_vaulting = b;
_vaultFrom = WorldPosition;
_mayCrossAgain = ReCrossDelay;
// ⚠️ LATCHED so MayCross can refuse this same barricade until the zombie is ReCrossDistance
// clear of it. The time cooldown above is only a floor.
_lastCrossed = b;
_crossedAt = 0f;
// ⚠️ And the GENERAL cooldown too, so a vault also delays the next ledge. A window that opens
// straight onto a drop is exactly where two traversals fire back to back.
MarkTraversed();
// ⛔ THE BARRICADE'S OWN LANDING POINT, ALWAYS THE SAME ONE. This was
// `WorldPosition + (toward the target) * VaultCrossDistance` — 70 units from wherever the
// zombie happened to be, in whatever direction the player happened to be, with nothing
// checking the result against geometry. Approaching at an angle put the landing inside a
// wall, which is exactly the reported bug. Barricade.LandingFor picks the fixed point on the
// far side of the run, traced to the floor and snapped to the navmesh when it was built.
//
// ⚠️ THE OLD COMMENT HERE ARGUED FOR AIMING AT THE TARGET, on the grounds that using the
// run's normal would shove an angled approach sideways and read as a slide. That trade is
// real but it was the wrong way round: a slide is a cosmetic complaint, landing inside a
// wall is a lost zombie. TickVault's own lift-and-lerp smooths the sideways component, and
// CrossOffset is small enough (34u) that the correction is a step, not a shove.
//
// ⚠️ FALLS BACK to the old behaviour only if the crossing never built — better an
// unvalidated landing than a zombie that cannot cross at all.
if ( to is Vector3 given )
{
_vaultTo = given;
}
else if ( b.CrossValid )
{
_vaultTo = b.LandingFor( WorldPosition );
}
else
{
var dir = (Target.IsValid()
? (Target.WorldPosition - WorldPosition)
: b.RunNormal).WithZ( 0 ).Normal;
_vaultTo = WorldPosition + dir * VaultCrossDistance;
}
StopMoving();
// ⛔ TAKE THE TRANSFORM, exactly as the spawn entrance does. Writing
// WorldPosition while the agent still owns it is why the vault LOOPED:
// the agent never learned the body had moved, so the moment the clip
// ended it steered back to its own idea of position — 65u the way we
// came — and the side test then saw a barricade in the way again. The
// trace showed it landing at 4298,4179 and walking back to 4319,4238
// before re-triggering, over and over.
TakeTransform();
var clip = string.IsNullOrWhiteSpace( VaultClipOverride )
? PlayAction( WalkerAnimations.MantleForSpeed( SpeedRating ), VaultSpeed )
: PlayAction( new List<string> { VaultClipOverride }, 1f );
// ⛔ NO MANTLE CLIP, AND STILL THROUGH THE WINDOW — carried on the clock (`_vaultHop`), its own run playing on, hopping the
// sill. TickVault ended a vault the moment no clip was playing, which for a body without one was the next frame: the
// pest (CoD WWII's sprinter — 15 sequences, none of them a mantle) never moved, the latch refused it the window, and the
// stuck watchdog moved it every five seconds, 139 times in one 24-second pest round on basalt (2026-09-27): *"the pests
// get stuck on windows a lot"*.
var across = _vaultFrom.WithZ( 0 ).Distance( _vaultTo.WithZ( 0 ) );
_vaultHop = clip > 0.01f ? 0f : Math.Clamp( across / MathF.Max( MinMoveSpeed, MoveSpeed ), HopMin, HopMax );
_vaultHopSpeed = _vaultHop > 0f ? across / _vaultHop : 0f;
_vaultSince = 0f;
if ( _vaultHop > 0f && _hopSaid.Add( Variant?.ResourcePath ?? "?" ) )
Log.Info( $"[ZombieAI] {Variant?.ResourcePath ?? "a zombie"} has no mantle clip — it hops the window instead"
+ $" ({_vaultHop:0.00}s over {across:0}u)" );
}
/// <summary>
/// Playback rate for the mantle clip.
///
/// ⚠️ The authored clip runs about five seconds — the trace showed one vault
/// spanning 12:39:02 to 12:39:09. That is a climb in slow motion, not a
/// zombie coming through a window. 3x puts it near 1.6s, which reads as
/// urgent without outrunning the pose.
/// </summary>
[Property, Range( 0.5f, 6f )] public float VaultSpeed { get; set; } = 3f;
/// <summary>
/// Arc height as a fraction of the sill. The clip does most of it.
///
/// ⚠️ 0.1 — 0.25 halved, then trimmed a further 20%%, both on sight. On a 44u
/// sill that is ~4.4u of
/// positional lift, which is the point: the authored climb supplies the
/// height, and this only needs to keep the origin from dragging through the
/// lip. Every increase past that reads as the zombie hopping rather than
/// hauling itself over.
/// </summary>
[Property, Range( 0f, 1f )] public float VaultLift { get; set; } = 0.1f;
/// <summary>
/// Carry the zombie across while the clip plays. Returns true while vaulting.
///
/// ⛔ POSITION IS DRIVEN HERE, NOT BY ROOT MOTION. The clip's own translation
/// is authored for the height in its name, and our sill is 44u against a clip
/// built for 48 — close enough to look right, not close enough to land on. A
/// lerp between two known points cannot end up inside the wall.
///
/// ⚠️ The arc lifts them over the sill. Without it they slide THROUGH the
/// barricade, which is the same visual as having no vault at all.
/// </summary>
// ── SCRIPTED ARC ─────────────────────────────────────────────────────────
private Vector3 _arcFrom, _arcTo;
private float _arcSeconds, _arcHeight;
private TimeSince _arcSince;
private bool _arcing;
/// <summary>Is the body currently being carried along a scripted arc.</summary>
public bool Arcing => _arcing;
/// <summary>
/// Carry the body from where it is to <paramref name="to"/>, peaking `height` above the line.
/// </summary>
///
/// ⚠️ OBERON'S LEAP IS THE ONLY CALLER, and it needs one: `zbs_attack3` bakes the jump into
/// its bones and the model declares no motion extraction, so the engine lifts the MESH and
/// leaves the GameObject where it stood. Nothing that plays the clip can make him cross ground.
///
/// ⛔ THE AGENT HAS TO BE TOLD, NOT JUST OUTRUN. `TakeTransform` is what stops it writing the
/// position back — without it the agent never learns the body moved, and the moment the clip
/// ends it steers back to its own idea of where the zombie is. That is the bug the barricade
/// vault documents having shipped, and a leap is the same shape with a bigger distance.
///
/// ⚠️ THE LANDING IS SNAPPED TO THE NAVMESH FIRST. A leap that ends off the mesh strands the
/// agent: it has a position it cannot path from, and the zombie stands still for good.
public bool BeginArc( Vector3 to, float seconds, float height, bool snapLanding = true )
{
if ( seconds <= 0.01f ) return false;
// ⚠️ UNLESS TOLD NOT TO: basalt's beast dives into the lava, off the mesh on purpose, and is gone before he lands
// (`OberonBoss.Dive`)
var landing = snapLanding ? (Scene.NavMesh?.GetClosestPoint( to ) ?? to) : to;
_arcFrom = WorldPosition;
_arcTo = landing;
_arcSeconds = seconds;
_arcHeight = height;
_arcSince = 0f;
_arcing = true;
StopMoving();
TakeTransform();
return true;
}
/// <summary>Move along the arc. Returns true while it owns the body.</summary>
///
/// ⚠️ SAMPLED AT FRAME RATE, AHEAD OF THINK — the same reason `TickVault` is: Think runs at
/// 10/sec and a lerp driven from there redraws the same position six times and then jumps.
private bool TickArc()
{
if ( !_arcing ) return false;
// ⛔ A CORPSE DOES NOT FINISH ITS JUMP. Dying mid-air would otherwise keep carrying the body
// to the landing point with the death clip playing, and hand the transform back to an agent
// on a zombie that no longer has one.
if ( State == ZombieState.Dead )
{
_arcing = false;
GiveTransformToAgent();
return false;
}
var t = MathX.Clamp( (float)_arcSince / _arcSeconds, 0f, 1f );
var flat = Vector3.Lerp( _arcFrom, _arcTo, t );
// Half a sine: zero at both ends, peak in the middle.
var lift = MathF.Sin( t * MathF.PI ) * _arcHeight;
// ⛔ FACING ALONG THE COURSE, COMPOSED WITH `ModelTurn`. `FaceMovement` steers toward the
// TRAVEL direction and an arcing zombie has been `StopMoving`'d, so the facing it would
// derive is meaningless — the same reason the vault sets its own.
var course = (_arcTo - _arcFrom).WithZ( 0f );
if ( course.LengthSquared > 1f )
WorldRotation = Rotation.LookAt( course.Normal, Vector3.Up ) * ModelTurn;
WorldPosition = flat.WithZ( MathX.Lerp( _arcFrom.z, _arcTo.z, t ) + lift );
if ( t < 1f ) return true;
_arcing = false;
GiveTransformToAgent();
return false;
}
private bool TickVault()
{
if ( _vaulting is null ) return false;
// ⚠️ A HOP ENDS ON ITS CLOCK — there is no clip to end it (`_vaultHop`)
var hop = _vaultHop > 0f;
if ( (hop ? _vaultSince >= _vaultHop : !ActionPlaying) || !_vaulting.IsValid() )
{
// ⚠️ AND LANDS EXACTLY ON THE FAR SIDE, as a link crossing does — the last frame's lerp stops a little short
if ( hop && _vaulting.IsValid() ) WorldPosition = _vaultTo;
_vaultHop = 0f;
_vaultHopSpeed = 0f;
_vaulting = null;
// ⚠️ HAND THE TRANSFORM BACK, and note the order inside it: tell the
// agent where the body ended up, THEN let it write again. Skipped, the
// agent keeps its stale position and drags the zombie back across.
GiveTransformToAgent();
// ⚠️ And release the link, for the same reason and in the same order. A vault reached
// from the proximity fallback has no link to release; CompleteTraversal checks.
CompleteTraversal();
return false;
}
float t = hop
? ((float)_vaultSince / _vaultHop).Clamp( 0f, 1f )
: _actionLength > 0.01f
? (1f - (float)_actionDone / _actionLength).Clamp( 0f, 1f )
: 1f;
var flat = Vector3.Lerp( _vaultFrom, _vaultTo, t );
// ⛔ FACING IS SET HERE TOO. FaceMovement steers toward TRAVEL direction,
// and a vaulting zombie has been StopMoving'd — so its velocity is zero
// and the facing it derives is meaningless. That is the "pointing the
// wrong way". Aim along the crossing instead, which is the direction it
// is actually going.
var course = (_vaultTo - _vaultFrom).WithZ( 0 );
if ( course.LengthSquared > 1f )
{
// ⛔ COMPOSED THE SAME WAY FaceMovement DOES, ModelYawOffset INCLUDED.
// This was `Rotation.From(0, yaw, 0)`, which ignores the rig's own yaw
// offset — so the zombie vaulted with its back to the barricade, a
// clean 180 out. Every other facing in this class goes through that
// offset; a scripted one that builds its rotation from scratch has to
// as well.
WorldRotation = Rotation.LookAt( course.Normal, Vector3.Up ) * ModelTurn;
}
// Half a sine gives the hop: zero at both ends, peak in the middle.
// ⚠️ A FRACTION OF THE SILL, and a small one. The CLIP already raises the
// body — it is an authored climb — so this only has to carry the origin
// over the lip. At 0.6 the two lifts stacked and the zombie sailed well
// above the barricade; the trace showed it peaking 26u over a wall it
// only needed to skim.
// ⚠️ A HOP LIFTS ITSELF OVER THE SILL — there is no authored climb to raise the body (`HopLift`)
float lift = MathF.Sin( t * MathF.PI ) * (_vaulting.Size.z * (hop ? HopLift : VaultLift));
WorldPosition = flat.WithZ( MathX.Lerp( _vaultFrom.z, _vaultTo.z, t ) + lift );
return true;
}
/// <summary>
/// A boarded barricade close enough to tear, or null.
///
/// ⛔ NEAREST BOARDED ONE IN RANGE, not "one on the path". A path test would
/// be more correct and needs the navmesh corners, which the agent does not
/// expose — and a zombie that stops at any barricade it is touching is the
/// behaviour we want anyway: they cluster at windows.
///
/// ⚠️ Open ones are skipped, so a torn-out barricade stops attracting anybody
/// and the horde moves through it.
/// </summary>
/// <summary>
/// The Banana Colada M2 wall in front of this zombie, or null.
///
/// ⚠️ IT HONOURS `IgnoresBarricades` FOR THE SAME REASON THE BARRICADE DOES. A hound that runs
/// through a boarded window and then stops dead at a decal would read as a bug, and the flag
/// already means "this thing does not respect player-made obstacles".
/// </summary>
private Placeable BlockingWall()
{
if ( Variant?.IgnoresBarricades == true ) return null;
return Placeable.WallBlocking( WorldPosition, BarricadeReach );
}
private Barricade BlockingBarricade()
{
// ⚠️ DELIBERATE EARLY-OUT, AND IT GATES FIVE CALL SITES — the stop-and-tear
// decision, the "am I blocked" check, the attack target, the tear tick and
// the plank damage all ask this one question. Returning null here is what
// makes a hound run past a boarded window instead of stopping to chew it,
// which is the single biggest tell that a dog is a reskinned walker.
// VaultableBarricade is left alone: it only matches ALREADY-OPEN ones, so
// a hound still crosses a torn-out window normally.
if ( Variant?.IgnoresBarricades == true ) return null;
Barricade best = null;
float bestDist = BarricadeReach;
foreach ( var b in Barricade.All )
{
if ( !b.IsValid() || b.IsOpen ) continue;
// ⛔ THE STOP-AND-TEAR DECISION, and the half of the report about zombies: one a
// kilometre below a barricade stopped to break it. bestDist starts at BarricadeReach so
// this WAS bounded horizontally — it was never bounded vertically.
if ( !b.InReach( WorldPosition, BarricadeReach ) ) continue;
// ⚠️ To the RUN, not the origin — see Barricade.DistanceToRun.
float d = b.DistanceToRun( WorldPosition );
if ( d >= bestDist ) continue;
bestDist = d;
best = b;
}
return best;
}
/// <summary>
/// Is this zombie attacking <paramref name="b"/>: standing in its tearing reach, with that
/// window the boarded one its swings land on. For Tortoise's m3 Handyman (2026-10-03).
///
/// ⛔ ASKED BY POSITION, NOT BY `State`, AND THAT IS WHAT MAKES IT TRUE ON A CLIENT. Zombies
/// think only on the host; everywhere else a zombie is a puppet whose `State` stays Chasing
/// (`BeginPuppet`), and Handyman runs on the REPAIRER's machine. `BlockingBarricade` reads only
/// what reaches every machine — the zombie's position and the window's board count — so the
/// host and a client get the same answer.
///
/// ⚠️ IT IS THE GAME'S OWN RULE FOR ATTACKING A WINDOW. A zombie this is true for has its swings
/// taken by the boards (`DoAttackDamage`) instead of by a player, and a hound
/// (`IgnoresBarricades`) never is.
/// </summary>
public bool IsTearing( Barricade b ) => b.IsValid() && BlockingBarricade() == b;
/// <summary>
/// Seconds until this zombie next re-acquires a target. For `nz_target`.
///
/// ⚠️ EXPOSED BECAUSE A TIMER THAT NEVER ELAPSES AND A CANDIDATE LIST THAT REJECTS
/// EVERYONE PRODUCE THE SAME SYMPTOM — a zombie that ignores you — and nothing outside
/// this class could tell them apart. Three wrong guesses about which one it was is what
/// this property costs less than.
/// </summary>
public float RetargetIn => _untilRetarget;
/// <summary>
/// What this zombie currently considers targetable, by name. For `nz_target`.
///
/// ⛔ CALLS THE REAL `GetTargetables`, NOT A COPY OF ITS FILTER. A diagnostic that
/// reimplements the thing it measures agrees with itself while the game does something
/// else — §2, a measurement deriving its reference from the thing under test.
/// </summary>
public string[] TargetableNames()
=> GetTargetables().Where( g => g.IsValid() ).Select( g => g.Name ).ToArray();
/// <summary>
/// Re-acquire NOW, from outside, ignoring the retarget cadence.
///
/// ⛔ A PUSH, NOT A FLAG, AND THAT IS THE WHOLE POINT. Every other retarget in this class
/// is PULLED by `TickChase` — which only runs if `OnUpdate` reaches `Think`, if the state
/// switch dispatches, and if the `_vaulting` guard lets it past. A zombie stalled at any
/// of those three never re-acquires no matter how overdue its timer gets, and that is the
/// bug this method exists for: zombies that never target the player again after Vulture
/// Aid's gas clears. Calling in from the outside cannot be blocked by any of them.
///
/// ⚠️ IT ISSUES THE MOVE ORDER ITSELF rather than trusting the next tick to. If `Think`
/// is the thing that is stalled, setting `Target` alone produces a zombie that has a
/// target and still stands still — which looks exactly as broken as before.
///
/// ⚠️ Dead and Spawning are skipped. Both own their whole tick — a corpse must not walk,
/// and an entrance animation that starts pathing mid-climb is worse than no entrance.
/// </summary>
public void ForceRetarget()
{
InvalidatePlayerTargets();
ForceRetargetCore();
}
/// <summary>
/// The re-acquire itself, WITHOUT throwing the candidate cache away.
///
/// ⚠️ SEPARATE SO `ForceRetargetAll` CAN INVALIDATE ONCE for the whole horde. Every caller
/// outside this class wants `ForceRetarget`; this exists only so the sweep is not quadratic.
/// </summary>
void ForceRetargetCore()
{
if ( State == ZombieState.Dead || State == ZombieState.Spawning ) return;
Target = null;
AcquireTarget();
if ( !Target.IsValid() ) return;
_wanderTo = Vector3.Zero;
SetState( ZombieState.Chasing );
if ( !Agent.IsValid() ) return;
// ⚠️ SPEED RESTORED HERE TOO. The idle-drift path is not the only one that can leave
// MaxSpeed somewhere other than MoveSpeed, and a zombie that re-acquires correctly but
// crawls reads as the retarget still being broken.
Agent.MaxSpeed = AgentSpeed;
Agent.MoveTo( Target.WorldPosition );
}
/// <summary>
/// Push a re-acquire at every zombie alive. Called when a player stops being untargetable.
///
/// ⚠️ 35 acquires in one frame is a spike, but it happens on a cloud expiring or a player
/// stepping out of one — seconds apart at worst, not per frame. The alternative is the
/// 0.4–15s cadence, which is what was leaving zombies frozen.
/// </summary>
public static void ForceRetargetAll()
{
// ⚠️ ONCE, FOR THE WHOLE SWEEP. `ForceRetarget` invalidates too — correct when one zombie
// is pushed on its own (a Banana Stand pull, an unstuck relocate) and quadratic here, where
// the first zombie's rebuild is the answer the other 34 want. Clearing it up front means the
// first `AcquireTarget` rebuilds and the rest read that.
InvalidatePlayerTargets();
foreach ( var z in All )
if ( z.IsValid() ) z.ForceRetargetCore();
}
/// <summary>
/// Every link in this zombie's speed chain, as one line. For `nz_zspeed_why`.
///
/// ⛔ IT PRINTS THE AGENT'S OWN `MaxSpeed` BESIDE `MoveSpeed`, AND THAT COMPARISON IS THE
/// WHOLE POINT. `MoveSpeed` is what this class THINKS the zombie should do; the agent's
/// `MaxSpeed` is what it will actually do. Nine places in this file write that field and one
/// of them writes ZERO — so the two can disagree, and when they do, no amount of reading the
/// slow arithmetic will explain a stopped zombie.
///
/// ⚠ VELOCITY TOO, because a correct `MaxSpeed` and a zero velocity is a THIRD failure —
/// pathing, steering or separation — and it looks identical to the other two from outside.
/// </summary>
public string SpeedReport()
{
var agentMax = _agent.IsValid() ? _agent.MaxSpeed : -1f;
var vel = _agent.IsValid() ? _agent.Velocity.Length : -1f;
var diverged = _agent.IsValid() && MathF.Abs( agentMax - MoveSpeed ) > 0.51f;
// ⚠ THE FLOOR AND THE CLIP SPEED ARE IN HERE because they BOUND everything else.
// `_baseMoveSpeed` is `max( _clipGroundSpeed, MinMoveSpeed )`, so a 55u floor means a
// x0.167 slow lands at 9u whatever the clip says - and the animation rate is
// `clamp( velocity / _clipGroundSpeed, 0.05, MaxAnimRate )`, which is the other place a low speed
// can turn into a visibly stopped zombie.
// ⛔ THE FIXED MARKER MATTERS MORE THAN IT LOOKS. When an absolute speed is in play,
// `clip` and `floor` no longer explain `base` at all — they are printed, they are real, and
// they are IGNORED. Without the marker this line reads as an arithmetic error.
var fixedAt = SpeedOverride > 0f ? SpeedOverride : (Variant?.FixedSpeed ?? 0f);
return $"state {State,-9}"
+ (fixedAt > 0f ? $" FIXED {fixedAt,5:0.#} (clip/floor ignored)" : "")
+ $" clip {_clipGroundSpeed,5:0.#}"
+ $" floor {MinMoveSpeed,4:0.#}"
+ $" base {_baseMoveSpeed,5:0.#}"
+ $" × status {_statusSpeedScale:0.###}"
+ $" × time {_timeScale:0.###}"
+ $" = MoveSpeed {MoveSpeed,5:0.#}"
+ (MoveSpeed > 0.5f && MoveSpeed < MinAgentSpeed
? $" ⇒ FLOORED to {AgentSpeed:0.#} (below {MinAgentSpeed:0.#} the agent will not move)"
: "")
+ $" | agent MaxSpeed {agentMax,5:0.#}"
+ $" velocity {vel,5:0.#}"
+ $" animrate {(_renderer.IsValid() ? _renderer.PlaybackRate : -1f):0.00}"
// ⚠️ RADIUS IS HERE BECAUSE A WIDE AGENT LOOKS EXACTLY LIKE A SLOW ONE FROM OUTSIDE.
// `_agent.Radius = BodyRadius`, and a variant that widens it (Brutus: 22 against a
// walker's 9) can fail to find a path through geometry a walker walks through — which
// presents as "not moving" with a perfectly healthy MaxSpeed and a zero velocity.
+ $" radius {(_agent.IsValid() ? _agent.Radius : -1f),4:0.#}"
+ (diverged ? " ⛔ DIVERGED" : "")
+ (_agent.IsValid() ? "" : " ⛔ NO AGENT")
+ $" target {(Target.IsValid() ? "yes" : "NONE")}";
}
/// <summary>
/// Scale the rendered body and the dimensions that describe it. For `nz_zscale`.
///
/// ⚠️ IT SCALES THE ROOT, AND IT HAS TO. `EnsureBody` does `Components.Create` on THIS object,
/// so the renderer and the ZombieAI share one GameObject — there is no child to scale instead.
/// A first version guarded on `_renderer.GameObject != GameObject` and would therefore have
/// applied nothing at all, silently, while every number in the report looked right.
///
/// ⚠️ THE PER-BONE HITBOXES ARE CHILDREN AND COME ALONG, which is the point: a scaled body with
/// unscaled hitboxes is one you shoot straight past.
///
/// ⛔ ABSOLUTE, NOT CUMULATIVE — and that costs the division below. `BodyHeight` and friends are
/// already carrying the LAST scale by the time this runs a second time, so multiplying again
/// would compound: two calls of 1.35 giving 1.82. `nz_zscale` is for trying sizes back to back,
/// which is exactly the path that would hit it.
/// </summary>
public void ApplyModelScale( float scale )
{
if ( scale <= 0f ) scale = 1f;
var factor = scale / (_modelScale <= 0f ? 1f : _modelScale);
_modelScale = scale;
if ( factor == 1f ) return;
GameObject.LocalScale = scale;
BodyHeight *= factor;
HitRadius *= factor;
AttackRange *= factor;
// ⛔ `HeadClearance` MUST SCALE WITH `BodyHeight` OR HEADSHOTS DIE. The hit capsule ends at
// `BodyHeight - HeadClearance`, and that gap exists so the head is left to the head hitbox
// alone — a capsule reaching the head sits in front of it and every headshot silently
// registers as a body shot. Scaling the height while leaving the clearance at 18 shrinks
// the gap in proportion, so the taller the boss the more certainly it swallows his head.
HeadClearance *= factor;
// ⛔ `BodyRadius` IS NOT HERE — see `ZombieVariant.ModelScale`. It is the nav agent's
// radius, and widening it is what left Brutus standing still with a flawless speed chain.
if ( _agent.IsValid() ) _agent.Height = BodyHeight;
}
/// <summary>What `ApplyModelScale` last applied, for the reports.</summary>
private float _modelScale = 1f;
public float ModelScale => _modelScale;
/// <summary>
/// Resize this zombie's nav agent live. For `nz_agent_size`.
///
/// ⛔ IT EXISTS TO SETTLE "SLOW OR STUCK" WITHOUT RESPAWNING ANYTHING. A matching MaxSpeed and a
/// zero velocity means pathing, and the two things a variant pushes into the agent are its
/// radius and its height — so bisecting them live is the whole diagnosis. Editing the `.zvar`
/// and respawning changes the variant AND the zombie, which is two variables for one question.
///
/// ⚠️ IT REPATHS, AND WITHOUT THAT IT WOULD PROVE NOTHING. Writing an agent field cancels the
/// current path — the same trap that made slowed zombies stop dead — so a resize with no
/// `Repath` leaves a zombie stationary whatever the new size is, and every reading taken after
/// it would say "still stuck".
///
/// ⚠️ 0 LEAVES A FIELD ALONE, so one of the two can be tested without disturbing the other.
/// </summary>
public void SetAgentSize( float radius, float height )
{
if ( !_agent.IsValid() ) return;
if ( radius > 0f ) _agent.Radius = radius;
if ( height > 0f ) _agent.Height = height;
_agent.MaxSpeed = AgentSpeed;
if ( Target.IsValid() ) Repath();
}
/// <summary>
/// Force the agent's speed back to `MoveSpeed`. For `nz_zspeed_push`.
///
/// ⚠ AN INTERVENTION, NOT A FIX. If pushing this un-freezes a stopped zombie then the cause
/// is a STALE `MaxSpeed` — some path wrote it and nothing wrote it back — and the real fix is
/// at whichever path that was. If pushing it changes nothing, the speed was never the
/// problem and the agent is not moving for some other reason.
/// </summary>
/// <summary>
/// Write a RAW speed onto the agent, bypassing `MoveSpeed` and every multiplier.
///
/// ⛔ THE POINT IS TO TEST THE AGENT, NOT THIS CLASS. "Zombies freeze when slowed" could be
/// the slow arithmetic, a stale write, or the navmesh agent simply not moving below some
/// speed — and the third has never been ruled out because THIS PROJECT HAS NEVER HAD A
/// PARTIAL SLOW. Every status effect to date is `SpeedScale = 0` (the web) or 1; Timeslip is
/// the first thing to ask for 50%.
///
/// ⚠ IT STICKS UNTIL A FACTOR CHANGES, because `TickStatusSpeed` only writes on change. That
/// is what makes it usable as a test rather than being overwritten next tick.
/// </summary>
public void ForceAgentSpeed( float speed )
{
if ( _agent.IsValid() ) _agent.MaxSpeed = speed;
}
/// <summary>
/// Re-run the animation and speed derivation from outside.
///
/// ⛔ IT EXISTS BECAUSE `ExtraSpeedMultiplier` DOES NOTHING ON ITS OWN. That field multiplies
/// into `_clipGroundSpeed`, which `_baseMoveSpeed` is derived from - and both are only computed
/// inside `PickAnimations`. So a caller that changes the multiplier and nothing else keeps the
/// old speed until the next tier change happens to re-pick, which may be never.
///
/// ⚠️ AND IT RE-PICKS THE CLIPS TOO, which is deliberate rather than a side effect. Brutus
/// speeds up when his helmet breaks; a new gait for a newly angry boss is the right look, and
/// exposing a speed-only refresh would mean duplicating the derivation.
///
/// ⚠️ `PickAnimations` STAYS PRIVATE. This is a narrow, documented door rather than widening
/// the method itself, so the set of things that may re-pick stays enumerable.
/// </summary>
public void RepickAnimations()
{
// ⛔ THE TIER FIRST. `PickAnimations` reads `_tier`, so re-picking clips without re-resolving
// which tier we are in re-rolls the SAME pool. Brutus's helmet breaking is precisely a tier
// change — 85 (run) to 170 (sprint) — and without this line he kept his run clips.
RefreshTier();
PickAnimations();
PushSpeed();
}
public void PushSpeed()
{
if ( _agent.IsValid() ) _agent.MaxSpeed = AgentSpeed;
}
private void AcquireTarget()
{
// ⛔ A BOSS'S LOCK FIRST (`LockTarget`, 2026-10-07): its player while still a candidate, and the next look as the lock runs
// out, so the nearest takes over then
if ( TargetLock.IsValid() && Time.Now < TargetLockUntil )
{
foreach ( var candidate in GetTargetables() )
{
if ( candidate != TargetLock ) continue;
Target = candidate;
_wanderTo = Vector3.Zero;
_untilRetarget = MathF.Max( 0.1f, TargetLockUntil - Time.Now );
return;
}
}
GameObject best = null;
float bestDist = float.MaxValue;
foreach ( var candidate in GetTargetables() )
{
if ( !candidate.IsValid() ) continue;
float d = Vector3.DistanceBetween( WorldPosition, candidate.WorldPosition );
if ( d >= bestDist ) continue;
bestDist = d;
best = candidate;
}
Target = best;
// ⚠️ THE WANDER DESTINATION IS DROPPED THE MOMENT A TARGET EXISTS. Keeping it would
// mean a zombie that lost its target again resumed a walk toward a spawn it may now
// be nowhere near — and worse, one that had already arrived would never re-pick,
// because the arrival test would still read true.
if ( best.IsValid() ) _wanderTo = Vector3.Zero;
// Retarget cadence: Clamp(dist/200, 3, 15) seconds. This is the primary
// multiplayer load-spreading mechanism — the original notes it "can
// vastly help the performance of multiplayer games" (moo:6680).
_untilRetarget = best is null ? 1f : Math.Clamp( bestDist / 200f, 3f, 15f );
}
/// <summary>Targetable things. The original keeps a maintained cache and
/// excludes downed/spectating players by setting their priority to NONE
/// rather than filtering here.</summary>
private IEnumerable<GameObject> GetTargetables()
{
// ⚠️ SCOPED BECAUSE THE TODO ABOVE IS A HYPOTHESIS, NOT A MEASUREMENT. It says scanning per
// zombie "does not scale to 35" — plausible, and it does walk the whole scene graph, but it
// runs on a 3-15s retarget cadence rather than per frame, which is ~12 walks a second at 36
// zombies. Whether that matters is now a number rather than an argument.
//
// ⚠️ The scope closes when this method RETURNS, and it returns a LINQ chain that has not been
// enumerated yet — so this measures building the query, not running it. The enumeration cost
// lands in whichever scope calls MoveNext, which is zombie.think via AcquireTarget.
using var _scope = CpuScope.Measure( "zombie.targetscan" );
// ✅ THE TODO THAT WAS HERE — *"replace with a maintained cache … scanning per zombie does
// not scale to 35"* — IS HALF DONE. The player half is cached per frame in `PlayerTargets`;
// what is left of the idea is the priority component, which is a design change rather than
// a performance one.
//
// ⛔ BANANA COLADA'S M3 STAND *REPLACES* THIS LIST WHEN ONE IS IN RANGE — it used to be
// `Concat`ed onto it, and that is the change. `AcquireTarget` takes the NEAREST candidate,
// so as one entry among many the stand lost to any player who happened to be closer, and a
// zombie already on top of you simply kept you. Requested outright: *"as long as a banana
// bunch exists, all zombies inside its area completely ignore the players and focus only
// on it."* Absolute priority inside the radius is the only way to deliver that — a
// weighting would still lose at point-blank range, which is exactly when the lure is
// thrown.
//
// ⛔ THE OLD BEHAVIOUR WAS DELIBERATE AND THE REASON IT GAVE IS NOW A KNOWN COST, not an
// oversight: `BananaStand`'s note warned that overriding proximity *"would let you place a
// stand under your own feet and become invulnerable"*. It does. What bounds it is that the
// stand is not a damage sponge — durability is SWINGS ABSORBED (80) and it carries a
// lifetime, so it is a strong finite panic button rather than immunity.
//
// ⚠️ THE RANGE FILTER IS STILL `TargetsFor`'s, against THIS zombie's position, so "inside
// its area" means the asker is inside — not that a stand exists somewhere on the map.
// Outside the radius nothing changes and players are the whole list, as before.
//
// ⚠️ IT ENUMERATES THE STAND SWEEP HERE, so unlike the player half that cost now lands
// inside the `zombie.targetscan` scope above rather than in the caller. That is the honest
// place for it and it is a sweep over a list that is almost always empty.
// ⛔ BASALT'S ALTAR DEFENSE'S WAVE GOES FOR THE ALTAR AND NOTHING ELSE — *"these zombies will not target the players,
// instead they will target the altar"*. Above the stand, which must not steal them either. None while the defense
// is not running: the wave is despawned then anyway.
if ( AltarWave )
{
var altar = HexPlatforms.AltarTarget;
return altar.IsValid() ? new[] { altar } : Array.Empty<GameObject>();
}
var lure = BananaStand.TargetsFor( WorldPosition ).ToList();
// ⚠️ RE-ANIMATOR'S HUMAN JOINS THE LURES (ammo mod, 2026-10-04): inside its reach it is chased instead of the players,
// and the nearest of the lures wins as among the stands.
ReanimatorHuman.AddTargetsFor( WorldPosition, lure );
if ( lure.Count > 0 ) return lure;
return PlayerTargets();
}
/// <summary>
/// The player half of the candidate list, built ONCE PER FRAME for the whole horde.
///
/// ⛔ EVERY ZOMBIE WAS WALKING THE WHOLE SCENE GRAPH FOR AN ANSWER IDENTICAL TO ITS
/// NEIGHBOUR'S. `Scene.GetAllComponents<PlayerController>()` plus five filters does not depend
/// on WHICH zombie is asking.
///
/// ⚠️ IT MATTERS MOST AT THE WORST POSSIBLE MOMENT, WHICH IS WHY IT IS DONE NOW. The retarget
/// cadence is 3-15s while a target exists, but `AcquireTarget` drops it to **1 second** when it
/// finds nobody — and "nobody" is exactly what a downed or bled-out player produces. So the
/// instant someone goes down, 35 zombies begin walking the scene graph once a second EACH
/// instead of ~12 times a second between them: a 3x jump in the scan, arriving on the same frame
/// as the down. Cached, it is one walk per frame however many are asking.
///
/// ⚠️ THE STAMP SEEDS TO -1, not 0, so the first frame of a session cannot match it and serve
/// an empty list (INSTRUCTIONS.md §1 — a static that starts wrong).
///
/// ⚠️ AND MATERIALISING IT MOVES REAL WORK INTO `zombie.targetscan`. That scope's own note
/// says it measured only query CONSTRUCTION, because the chain was returned unenumerated; with
/// a `ToList` the scan is now honestly inside its own column.
/// </summary>
static float _playerTargetStamp = -1f;
static List<GameObject> _playerTargets;
/// <summary>
/// Throw the cached candidate list away, so the next ask rebuilds it.
///
/// ⛔ A PUSHED RE-ACQUIRE MUST NOT READ A SNAPSHOT OLDER THAN THE REASON IT WAS PUSHED, AND
/// THIS IS WHERE THOSE TWO IDEAS COLLIDED. `PlayerTargets` caches for a frame; `ForceRetargetAll`
/// fires the instant a player goes down — during a frame whose cache may already have been
/// built, with that player still in it. So all 35 zombies cleared their target, re-acquired the
/// player who had just gone down, and then set `_untilRetarget` to 3-15 SECONDS and slept on it.
///
/// What that looks like from the floor is the horde converging on the spot where somebody went
/// down and standing there. User: *"the zombies keep walking towards random spots where a player
/// has been in the past and staying there."*
///
/// ⚠️ INVALIDATE, DO NOT REBUILD. The rebuild costs a scene walk and the caller is about to
/// ask anyway — doing it here would pay for it twice when several pushes land in one frame.
/// </summary>
public static void InvalidatePlayerTargets() => _playerTargetStamp = -1f;
private static List<GameObject> PlayerTargets()
{
if ( _playerTargetStamp == Time.Now && _playerTargets is not null ) return _playerTargets;
_playerTargetStamp = Time.Now;
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return _playerTargets = new List<GameObject>();
return _playerTargets = scene.GetAllComponents<PlayerController>()
.Select( p => p.GameObject )
.Where( g => g.IsValid() )
// ⚠️ DOWNED PLAYERS ARE NOT TARGETS. The original sets
// TARGET_PRIORITY_NONE on down (revive_system/sh_meta.lua). Without
// it the horde crowds the body and the 45s bleedout is spent being
// eaten — which reads as a bug rather than the grace period it is.
//
// ⚠️ Filtered at the CANDIDATE list, not by clearing Target on the
// zombies. Retargeting runs on its own 3–15s cadence, so pushing a
// change at them from the player would be undone at the next
// acquire; excluding them here is picked up by every zombie
// automatically, and lets them re-acquire on revive just as
// automatically.
// ⚠️ `IsOutOfRound` IS TESTED SEPARATELY AND NOT FOLDED INTO THE LINE ABOVE. It happens
// to be redundant today — `IsDown` stays true through a bleedout — and that is exactly
// why it is written down: the redundancy is an accident of where `Revive` clears the
// flag, not a rule, and the day a bled-out player stops counting as "down" this filter
// would silently start feeding the horde a body that is not in the world.
.Where( g => g.Components.Get<NZPlayer>() is not { IsDown: true } )
.Where( g => g.Components.Get<NZPlayer>() is not { IsOutOfRound: true } )
// ── VULTURE AID'S GAS ────────────────────────────────────────────
//
// ⛔ EXCLUDED HERE, FOR THE REASON THE BLOCK ABOVE ALREADY ARGUES. The original
// does `SetNoTarget(true)` plus `SetTargetPriority(TARGET_PRIORITY_NONE)` — it
// makes the player untargetable rather than telling each zombie to forget them.
// Filtering the candidate list is the same idea: every zombie picks it up on its
// next acquire automatically, and stepping out of the gas restores you just as
// automatically with nothing to un-set.
//
// ⚠️ ONE `IsInGas` CALL PER PLAYER PER ACQUIRE, not per zombie — this Where runs
// inside `GetTargetables`, which is per zombie, so with 35 zombies a naive
// implementation would sweep the cloud list 35 times. `VultureStink.IsInGas`
// caches per frame for exactly that reason.
// ⛔ ONE TEST, NOT ONE PER CAUSE. This was `VultureStink.IsInGas`; Timeslip's m2
// Time Out is a second reason a player cannot be seen, and adding a second `.Where`
// beside it is the §3 shape — two filters that must both be remembered, and the one
// that gets missed is whichever cause is added next. `NZPlayer.IsUntargetable` is
// where the causes are combined.
//
// ⚠ A NULL NZPlayer PASSES, deliberately, exactly as it does for the downed test
// above: `EnsurePlayerSetup` runs in the `Select` BELOW this, so a brand-new player
// object legitimately has no NZPlayer yet and must not be filtered out for it.
.Where( g => g.Components.Get<NZPlayer>() is not { IsUntargetable: true } )
// ⛔ AND THE NAVMESH HAS TO BE ABLE TO REACH THEM, WHICH NOTHING USED TO CHECK. A
// target that cannot be pathed to is worse than no target at all: the agent walks the
// route as far as it goes, stops, and from its own point of view is finished — it is AT
// the end of its path. What that looks like is the horde freezing at some arbitrary spot
// short of the player, which is indistinguishable from the no-target bug and has an
// entirely different cause.
//
// Bodies end up off the mesh in ordinary play: `ApplyOutOfRoundBody` freezes a bled-out
// player with gravity and motion off exactly where they fell — mid-air, on a ledge,
// clipped into geometry — and a revive, a Phase Shift or a teleporter can place one just
// outside it.
//
// ⚠️ `GetClosestPoint`, NOT `CalculatePath`. This runs in the per-frame sweep, so it
// costs ONE spatial query per player per frame; a real path would be one per player PER
// ZOMBIE and is what the repath cadence exists to ration. It catches "off the mesh",
// which is the case that actually happens — a player on a disconnected island is still
// covered, one layer down, by `TickAntiStuck`.
//
// ⚠️ NO NAVMESH MEANS NO OPINION. If the mesh is missing or disabled, this must not
// filter everybody out and leave the horde idle — that would turn a map-setup problem
// into a frozen game.
.Where( g => Reachable( g.WorldPosition ) )
.Select( EnsurePlayerSetup )
// ⛔ BANANA COLADA'S M3 STAND, APPENDED AFTER THE PLAYERS. It is a lure: one more
// candidate for `AcquireTarget` to consider, which then takes whichever is NEAREST. That
// nearest-wins rule is what stops the augment being an invulnerability button — a stand
// under your own feet does not out-compete you.
//
// ⚠️ RANGE-FILTERED BY THE STAND'S OWN RADIUS, because `AcquireTarget` has NO distance
// limit of its own — it scans every candidate and picks the closest. An unfiltered stand
// would pull zombies from across the whole map.
//
// ⚠️ AND IT DOES NOT GO THROUGH `EnsurePlayerSetup`, obviously, but note WHY that is
// safe: a stand needs no `Health` because the swing that lands on it is absorbed in
// `DoAttackDamage` the way a barricade's is, before any damage is resolved.
.ToList();
}
/// <summary>
/// Make sure any player a zombie can see is a valid TARGET.
///
/// Without NZPlayer + Health a player has no health for an attack to land
/// on, so zombies swing at it forever and nothing happens. Wiring those in
/// the scene works until a player is spawned at runtime (respawn, or a
/// second player joining), and then it silently does not.
///
/// Done here because this is the one place that already enumerates every
/// player, and it runs continuously — so a player that appears later is
/// picked up automatically. GetOrCreate is idempotent, so a player already
/// set up in the scene is untouched.
/// </summary>
private static GameObject EnsurePlayerSetup( GameObject go )
{
if ( go.Components.Get<NZPlayer>() is null )
{
go.Components.GetOrCreate<NZPlayer>();
Log.Info( $"[ZombieAI] added NZPlayer to '{go.Name}' — it spawned "
+ "without one" );
}
go.Components.GetOrCreate<Health>();
return go;
}
/// <summary>
/// Where this zombie is drifting to while it has nobody to chase, or zero.
///
/// ⚠️ LATCHED SO THE WALK IS COMMITTED. Re-picking the nearest spawn every tick would
/// make a zombie standing between two of them jitter between headings forever, and
/// `EligibleSpawns` can change under it when a door is bought.
/// </summary>
private Vector3 _wanderTo;
/// <summary>
/// How close counts as arrived. Beyond a spawn's own footprint there is nothing to do
/// there, so a loose radius avoids a zombie shuffling for the last few units.
/// </summary>
private const float WanderArriveDistance = 80f;
/// <summary>
/// Nothing to chase — walk toward the nearest zombie spawn instead of standing still.
///
/// ⛔ IT KEEPS RETARGETING WHILE IT WALKS, and that is the half that matters. The retarget
/// check sits ABOVE the `!Target.IsValid()` branch that calls this, so a zombie with no
/// target still re-acquires every 0.4s — which is how stepping out of Vulture Aid's gas
/// gets you chased again. Standing still and re-acquiring worked; it just looked like the
/// AI had given up.
///
/// ⚠️ THE SPAWNS, NOT A RANDOM POINT. A zombie heading for a spawn reads as one that
/// came from somewhere and is going back — and it concentrates the idle horde where the
/// player expects zombies to be, rather than scattering them into corners the player has
/// to go and check.
///
/// ⚠️ `EligibleSpawns`, NOT the raw list, so a zombie never walks toward a spawn behind
/// debris the player has not bought. That property is what makes buying a door change
/// where the horde comes from, and idle drift has to respect it or it would walk the
/// horde through the map's locked half.
///
/// ⚠️ STATE STAYS Idle. The walk is a destination, not a behaviour — Chase would make
/// the animation and the attack logic believe there is a target.
/// </summary>
private void OnNoTarget()
{
SetState( ZombieState.Idle );
_untilRetarget = 0.4f;
if ( !Agent.IsValid() ) return;
// ⛔ THE SPEED IS RESTORED FIRST, UNCONDITIONALLY, AND THAT IS THE FIX FOR THE BUG
// THIS METHOD SHIPPED WITH. Several paths leave `MaxSpeed` somewhere other than
// `MoveSpeed` — `TickSpawn` zeroes it for the entrance, the attack holds position —
// and every one of them relies on a LATER path putting it back. Losing the target is
// not one of those paths, so a zombie whose player vanished kept whatever speed it
// happened to be carrying, permanently. It presented as "ultra slowed down forever".
//
// ⚠️ ABOVE THE EARLY RETURN, deliberately. The first version set it only when there
// was somewhere to walk, so the one case that could not restore the speed was the
// same case that could not move — which is exactly the state that was reported.
Agent.MaxSpeed = AgentSpeed;
// Arrived, or never had one: pick the nearest eligible spawn.
if ( _wanderTo == Vector3.Zero
|| WorldPosition.Distance( _wanderTo ) <= WanderArriveDistance )
{
_wanderTo = NearestSpawn();
}
// ⛔ NOWHERE TO GO MEANS STOP HERE, NOT KEEP THE OLD ORDER. Returning without
// touching the agent leaves the destination from the last chase tick standing — so
// the zombie walks to where the player USED to be and halts there, which is the other
// half of what was reported. `MoveTo( WorldPosition )` is how `StopMoving` cancels an
// order in this file.
if ( _wanderTo == Vector3.Zero )
{
Agent.MoveTo( WorldPosition );
return;
}
Agent.MoveTo( _wanderTo );
}
/// <summary>
/// The closest eligible zombie spawn, or zero when there are none.
///
/// ⚠️ RETURNS ZERO RATHER THAN THROWING OR GUESSING. A map with no spawns placed, or one
/// whose every spawn is behind unbought debris, is a real state — `SpawnOne` warns about
/// the first — and a zombie with nowhere to drift should simply stand still rather than
/// walk to the world origin.
/// </summary>
private Vector3 NearestSpawn()
{
// ⛔ THE STATIC, NOT `Instance.EligibleSpawns`. RoundManager is created on demand, so
// in Creative — where this is tested — `Instance` is null and the instance property
// could never be reached. That returned "no spawns" and was indistinguishable from a
// map with none placed.
//
// ⚠️ Round 1 when there is no round yet, which is the permissive answer: `IsEligible`
// only uses the round to gate spawns with an `ActiveRound` floor, so assuming 1
// excludes exactly the late-round spawns a round-less session should not be using
// anyway.
var round = RoundManager.Instance?.Round ?? 1;
var best = Vector3.Zero;
var bestDist = float.MaxValue;
foreach ( var sp in RoundManager.EligibleSpawnsFor( round ) )
{
var d = WorldPosition.Distance( sp.Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = sp.Position;
}
return best;
}
// ── AI/PATHING ───────────────────────────────────────────────────────────
/// <summary>
/// Repath interval, scaled by BOTH distance and horde size.
///
/// This is the single most important performance lever in the whole AI —
/// reference doc §3.3. The original tried a shared path cache and a
/// Dijkstra flow field, measured them, and ABANDONED both for no
/// measurable gain. This is what actually bought them the headroom.
///
/// mod = RepathCrowdMod * livingZombies (0.02, was a hardcoded 0.05)
/// far (>850) : Clamp(dist²/1000², 1.5 + mod, 15)
/// near : Clamp(dist²/295² , 0.5 + mod, 1)
/// … all of it × RepathIntervalScale (0.3, was 0.5)
///
/// At 35 zombies mod is now 0.7s rather than 1.75s, so the close-range interval is ~0.36s
/// against the old ~1.1s. `nz_repath` prints the resulting table at several horde sizes rather
/// than leaving it to be worked out from these three lines.
/// </summary>
/// <summary>
/// Scales every repath interval. 1.0 = the original's timings, 0.5 = twice
/// as often.
///
/// This is the main AI cost lever, so it's exposed rather than baked in:
/// repathing twice as often roughly doubles the pathfinding load, and the
/// 35-zombie perf test is what decides whether we can afford it.
///
/// ⚠️ 0.5 → 0.3 ON A MEASURED CALL, not a hopeful one. User: *"given the performance is
/// pretty good, we can make the ai recalculate path more frequently."* The perf work this year
/// (the trace range bound, the CpuScope columns, the per-frame caches) is what bought the
/// headroom this spends. If `zombie.update` starts dominating a frame again, this is the first
/// number to put back.
/// </summary>
[Property, Range( 0.1f, 2f )] public float RepathIntervalScale { get; set; } = 0.3f;
/// <summary>
/// Seconds added to every repath FLOOR per living zombie. 0.02.
///
/// ⛔ THIS TERM, NOT THE SCALE, IS WHY A HEAVY ROUND FELT SLUGGISH — and it was a hardcoded
/// literal, so it could not be tuned against the thing it protects. It is a flat add, so it
/// dominates at exactly the moment it hurts most: at the old 0.05 a 35-zombie round added
/// **1.75s** to the close-range floor, taking the true interval to ~1.1s. The path — and with
/// it the agent's heading — refreshed about once a second and then moved in one step, which is
/// the jump `_faceDir`'s low-pass exists to hide.
///
/// ⚠️ AT 0.02 THE SAME ROUND ADDS 0.7s, and with the scale at 0.3 the close-range interval
/// lands near 0.36s — roughly three times more responsive in the case that was worst.
///
/// ⚠️ IT IS STILL A REAL SAFETY VALVE AND MUST NOT GO TO ZERO. Pathfinding cost is per
/// zombie per repath; without a crowd term a 35-zombie round pays 35× the single-zombie cost at
/// the single-zombie rate, which is the cliff the original hit before it gave up on flow fields.
/// </summary>
[Property, Range( 0f, 0.2f )] public float RepathCrowdMod { get; set; } = 0.02f;
private float RepathInterval( float dist )
{
float mod = RepathCrowdMod * All.Count;
// ⚠️ The ceilings must not fall below the floors. At 11+ zombies
// mod exceeds 0.5, so (0.5 + mod) > 1 and .NET's Math.Clamp THROWS
// (ArgumentException: min cannot be greater than max). GMod's Lua
// math.Clamp silently returns nonsense instead, which is why the
// original never hit this — at 35 zombies it would have thrown on
// every near-range repath.
float interval;
if ( dist > 850f )
{
float min = 1.5f + mod;
interval = Math.Clamp( dist * dist / (1000f * 1000f), min, MathF.Max( min, 15f ) );
}
else
{
float nearMin = 0.5f + mod;
interval = Math.Clamp( dist * dist / (295f * 295f), nearMin, MathF.Max( nearMin, 1f ) );
}
return interval * RepathIntervalScale;
}
// ── AI/ATTACK ────────────────────────────────────────────────────────────
private float EffectiveAttackRange() =>
(IsCrawler ? CrawlAttackRange : AttackRange) + AttackRangePadding;
/// <summary>
/// Anti-exploit, moo:2644-2699. A trace decides whether we're blocked, and
/// blocked means attack range collapses to 1. BUT if the player is within
/// damage range anyway, FailedAttack increments and after 6 failures the
/// zombie attacks THROUGH cover. That defeats "stand behind a thin prop and
/// farm". Preserve it — it's load-bearing for the game being a game.
/// </summary>
/// <summary>
/// Currently unable to attack — Widow's Wine's web, Elemental Pop's stun, or whatever
/// roots next. One accessor so the two attack gates cannot disagree, and it asks
/// `StatusEffects.Disarms` rather than naming statuses here, so a new one does not need
/// this file edited at all.
/// </summary>
private bool IsDisarmed => StatusEffects.IsDisarmed( GameObject );
private bool CanAttack()
{
// ⛔ ROOTED MEANS DISARMED, AND THIS CHECK GOES FIRST FOR A REASON. The barricade
// branch below `return true`s past everything else, so a web tested after it would
// still let a snared zombie tear boards — which is the one thing it must not do,
// since tearing is how it reaches you at all.
//
// ⚠️ Widow's Wine already roots them via `SpeedScale = 0`, but rooting only stops
// a zombie ARRIVING. One already in reach when the web lands keeps swinging, which
// is exactly the case the perk is bought for.
if ( IsDisarmed ) return false;
if ( _untilAttackReady > 0 ) return false;
if ( !Target.IsValid() ) return false;
// ⛔ LINE OF SIGHT TO THE PLAYER IS IRRELEVANT WHEN TEARING BOARDS — and
// worse, the barricade is exactly the thing breaking that line. The
// cover check below would refuse the swing until AttacksBeforeIgnoringCover
// failures had accumulated, so the one obstacle a zombie is meant to
// attack was the one that stopped it attacking. Only the cooldown applies.
if ( BlockingBarricade() is not null ) return true;
if ( IsPathToTargetBlocked() )
{
_failedAttacks++;
return _failedAttacks >= AttacksBeforeIgnoringCover;
}
_failedAttacks = 0;
return true;
}
/// <summary>
/// AI/STATE — transitions with entry/exit behaviour.
///
/// Attacking must HALT movement. In the original the attack call blocks the
/// behaviour coroutine until the animation finishes (moo:4644), so the
/// zombie physically cannot path mid-swing. We have no such block, so
/// without stopping the agent it keeps walking into you while hitting —
/// which reads as sliding through the player.
/// </summary>
// ── ABILITIES ───────────────────────────────────────────────────────────────────────────
private float _specialUntil;
private string _specialClip;
/// <summary>
/// The playback rate the running special asked for (`PlaySpecial`'s `rate`, 1 by default). ⚠️ READ AS 1 WHEN IT IS 0: a field
/// added to a live component arrives zeroed after a hotload, and a special at rate 0 would freeze mid-clip.
/// </summary>
private float _specialRate = 1f;
/// <summary>Is this zombie mid-ability? Nothing else may drive it while true.</summary>
public bool InSpecial => State == ZombieState.Special;
/// <summary>
/// End a special animation NOW rather than waiting out the seconds it was given.
///
/// ⛔ `PlaySpecial` WAS WRITTEN FOR ABILITIES THAT ALWAYS COMPLETE — it holds the state for a
/// fixed duration and nothing could shorten it. This is the escape hatch for the case where
/// something interrupts one.
///
/// ⚠️ NOTHING CALLS IT TODAY, AND IT IS KEPT DELIBERATELY. It was added so a napalm zombie could
/// abandon a wind-up when its target escaped; that cancel was then removed by design — the fuse
/// is meant to be uncancellable — but the gap it exposed in `PlaySpecial` is real and the next
/// interruptible special will want exactly this. Delete it if a second one never appears.
///
/// ⚠️ IT DOES NOT SKIP THE TIDY-UP. Setting the deadline into the past lets `TickSpecial` run
/// its own exit on the next frame — upright, unfrozen, animations repicked, repathed — rather
/// than half of it here and the other half never.
/// </summary>
public void CancelSpecial()
{
if ( State != ZombieState.Special ) return;
_specialUntil = 0f;
}
/// <summary>
/// Play a one-shot animation and root the zombie until it ends.
///
/// ⚠️ IT STOPS THE AGENT RATHER THAN DISABLING IT. `TickSpawn` sets `MaxSpeed = 0` for the same
/// reason: disabling the agent hands the transform back and the body drops, which is the
/// "spawns underground" bug `nz_handover` was written to catch.
///
/// ⚠️ `restart: true` MATTERS. Without it a repeat of the same clip keeps the old cycle and the
/// second slam plays from wherever the first stopped.
///
/// ⛔ IT REFUSES WHEN THE MODEL LACKS THE CLIP, rather than rooting the zombie for `seconds`
/// with nothing playing. A stale name renders the bind pose — a boss frozen in a T-pose for two
/// and a half seconds is far worse than one that simply never slams.
/// </summary>
/// <summary>
/// Go over flat on your face: freeze the pose and tip 90 degrees forward, pivoting on the feet.
///
/// ⛔ NO CLIP, AND THAT IS THE POINT. The authored slip animations
/// (`nz_sprint_slipslide`, `_a`, `nz_slipslide_collapse`) are decompiled and staged but do NOT
/// survive conversion — `smd_to_fbx.py` reports the collapse clip's root Z as 68719476736 (2^36)
/// with a NaN lean from provably clean source data, and the other two compile into the model,
/// report as present, and render the zombie INVISIBLE the moment they play. A corrupt sequence
/// throws the bones somewhere absurd and the mesh goes with them.
///
/// ⚠️ SO THIS IS DELIBERATELY NOT AN ANIMATION. It holds whatever pose the zombie was already
/// in and rotates the body — crude, but it reads as "went over" and cannot be corrupted by a
/// bad DMX. Swap it for `PlaySpecial( clip, seconds )` the day the clips convert.
///
/// ⚠️ THE ROOT IS WHAT ROTATES, because `EnsureBody` puts the renderer on THIS GameObject —
/// there is no child body to tip on its own (see `ApplyModelScale`, same constraint). The
/// per-bone hitboxes are children and come along, which is right: a downed body should be
/// shootable where it actually lies.
///
/// ⚠️ AND THE PIVOT IS THE FEET FOR FREE. The object's origin sits at the feet, so rotating the
/// transform tips it about them rather than about its middle.
/// </summary>
public bool PlayPratfall( float seconds )
{
if ( seconds <= 0f ) return false;
if ( State is ZombieState.Dead or ZombieState.Spawning or ZombieState.Special ) return false;
_specialClip = null;
_specialUntil = Time.Now + seconds;
SetState( ZombieState.Special );
StopMoving();
if ( _agent.IsValid() ) _agent.MaxSpeed = 0f;
// ⚠️ FREEZE RATHER THAN STOP. Rate 0 holds the current frame; leaving it at 1 would have
// them jogging on the spot while lying down.
if ( _renderer.IsValid() ) _renderer.PlaybackRate = 0f;
_uprightRotation = WorldRotation;
// ⚠️ YAW KEPT, PITCH REPLACED — they fall the way they were already facing.
_downRotation = Rotation.From( PratfallPitch, WorldRotation.Yaw(), 0f );
// ⛔ THE FALL IS ANIMATED, NOT APPLIED HERE. Snapping straight to the down rotation read as
// a teleport — the body was simply on the floor the next frame. `TickSpecial` walks it over
// `PratfallSeconds`, which is the only motion this effect has now that the authored clips
// are unusable, so it is doing the whole job of selling the slip.
_pratfallStart = Time.Now;
_pratfall = true;
// ⚠️ A PLAIN SLIP STANDS STRAIGHT BACK UP WHERE IT FELL. Shockwave's `Knockback` sets these after this call.
_riseSeconds = 0f;
_knocking = false;
// ⚠️ AND EVERY WATCHING MACHINE HOLDS THE POSE TOO (2026-10-04, `FreezeAsPuppet`). The tip itself arrives with the
// transform; the frozen frame does not, so this one message says so.
if ( Networking.IsActive && NZGame.IsHost ) NZNet.ZombieFreeze( GameObject.Id, seconds );
return true;
}
/// <summary>Is this zombie face-down from a pratfall right now.</summary>
private bool _pratfall;
/// <summary>What to stand it back up to.</summary>
private Rotation _uprightRotation;
/// <summary>Where it is falling to.</summary>
private Rotation _downRotation;
/// <summary>When the fall started, for the tip-over.</summary>
private float _pratfallStart;
static float? _pratfallSeconds;
/// <summary>
/// How long the tip-over takes, in seconds. 0.15.
///
/// ⚠️ SHORT ON PURPOSE. A slip is a loss of footing, not a swoon — long enough to read as
/// falling rather than teleporting, short enough that it is over before you have looked away.
/// </summary>
public static float PratfallSeconds { get => _pratfallSeconds ?? 0.15f; set => _pratfallSeconds = value; }
static float? _pratfallPitch;
/// <summary>How far forward a pratfall tips, in degrees. 90 — flat on the floor.</summary>
public static float PratfallPitch { get => _pratfallPitch ?? 90f; set => _pratfallPitch = value; }
// ══ SHOCKWAVE'S KNOCKDOWN (ammo mod, 2026-10-04) ════════════════════════════════════════════════════════════
/// <summary>How long the last part of a pratfall spends getting back up. 0 = stand straight up at the end (a slip).</summary>
private float _riseSeconds;
/// <summary>Is this pratfall also a shove — the body slid from `_knockFrom` to `_knockTo` with the agent held off.</summary>
private bool _knocking;
private Vector3 _knockFrom, _knockTo;
/// <summary>
/// How long this pratfall's slide takes, and how: a shove fast and settling (`Knockback`), a pull slow and then rushing in
/// (`Pull`, `_slideEaseIn`).
/// </summary>
private float _slideSeconds;
private bool _slideEaseIn;
static float? _knockbackSlide;
/// <summary>How long a shove takes to slide its distance. 0.25s — fast, then settling.</summary>
public static float KnockbackSlideSeconds { get => _knockbackSlide ?? 0.25f; set => _knockbackSlide = value; }
static float? _shoveLean;
/// <summary>How far a shove that does not trip leans the zombie back, in degrees: Shockwave since 2026-10-06 (`Knockback`'s `tip`). 15.</summary>
public static float ShoveLean { get => _shoveLean ?? 15f; set => _shoveLean = value; }
/// <summary>
/// THE BANANA PRATFALL RUN BACKWARDS, WITH A SHOVE (2026-10-04, Shockwave — the user: *"use the falling we used for one of
/// the banana colada augments, but backwards ... all zombies in that radius just fall backwards and move backwards a
/// bit"*). It turns to face <paramref name="from"/>, goes over onto its back away from it while sliding
/// <paramref name="distance"/> along the floor, lies there, and gets up over the last <paramref name="rise"/> seconds of
/// <paramref name="seconds"/>. HOST — the AI's own; watching machines get the pose through `PlayPratfall`'s relay.
/// </summary>
/// <param name="tip">
/// ⛔ FALSE FOR SHOCKWAVE SINCE 2026-10-06: NO FALL. It leans back (`ShoveLean`) as it slides, is held there (frozen, rooted,
/// unable to swing: the stun) and straightens over <paramref name="rise"/>. The user: *"shockwave should no longer make them trip, instead just pushes them back and stuns them"*.
/// Avogadro's shove keeps the fall (the default).
/// </param>
/// <returns>False when it can't go over: see the guards.</returns>
public bool Knockback( Vector3 from, float distance, float seconds, float rise, bool tip = true )
{
// ⛔ NOT MID-WINDOW OR MID-LINK. A zombie tearing boards, climbing through a window or crossing a link is being driven
// by that, and a shove would carry it through the boards or off the link. And not a boss.
if ( _crossing || _vaulting is not null || OnLink || ParkedAt is not null ) return false;
if ( BlockingBarricade() is not null ) return false;
if ( Variant?.IsBoss ?? false ) return false;
var away = (WorldPosition - from).WithZ( 0f );
if ( away.Length < 1f ) away = -WorldRotation.Forward.WithZ( 0f );
if ( away.Length < 0.01f ) return false;
away = away.Normal;
if ( !PlayPratfall( seconds ) ) return false;
// ⚠️ FACING THE BLAST, SO "BACKWARDS" IS AWAY FROM IT — `PlayPratfall` tips forward along the way it already faced.
// ⛔ AND WITHOUT `tip` ONLY A LEAN, NOT THE FALL (Shockwave, 2026-10-06): reeling back from the blow, not lying on its back.
var yaw = Rotation.LookAt( -away ).Yaw();
_uprightRotation = Rotation.From( 0f, yaw, 0f );
_downRotation = Rotation.From( tip ? -PratfallPitch : -MathF.Max( 0f, ShoveLean ), yaw, 0f );
_riseSeconds = Math.Clamp( rise, 0f, seconds * 0.5f );
_knockFrom = WorldPosition;
_knockTo = KnockbackEnd( away, distance );
_slideSeconds = KnockbackSlideSeconds;
_slideEaseIn = false;
_knocking = (_knockTo - _knockFrom).Length > 1f;
// ⚠️ THE BODY IS OURS WHILE IT SLIDES (`TakeTransform`), or the agent drags it back every frame; it is handed back
// where it landed when it gets up (`TickSpecial`).
if ( _knocking ) TakeTransform();
return true;
}
/// <summary>
/// Where a shove of <paramref name="distance"/> along <paramref name="away"/> ends: short of any wall, on the navmesh of the
/// same level. Its own spot when there is nowhere to go.
/// </summary>
Vector3 KnockbackEnd( Vector3 away, float distance )
{
var start = WorldPosition;
if ( distance <= 0f ) return start;
// ⚠️ NOT THROUGH A WALL OR A BOARD: a ray at knee height, stopped a body's width short of whatever it meets.
var lift = Vector3.Up * 24f;
var tr = Scene.Trace.Ray( start + lift, start + lift + away * distance )
.WithoutTags( "player", "zombie", "trigger", "ragdoll", "corpse" )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
var reach = tr.Hit ? MathF.Max( 0f, tr.Distance - BodyRadius ) : distance;
if ( reach < 4f ) return start;
// ⚠️ ONTO THE NAVMESH OF ITS OWN LEVEL, so the agent can take it from where it lands — and never off a ledge: an end a
// step above or below, or a mesh point that is not where the shove went, is no shove at all.
var end = NavGround( Scene, start + away * reach );
if ( MathF.Abs( end.z - start.z ) > 24f ) return start;
if ( (end - start).WithZ( 0f ).Length > reach + 16f ) return start;
return end;
}
// ══ GRAVITY WELL'S PULL (ammo mod, 2026-10-04) ══════════════════════════════════════════════════════════════
static float? _pullLean;
/// <summary>How far a pulled zombie leans into the pull, in degrees. 25.</summary>
public static float PullLean { get => _pullLean ?? 25f; set => _pullLean = value; }
/// <summary>
/// Held as a pratfall holds it — frozen, rooted, unable to swing — but upright and leaning in, dragged toward
/// <paramref name="centre"/> until it is <paramref name="knot"/> from it, over <paramref name="pullSeconds"/>, and kept there
/// until <paramref name="seconds"/> are up. HOST. The guards are `Knockback`'s; the slide is its, run inward.
/// </summary>
/// <returns>False when it can't be pulled.</returns>
public bool Pull( Vector3 centre, float knot, float seconds, float pullSeconds )
{
if ( _crossing || _vaulting is not null || OnLink || ParkedAt is not null ) return false;
if ( BlockingBarricade() is not null ) return false;
if ( Variant?.IsBoss ?? false ) return false;
var toward = (centre - WorldPosition).WithZ( 0f );
var dist = toward.Length;
toward = dist > 1f ? toward / dist : WorldRotation.Forward.WithZ( 0f ).Normal;
if ( !PlayPratfall( seconds ) ) return false;
// ⚠️ FACING THE PULL AND LEANING INTO IT, not tipped over: it is being dragged, not knocked down.
var yaw = Rotation.LookAt( toward ).Yaw();
_uprightRotation = Rotation.From( 0f, yaw, 0f );
_downRotation = Rotation.From( PullLean, yaw, 0f );
_riseSeconds = Math.Clamp( 0.2f, 0f, seconds * 0.5f );
// ⚠️ INTO THE KNOT AND NO FURTHER — one already inside it stays where it is — short of any wall on the way, on the
// navmesh of its own level (`KnockbackEnd`).
_knockFrom = WorldPosition;
_knockTo = KnockbackEnd( toward, MathF.Max( 0f, dist - knot ) );
_slideSeconds = MathF.Max( 0.05f, pullSeconds );
_slideEaseIn = true;
_knocking = (_knockTo - _knockFrom).Length > 1f;
if ( _knocking ) TakeTransform();
return true;
}
// ══ ICE WALL'S HOLD (ammo mod, 2026-10-04) ═══════════════════════════════════════════════════════════════════
/// <summary>
/// Keep this zombie's middle within <paramref name="radius"/> of <paramref name="centre"/> on the floor. One that has
/// walked past the line is put back on it — through the agent too, or the agent would carry it straight back out. HOST.
/// It goes on chasing, pressing against the line, and can still swing at whatever is in reach: a wall, not a freeze.
/// </summary>
public void KeepWithin( Vector3 centre, float radius )
{
if ( State is ZombieState.Dead or ZombieState.Spawning ) return;
// ⚠️ NOT WHILE SOMETHING ELSE DRIVES THE BODY: a window, a link, a climb, or a pratfall's slide (`_knocking`).
if ( _crossing || _vaulting is not null || OnLink || ParkedAt is not null || _knocking ) return;
var off = (WorldPosition - centre).WithZ( 0f );
var d = off.Length;
if ( d <= radius || d < 0.01f ) return;
var back = new Vector3( centre.x, centre.y, WorldPosition.z ) + off / d * radius;
WorldPosition = back;
if ( _agent.IsValid() ) _agent.SetAgentPosition( back );
}
/// <remarks>
/// ⚠️ <paramref name="rate"/> (2026-10-06, the Astronaut's headbutt quickening with every miss): the clip plays that much
/// faster, and the caller's <paramref name="seconds"/> must already be the shortened length. Every other caller passes none.
/// </remarks>
public bool PlaySpecial( string clip, float seconds, float rate = 1f )
{
if ( string.IsNullOrWhiteSpace( clip ) || seconds <= 0f ) return false;
if ( State is ZombieState.Dead or ZombieState.Spawning or ZombieState.Special ) return false;
if ( !ModelHasSequence( clip ) )
{
Log.Warning( $"[ZombieAI] no sequence '{clip}' on this model — ability skipped" );
return false;
}
_specialClip = clip;
_specialUntil = Time.Now + seconds;
_specialRate = rate > 0.01f ? rate : 1f;
SetState( ZombieState.Special );
StopMoving();
if ( _agent.IsValid() ) _agent.MaxSpeed = 0f;
PlaySequence( clip, restart: true );
if ( _renderer.IsValid() ) _renderer.PlaybackRate = _specialRate;
return true;
}
/// <summary>
/// Re-arm the ability hold, for a caller that only learned the real length after starting it.
/// </summary>
///
/// ⚠️ IT EXISTS BECAUSE THE HOLD AND THE CLIP ARE SET IN THAT ORDER. `PlaySpecial` is told how
/// long to hold BEFORE the sequence is playing, so a caller that wants to hold for exactly one
/// play of the clip cannot know the number it needs until the call has already returned. Every
/// other caller passes a deliberate hold that is NOT the clip length — a 2s peek at a looping
/// idle, a wind-up plus a tail — which is why this is a second call and not a rule inside
/// `PlaySpecial`.
///
/// ⚠️ ONLY WHILE THE ABILITY IS RUNNING. Outside `Special` there is no hold to extend and setting
/// one would strand the zombie for as long as it lasted.
public void HoldSpecialFor( float seconds )
{
if ( State != ZombieState.Special || seconds <= 0f ) return;
_specialUntil = Time.Now + seconds;
}
/// <summary>
/// Hold the ability, then hand control back.
///
/// ⚠️ IT RESTORES `PlaybackRate` AND RE-PICKS THE WALK CLIP. The ability ran at rate 1; the walk
/// cycle is driven by `velocity / GroundSpeed` and the renderer is still playing the ability's
/// sequence, so without both the zombie walks away doing a slam at the wrong speed.
/// </summary>
private void TickSpecial()
{
// ⚠️ THE FALL IS DRIVEN HERE, ABOVE THE EXPIRY CHECK, because that check returns for the
// whole hold — anything below it only runs on the frame the ability ends.
//
// ⚠️ `SmoothStep` RATHER THAN LINEAR. A constant-rate tip looks mechanical; easing out means
// it accelerates away from the feet and settles, which is roughly what falling does.
if ( _pratfall )
{
var t = PratfallSeconds <= 0f
? 1f
: Math.Clamp( (Time.Now - _pratfallStart) / PratfallSeconds, 0f, 1f );
var pose = Rotation.Lerp( _uprightRotation, _downRotation, t * t * (3f - 2f * t) );
// ⚠️ SHOCKWAVE'S GET-UP (2026-10-04): the last `_riseSeconds` of the hold bring it back upright, eased the same way.
// A slip has none and stands straight up at the end, as it always has.
var left = _specialUntil - Time.Now;
if ( _riseSeconds > 0f && left < _riseSeconds )
{
var u = Math.Clamp( 1f - left / _riseSeconds, 0f, 1f );
pose = Rotation.Lerp( _downRotation, _uprightRotation, u * u * (3f - 2f * u) );
}
WorldRotation = pose;
// ⚠️ AND ITS SLIDE: a shove fast then settling, a pull slow then rushing in (`_slideEaseIn`).
if ( _knocking )
{
var s = _slideSeconds <= 0f
? 1f
: Math.Clamp( (Time.Now - _pratfallStart) / _slideSeconds, 0f, 1f );
WorldPosition = Vector3.Lerp( _knockFrom, _knockTo, _slideEaseIn ? s * s : 1f - (1f - s) * (1f - s) );
}
}
if ( Time.Now < _specialUntil ) return;
// ⚠️ UPRIGHT AND UNFROZEN BEFORE ANYTHING ELSE, so the walk clip picked below plays on a
// body that is standing and running again rather than lying still on its face.
if ( _pratfall )
{
WorldRotation = _uprightRotation;
if ( _renderer.IsValid() ) _renderer.PlaybackRate = 1f;
_pratfall = false;
// ⚠️ A SHOVE HANDS THE BODY BACK TO THE AGENT WHERE IT LANDED, or the agent would drag it home.
if ( _knocking )
{
WorldPosition = _knockTo;
_knocking = false;
GiveTransformToAgent();
}
}
_specialClip = null;
SetState( ZombieState.Chasing );
RepickAnimations();
if ( _walkSequence is not null ) PlaySequence( _walkSequence, restart: true );
if ( Target.IsValid() ) Repath();
}
private void SetState( ZombieState next )
{
if ( State == next ) return;
// leaving
if ( State == ZombieState.Attacking )
_sinceRepath = 999f; // force an immediate repath on resume
State = next;
// entering
if ( next == ZombieState.Attacking )
StopMoving();
}
private void TickAttack()
{
if ( !Target.IsValid() ) { SetState( ZombieState.Chasing ); return; }
// ⛔ A SWING DOES NOT ALWAYS STOP THE ZOMBIE. Verified from the source:
// PlayAttackAndWait's loop body runs EVERY tick while the clip plays,
// and it is gated on the standing flag (moo:4773-4781):
//
// if !self:IsStandingAttack() and !self:GetCrawler() then
// self.loco:SetDesiredSpeed( self:GetRunSpeed() )
// self.loco:Approach( self:GetTarget():GetPos(), 10 )
// self.loco:FaceTowards( self:GetTarget():GetPos() )
// end
//
// So a STANDING attack plants the zombie, and a MOVING attack keeps it
// closing at FULL RUN SPEED and re-facing its target for the whole
// swing. That is what lets a sprinter stay on a running player instead
// of stopping dead each time it swings — and since the standing set is
// chosen by the TARGET's speed, standing still is what makes them
// plant. We were calling StopMoving() unconditionally, which turned
// every attack into a standing one.
// ⛔ TEARING ALWAYS PLANTS THE ZOMBIE. Without this the MOVING attack keeps
// calling MoveTo(player) mid-swing — and since the navmesh does not know
// the barricade exists, that walks it straight through the boards it is
// supposed to be tearing. This is why they went through it.
if ( BlockingBarricade() is not null )
{
StopMoving();
}
else if ( _standingAttack )
{
// Hold position for the whole swing, not just on entry — the agent
// can still be carrying velocity from the approach.
StopMoving();
}
else if ( Agent.IsValid() )
{
Agent.MaxSpeed = AgentSpeed;
Agent.MoveTo( Target.WorldPosition );
// The source also calls FaceTowards(target) here. FaceMovement()
// already runs every tick and steers toward travel direction —
// which, while approaching the target, points at the target — so
// it covers this without a second facing path fighting it.
}
// ── mid-swing ────────────────────────────────────────────────────────
// The original blocks its coroutine on the sequence, so the swing runs
// to completion even if the player backs off. Damage lands ONCE, part
// way through — not on entry, or the hit registers before the arm has
// moved.
if ( ActionPlaying )
{
// ⛔ CONVERTED TO ELAPSED, BECAUSE `_actionDone` IS A `TimeUntil` AND READS AS
// REMAINING. The old test was `_actionDone <= _actionLength * AttackDamagePoint`, which
// silently inverted the knob — see `AttackDamagePoint`, which documented itself as an
// elapsed fraction and was used as a remaining one, so every retune moved the hit the
// wrong way.
//
// ⚠️ A ZERO-LENGTH CLIP COUNTS AS FULLY ELAPSED so the swing still lands. Dividing by it
// would give an infinity, and the `dur <= 0f` fallback further down already handles a
// missing clip — but this branch is reached first when a clip reports a length of zero
// while still playing.
var elapsed = _actionLength <= 0.001f
? 1f
: 1f - (float)_actionDone / _actionLength;
if ( !_swingDamaged && elapsed >= ScaledAttackDamagePoint )
{
_swingDamaged = true;
DoAttackDamage();
}
return;
}
// ── swing finished: leave, or start another ──────────────────────────
float dist = Vector3.DistanceBetween( WorldPosition, Target.WorldPosition );
// ⛔ A ZOMBIE TEARING BOARDS MUST NOT BE SENT BACK TO CHASING. This read
// "is the PLAYER in range" — and a zombie at a barricade is by definition
// nowhere near them, so it bounced straight back to Chasing, which saw the
// barricade and returned it to Attacking, forever. It planted itself
// (StopMoving) and ping-ponged between the two states without ever
// reaching the swing below. That is the "stands there doing nothing" bug:
// the tearing was wired, and unreachable.
// ⚠️ THE WALL COUNTS AS SOMETHING TO KEEP HITTING, exactly as a boarded barricade does —
// without it the zombie swings once, finds the player out of range, and walks off mid-wall.
var tearing = (object)BlockingBarricade() ?? BlockingWall();
if ( tearing is null && dist > EffectiveAttackRange() )
{
SetState( ZombieState.Chasing );
return;
}
// ⚠️ Pacing is the ANIMATION LENGTH (moo:4644,4706), not a tuned
// cooldown. Before this, TickAttack landed damage on every think with
// no cooldown check — _untilAttackReady only gates ENTERING the state,
// so a zombie in range hit 10x a second.
float dur = PlayAction( AttackClipsForNow(), ScaledAttackSpeed );
_swingDamaged = false;
// Also unbudgeted: this is the sound of something swinging at you, and
// it is the cue the player most needs to hear through a horde.
//
// Kept and followed — a MOVING attack keeps closing while it swings
// (see AttackClipsForNow), so this is the one cue where the emitter
// would otherwise be left behind at the exact moment it matters most.
// ⚠️ SHARED: this is the sound of something swinging at you, and on a client it is the one
// cue that most needs to be heard through a horde. Zombies think only on the host, so
// without the relay a client is attacked in silence.
TrackVoice( NZSound.PlayShared(
ZombieVariant.Cue( Variant?.AttackSound, NZSound.ZombieAttack ),
VoicePosition, SoundGate.Attack ) );
if ( dur <= 0f )
{
// No usable attack clip — fall back to the old timed behaviour so
// a missing animation degrades instead of freezing the zombie.
DoAttackDamage();
_actionLength = 0f;
_untilAttackReady = AttackCooldownPlaceholder;
}
else
{
_untilAttackReady = dur;
}
}
/// <summary>
/// There is NO melee trace. Damage is a pure omnidirectional distance check
/// — a zombie facing away still connects (moo:4658-4754). Line of sight is
/// checked before the swing (CanAttack), never during it.
/// </summary>
private void DoAttackDamage()
{
// ⚠️ CHECKED AGAIN HERE, and the doc comment above says why it has to be: line of
// sight is tested before the swing and never during it, so a zombie that commits a
// swing and gets webbed mid-animation would still connect. CanAttack alone makes
// "unable to attack" true on average rather than always.
if ( IsDisarmed ) return;
// ⛔ THE BARRICADE ABSORBS THE SWING. One board per swing, and the player
// takes nothing this hit — a zombie halfway through a window is not also
// mauling someone on the far side of it.
var wall = BlockingBarricade();
if ( wall is not null )
{
if ( wall.TearPlank() )
Log.Info( $"[nz] board torn — {wall.Planks}/{Barricade.MaxPlanks} left" );
return;
}
// ⛔ A BANANA COLADA PLACEABLE ABSORBS THE SWING, EXACTLY AS THE BARRICADE ABOVE DOES, and it
// is placed immediately after for that reason — same rule, same shape, one after the other.
// The zombie is swinging at the object because `AcquireTarget` chose it as a target, so the
// player takes nothing this hit.
//
// ⚠️ THIS IS WHY A STAND NEEDS NO `Health`. Returning here is ahead of every damage line
// below, so nothing in the damage pipeline ever sees a non-zombie, non-player target — which
// would otherwise mean giving a world object a `Health` and reasoning about Insta-Kill,
// Deadshot and armor applying to a bunch of bananas.
if ( BananaStand.AbsorbSwing( Target ) ) return;
// ⚠️ AND A RE-ANIMATED HUMAN TAKES NOTHING: it is a lure that runs, not a body with health (2026-10-04).
if ( ReanimatorHuman.IsHuman( Target ) ) return;
// ⛔ AND BASALT'S ALTAR TAKES ITS DEFENSE'S WAVE'S SWING, THE SAME WAY: a swing that reaches it is one of the ten hits
// that fail the defense (`HexPlatforms.AltarHit`), with this zombie's own impact sound, and nobody takes anything.
// The reach is the one a player's hit is measured with below, to the altar's foot.
if ( HexPlatforms.IsAltar( Target ) )
{
if ( Vector3.DistanceBetween( WorldPosition, Target.WorldPosition ) <= EffectiveAttackRange() * ScaledAttackReach )
{
NZSound.PlayShared( ZombieVariant.Cue( Variant?.HitSound, NZSound.ZombieHit ), WorldPosition, SoundGate.Feedback );
HexPlatforms.AltarHit();
}
return;
}
// ⛔ AND THE M2 WALL ABSORBS ONE TOO. Unlike the stand this is NOT reached through `Target`
// — nothing targets a wall, the zombie is simply standing in front of one on its way to the
// player — so it is asked by position rather than by what it was aiming at.
if ( Placeable.AbsorbWallSwing( WorldPosition, BarricadeReach ) ) return;
int round = RoundManager.Instance?.Round ?? 1;
if ( round < 1 ) round = 1;
// ⚠️ AND THE MATCH'S ZOMBIE DAMAGE (the lobby's Difficulty, 2026-10-05), a boss's swing included; Oberon's cap below
// still holds.
float damage = ZombieStats.AttackDamageForRound( round )
* (Variant?.DamageMultiplier ?? 1f)
* Difficulty.ZombieDamage
* SwingScale;
// The 0.5s victim immunity lives on the TARGET's Health component
// (NZPlayer sets ImmunityAfterHit), not here — otherwise every zombie
// would have to know about every other zombie's hits. Apply returns 0
// when the window swallowed it, which is also our cue not to log.
// ⛔ RE-CHECK RANGE AT THE MOMENT OF THE HIT, not when the swing started.
//
// Damage lands `AttackDamagePoint` of the way through the attack animation — 10% now, at the
// start, by request — but the range test only ran when the zombie ENTERED the attack state.
// Run away mid-swing and the hit still connected from any distance, and because every zombie
// in a crowd resolves its own swing on its own timeline, the player kept taking hits well
// after breaking away. That reads as damage being QUEUED during the immunity window rather
// than as a swing that should have missed.
//
// ⚠️ THIS CHECK MATTERS MUCH LESS NOW THAN WHEN IT WAS WRITTEN, and it is kept rather than
// removed. At 10% the swing resolves almost as soon as it starts, so there is barely a window
// in which the player could have left — but the fallback path below still calls this with no
// animation at all, and a barricade or a vault can still stretch the gap.
//
// ⚠️ A little padding: the player is usually moving away and the check is
// a frame late, so being strict here would make legitimate hits whiff.
// ⛔ THE SWING REACHES FURTHER THAN THE RANGE THAT TRIGGERED IT. This was
// `EffectiveAttackRange() + AttackRangePadding` — 65u against a 52.5u
// trigger, so a swing could only land on someone who had barely moved.
// The multiplier is what makes a committed attack worth dodging rather
// than worth ignoring.
float reach = EffectiveAttackRange() * ScaledAttackReach;
if ( Vector3.DistanceBetween( WorldPosition, Target.WorldPosition ) > reach )
return;
var hp = Target.Components.Get<Health>();
if ( !hp.IsValid() ) return;
// ⛔ NO SWING TAKES A PLAYER DOWN FROM FULL HEALTH WHERE A CAP IS SET — Oberon's (`MaxHitDamage`)
if ( MaxHitDamage > 0f ) damage = MathF.Min( damage, MaxHitDamage );
// ⚠️ Passes the zombie so the victim can tell WHERE the hit came from —
// Victorious Tortoise only reduces damage taken from behind.
float dealt = hp.Apply( damage, false, GameObject );
if ( dealt > 0f )
{
// ⚠️ GATED ON `dealt`, not on reaching this line. Health.Apply returns
// 0 for a hit swallowed by the victim-immunity window, and with six
// zombies in contact most swings are — playing the impact regardless
// would be a wall of hit sounds for damage that never happened.
//
// ⚠️ Positioned at the ZOMBIE, matching the original's `self:EmitSound`.
// It is the claw that makes the noise, so being surrounded should
// sound like it is coming from all sides.
NZSound.PlayShared( ZombieVariant.Cue( Variant?.HitSound, NZSound.ZombieHit ),
WorldPosition, SoundGate.Feedback );
Log.Info( $"[ZombieAI] hit {Target.Name} for {dealt:0}" );
// ⚠️ AND BASALT'S CURSED FLAME, GATED WITH THE SOUND: a hit that lands on its carrier snuffs it out until the next
// round (`HexPlatforms.Torch.cs`), and a swing the immunity window swallowed takes nothing.
HexPlatforms.OnPlayerHit( Target );
}
}
// ── AI/DAMAGE ────────────────────────────────────────────────────────────
/// <summary>Convenience for code and console commands that just want to
/// deal a number. Real hits arrive through the component's IDamageable.</summary>
public void TakeDamage( int amount, bool headshot = false )
{
if ( !_hp.IsValid() ) return;
_hp.Apply( amount, headshot );
}
/// <summary>
/// Reaction to a non-fatal hit. Death is handled separately via OnKilled.
///
/// Decapitation: health <= 10% of max AND the hit landed near the head
/// (moo:2315). Relaxes to <=50% on the killing blow (moo:4073).
/// </summary>
private void OnHurt( bool headshot )
{
// ⛔ THE PER-HIT AWARD CHAIN, WHICH NOTHING MEASURED. OnHurt -> AwardPoints ->
// AddPoints does a hierarchy walk for the player, a PlayerStats lookup and a points
// popup, once per body hit. dmg.react measured only the delegate invoke around it.
using var _cpu = NZombies.CpuScope.Measure( "dmg.award" );
if ( State == ZombieState.Dead ) return;
// The original pays per HIT as well as per kill - 10 a hit. That drip
// is most of a player's early-round income, so it is not cosmetic.
//
// ⚠️ Paid even on the killing blow, and the kill award stacks on top —
// the original does the same, so a headshot kill is 110, not 100.
// ⚠️ `isKill: false` — the Bounty node pays on the kill award only, and this
// same method is what pays both. See AwardPoints.
// ✅ DEATH PERCEPTION M4's HIT HALF. Its own note explained that "ordinary zombie hits do
// already pay in this port, so the request's 'usually you gain no points per hit' is
// specifically about bosses" — so this is a bonus ON TOP of the normal hit award rather than
// a replacement for it. 0 unless the victim is a boss and the attacker owns M4.
// ⛔ NOT EVERY HIT PAYS ANY MORE, AND THE ECONOMY DEPENDED ON IT. Points are per BODY hit,
// so penetration and pellet count multiplied this line: one round through a packed horde
// paid for the whole line, and every pellet of a shotgun blast paid independently — twice
// over with Double Tap M1. User, on an Olympia with the ×6 pellet tech and Double Tap's
// penetration augment: *"each shot can give me literally like 14k points."*
//
// ⚠️ THE SHOOTER DECIDED THIS, NOT US. Only the machine that fired knows which pellet of
// which shot a hit belongs to; `ShotPoints` works it out there and the answer arrives as a
// tag on the damage, latched by `Health.LastHitPays`. The award itself is untouched — one
// author for what a hit is worth, as before.
//
// ⚠️ DAMAGE, STATUS AND THE KILL AWARD ARE ALL UNAFFECTED. A refused hit still hurts,
// still burns, still counts toward the kill; it simply does not pay the drip.
// ⚠️ MIDAS I GILDED HITS (ammo mod upgrade, 2026-10-05): +1 on a paying hit from a Midas gun whose shooter owns it,
// by the shooter's synced level (`KillMods.MidasHitPoints`). In the amount, so Double Points doubles it as it does Midas's
// kill bonus below — both before it.
// ⚠️ THE MATCH'S POINTS PER SHOT (the lobby's Difficulty, 2026-10-05): the gamemode's own unless the host changed it
if ( _lastHitPaid )
AwardPoints( Difficulty.PointsHit
+ NZombies.DeathAugments.BossPoints( _lastAttacker, GameObject, killed: false )
+ KillMods.MidasHitPoints( _hp.IsValid() ? _hp.LastMod : "", _lastAttacker ), false );
// ⛔ NO FLINCH ANIMATION ON HIT — deliberately unwired 2026-08-14.
//
// TryFlinch() and the Pain clip list still exist and still work; nothing
// calls them. The reason is the limitation TryFlinch's own docs predicted:
// the original layers a flinch as an ADDITIVE GESTURE over the walk
// (ACT_GESTURE_FLINCH_HEAD), and s&box's direct Sequence playback has no
// gesture layer, so ours had to replace the WHOLE BODY. A zombie under
// fire therefore stopped walking to play a flinch, and it read worse than
// no reaction at all.
//
// Re-wire it the moment there is an AnimGraph with a real gesture layer —
// at that point the cooldown should also drop from 0.6s to the original's
// 0.1s, since a layered flinch never interrupts movement.
}
/// <summary>
/// React to being shot.
///
/// ⛔ CURRENTLY UNWIRED — nothing calls this. See OnHurt for why (no gesture
/// layer, so a flinch replaced the whole body and stopped the zombie
/// walking). Kept intact, not deleted, because it is correct and becomes
/// usable the moment there is an AnimGraph gesture layer.
///
/// ⛔ THERE IS NO RANDOM CHANCE IN THE ORIGINAL. We had one (PainChance,
/// 25%) and it was invented, not ported — combined with the mid-swing skip
/// and zombies dying in ~3 hits, it meant a flinch almost never played,
/// which is why damage reactions looked absent. The source fires on EVERY
/// damage event (moo:3692-3699), gated only on state:
///
/// if !self:GetSpecialAnimation() and !self.Dying and !self:IsAttacking()
/// and !self.IsBeingStunned and CurTime() > self.LastFlinch then
///
/// ⚠️ ONE DELIBERATE DEVIATION — the cooldown. Theirs is 0.1s because a
/// flinch there is an ADDITIVE GESTURE (ACT_GESTURE_FLINCH_HEAD) layered
/// over the walk, so it never interrupts movement. s&box's direct sequence
/// playback has no layering (same limitation behind the abrupt attack
/// transition), so ours must replace the whole body. At 0.1s under
/// sustained fire that would re-trigger every frame and freeze the zombie
/// mid-stride. FlinchCooldown is the stand-in; drop it to ~0.1s once an
/// AnimGraph gives us a real gesture layer.
///
/// NOT IMPLEMENTED — the STUMBLE system (moo:3714-3775). Those are the
/// directional Head/LeftArm/RightArm pain sets, and they sit behind
/// RagdollForceTest(hitforce)/CrawlerForceTest — damage FORCE, which is
/// negligible for bullets and large for explosives. It is an explosive
/// reaction with an 8s cooldown and a per-map `stumbling` setting, so it
/// belongs with explosive damage, not here. The per-bone hitbox tags
/// (head / arm left / arm right) are already in DamageInfo.Tags and ready
/// for it.
/// </summary>
private void TryFlinch()
{
// ActionPlaying covers both "is attacking" and "special animation" —
// one-shots are the only thing that owns the renderer.
if ( ActionPlaying ) return;
if ( _untilFlinchReady > 0 ) return;
if ( PlayAction( WalkerAnimations.Pain ) > 0f )
_untilFlinchReady = FlinchCooldown;
}
/// <summary>
/// Minimum gap between flinches.
///
/// The original's equivalent is 0.1s — see TryFlinch for why ours has to be
/// longer until we have gesture layering. 1.5s was too long once the random
/// chance was removed: with ~3 hits to kill, only the first would ever show
/// a reaction. Short enough to react repeatedly, long enough that sustained
/// fire cannot re-trigger it every frame and freeze the zombie mid-stride.
/// </summary>
[Property] public float FlinchCooldown { get; set; } = 0.6f;
private TimeUntil _untilFlinchReady;
/// <summary>Seconds a corpse stays put after its death clip finishes.</summary>
[Property] public float CorpseLinger { get; set; } = 6f;
/// <summary>
/// Go limp when the death clip ends, if the model has physics.
///
/// ⛔ OFF 2026-08-15, at the user's call: *"remove that, leave them on the
/// last frame of death position until the body disappears"*.
///
/// The death animations now land the body properly on their own — that was
/// the whole point of the root-motion fix, and 99 of 105 clips end within 8
/// units of the floor. The ragdoll was compensating for clips that could not
/// fall, and once they could it stopped adding anything and started taking
/// things away: it hands over from whatever pose the renderer is in, so any
/// glitch at the end of the clip becomes a corpse frozen in that glitch.
///
/// Everything behind it still works — `Ragdoll()`, the 17-body physics group
/// in the .vmdl, `SoftenCorpse` — and turning this back on re-enables the
/// lot. It is one flag, not a deletion, because a ragdoll is the right answer
/// again the moment corpses need to react to being shot or shoved.
/// </summary>
[Property] public bool RagdollOnDeath { get; set; } = false;
/// <summary>
/// BACKSTOP for how long the death clip may play before physics takes over.
///
/// ⚠️ MEANING CHANGED 2026-08-14. This used to be the normal hand-over time
/// at 0.25s, because the death clips' vertical motion did not survive the
/// conversion and had to be hidden. It does now (they fall, land on the
/// floor, and 79 of 105 end past 60 degrees of lean), so the clip plays to
/// completion as the original does and this is only a safety net for a clip
/// that never reports finishing — a mislabelled looping death would
/// otherwise hold the corpse upright forever.
/// </summary>
[Property] public float RagdollAfter { get; set; } = 4f;
private TimeUntil _corpseExpires;
private TimeSince _sinceDeath;
/// <summary>
/// Time on the death clip to hold the corpse at. 0 = nothing captured.
///
/// ⚠️ A TIME, not a flag. "Freeze the renderer" was tried and is not enough;
/// only re-stating the exact pose survives whatever else touches it.
/// </summary>
private float _deathHoldTime;
private TimeSince _sinceRagdoll;
private bool _ragdollStarted;
private bool _corpseSettled;
private static bool _warnedNoPhysics;
/// <summary>
/// A dead zombie plays its death clip, then LIES THERE.
///
/// It used to be destroyed the instant the clip ended, so a kill read as the
/// zombie blinking out of existence mid-animation. Now the clip runs to
/// completion, the body settles, and it stays for CorpseLinger seconds.
///
/// ⚠️ Deliberately does NOT call UpdateAnimation: that clears _actionClip
/// once the one-shot is done and falls back to _walkSequence, which would
/// put a corpse back into its walk cycle while lying on the floor.
/// </summary>
private void TickCorpse()
{
if ( !_renderer.IsValid() ) return;
if ( !_corpseSettled )
{
// PLAY THE DEATH CLIP OUT, THEN HAND OVER — as the original does
// (moo:4132-4303).
//
// ⚠️ THIS WAS THE "FALLS BACKWARDS" BUG. It used to drop to physics
// after 0.25s, which was a workaround for death clips whose vertical
// motion our converter destroyed (fixed 2026-08-14 — they now fall,
// land on the floor, and 79 of 105 end past 60 degrees of lean). At
// a quarter second the zombie is barely into the clip and still
// essentially upright, so physics received a standing body with no
// committed direction and let it crumple whichever way the solver
// resolved first — often backwards over its own hips.
//
// The animation is what knows the body should go face-down. Let it
// establish that, then let gravity finish the job.
// ⚠️ A PUPPET NEVER HANDS OVER TO PHYSICS. Its root transform is replicated from the
// host every tick, so a locally simulated ragdoll would be fighting the network for
// the same object — the body jitters between what physics wants and what arrives.
// The host ragdolls, that root position replicates, and the puppet follows it holding
// the settled death pose. Not identical, and not a corpse doing the worm.
bool handOver = RagdollOnDeath && !IsPuppet
&& (!ActionPlaying || _sinceDeath >= RagdollAfter);
// ⚠️ SETTLE ONE FRAME EARLY, at 98% rather than at the end.
//
// This is the single-frame stand-up flash. The hold below corrects
// the pose from the settle frame onward, but the frame BEFORE it is
// still playing — and that is the frame the clock wraps on, so frame
// 0 (standing) gets drawn once before anything can catch it.
//
// Two percent of a death clip is 20-60ms of its very end, which is
// not visible; a wrapped frame is.
var live = _renderer.Sequence;
bool nearlyDone = live is not null && live.Name == _actionClip
&& live.TimeNormalized >= 0.98f;
if ( !handOver && ActionPlaying && !nearlyDone )
{
_renderer.PlaybackRate = 1f; // death plays at authored speed
return;
}
_corpseSettled = true;
_corpseExpires = CorpseLinger;
_renderer.PlaybackRate = 0f;
// ⛔ REMEMBER *WHERE* TO HOLD, as a time on the clip.
//
// Pinning PlaybackRate to 0 was not enough and never could be: it
// stops the clock ADVANCING, it does not stop anything else moving
// the pose, and by the time we get here the pose may already be
// wrong. The corpse stood up regardless.
//
// So stop reasoning about what moves it and assert the answer
// instead: record the end of the death clip and force the sequence
// back to that time every tick (below). `Sequence.Time` is settable
// — PlaySequence already uses it to rewind — which is what makes
// this possible without re-playing the clip from frame 0.
var seq = _renderer.Sequence;
if ( seq is not null && !string.IsNullOrEmpty( _actionClip )
&& seq.Name == _actionClip )
{
// A hair inside the end. Exactly Duration can read as finished
// and wrap, which is the thing being defended against.
_deathHoldTime = MathF.Max( 0f, seq.Duration - 0.03f );
}
if ( RagdollOnDeath ) Ragdoll();
}
// ⛔ RE-ASSERT THE FREEZE EVERY TICK, not just on the frame it settles.
//
// "Leave them on the last frame" has to survive anything else that
// touches the renderer afterwards. A single assignment does not: the
// playback clock has already been caught wrapping at the end of a clip
// (see TickOneShot), and a rate of 0 set one frame earlier does not undo
// a wrap that happens on the next. Holding it every tick means the worst
// case is one bad frame rather than a corpse stuck in the wrong pose for
// its whole linger.
//
// ⚠️ Cheap and idempotent — two field writes on an object that is about
// to be destroyed anyway.
// ⛔ HOLD THE DEATH POSE BY FORCE, EVERY TICK.
//
// This is the fix for "it just stands up straight and stays like that".
// Three separate attempts assumed something specific was moving the pose
// — the ragdoll taking over from a bad frame, the playback clock
// wrapping, ModelPhysics building from the bind pose — and each fixed a
// real bug without fixing this. So this one does not care what moves it:
// it states the intended pose again on every frame.
//
// ⚠️ Name FIRST, then Time. Setting the name alone is what starts a clip
// at frame 0 — which is STANDING for a death, and is very probably the
// pose in the report. Setting Time straight after lands it back on the
// last frame in the same tick, so nothing draws frame 0.
if ( _corpseSettled && _renderer.IsValid() && !_ragdollStarted )
{
_renderer.PlaybackRate = 0f;
var held = _renderer.Sequence;
if ( held is not null && _deathHoldTime > 0f
&& !string.IsNullOrEmpty( _actionClip ) )
{
if ( held.Name != _actionClip ) held.Name = _actionClip;
held.Time = _deathHoldTime;
}
}
SoftenCorpse();
// ⚠️ THE GUARD STAYS EVEN THOUGH THE PUPPET NO LONGER REACHES HERE. A proxy deleting a
// networked object it does not own is a real hazard whatever else changes, and it costs
// one comparison.
if ( _corpseExpires <= 0f && !IsPuppet ) GameObject.Destroy();
}
/// <summary>
/// Seconds of ragdoll simulation before a corpse freezes and goes
/// intangible.
///
/// ⚠️ This window is the ONLY time a corpse can shove another corpse or the
/// player, because solid collisions are all-or-nothing — during the fall it
/// must collide with everything in order to collide with the floor at all.
/// So keep it short: just long enough to land. Raising it brings the
/// piling back.
/// </summary>
[Property] public float CorpseSoftenAfter { get; set; } = 0.7f;
private bool _corpseSoftened;
/// <summary>
/// Let a corpse fall properly, then stop it interacting with anything.
///
/// ⛔ THE COLLISION MATRIX IS NOT THE MECHANISM HERE — measured, not
/// assumed. nz_physics showed the player carries a Rigidbody (so it is a
/// DYNAMIC body, not a character sweep), a living zombie has only a static
/// CapsuleCollider, and a corpse is a fully dynamic ModelPhysics group.
/// So corpse-vs-player and corpse-vs-corpse are plain rigid-body contacts,
/// and no tag rule was ever going to touch them.
///
/// PhysicsBody.EnableSolidCollisions is a plain public bool, so this needs
/// no project settings at all.
///
/// ⚠️ TIMED, not immediate. Solid collisions are what let the ragdoll land
/// on the floor in the first place — switch them off at death and the
/// corpse falls through the world. So it simulates normally for
/// CorpseSoftenAfter seconds, lands, and only then goes intangible for the
/// rest of its linger.
///
/// ⚠️ If corpses sink after settling, gravity is still pulling on a body
/// that no longer collides with the floor — the fix then is to freeze the
/// bodies too (ModelPhysics.Locking), not to revert this.
/// </summary>
private void SoftenCorpse()
{
if ( _corpseSoftened ) return;
// ⚠️ Timed from when the ragdoll ACTUALLY started, not from death plus
// RagdollAfter. Those were the same thing while RagdollAfter was the
// hand-over time; it is now a 4-second backstop, and keying off it would
// delay softening past the corpse's own linger so it never ran.
if ( !_ragdollStarted || _sinceRagdoll < CorpseSoftenAfter ) return;
var phys = Components.Get<ModelPhysics>();
if ( phys is null ) { _corpseSoftened = true; return; }
// ⛔ NOT via PhysicsGroup — it is marked obsolete ("No longer in use")
// and its EnableSolidCollisions isn't reachable even though the
// metadata reports it public, so the type itself must be gated.
// The live path is Bodies -> Body.Component (a Rigidbody) ->
// PhysicsBody, every step of which is public.
var bodies = phys.Bodies;
if ( bodies is null || bodies.Count == 0 ) return; // not built yet
int softened = 0;
foreach ( var b in bodies )
{
var body = b.Component?.PhysicsBody;
if ( body is null ) continue;
// ⚠️ DISABLED PENDING RESEARCH — see the class comment. Three
// variants of this were tried and each traded one artefact for a
// worse one:
// EnableSolidCollisions=false -> corpse sinks through world
// + BodyType=Keyframed, no gravity -> joints stop constraining,
// bones drift apart
// The real problem is that we still do not know how s&box expresses
// per-pair collision filtering, and every attempt so far has been a
// workaround for not knowing. Left inert rather than shipping the
// least-bad artefact.
_ = body;
softened++;
}
if ( softened == 0 ) return; // nothing took — retry next tick
_corpseSoftened = true;
}
/// <summary>
/// Hand the corpse to physics.
///
/// ⚠️ Requires the MODEL to carry a jointed physics group — ModelPhysics
/// builds its bodies from the model, so on a model with none it produces
/// nothing. The source QC has both ($collisionjoints + $jointconstrain), so
/// this switches itself on as soon as those are compiled into the vmdl;
/// until then the corpse simply holds its final death pose, which is why
/// the guard logs rather than throwing.
/// </summary>
private void Ragdoll()
{
if ( Components.Get<ModelPhysics>() is not null ) return;
var model = _renderer.Model;
if ( model is null ) return;
// No physics shapes => empty bounds. Cheaper and more honest than
// creating a ModelPhysics that silently does nothing.
if ( model.PhysicsBounds.Size.Length < 1f )
{
if ( !_warnedNoPhysics )
{
_warnedNoPhysics = true;
Log.Warning( "[ZombieAI] model has no physics group — corpses "
+ "hold their death pose instead of ragdolling. Port the "
+ "QC's $collisionjoints into the vmdl to enable it." );
}
return;
}
// ⛔ RETAG BEFORE creating the physics, not after. The shapes pick up
// the GameObject's tags as they are built, so a corpse created while
// still tagged "zombie" would be filtered as a living zombie for the
// rest of its life — colliding with the player and shoving other
// corpses around.
//
// Normally already done by Die(). Repeated here because Ragdoll() is
// reachable on its own (nz_ragdoll, and a future gib/explode path), and
// getting the tags wrong is silent — it costs a corpse that shoves the
// player for six seconds, with nothing in the log to say why.
StopBeingSolid();
// ⛔ CREATE IT DISABLED, WIRE IT, *THEN* ENABLE.
//
// This is the "corpse snaps upright then ragdolls" bug. Components.Create
// enables immediately, so the component initialised with no Model and no
// Renderer and built its bodies from the only pose it had — the model's
// BIND pose, which is standing. Assigning Model and Renderer afterwards
// linked them up, but the bodies were already standing, so the corpse
// jumped from its final death frame to upright and collapsed from there.
//
// ⚠️ It was invisible until today. The ragdoll used to take over 0.25s
// into the death clip, while the body was still near-upright anyway, so a
// snap to upright looked like part of the fall. Letting the clip play out
// is what exposed it.
var phys = Components.Create<ModelPhysics>( false );
phys.Model = model;
phys.Renderer = _renderer;
phys.MotionEnabled = true; // physics drives the renderer, not the reverse
phys.Enabled = true; // build the bodies NOW, from the pose on screen
// SoftenCorpse times off this, not off death — see there.
_ragdollStarted = true;
_sinceRagdoll = 0f;
}
/// <summary>
/// Stop being a solid obstacle. Called at DEATH, not at ragdoll.
///
/// ⚠️ THESE TWO USED TO BE THE SAME MOMENT. The ragdoll took over 0.25s after
/// death, so a corpse was only briefly solid and nobody noticed. Now the death
/// clip plays to completion first, which left a body blocking the player and
/// shoving other zombies for the two-plus seconds it takes to fall over.
///
/// ⚠️ Safe to drop the capsule this early: it has no Rigidbody, so it is
/// static/keyframed and holds nothing up — the body's position comes from the
/// transform while the clip plays, and from the ragdoll bodies afterwards.
///
/// ⚠️ Retagging here is also still BEFORE the physics shapes are built, which
/// is the ordering Ragdoll() depends on — shapes inherit the GameObject's tags
/// as they are created, and a corpse built while tagged "zombie" is filtered
/// as a living one for the rest of its life.
/// </summary>
private void StopBeingSolid()
{
GameObject.Tags.Remove( "zombie" );
if ( !GameObject.Tags.Has( "ragdoll" ) )
GameObject.Tags.Add( "ragdoll" );
Components.Get<CapsuleCollider>()?.Destroy();
// ⚠️ THE AGENT IS THE SECOND HALF OF "SOLID", and the easier one to miss.
// The capsule is what the PLAYER walks into; the nav agent is what other
// ZOMBIES steer around. StopMoving() only parks it — an enabled agent
// still occupies its slot and still pushes the horde apart, so a corpse
// left agent-enabled keeps herding the living for its whole death clip.
if ( _agent.IsValid() )
_agent.Enabled = false;
}
private void Die( bool headshot )
{
SetState( ZombieState.Dead );
// ⚠️ THE HEAD, IF THE KILL TOOK IT (a hit to the head, or Insta-Kill), and an arm on an explosive kill — decided here,
// on the host, and announced (`ZombieAI.Gore.cs`). A puppet never reaches `Die`.
GoreOnDeath();
// one of this round's kills, for the round bar (`RoundManager.RoundKills`)
RoundManager.OnZombieDied( this );
StopMoving();
_sinceDeath = 0f;
// Before the clip starts, so nothing collides with a corpse mid-fall.
StopBeingSolid();
// ⛔ ITS EYES GO OUT (2026-09-28): *"when a zombie dies their eyes should stop glowing"* — on every machine: here on the host,
// `DieAsPuppet` on each client (`ZombieEyes.Darken`)
ZombieEyes.Darken( GameObject );
// ⛔ STATUSES DIE WITH THE ZOMBIE, AND UNTIL 2026-08-20 NOTHING SAID SO. Every
// status was time-limited, so a corpse simply outlived its burn; Adrenaline
// Rounds is permanent, and a corpse lingers for CorpseLinger seconds with Health
// and this component intact — so an adrenalized body kept a child PointLight and
// a per-frame Present() alive for its whole linger, on every kill. StatusEffects
// only has OnDestroy, which is several seconds too late.
//
// ⚠️ IT ALSO MAKES THE NODE READ CORRECTLY ON A SHOTGUN. Pellet one wounds and
// adrenalizes, pellet two kills — with no clear here the horde would speed up
// from kills, which is the exact opposite of "fail to kill".
// ⛔ BEFORE `ClearAll`, AND THAT ORDERING IS THE WHOLE MECHANIC. The Prisma's chain reads
// how much of its fuse is LEFT on the body it just killed and hands that remainder to the
// zombies around it. Read after the statuses are torn down it is always zero — the chain
// would stop at the first link while looking exactly like a chain that worked.
PrismaChain.OnDied( GameObject );
// ⚠️ AND THE AMMO MODS' DEATH HOOK (2026-10-06), BEFORE `ClearAll` FOR THE SAME REASON: Silk Shot V's Brood and Bloodhound V's
// Blood Trail read the web and the mark, and who put them there, off this body (`AmmoModDeaths`).
AmmoModDeaths.Died( this );
StatusEffects.ClearAll( GameObject );
// Death is animation-first, ragdoll-second: play the death sequence to
// completion, THEN become a ragdoll (moo:4132-4303). OnUpdate keeps
// ticking the clip while Dead and destroys the object when it ends, so
// a zombie with no usable death clip disappears immediately as before.
// If no death clip is usable, settle immediately rather than vanishing —
// TickCorpse still gives the body its linger, so a missing clip costs
// the animation, not the corpse.
// ⚠️ WalkerDeaths.Normal, NOT WalkerAnimations.Death. The full list holds
// 34 clips authored for a specific cause of death — fire, ice, blast,
// electricity — and rolling those on a bullet kill is why a shot zombie
// sometimes froze solid or was flung by an explosion that never happened.
// See WalkerDeaths.
// ⛔ Same gap as the attack clips: `DeathSequences` was declared and never
// read, so a hound died on `nz_death_1` — a clip its skeleton does not
// have — and simply vanished instead of falling over.
var deathClips = Variant?.DeathSequences;
if ( deathClips is null || deathClips.Count == 0 ) deathClips = WalkerDeaths.Normal;
PlayAction( deathClips );
// ⛔ AND EVERY OTHER MACHINE IS TOLD, OR THE ZOMBIE SIMPLY VANISHES THERE. A puppet
// runs no AI, so nothing on a client ever notices a death — the body walks until the
// host destroys the object and then blinks out mid-stride. The corpse lingers on the host
// for the length of the clip, so there is time for the clip to play everywhere.
//
// ⚠️ ANNOUNCED FROM `Die`, NOT FROM THE DESTROY. By the time the object is being
// destroyed there is nothing left to animate; the death has to cross while the body is
// still standing.
NZNet.ZombieDied( GameObject.Id );
// ⚠️ AND NOW THERE IS A CATEGORY THAT HONOURS THIS. A death is feedback on the player's own
// shot — the one voice cue you must never drop to make room for ambient groaning — and for a
// while it sat in the same budget as the groaning. `SoundGate.Feedback` is that rule.
NZSound.Play( ZombieVariant.Cue( Variant?.DeathSound, NZSound.ZombieDeath ),
VoicePosition, SoundGate.Feedback );
// ⚠️ Stop the groans it was already making. Without this a zombie that
// dies mid-idle keeps grunting from a corpse, and worse, FollowVoices
// stops running the moment State goes Dead — so the leftover voice is
// stranded at wherever it was standing when it was shot.
foreach ( var h in _voices )
if ( h.IsValid() ) h.Stop();
_voices.Clear();
AwardKillPoints( headshot );
// ⚠️ HERE, in the zombie's own death, so a kill by ANY means can drop — shot,
// knifed, grenaded or nuked. Rolling on the weapon path instead would mean no
// drops from a Nuke, which is exactly where the original's come from.
// ⚠️ THE KILLER IS PASSED for Death Perception's m2, the same reason the
// pickup roll below takes it for Vulture Aid.
// ⛔ THROUGH `Barricade.ResolveDrop`, NOT AT `WorldPosition`. A zombie killed
// while tearing boards or climbing through dies OUTSIDE, and the drop landed in
// the street behind an intact window — visible, unreachable, and a direct
// punishment for keeping the barricade boarded. Both drop rolls go through it.
var dropAt = Barricade.ResolveDrop( WorldPosition );
PowerupDrops.RollOnDeath( dropAt, _lastAttacker );
// ⚠️ SALVAGE, PLATES AND VULTURE, all from the one death site, for the same
// reason the powerup roll is here — a kill by any means can pay out.
//
// ⚠️ The KILLER is passed because Vulture Aid belongs to whoever pulled the
// trigger. Salvage and plates ignore it: they come off the zombie and anyone
// may pick them up.
//
// ⚠️ Specialness is resolved from the VARIANT, not from Behaviour — see
// SpecialEnemies.IsSpecial, since Behaviour is an attack style and would
// classify six ordinary leaping walkers as specials.
//
// ⚠️ `GameObject` is passed so the ground trace can ignore THIS corpse —
// otherwise the body is what the drop lands on.
PickupDrops.RollOnDeath( dropAt, _lastAttacker,
SpecialEnemies.IsSpecial( Variant ), GameObject );
// ⚠️ THE EASTER EGG'S BUILD PART, FROM THE SAME DEATH AND THROUGH THE SAME `dropAt`. A
// napalm killed while tearing at a window dies OUTSIDE it, and a piece of the wonder weapon
// landing in the street behind intact boards would be the worst version of the bug
// `Barricade.ResolveDrop` exists to stop.
//
// ⚠️ NOT A ROLL. This is a scripted award, not a chance — the first one killed owes the
// piece, so there is nothing to randomise and nothing to tune.
//
// ⚠️ `Drop` IS HOST-ONLY AND BROADCASTS, so calling it from every machine's copy of this
// death is correct and costs a branch on a client.
var owed = Variant?.DropsBuildPart ?? 0;
if ( BuildParts.Valid( owed ) )
{
var parts = BuildPartManager.Ensure( Scene );
if ( parts.IsValid() && !parts.AlreadyAwarded( owed ) )
{
parts.Drop( owed, dropAt, WorldRotation.Yaw() );
Log.Info( $"[nz-build] {Variant.ResourceName} dropped the"
+ $" {BuildParts.Name( owed )} (part {owed})" );
}
}
// ⚠️ AUGMENT KILL EFFECTS FROM THE SAME SITE, for the same reason as the two
// rolls above — a kill by any means should pay out, and hanging this off the
// weapon path would mean no Bloodthirst heal from a grenade or a Nuke.
//
// ⚠️ `headshot` IS PASSED THROUGH because Juggernog's m1 "Hardplate" needs it and
// its M4 "Bloodthirst" does not. Resolving it inside the augment layer is not
// possible — this is the only place that knows where the fatal shot landed.
// ⚠️ THE HIT SIZE COMES FROM `Health.LastDamage`, latched there for exactly this —
// Deadshot's M3 splashes a fraction of the killing blow and this is the only site
// that knows a death happened. `GameObject` is passed so the splash can exclude the
// corpse by identity rather than by a health test that races the death handler.
AugmentEffects.OnZombieKilled( _lastAttacker, headshot,
WorldPosition + Vector3.Up * 32f,
// ⚠️ `LastHitDamage`, NOT `LastDamage` (2026-10-04): an Insta-Kill or Executioner kill's `Max × 10` stand-in is not
// a hit to splash. Deadshot's Cranial Detonation reads this too, and had the same room-wide splash.
_hp.IsValid() ? _hp.LastHitDamage : 0f,
GameObject,
// ⚠️ THE MOD OF THE GUN THAT SHOT IT LAST (2026-10-04), for the kill mods on the killer's machine (`KillMods`).
_hp.IsValid() ? _hp.LastMod : "" );
// ⚠️ THE PER-CLASS WEAPON TECH'S KILL EFFECTS (2026-10-04): Quartermaster, Recycler and the Underbarrel Launcher's
// charge, on the gun that last hit it, on its owner's machine (`ClassTech.OnZombieKilled` relays). With `headshot`, for
// Trick Shot's headshot kills (revolver tier 3, 2026-10-04).
ClassTech.OnZombieKilled( _hp.IsValid() ? _hp.LastTech : default, headshot );
// ⚠️ AMMO MODS GET THE SAME DEATH, from the same site and for the same reason as the
// two rolls and the augment hook above: a kill by ANY means should pay out. Blast
// Furnace detonates here, and hanging it off the weapon path would mean no detonation
// from a grenade or a Nuke.
// ⛔ MOVED INTO `AugmentEffects.OnZombieKilled`, WHICH RELAYS. This call and Banana's below
// it were left behind when the augment hook next to them was moved onto the killer's own
// machine — so Blast Furnace, which detonates through here, has never fired for a client:
// `AmmoMods.Held( player )` asks a proxy for its weapon, and weapons are
// `NetworkMode.Never`. Banana's whole charge meter was dead for the same reason.
//
// ⚠️ THE OTHER TWO HOOKS AT THIS SITE STAY. `PickupDrops.RollOnDeath` must run on the
// host because the host has to spawn the pickup, and `SoulBoxManager` already crosses
// through `NZNet.SoulBoxSouls`.
//
// (see AugmentEffects.OnZombieKilled)
// ⚠️ BANANA COLADA BANKS CHARGE HERE, beside the ammo-mod hook and the two drop rolls, because
// this is the ONE death site every kill funnels through — shot, knifed, grenaded or nuked. A
// hook on the shooting path would have quietly excluded half the ways a zombie dies.
// (moved — see the note above AmmoMods)
// ⚠️ SOUL BOXES COLLECT HERE, beside the augment hook, the ammo mods and the two drop
// rolls, because this is the ONE death site every kill funnels through. Upstream hangs its
// soul catcher off `OnZombieKilled` for exactly the same reason, and a hook on the shooting
// path would silently exclude grenades, the knife and the Nuke.
//
// ⚠️ THE CORPSE'S POSITION, NOT THE KILLER'S. A box measures the distance to where the
// zombie DIED — the player can be across the room and it still counts, which is what makes
// a box a place you fight at.
SoulBoxManager.OnZombieKilled( WorldPosition );
// ⚠️ AND BASALT'S BONFIRE, for the soul boxes' reason: a napalm zombie dying on tile 1 sets it burning, pests dying
// on the burning platform count, and a Shrieker dying in it once Color Rings is done puts it out (step 4). The
// variant goes with the position, since only those three count.
HexPlatforms.OnZombieKilled( Variant, WorldPosition );
// ⚠️ Still no corpse cap. The original has none of any kind, so the
// count is bounded only by CorpseLinger x kill rate — at 6s and a
// heavy horde that is a few dozen bodies, which is survivable but
// wants a hard cap before round counts get high.
}
/// <summary>
/// Points for the kill. 50 body / 100 headshot in the original.
///
/// ⚠️ Attribution is a placeholder: it pays the first player in the scene,
/// because nothing tracks WHO landed the killing blow yet. DamageInfo has
/// an Attacker field for exactly this - wire it through Health's events
/// when weapons are properly owned.
/// </summary>
/// <summary>
/// The player behind the last hit, or null.
///
/// ⚠️ ANCESTORS TOO. `_lastAttacker` is usually the player root, but a weapon or
/// controller child would otherwise resolve to nothing — the trap
/// `PerkEffects.HeadshotScaleFor` documents for the same lookup.
/// </summary>
private NZPlayer KillerPlayer()
=> _lastAttacker.IsValid()
? _lastAttacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
: null;
private void AwardKillPoints( bool headshot )
{
// ⚠️ 6% WITH A 7s COOLDOWN, AND BOTH MATTER. This runs on every kill — hundreds a round — so
// the rate is what keeps a quip a quip. `Say` does the rationing; this call site stays dumb.
//
// ⛔ NAMED, NOT LEFT TO DEFAULT TO "WHOEVER IS LOOKING". This method runs on the HOST for
// every kill in the game, and `Say`'s `player ??= PlayerCharacters.Local()` therefore made
// the HOST's character chirp at a client's kills while the client's own stayed mute. With
// the killer named, `Say` relays the question to them.
CharacterVoice.Say( "kill", KillerPlayer() );
// ⚠️ THE MATCH'S KILL POINTS (the lobby's Difficulty, 2026-10-05): the gamemode's own unless the host changed them
// ⚠️ MELEE BEATS HEADSHOT. A knife kill pays 130 whether or not it
// landed on the head — the original checks damage type first
// (sv_hooks.lua:246-257) and never adds the two together.
var amount = _lastHitWasMelee
? Difficulty.PointsKillKnife
: headshot ? Difficulty.PointsKillHeadshot : Difficulty.PointsKill;
// ✅ DEATH PERCEPTION M4 BOUNTY HUNTER, WIRED NOW THAT A BOSS EXISTS. It was catalogued as
// a printable no-op for exactly this moment. `BossPoints` returns 0 for anything that is not
// a boss and for a player who does not own M4, so this adds unconditionally.
//
// ⚠️ ADDED TO `amount` RATHER THAN AWARDED SEPARATELY, so Double Points multiplies the
// bounty too — which is what a player would expect of a points bonus, and what a second
// `AwardPoints` call would have quietly got wrong.
amount += NZombies.DeathAugments.BossPoints( _lastAttacker, GameObject, killed: true );
// ⛔ DEATH PERCEPTION'S m5 EXECUTIONER'S CUT GOES HERE, AND I FIRST PUT IT IN
// `AwardPoints` BESIDE DOUBLE POINTS. That looked right — one chokepoint, and its own
// comment lists the four award kinds as "hit / kill / headshot / knife". But listing the
// kinds is not knowing which one this is: `AwardPoints` receives an amount and a bool for
// "was this a kill", and nothing more. `headshot` is a parameter of THIS method and only
// this one. The compiler said so immediately; the comment had me reading past it.
//
// ⚠️ GATED ON `!_lastHitWasMelee` TOO, because of the rule directly above: melee beats
// headshot. A knifed head pays the KNIFE award, so scaling it by a headshot bonus would
// pay a premium on an award that was never the headshot one.
if ( !_lastHitWasMelee )
amount = DeathAugments.KillPoints( KillerPlayer(), amount, headshot );
// ⚠️ MIDAS (ammo mod, 2026-10-04): +50% when the gun that shot it last carries it (`Health.LastMod`, the host's own gun
// or the mod a client's hit carried). Here, before `AwardPoints`, so Double Points doubles the bonus with the rest.
// ⚠️ +100% WITH MIDAS II (2026-10-05), by the killer's synced level: hence the killer.
amount = KillMods.MidasPoints( _hp.IsValid() ? _hp.LastMod : "", amount, _lastAttacker );
// ⚠️ LATCHED FOR THE STATS, which are recorded inside `AwardPoints` where the killer is
// resolved. A knife kill on the head pays the KNIFE award but is still a headshot, so this
// records what happened rather than what it paid.
_lastHitWasHeadshot = headshot;
// ⚠️ All three kill payouts — body, headshot, knife — funnel through here, so
// this is the one place that has to declare itself a kill for Bounty.
AwardPoints( amount, true );
}
/// <summary>
/// Was the most recent damage melee? Decides whether a kill pays the knife
/// award.
///
/// ⚠️ Nothing sets this yet — there is no melee weapon. A knife only has to
/// tag its DamageInfo "melee" and Health will carry it through.
/// </summary>
private bool _lastHitWasMelee;
/// <summary>Did the hit that just landed earn points. See `ShotPoints`.</summary>
private bool _lastHitPaid = true;
/// <summary>
/// Was the killing blow a headshot. Latched by <see cref="AwardKillPoints"/>.
///
/// ⛔ A FIELD BECAUSE `AwardPoints` CANNOT SEE THE PARAMETER. `headshot` belongs to
/// `AwardKillPoints`, and the same mistake is already documented 20 lines below: a comment
/// listing the four award kinds made it look like `AwardPoints` knew which one it was holding.
/// It does not — it gets an amount and a bool for "is this a kill".
/// </summary>
private bool _lastHitWasHeadshot;
/// <summary>Who landed the killing blow, latched as damage arrives. Null for the
/// world, a trap or a Nuke.</summary>
private GameObject _lastAttacker;
/// <param name="isKill">Is this a kill award (50/100/130) rather than the
/// 10-per-hit drip. Bounty pays on kills only.</param>
private void AwardPoints( int amount, bool isKill )
{
// ── BOUNTY (t2_bounty) ────────────────────────────────────────────────
// ⛔ KILLS ONLY, WHICH IS WHY `isKill` EXISTS AND CARRIES NO DEFAULT. This
// method also pays `PointsHit`, and a high-RPM gun lands dozens of hits on one
// zombie — paid per hit the node would be worth hundreds of points a kill
// instead of ten, which is not a kill bonus at all. A defaulted parameter would
// let a future call site pick the wrong side of that silently.
//
// ⛔ ADDED BEFORE the `PointsMultiplier` line below, not after. After it, this
// would be the one award Double Points does not double — the kind of
// discrepancy nobody finds until a player counts their points. WeaponTech's own
// note on the node says the same.
//
// ⛔ `ifAbsent: 0f`. TechEffects.Factor defaults it to 1 because nearly every
// node MULTIPLIES; this one ADDS, so the default would quietly pay +1 point on
// every kill made with a weapon that never bought the node.
//
// ⚠️ THE HELD WEAPON, NOT PROVABLY THE KILLING ONE. Tech is per weapon prefab,
// so the gun that fired the killing shot is what should be read — but Health
// latches only `LastAttacker`, the shooter's GameObject, so the best answer
// available here is whatever that player has ACTIVE at death. With two slots
// (Mule Kick) the trees differ, so a kill scored with a gun that was swapped
// away between the shot and the death reads the wrong one. Closing that needs
// Health to carry the damaging weapon the way it already carries the attacker.
//
// ⚠️ A null attacker — the world, a trap, a Nuke — resolves to no weapon and
// `Factor` returns `ifAbsent`, so those kills correctly pay no bounty.
if ( isKill )
{
// ⛔ THE WEAPON THAT KILLED IT, NOT THE ONE IN HAND. This read
// `Rarity.HeldBy( killer )` until Health began latching `LastWeapon`, and
// that fallback was wrong in a specific case: with Mule Kick, a kill scored
// with a gun swapped away between the shot and the death paid the OTHER
// slot's bounty. Tech is per weapon prefab, so reading the active weapon is
// reading a different tree.
//
// ⚠️ FALLS BACK TO THE HELD WEAPON when LastWeapon is null, which is every
// non-bullet kill — knife, grenade, trap, Nuke. Those set no weapon on their
// DamageInfo, and a bullet-only bounty that silently paid nothing for a knife
// kill would look like the node was broken rather than scoped.
// ⛔ THROUGH `LastTech`, NOT `LastWeapon`, AND THAT IS WHAT MAKES BOUNTY PAY A CLIENT.
// `LastWeapon` is `damage.Weapon`, which is null for every hit a client relayed —
// weapon prefabs are `NetworkMode.Never` — so this fell through to the held-weapon
// fallback below, which resolves a weapon component on a body that has none on this
// machine. Two dead ends, one cause. `LastTech` carries the prefab path instead.
var killerTech = _hp.IsValid() ? _hp.LastTech : default;
if ( !killerTech.Valid )
{
// ⚠️ `EverythingInSelfAndAncestors`, matching PickupDrops.RollVulture: the
// attacker GameObject is whatever the damage named, which is not
// guaranteed to be the object the NZPlayer component sits on.
var killer = _lastAttacker.IsValid()
? _lastAttacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
: null;
killerTech = TechEffects.Of( Rarity.HeldBy( killer ) );
}
amount += (int)MathF.Round(
TechEffects.Factor( killerTech, "t2_bounty", 0f ) );
// ⚠️ DEAD OR ALIVE (revolver tier 3, 2026-10-04): +100 for a boss or a special zombie, from the same gun and on the
// same side of Double Points as Bounty.
amount += ClassTech.DeadOrAlivePoints( killerTech, GameObject );
}
// ⛔ DOUBLE POINTS APPLIES HERE, AT THE ONE CHOKE POINT. Every award a zombie
// pays — hit, kill, headshot, knife — routes through this method, so the
// multiplier is read once and cannot disagree between paths. Multiplying at
// each call site instead would be TWO places to forget (this said "four" until
// 2026-08-20 — the four award KINDS are hit / kill / headshot / knife, but three
// of those resolve inside AwardKillPoints, so there are only two callers:
// OnHurt:3548 and AwardKillPoints:4074). Enumerated because an unchecked count is
// a count that drifts, and this is the second one in this codebase found wrong in
// a single day. The argument is unchanged either way — one chokepoint beats two.
// Multiplying at each call site instead would be two places to forget, and the one that got
// missed would be the one nobody notices until a player counts their points.
//
// ⚠️ Read LIVE from the registry rather than flipped on at pickup: the timer
// already exists in one place, and a flag would need a matching flip-off.
amount *= PowerupEffects.PointsMultiplier;
// ⛔ BASALT'S ALTAR DEFENSE PAYS LITTLE, AND FLAT: while it runs every kill is worth 10 and every hit 1 — *"during this
// 1 minute, zombie kills award only 10 points, and shots award only 1 point"* (`HexPlatforms.Defense.cs`). LAST, over
// the bounty, the kill bonuses and Double Points above, since "only" is what the player is paid: its wave has no end,
// and must not be a points farm. Every zombie while it runs, the round's own as well.
if ( HexPlatforms.DefensePoints )
amount = isKill ? HexPlatforms.DefenseKillPoints : HexPlatforms.DefenseHitPoints;
// ⛔ THE KILLER, NOT THE FIRST PLAYER IN THE SCENE. This was
// `GetAllComponents<NZPlayer>().FirstOrDefault()` — FIX_LIST entry 5, whose own note said
// the missing ingredient was Health latching the attacker. It has latched it for a while:
// `KillerPlayer()` is defined 60 lines above and was already trusted by Death Perception's
// kill bonus two lines up, so the award was reading a different player from the augment
// that scaled it. Solo hid it completely.
//
// ⚠️ FALLS BACK TO THE FIRST PLAYER when nothing landed the blow — the world, a trap, a
// Nuke. Those still have to pay someone, and paying nobody would make Nuke kills feel
// broken; only the attribution is best-effort, never the payout.
var player = KillerPlayer()
?? NZPlayer.Local;
if ( player.IsValid() )
{
player.AddPoints( amount );
// ⚠️ HERE RATHER THAN IN `AwardKillPoints`, so the kill is recorded against the same
// player the points went to and cannot drift from it.
if ( isKill )
PlayerStats.For( player )?.RecordKill( _lastHitWasHeadshot );
}
}
// ── AI/NAV ───────────────────────────────────────────────────────────────
// ⚠️ UNVERIFIED API. Sandbox.Engine.dll exposes NavMeshAgent,
// GetSimplePath, CalculatePath and GetClosestPoint, but I could not
// compile-test the signatures. Nothing above this line depends on these
// bodies — if the API differs, the fix is contained here.
private NavMeshAgent _agent;
/// <summary>Get or create the pathing agent, sized to match the zombie.
/// The narrow radius is deliberate — see BodyRadius.</summary>
private NavMeshAgent Agent
{
get
{
if ( _agent.IsValid() ) return _agent;
_agent = Components.GetOrCreate<NavMeshAgent>();
if ( _agent.IsValid() )
{
_agent.Radius = BodyRadius;
_agent.Height = BodyHeight;
_agent.MaxSpeed = AgentSpeed;
// ⛔ WE OWN LINK TRAVERSAL, AND UNTIL NOW WE ONLY THOUGHT WE DID. The property's own
// documentation asks the question directly — "Should the agent automatically traverse
// links when it reaches them? Or do you want to implement your own link traversal
// logic?" — and this class plainly implements its own: BeginLinkCross, TickLinkCross,
// StartVault, TakeTransform. It was never set, so the engine was ALSO carrying the
// body across every link while our animation carried it across, and the two only
// happened to agree because TakeTransform let us win the fight.
//
// ⚠️ THE BUG THAT PROVED IT: a barricade with boards still up. The crossing handler
// refuses to animate a zombie through a boarded window, but the agent traversed it
// anyway — so zombies slid through intact boards with no animation at all. Refusing
// our half of a traversal means nothing while the engine performs the other half.
//
// ⚠️ MAKES CompleteLinkTraversal OUR RESPONSIBILITY. With this false the agent waits
// to be told a crossing finished; every path that ends one must say so, or the zombie
// stands in the link forever. See CompleteTraversal and RefuseTraversal.
_agent.AutoTraverseLinks = false;
// Zombies change direction sharply — they don't carry momentum
// like a vehicle. Low acceleration reads as ice-skating.
// GMod base: Acceleration 500, Deceleration 900 (moo:43-76).
_agent.Acceleration = Acceleration;
// Keep per-agent avoidance ON. The original disabled it to save
// the O(N^2) steering cost and REVERTED that: without steering
// they "clump on each other and geometry and stall the round".
// Reference doc §3.1.
// Separation is a FLOAT (strength), not a bool.
_agent.Separation = Separation;
// ⛔ TELL THE AGENT WHERE THE ZOMBIE IS. Without this it starts at
// its own uninitialised position and drags the body there, because
// NavMeshAgent.UpdatePosition is ON by default — the engine docs
// say it plainly: "Set the Position of the GameObject to the agent
// position every frame."
//
// This is the "drops back underground before it walks" bug. Caught
// by the drift column in nz_handover, which compares where WE left
// the transform against where it is before we touch it again:
//
// 365 after z=1088.0 drift= 0.00 agent=on
// 366 after z= 841.0 drift=-247.03 agent=on
// 367 after z= 886.6 drift=-201.36
//
// 247 units DOWN in one frame, the frame after the agent came
// online, then a decaying climb back (247→201→168→142→119…) as the
// agent's rate-limited ground trace converges. We were writing 1088
// the whole time; the pull was entirely the agent's.
//
// ⚠️ The lazy creation is what made it intermittent: the agent is
// built on the first Repath, which happens at the HANDOVER, so the
// jump lands exactly between the entrance ending and the walk
// starting. Its size is however far the agent's initial guess is
// from the truth, which is why it varied and sometimes vanished.
//
// ⛔ SETTING THE POSITION HERE IS NOT ENOUGH — MEASURED. With this
// call in place the drop was still there, unchanged:
//
// 352 after z=1091.0 drift= 0.00 agent=on
// 353 after z= 868.1 drift=-222.87 agent=on
//
// The component exists but has not initialised yet, so its own
// startup overwrites whatever we set. The real problem is not the
// value — it is that the agent takes OWNERSHIP of the transform the
// moment it exists, and nothing tells it when. See TakeTransform /
// GiveTransformToAgent for the handshake that actually works.
_agent.SetAgentPosition( WorldPosition );
}
return _agent;
}
}
/// <summary>
/// Steer away from crowding — except while committed to a one-shot.
///
/// This mirrors the original exactly (moo:629-634), which toggles
/// loco:SetAvoidAllowed per zombie on its busy state: OFF while busy so a
/// committed swing is not steered out of and the zombie stays planted on
/// its target, ON otherwise so the horde spreads.
///
/// ⚠️ Since nothing is solid, this is the ONLY thing stopping a horde
/// occupying one point. Do not "optimise" it away wholesale — the original
/// tried that to save the O(N^2) cost and reverted it, because with a
/// shared path cache funnelling chasers down one corridor and no steering
/// they "clump on each other and geometry and stall the round".
/// </summary>
private void UpdateSeparation()
{
if ( !_agent.IsValid() ) return;
// ⚠️ ATTACKING, not "any one-shot". The original gates on GetIsBusy,
// and a flinch is not busy — it's an additive gesture there and does
// not touch steering at all. Keying this off ActionPlaying would drop
// avoidance for every bullet that lands, so a horde under fire would
// stop spacing itself exactly when it is bunched around the player.
var want = State == ZombieState.Attacking ? 0f : Separation;
// Exact compare is safe: we only ever assign 0f or Separation.
if ( _agent.Separation != want )
_agent.Separation = want;
}
private void Repath()
{
if ( !Target.IsValid() ) return;
// ⚠️ NOT WHILE HELD AT A WINDOW (`ParkedAt`): its agent is off until it climbs in
if ( ParkedAt is not null ) return;
var agent = Agent;
if ( !agent.IsValid() ) return;
agent.MaxSpeed = AgentSpeed; // speed changes with round/tier
agent.MoveTo( Target.WorldPosition );
}
/// <summary>Halt the agent where it stands. Targets our own position rather
/// than calling a Stop() I haven't verified exists — MoveTo is known-good.</summary>
private void StopMoving()
{
var agent = Agent;
if ( agent.IsValid() )
agent.MoveTo( WorldPosition );
}
/// <summary>Trace from our eye to the target's. The original uses
/// COLLISION_GROUP_WORLD so zombies don't block each other (moo:2644).</summary>
private bool IsPathToTargetBlocked()
{
if ( !Target.IsValid() ) return false;
var from = WorldPosition + Vector3.Up * (BodyHeight - 8f);
var to = Target.WorldPosition + Vector3.Up * 40f;
var tr = Scene.Trace.Ray( from, to )
.IgnoreGameObject( GameObject )
.Run();
return tr.Hit && tr.GameObject != Target;
}
}
/// <summary>
/// AI/STATE — explicit, unlike the original's ~15 boolean flags.
///
/// Keep this small. The original's sub-states (stumble, mantle, stun, death)
/// worked by swapping the running coroutine out and restoring it afterwards —
/// "one of the most important functions in this whole base" (moo:4326). The C#
/// equivalent is an interruptible async action, NOT more enum members.
/// </summary>
public enum ZombieState
{
Idle,
/// <summary>Playing an entrance animation — climbing out of the ground or
/// down from a ceiling. Rooted in place until it finishes.</summary>
Spawning,
Chasing,
Attacking,
Stunned,
/// <summary>
/// Playing a one-shot ability animation, rooted until it finishes.
///
/// ⛔ SEPARATE FROM `Stunned` ON PURPOSE. A stun is something DONE TO the zombie — it has a
/// source, it is a status, and anything that clears status effects should clear it. An ability
/// is something the zombie CHOSE. Sharing one state would make a monkey bomb and a Brutus
/// ground slam indistinguishable to every reader of `State`.
/// </summary>
Special,
Dead,
}