A game component that spawns and manages an ice wall effect around a point. It creates a visual ring, tracks lifetime, and on the host keeps nearby zombies inside the ring by forcing their position and applying status effects for certain upgrades.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Ice Wall — a light blue circle on the floor around the zombie you hit; every zombie inside it is held inside until it
/// melts.
///
/// | | value |
/// |---|---|
/// | proc | **10%** per hit, **12s** cooldown (**8s** with IV, `AmmoMods.BaseCooldown`) |
/// | circle | **150u** (**220u** with I), lasts **5s** (**8s** with II), at the zombie you hit |
/// | hold | every zombie inside — those there when it forms AND any that walk in — cannot step out until it is gone |
/// | damage | **none** — with III every zombie held takes **×2** from everyone; with V **×3**, and it moves at **20%** |
///
/// ⚠️ THE USER (2026-10-04): *"ice wall, pretty simple visually / just make a light blue circle, and any zombie inside cannot
/// leave it untill it disapears"*. So the look is one `PitVisual` style (`Ice`): a bright, steady light blue ring over a
/// faint cold glow, fading as it melts. Ice cracks as it forms and shatters as it goes.
///
/// ⚠️ THEY CAN MOVE INSIDE; THEY CANNOT LEAVE. A held zombie keeps chasing — it walks up to the edge and is kept there
/// (`ZombieAI.KeepWithin`), pressing against the wall, and can still swing at a player within reach. That is a wall, not a
/// freeze; Cryofreeze is the freeze.
///
/// ⛔ ONLY THE HOST'S COPY HOLDS. The circle is announced once (`NZNet.WorldFx`) and each machine builds its own to look at,
/// but a zombie's position is the host's to change — written anywhere else it is overwritten by the next update.
///
/// ⚠️ A ZOMBIE THE GAME MOVES AWAY — relocated as stuck, or shoved out by a Shockwave — is let go rather than dragged back
/// across the map (see <see cref="LetGo"/>).
///
/// ⚠️ ITS THREE UPGRADES (2026-10-05, `AMMO_MODS.md` "Upgrades"; the user, 01:16: *"On III make zombies on the inside take
/// double damage"*): I Wide Ring, 220u (`WideRadius`); II Long Freeze, 8s (`LongLifetime`); III Brittle Ice, ×2 damage from
/// everyone on every zombie it holds (`brittle`, kept on by the host's copy in <see cref="Hold"/>). Each is the OWNER'S level,
/// read when the circle forms, by every machine for its own copy — see `_reach`.
///
/// ⚠️ TIERS IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"; the user, 23:53: *"Make V / Zombies inside the ring are slowed
/// down by 80% and take triple damage"*). IV Cold Snap is a cooldown, 8s (12s), and lives with the cooldowns
/// (`AmmoMods.BaseCooldown`), not here. V Absolute Zero puts <see cref="Frostbite"/> beside Brittle Ice on every zombie the circle
/// holds, topped up with it: ×0.2 speed, and ×1.5 on top of Brittle Ice's ×2. Triple REPLACES the ×2 rather than multiplying it
/// (my reading, given back to the user), and both numbers are the rule's (`StatusEffects`). The navmesh floor (42 u/s,
/// `ZombieAI.AgentSpeed`) still holds a walker up, as it does Tar Pit's slow; sprinters show it.
/// </summary>
public sealed class IceWall : Component
{
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED GETTERS — INSTRUCTIONS.md §1.
static float? _radius;
/// <summary>How wide the circle is. 150u.</summary>
public static float Radius { get => _radius ?? 150f; set => _radius = value; }
static float? _lifetime;
/// <summary>How long it holds. 5s.</summary>
public static float Lifetime { get => _lifetime ?? 5f; set => _lifetime = value; }
static float? _margin;
/// <summary>How far inside the ring a held zombie's middle is kept, so its body stops at the line. 10u.</summary>
public static float Margin { get => _margin ?? 10f; set => _margin = value; }
/// <summary>
/// How far past the ring a held zombie may be found before it is let go: farther than any step can carry it in a frame,
/// so only the game moving it — a relocation, a shove — gets past.
/// </summary>
const float LetGo = 96f;
// ── the upgrades (2026-10-05) ────────────────────────────────────────────
//
// ⚠️ EACH UPGRADED VALUE IS ITS OWN TUNABLE BESIDE THE BASE ONE, and the two `…For` below alone choose between them (§3).
// Brittle Ice's ×2 is a status rule's (`StatusEffects`, `brittle`).
/// <summary>The mod's id, for its upgrade level (`AmmoModUpgrades.Level`).</summary>
const string ModId = "icewall";
static float? _wideRadius;
/// <summary>How wide the circle is with I, Wide Ring. 220u (150).</summary>
public static float WideRadius { get => _wideRadius ?? 220f; set => _wideRadius = value; }
static float? _longLifetime;
/// <summary>How long it holds with II, Long Freeze. 8s (5).</summary>
public static float LongLifetime { get => _longLifetime ?? 8f; set => _longLifetime = value; }
static float? _brittleHold;
/// <summary>
/// How long one top-up of III's Brittle Ice lasts — and of V's <see cref="Frostbite"/>, which rides with it (2026-10-06). 0.35s —
/// renewed every tick while a zombie is held, so this is how long it outlasts the hold on a zombie the circle lets go. Never
/// past the ice itself (<see cref="Hold"/>).
/// </summary>
public static float BrittleHold { get => _brittleHold ?? 0.35f; set => _brittleHold = value; }
/// <summary>How often the host's copy tops Brittle Ice (and V's frostbite) up. 10 Hz, Tar Pit's rate for its slow.</summary>
const float BrittleEvery = 0.1f;
/// <summary>This player's circle: <see cref="WideRadius"/> from level I, <see cref="Radius"/> below it.</summary>
public static float RadiusFor( NZPlayer player ) => AmmoModUpgrades.Has( player, ModId, 1 ) ? WideRadius : Radius;
/// <summary>How long this player's circle holds: <see cref="LongLifetime"/> from level II, <see cref="Lifetime"/> below it.</summary>
public static float LifetimeFor( NZPlayer player ) => AmmoModUpgrades.Has( player, ModId, 2 ) ? LongLifetime : Lifetime;
/// <summary>III's status on every zombie the circle holds: its ×2 is on the rule (`StatusEffects`).</summary>
public const string Brittle = "brittle";
/// <summary>
/// V's status beside <see cref="Brittle"/> on every zombie the circle holds (Absolute Zero, 2026-10-06): its ×0.2 speed and its ×1.5
/// — ×3 with Brittle Ice's ×2 — are on the rule of this name (`StatusEffects`), as Brittle Ice's ×2 is.
/// </summary>
public const string Frostbite = "frostbite";
// ══ live state ═══════════════════════════════════════════════════════════
public NZPlayer Owner { get; set; }
TimeUntil _dies;
readonly HashSet<ZombieAI> _held = new();
/// <summary>
/// THIS circle's radius and life, and whether it is Brittle Ice — the owner's level, read when it forms (2026-10-05) — and
/// whether it is Absolute Zero (V, 2026-10-06).
///
/// ⛔ EVERY MACHINE READS THE OWNER'S LEVEL FOR ITS OWN COPY, and the announcement carries none. Each draws its ring at this
/// size and the HOST'S copy holds at it, so a host copy at the base numbers would hold a client's 220u ring at 150u. The
/// levels are synced (`NZPlayer.AmmoUpgradeNet`); only a copy whose owner `NZNet.WorldFx` could not find keeps the base.
///
/// ⚠️ NULLABLE (§1): a circle alive across a hotload has none of them, and keeps the base numbers.
/// </summary>
float? _reach;
float? _life;
bool? _brittle;
bool? _frost;
float Reach => _reach ?? Radius;
float Life => _life ?? Lifetime;
/// <summary>When the host's copy next tops Brittle Ice up. Due at once, so the frame it forms catches.</summary>
TimeUntil _nextBrittle;
/// <summary>How many zombies this circle has held, on the host.</summary>
public int Held => _held.Count;
/// <summary>
/// Raise a circle at <paramref name="at"/> — the zombie hit, on the shooter's machine; the announced point everywhere else.
/// </summary>
public static void Spawn( NZPlayer player, Vector3 at, bool announce = true )
{
// ⚠️ EVERY MACHINE BUILDS ITS OWN TO LOOK AT; ONLY THE HOST'S HOLDS. See the header and `NZNet.WorldFx`.
if ( announce && Networking.IsActive && Connection.Local is not null )
NZNet.WorldFx( Connection.Local.Id.ToString(),
NZPlayers.OwnerOf( player.IsValid() ? player.GameObject : null ),
(int)NZNet.FxKind.IceWall, at, Guid.Empty );
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
var go = scene.CreateObject();
go.Name = "nz_ice_wall";
go.WorldPosition = at;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN (§39) — built from the announcement everywhere
var wall = go.Components.Create<IceWall>();
wall.Owner = player;
// ⚠️ THE OWNER'S LEVEL, ON EVERY MACHINE (2026-10-05) — see `_reach`.
wall._reach = RadiusFor( player );
wall._life = LifetimeFor( player );
wall._brittle = AmmoModUpgrades.Has( player, ModId, 3 );
wall._frost = AmmoModUpgrades.Has( player, ModId, 5 );
wall._dies = MathF.Max( 0.2f, wall.Life );
// ⛔ THE VISUAL SNAPS THE CIRCLE TO THE FLOOR BEFORE THE FIRST HOLD — the ring on screen is the line that holds.
PitVisual.Attach( go, wall.Reach, PitVisual.Style.Ice, wall.Life );
Sound.Play( NZSound.PopCryofreezeFreeze, go.WorldPosition + Vector3.Up * 20f );
SWB.Shared.GameObjectExtensions.DestroyAsync( go, MathF.Max( 0.2f, wall.Life ) );
if ( announce )
Log.Info( $"[nz-ammo] ICE WALL — {wall.Reach:0}u for {wall.Life:0.#}s"
+ (wall._frost == true
? $" · absolute zero: ×{BrittleTaken() * FrostTaken():0.##} damage taken inside, at ×{FrostSpeed():0.##} speed"
: wall._brittle == true ? $" · brittle: ×{BrittleTaken():0.##} damage taken inside" : "") );
// ⚠️ IT CATCHES ON THE FRAME IT FORMS: the zombie that was shot is standing in it.
wall.Hold();
}
/// <summary>Catch every zombie inside, and keep every caught one from stepping out. THE HOST.</summary>
void Hold()
{
if ( !NZGame.IsHost ) return;
var at = WorldPosition;
var radius = MathF.Max( 8f, Reach );
var keep = MathF.Max( 4f, radius - MathF.Max( 0f, Margin ) );
// ⚠️ BRITTLE ICE (III, 2026-10-05): every zombie held wears `brittle`, topped up at 10 Hz as Tar Pit's slow is, never for
// longer than the ice has left — so it ends with the ice, and a moment after a zombie is let go. HERE, ON THE HOST, AND
// TOLD TO NOBODY (`ApplyHere`): the host scales every hit, and the status has no look for another machine to draw.
//
// ⚠️ AND ABSOLUTE ZERO (V, 2026-10-06): `frostbite` beside it, on the same zombies, on the same tick, for the same time — so
// the slow and the ×3 end with the ice as well. `ApplyHere` again: the host's AI is the one that reads a zombie's speed
// (`SpeedScaleOf`), and the rule has no look either.
var topUp = ((_brittle ?? false) || (_frost ?? false)) && _nextBrittle;
if ( topUp ) _nextBrittle = BrittleEvery;
var brittle = topUp && (_brittle ?? false);
var frost = topUp && (_frost ?? false);
var brittleFor = MathF.Max( 0.05f, MathF.Min( MathF.Max( BrittleEvery * 2f, BrittleHold ), (float)_dies ) );
var source = Owner.IsValid() ? Owner.GameObject : null;
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;
var d = (z.WorldPosition - at).WithZ( 0f ).Length;
// ⚠️ ANY ZOMBIE INSIDE, the user's word: the ones there when it formed, and any that walk in after.
if ( d <= radius ) _held.Add( z );
if ( !_held.Contains( z ) ) continue;
// ⚠️ MOVED BY THE GAME — relocated as stuck, shoved by a Shockwave: let go, not hauled back.
if ( d > radius + LetGo )
{
_held.Remove( z );
continue;
}
z.KeepWithin( at, keep );
if ( brittle ) StatusEffects.ApplyHere( z.GameObject, Brittle, source, seconds: brittleFor );
if ( frost ) StatusEffects.ApplyHere( z.GameObject, Frostbite, source, seconds: brittleFor );
}
}
/// <summary>What a zombie inside a level-III circle takes, read back from the `brittle` rule as the logs print it. ×2.</summary>
static float BrittleTaken() => StatusEffects.Rules.TryGetValue( Brittle, out var r ) ? r.Vulnerability : 1f;
/// <summary>What V's `frostbite` adds on top of Brittle Ice, read back from the rule as the logs print it. ×1.5 — ×3 with the ×2.</summary>
static float FrostTaken() => StatusEffects.Rules.TryGetValue( Frostbite, out var r ) ? r.Vulnerability : 1f;
/// <summary>The speed V's `frostbite` leaves a zombie the circle holds, read back from the rule. ×0.2.</summary>
static float FrostSpeed() => StatusEffects.Rules.TryGetValue( Frostbite, out var r ) ? r.SpeedScale : 1f;
protected override void OnUpdate()
{
if ( _dies )
{
// ⚠️ IT SHATTERS AS IT GOES, on every machine that built one.
Sound.Play( NZSound.PopCryofreezeShatter, WorldPosition + Vector3.Up * 20f );
if ( NZGame.IsHost && _held.Count > 0 )
Log.Info( $"[nz-ammo] ice wall gone — held {_held.Count} zombie(s)" );
GameObject.Destroy();
return;
}
// ⚠️ EVERY FRAME, NOT ON A TICK: a zombie walking at the line would get a step past it between ticks and be seen
// snapping back.
Hold();
}
// ══ diagnostics ══════════════════════════════════════════════════════════
/// <summary>`nz_icewall` — the resolved numbers and the live circles on this machine.</summary>
[ConCmd( "nz_icewall" )]
public static void Report()
{
var live = Game.ActiveScene?.GetAllComponents<IceWall>().ToList() ?? new List<IceWall>();
var mod = AmmoMods.Find( "icewall" );
// ⚠️ THE COOLDOWN IS YOURS, IV's in it (2026-10-06, `AmmoMods.BaseCooldown`): the catalogue's at level 0.
Log.Info( $"[nz-ammo] ICE WALL · {(mod?.Chance ?? 0f) * 100f:0.#}% per hit, {AmmoMods.BaseCooldown( NZPlayer.Local, mod ):0.#}s cooldown"
+ $" · {Radius:0}u for {Lifetime:0.#}s · held {Margin:0}u inside the line · {live.Count} live"
+ $"{(NZGame.IsHost ? "" : " (this machine draws them; the host holds)")}" );
// ⚠️ THE UPGRADES THIS FILE READS (2026-10-05; IV and V since 2026-10-06), and where your own level puts them. IV's cooldown is
// `AmmoMods`', shown here beside the rest.
var me = NZPlayer.Local;
Log.Info( $"[nz-ammo] upgrades: I {WideRadius:0}u · II {LongLifetime:0.#}s · III ×{BrittleTaken():0.##} inside"
+ $" · IV {AmmoMods.ColdSnapCooldown:0.#}s cooldown"
+ $" · V ×{BrittleTaken() * FrostTaken():0.##} inside at ×{FrostSpeed():0.##} speed"
+ (me.IsValid()
? $" · yours at level {AmmoModUpgrades.Level( me, ModId )}: {RadiusFor( me ):0}u for {LifetimeFor( me ):0.#}s"
+ (AmmoModUpgrades.Has( me, ModId, 5 ) ? ", absolute zero" : AmmoModUpgrades.Has( me, ModId, 3 ) ? ", brittle" : "")
: "") );
foreach ( var w in live )
Log.Info( $"[nz-ammo] circle at {w.WorldPosition} · {w.Reach:0}u for {w.Life:0.#}s"
+ $"{(w._frost == true ? " · absolute zero" : w._brittle == true ? " · brittle" : "")} · holding {w.Held}" );
}
/// <summary>`nz_icewall_set <radius|life|margin|wideradius|longlife|brittlehold> <value>` — retune it live.</summary>
[ConCmd( "nz_icewall_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "radius": Radius = value; break;
case "life": Lifetime = value; break;
case "margin": Margin = value; break;
case "wideradius": WideRadius = value; break;
case "longlife": LongLifetime = value; break;
case "brittlehold": BrittleHold = value; break;
// ⚠️ III'S ×2 AND V'S ×0.2 SPEED AND ×1.5 ARE THE STATUS RULES' (`brittle`, `frostbite` in `StatusEffects`), not knobs here,
// and IV's cooldown is `AmmoMods.ColdSnapCooldown`: two authors of one number is what §3 forbids.
default:
Log.Info( "[nz-ammo] nz_icewall_set <radius|life|margin|wideradius|longlife|brittlehold> <value>" );
Log.Info( "[nz-ammo] III's x2 and V's speed and x3 are the `brittle` and `frostbite` rules (StatusEffects)" );
return;
}
Report();
}
}