Component that spawns and manages a tar pit game object. It creates a visual pit, periodically applies a slowing status to nearby zombies (per-machine), handles lifetime, bubbling sounds, upgrade-adjusted radius/lifetime/speed/linger, and console commands to inspect and retune values.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// Tar Pit — leaves a pool of tar where the zombie was hit; zombies in it wade at 40% speed.
///
/// | | value |
/// |---|---|
/// | proc | **15%** per hit, **8s** cooldown |
/// | pool | **140u**, lives **8s**, at the zombie you hit — I **220u**, II **12s**; V, Tar Flood: **440u** for **24s** |
/// | slow | the `tar` status — **×0.4** speed while inside, and a moment after (III: **5s** after); IV, Thick Tar: **×0.2** |
/// | damage | **none** |
///
/// ⚠️ THE USER (2026-10-04): *"make it spawn a circle like in radioactive decay, but make it tar colored"*. So the pool is
/// Radioactive Decay's pit visual (`PitVisual`) in a `Tar` style: dark fumes hugging the floor, a dark edge, two slow
/// ripples crawling across — and no glow, because light cannot be dark. Its size is the fallout's, 140u.
///
/// ⛔ EVERY MACHINE SLOWS ITS OWN COPY, AND NOTHING ELSE IS SENT. The pool is announced once (`NZNet.WorldFx`), each machine
/// builds its own, and each puts `tar` on the zombies standing in it there (`StatusEffects.ApplyHere`). The HOST'S copy is
/// the one the AI obeys (`ZombieAI.TickStatusSpeed`); every other copy is only the tint. Relaying the status would have
/// been a message per zombie per refresh per machine — and a refresh does not travel anyway, so a client's own pool would
/// have slowed the host's zombies for the first instant only.
///
/// ⚠️ THE SLOW IS SHORT AND KEPT TOPPED UP (`Hold`), so it ends a moment after a zombie wades out rather than when the pool
/// does. The 0.4 lives on the status rule, retuned with `nz_status`; IV's 0.2 is this file's (`ThickSpeed`).
///
/// ⚠️ 40% IS FLOORED BY THE NAVMESH AGENT: `ZombieAI.AgentSpeed` lifts any non-zero speed to 42 u/s, below which the agent
/// stops moving. A sprinter (≈220 u/s) wades at ≈90; a walker (55) only drops to 42.
///
/// ⚠️ ITS THREE UPGRADES (2026-10-05, `AMMO_MODS.md` "Upgrades"): I Wide Pool, 220u (`WideRadius`); II Long Spill, 12s
/// (`LongLifetime`); III Clinging Tar, the slow lingering 5s after a zombie leaves (`Cling`). Each is the OWNER'S level, read
/// when the pool is poured, by every machine for its own copy — see `_reach`.
///
/// ⚠️ TIERS IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"), read the same way. IV Thick Tar: ×0.2 inside (`ThickSpeed`),
/// handed in with each top-up — one shared `tar` rule slows every pool, so the rule keeps the base ×0.4 for everyone else's. V Tar
/// Flood, the user's (23:34): *"V Pool lasts longer and is 2x bigger"* — twice I's reach, 440u, and twice II's life, 24s
/// ("longer" named no length; `FloodRadius`, `FloodLifetime`). With the 8s cooldown up to three pools can lie at once. The navmesh
/// floor below still holds, and III's 5s cling carries the ×0.2 with it (unless it wades into a plain pool: `Tick`).
/// </summary>
public sealed class TarPit : Component
{
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED GETTERS — INSTRUCTIONS.md §1.
static float? _radius;
/// <summary>How wide the pool is. 140u, the fallout's.</summary>
public static float Radius { get => _radius ?? 140f; set => _radius = value; }
static float? _lifetime;
/// <summary>How long the pool lasts. 8s.</summary>
public static float Lifetime { get => _lifetime ?? 8f; set => _lifetime = value; }
static float? _hold;
/// <summary>
/// How long one application of the slow lasts. 0.35s — topped up every tick while a zombie is in the pool, so this is
/// how long it lingers once it wades out.
/// </summary>
public static float Hold { get => _hold ?? 0.35f; set => _hold = value; }
// ── the upgrades (2026-10-05) ────────────────────────────────────────────
//
// ⚠️ EACH UPGRADED VALUE IS ITS OWN TUNABLE BESIDE THE BASE ONE, and the `…For` below (four since IV) are the only places
// that choose between them (§3).
/// <summary>The mod's id, for its upgrade level (`AmmoModUpgrades.Level`).</summary>
const string ModId = "tarpit";
static float? _wideRadius;
/// <summary>How wide the pool is with I, Wide Pool. 220u (140).</summary>
public static float WideRadius { get => _wideRadius ?? 220f; set => _wideRadius = value; }
static float? _longLifetime;
/// <summary>How long the pool lasts with II, Long Spill. 12s (8).</summary>
public static float LongLifetime { get => _longLifetime ?? 12f; set => _longLifetime = value; }
static float? _cling;
/// <summary>
/// How long the slow lingers past its last top-up with III, Clinging Tar. 5s (`Hold`, 0.35).
///
/// ⚠️ THE USER (2026-10-05 01:12): *"make it so III makes any zombie that steps on it slow for 5 seconds after it
/// leaves the pit still"*. It is `Hold` made long, nothing new: the slow is topped up every tick while a zombie is in the
/// pool, so it ends 5s after the last one — after the zombie wades out, or after the pool dries up under it. The navmesh
/// agent's floor (42 u/s) still applies, so a walker barely shows it.
/// </summary>
public static float Cling { get => _cling ?? 5f; set => _cling = value; }
// ── tiers IV and V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ──
//
// ⚠️ THE SAME SHAPE: each its own tunable beside the one it replaces (§3, §1), and the `…For` below ask the HIGHEST level first,
// since a level-V owner has I and II too.
static float? _thickSpeed;
/// <summary>
/// How fast a zombie wades with IV, Thick Tar. ×0.2 (the `tar` rule's ×0.4).
///
/// ⚠️ PER APPLICATION, NOT ON THE RULE (`StatusEffects.ApplyHere`'s `speedScale`): the rule is shared by every pool, so a
/// level-IV owner's pool hands its own factor in with each top-up, and the rule keeps the base for everyone else's.
///
/// ⚠️ FLOORED AT 0.01 (`SpeedFor`): a 0 handed to `ApplyHere` means "the rule's own", so `nz_tarpit_set thick 0` would have
/// given back ×0.4, not a stop. The navmesh agent's floor (42 u/s, `ZombieAI.AgentSpeed`) holds a walker up whatever this says.
/// </summary>
public static float ThickSpeed { get => _thickSpeed ?? 0.2f; set => _thickSpeed = value; }
static float? _floodRadius;
/// <summary>How wide the pool is with V, Tar Flood. 440u (I's 220) — four times the ground it covers.</summary>
public static float FloodRadius { get => _floodRadius ?? 440f; set => _floodRadius = value; }
static float? _floodLifetime;
/// <summary>How long the pool lasts with V, Tar Flood. 24s (II's 12).</summary>
public static float FloodLifetime { get => _floodLifetime ?? 24f; set => _floodLifetime = value; }
/// <summary>
/// This player's pool radius: <see cref="FloodRadius"/> at level V, <see cref="WideRadius"/> from I, <see cref="Radius"/> below.
/// </summary>
public static float RadiusFor( NZPlayer player )
=> AmmoModUpgrades.Has( player, ModId, 5 ) ? FloodRadius
: AmmoModUpgrades.Has( player, ModId, 1 ) ? WideRadius : Radius;
/// <summary>
/// This player's pool life: <see cref="FloodLifetime"/> at level V, <see cref="LongLifetime"/> from II, <see cref="Lifetime"/>
/// below.
/// </summary>
public static float LifetimeFor( NZPlayer player )
=> AmmoModUpgrades.Has( player, ModId, 5 ) ? FloodLifetime
: AmmoModUpgrades.Has( player, ModId, 2 ) ? LongLifetime : Lifetime;
/// <summary>How long this player's slow lingers: <see cref="Cling"/> at level III, <see cref="Hold"/> below it.</summary>
public static float HoldFor( NZPlayer player ) => AmmoModUpgrades.Has( player, ModId, 3 ) ? Cling : Hold;
/// <summary>
/// How fast this player's pool lets a zombie wade: <see cref="ThickSpeed"/> from level IV (never below 0.01, see it), the
/// `tar` rule's own ×0.4 below it.
/// </summary>
public static float SpeedFor( NZPlayer player )
=> AmmoModUpgrades.Has( player, ModId, 4 ) ? MathF.Max( 0.01f, ThickSpeed ) : SpeedOf();
/// <summary>The status that is the slow. Its ×0.4 is on the rule (`StatusEffects`); IV's ×0.2 goes in per application.</summary>
public const string Status = "tar";
/// <summary>The pool's gloop — basalt's lava, five takes picked at random.</summary>
const string Gloop = "nz.basalt.sfx.lava_gloop";
/// <summary>How often the pool checks who is in it. 10 Hz.</summary>
const float TickEvery = 0.1f;
// ══ live state ═══════════════════════════════════════════════════════════
public NZPlayer Owner { get; set; }
TimeUntil _dies;
TimeUntil _nextTick;
TimeUntil _nextGloop;
/// <summary>
/// THIS pool's radius and how long its slow lingers — the owner's `RadiusFor` and `HoldFor`, read when it is poured
/// (2026-10-05).
///
/// ⛔ EVERY MACHINE READS THE OWNER'S LEVEL FOR ITS OWN COPY, and the announcement carries none. Each copy slows its own
/// zombies and the HOST'S is the one the AI obeys, so a host copy at the base numbers would cancel a client's upgrades.
/// The levels are synced (`NZPlayer.AmmoUpgradeNet`); only a copy whose owner `NZNet.WorldFx` could not find falls back to
/// the base numbers.
///
/// ⚠️ NULLABLE (§1): a pool alive across a hotload has neither, and keeps the base numbers.
///
/// ⚠️ AND ITS SPEED (2026-10-06, Thick Tar): the owner's `SpeedFor`, kept the same way — so below IV it is the `tar` rule's
/// factor as it stood when the pool was poured, and `nz_status` retunes the next pool, not one already lying there.
/// </summary>
float? _reach;
float? _linger;
float? _speed;
float Reach => _reach ?? Radius;
float Linger => _linger ?? Hold;
float Speed => _speed ?? SpeedOf();
/// <summary>How many zombies this pool has caught, on this machine.</summary>
public int Caught { get; private set; }
readonly System.Collections.Generic.HashSet<Guid> _seen = new();
/// <summary>
/// Pour a pool at <paramref name="at"/> — the zombie hit, on the shooter's machine; the announced point everywhere else.
///
/// ⚠️ A POINT, NOT THE ZOMBIE, so a machine the zombie has already died on still builds the pool where it was.
/// </summary>
public static void Spawn( NZPlayer player, Vector3 at, bool announce = true )
{
// ⚠️ EVERY MACHINE BUILDS ITS OWN; EACH SLOWS ITS OWN COPY. 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.TarPit, at, Guid.Empty );
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
var go = scene.CreateObject();
go.Name = "nz_tar_pit";
go.WorldPosition = at;
// ⛔ THIS MACHINE'S OWN (INSTRUCTIONS §39): each machine builds its pool from the announcement, so a copy in a joiner's
// snapshot would only stand there frozen.
go.NetworkMode = NetworkMode.Never;
var pit = go.Components.Create<TarPit>();
// ⚠️ THE OWNER'S UPGRADES ARE READ HERE, ONCE, and kept by the pool (2026-10-05): a level bought while it lies there
// changes the next pool, not this one.
var life = LifetimeFor( player );
pit.Owner = player;
pit._reach = RadiusFor( player );
pit._linger = HoldFor( player );
pit._speed = SpeedFor( player );
pit._dies = MathF.Max( 0.2f, life );
pit._nextGloop = Game.Random.Float( 0.8f, 1.6f );
// ⛔ THE VISUAL SNAPS THE POOL TO THE FLOOR, AND IT GOES ON BEFORE THE FIRST TICK — the slowing circle and the one on
// screen must be the same circle (see `RadioactiveDecay.Spawn`).
PitVisual.Attach( go, pit.Reach, PitVisual.Style.Tar, life );
Sound.Play( Gloop, go.WorldPosition );
// ⚠️ A SECOND, TIMER-BASED CLEANUP, as every pit has: it fires even if this component never updates.
SWB.Shared.GameObjectExtensions.DestroyAsync( go, MathF.Max( 0.2f, life ) );
if ( announce )
Log.Info( $"[nz-ammo] TAR PIT — {pit.Reach:0}u for {life:0.#}s · ×{pit.Speed:0.##} speed inside"
+ $" · lingers {pit.Linger:0.##}s · level {AmmoModUpgrades.Level( player, ModId )}" );
// ⚠️ IT CATCHES ON THE FRAME IT LANDS: the zombie that was shot is standing in it.
pit.Tick();
}
/// <summary>The `tar` rule's own speed factor: the base slow, below IV (`SpeedFor`), and the logs'.</summary>
static float SpeedOf() => StatusEffects.Rules.TryGetValue( Status, out var r ) ? r.SpeedScale : 1f;
/// <summary>Top up the slow on every zombie standing in the pool. EVERY MACHINE, on its own copies.</summary>
void Tick()
{
var reach = MathF.Max( 0f, Reach );
var at = WorldPosition;
// ⚠️ CLINGING TAR IS THIS NUMBER (2026-10-05): `Linger` is 5s at level III, and `StatusEffects.Add` keeps the LATER
// expiry on a refresh, so a zombie in the pool sits at 5s left and keeps 5s once it is out. Two pools overlapping keep
// the longer of their two lingers.
var hold = MathF.Max( TickEvery * 2f, Linger );
var source = Owner.IsValid() ? Owner.GameObject : null;
// ⚠️ WHERE POOLS OVERLAP, THE SLOWEST WINS (2026-10-06, Thick Tar). One `tar` status holds one speed and every top-up
// replaces it (`StatusEffects.Add`), so a level-IV pool and a plain one over the same zombie would flip it between ×0.2 and
// ×0.4 at their two 10 Hz ticks — and the AI re-paths on every change (`ZombieAI.TickStatusSpeed`). Each pool hands on the
// slowest factor of all the pools on this machine the zombie stands in, so whichever tops up last says the same thing.
//
// ⚠️ ONLY THE POOLS IT STANDS IN: a zombie still clinging to a Thick Tar pool's ×0.2 (III) that wades into a plain one takes
// the plain one's ×0.4 from its first top-up, for the rest of that cling too — one status keeps one speed, with no expiry per pool.
var others = Scene.GetAllComponents<TarPit>().Where( p => p != this && p.IsValid() ).ToList();
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() || !z.GameObject.IsValid() || z.State == ZombieState.Dead ) continue;
if ( at.Distance( z.WorldPosition ) > reach ) continue;
var speed = Speed;
foreach ( var p in others )
if ( p.Speed < speed && p.WorldPosition.Distance( z.WorldPosition ) <= MathF.Max( 0f, p.Reach ) )
speed = p.Speed;
// ⚠️ THE SPEED GOES IN WITH EVERY TOP-UP (2026-10-06): the rule's own below IV, Thick Tar's ×0.2 from it (`SpeedFor`).
StatusEffects.ApplyHere( z.GameObject, Status, source, seconds: hold, speedScale: speed );
if ( _seen.Add( z.GameObject.Id ) ) Caught++;
}
}
protected override void OnUpdate()
{
// ⚠️ EXPIRY FIRST, ON EVERY MACHINE — `RadioactiveDecay` records the leak that came of putting it behind a gate.
if ( _dies )
{
GameObject.Destroy();
return;
}
if ( _nextTick )
{
_nextTick = TickEvery;
Tick();
}
// ⚠️ THE POOL BUBBLES: a gloop now and then somewhere in it, while it lasts.
if ( _nextGloop )
{
_nextGloop = Game.Random.Float( 1.4f, 2.6f );
var a = Game.Random.Float( 0f, MathF.PI * 2f );
var r = Reach * MathF.Sqrt( Game.Random.Float( 0f, 1f ) ) * 0.8f;
Sound.Play( Gloop, WorldPosition + new Vector3( MathF.Cos( a ) * r, MathF.Sin( a ) * r, 4f ) );
}
}
// ══ diagnostics ══════════════════════════════════════════════════════════
/// <summary>`nz_tarpit` — the resolved numbers and the live pools on this machine.</summary>
[ConCmd( "nz_tarpit" )]
public static void Report()
{
var live = Game.ActiveScene?.GetAllComponents<TarPit>().ToList()
?? new System.Collections.Generic.List<TarPit>();
var mod = AmmoMods.Find( "tarpit" );
var tarred = ZombieAI.All.Count( x => x.IsValid() && StatusEffects.Has( x.GameObject, Status ) );
Log.Info( $"[nz-ammo] TAR PIT · {(mod?.Chance ?? 0f) * 100f:0.#}% per hit, {mod?.Cooldown ?? 0f:0.#}s cooldown"
+ $" · {Radius:0}u for {Lifetime:0.#}s · ×{SpeedOf():0.##} speed inside (topped up {Hold:0.##}s)"
+ $" · {live.Count} live · {tarred} zombie(s) tarred here" );
// ⚠️ THE UPGRADES (2026-10-05; IV and V 2026-10-06), and where your own level puts them.
var me = NZPlayer.Local;
Log.Info( $"[nz-ammo] upgrades: I {WideRadius:0}u · II {LongLifetime:0.#}s · III lingers {Cling:0.#}s"
+ $" · IV ×{ThickSpeed:0.##} speed · V {FloodRadius:0}u for {FloodLifetime:0.#}s" );
if ( me.IsValid() )
Log.Info( $"[nz-ammo] yours at level {AmmoModUpgrades.Level( me, ModId )}: {RadiusFor( me ):0}u for"
+ $" {LifetimeFor( me ):0.#}s, ×{SpeedFor( me ):0.##} speed, lingers {HoldFor( me ):0.##}s" );
foreach ( var p in live )
Log.Info( $"[nz-ammo] pool at {p.WorldPosition} · {p.Reach:0}u, ×{p.Speed:0.##} speed, lingers {p.Linger:0.##}s"
+ $" · caught {p.Caught}" );
}
/// <summary>
/// `nz_tarpit_set <radius|life|hold|wide|long|cling|thick|flood|floodlife> <value>` — retune the pool; from `wide`
/// on they are the upgrades' (IV and V from `thick`, 2026-10-06). The base speed: `nz_status`.
/// </summary>
[ConCmd( "nz_tarpit_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "radius": Radius = value; break;
case "life": Lifetime = value; break;
case "hold": Hold = value; break;
case "wide": WideRadius = value; break;
case "long": LongLifetime = value; break;
case "cling": Cling = value; break;
case "thick": ThickSpeed = value; break;
case "flood": FloodRadius = value; break;
case "floodlife": FloodLifetime = value; break;
default:
Log.Info( "[nz-ammo] nz_tarpit_set <radius|life|hold|wide|long|cling|thick|flood|floodlife> <value>"
+ " — the base speed is the `tar` status rule's, IV's is `thick`" );
return;
}
Report();
}
}