Bleeder and Bleeding gameplay code. Bleeder is a static utility that configures and starts bleed effects on zombies when hit, including upgrade-adjusted duration, rate, stacks and status tint. Bleeding is a Component attached to a zombie that tracks a list of bleed stacks, pays out damage over time in timed intervals, enforces stack caps and removes oldest stacks when full.
using Sandbox;
using System;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// Bleeder — every hit bleeds the zombie for that hit's damage again, over 8 seconds.
///
/// | | value |
/// |---|---|
/// | proc | **every bullet hit**, no roll, no cooldown |
/// | bleed | **100%** of the hit's damage, paid over **8s**; **4s** at II Fast Bleed, **200%** at III Deep Bleed, **1s** at V Exsanguinate |
/// | pay | every **0.5s**; every **0.25s** while an Exsanguinate bleed is on the zombie |
/// | stacks | **5** at once; a 6th replaces the oldest; **8** at I Deep Cuts, **12** at IV Lacerate |
///
/// ⚠️ THE USER'S WORDS (2026-10-04): *"always apply on hit, no cooldown, deals 100% of the damage it took over 8 seconds,
/// stacks 5 times"*, and for a 6th, *"replace the oldest"*.
///
/// ⛔ HOST-SIDE, LIKE PERFORATOR'S BLEED, AND FOR ITS REASON: the zombie's health is the host's, and "that hit's damage"
/// is only final there — after the head and limb multipliers, the perks, the burn. It starts in `Health.OnDamage` below
/// the client relay, where the hit's mod is known on either machine (`Health.LastMod`: the host's own gun, or the mod a
/// client's hit carried, `NZNet.HurtRemote`).
///
/// ⚠️ UNPAID TICKS (`Health.ApplyUnpaid`), as Perforator's are: the hit that started a bleed already paid its points.
///
/// ⚠️ THE RED TINT IS A STATUS (`bleed`) WITH NO DAMAGE OF ITS OWN. The damage is here, where the stacks are; the status is
/// only the look, and it relays so every machine sees it. While a bleed is refreshed only the host's tint is extended — a
/// zombie bled for longer than 8s by one stream of fire can lose the tint on other screens while it still bleeds.
/// ⚠️ THAT WINDOW IS ONE BLEED'S LENGTH (2026-10-06): 4s with II, and only **1s** with V, so on screens other than the host's
/// an unbroken stream of Exsanguinate fire shows the red for its first second. Only a NEW status travels (`StatusEffects.Apply`),
/// and relaying every refresh would be a message per bullet per zombie. The damage is unaffected.
///
/// ⚠️ ITS THREE UPGRADES (2026-10-05, `AMMO_MODS.md` "Upgrades"): I Deep Cuts, 8 stacks (`DeepCutsStacks`); II Fast Bleed,
/// the same total in 4s (`FastSeconds`); III Deep Bleed, 200% of the hit (`DeepShare`) — the user: *"change III to make it
/// so bleed does double the damage"*. With II, a bleed is 200% of the hit in 4s. Each is the SHOOTER'S level, read on the
/// host when a bleed starts and kept by that bleed (`Start`).
///
/// ⚠️ TIERS IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"; the user, 23:28: *"V, bleed deals it's full damage in 1
/// second"*). IV Lacerate, 12 stacks (`LacerateStacks`). V Exsanguinate, the same total in 1s (`ExsanguinateSeconds`): III's 200%
/// four times faster than II, so nearly all of it lands before the zombie dies; paid every 0.25s (`ExsanguinateInterval`), since a
/// 1s bleed paid every 0.5s is two lumps. The SHOOTER'S level again, read on the host when a bleed starts and kept by that
/// bleed. Bleeds at once equal hits a second, so only guns above 720 rpm reach IV's 12.
/// </summary>
public static class Bleeder
{
// ⛔ NULLABLE-BACKED GETTERS — INSTRUCTIONS.md §1.
static float? _share;
/// <summary>The share of the hit each bleed deals in total. 1 — 100%.</summary>
public static float Share { get => _share ?? 1f; set => _share = value; }
static float? _seconds;
/// <summary>How long one bleed takes to pay out. 8s.</summary>
public static float Seconds { get => _seconds ?? 8f; set => _seconds = value; }
static int? _stacks;
/// <summary>How many bleeds one zombie can carry. 5.</summary>
public static int MaxStacks { get => _stacks ?? 5; set => _stacks = value; }
static float? _interval;
/// <summary>How often the bleeds pay. 0.5s.</summary>
public static float Interval { get => _interval ?? 0.5f; set => _interval = value; }
// ── the upgrades (2026-10-05) ────────────────────────────────────────────
//
// ⚠️ EACH UPGRADED VALUE IS ITS OWN TUNABLE BESIDE THE BASE ONE, and `StacksFor`, `IntervalFor`, `SecondsFor` and `ShareFor`
// are the only places that choose between them (§3).
/// <summary>The mod's id, for its upgrade level (`AmmoModUpgrades.Level`).</summary>
const string ModId = "bleeder";
static int? _deepCutsStacks;
/// <summary>How many bleeds one zombie can carry with I, Deep Cuts. 8 (5).</summary>
public static int DeepCutsStacks { get => _deepCutsStacks ?? 8; set => _deepCutsStacks = value; }
static float? _fastSeconds;
/// <summary>How long one bleed takes with II, Fast Bleed. 4s, the same total: every tick twice the size (8s).</summary>
public static float FastSeconds { get => _fastSeconds ?? 4f; set => _fastSeconds = value; }
static float? _deepShare;
/// <summary>The share of the hit each bleed deals with III, Deep Bleed. 2 — 200% (100%).</summary>
public static float DeepShare { get => _deepShare ?? 2f; set => _deepShare = value; }
// ── tiers IV and V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ─────────
//
// ⚠️ EACH ITS OWN TUNABLE BESIDE THE ONE IT REPLACES (§3), NULLABLE-BACKED (§1). The helpers ask the higher level first.
static int? _lacerateStacks;
/// <summary>How many bleeds one zombie can carry with IV, Lacerate. 12 (I's 8).</summary>
public static int LacerateStacks { get => _lacerateStacks ?? 12; set => _lacerateStacks = value; }
static float? _exsanguinateSeconds;
/// <summary>How long one bleed takes with V, Exsanguinate. 1s, the same total: every tick four times II's (4s).</summary>
public static float ExsanguinateSeconds { get => _exsanguinateSeconds ?? 1f; set => _exsanguinateSeconds = value; }
static float? _exsanguinateInterval;
/// <summary>
/// How often a zombie's bleeds pay while an Exsanguinate bleed (V) is among them. 0.25s (<see cref="Interval"/>, 0.5).
///
/// ⚠️ A 1s BLEED PAID EVERY 0.5s IS TWO LUMPS (`AMMO_MODS.md`'s building note), so it pays four times. Each payment is for the
/// time that passed (`Bleeding.OnUpdate`), so the other bleeds on that zombie pay the same totals in smaller pieces.
/// </summary>
public static float ExsanguinateInterval { get => _exsanguinateInterval ?? 0.25f; set => _exsanguinateInterval = value; }
/// <summary>The level a bleed keeps: its shooter's, 0 to `AmmoModUpgrades.MaxLevel`, read as it starts (`Start`). No player is 0.</summary>
public static int LevelOf( NZPlayer player ) => AmmoModUpgrades.Level( player, ModId );
/// <summary>
/// How many bleeds a zombie may carry, by the HIGHEST level among its bleeds, the new one included (`Bleeding.Add`):
/// <see cref="LacerateStacks"/> from IV, <see cref="DeepCutsStacks"/> from I, else <see cref="MaxStacks"/>.
///
/// ⚠️ A LEVEL, NOT A PLAYER (2026-10-06; a Deep Cuts flag until IV gave the cap a third step): the cap belongs to the zombie's
/// stacks, which every shooter shares, so each bleed keeps the level it began with and the zombie answers with the highest.
/// The numbers themselves are read live.
/// </summary>
public static int StacksFor( int level ) => level >= 4 ? LacerateStacks : level >= 1 ? DeepCutsStacks : MaxStacks;
/// <summary>
/// How often a zombie's bleeds pay, by the HIGHEST level among them (`Bleeding.OnUpdate`): <see cref="ExsanguinateInterval"/>
/// with an Exsanguinate bleed (V) among them, else <see cref="Interval"/>. A level for `StacksFor`'s reason.
/// </summary>
public static float IntervalFor( int level ) => level >= 5 ? ExsanguinateInterval : Interval;
/// <summary>
/// How long this player's bleed takes: <see cref="ExsanguinateSeconds"/> at level V, <see cref="FastSeconds"/> from II, else
/// <see cref="Seconds"/>.
/// </summary>
public static float SecondsFor( NZPlayer player )
=> AmmoModUpgrades.Has( player, ModId, 5 ) ? ExsanguinateSeconds
: AmmoModUpgrades.Has( player, ModId, 2 ) ? FastSeconds
: Seconds;
/// <summary>
/// The share of the hit this player's bleed deals: <see cref="DeepShare"/> at level III, else <see cref="Share"/>.
/// </summary>
public static float ShareFor( NZPlayer player ) => AmmoModUpgrades.Has( player, ModId, 3 ) ? DeepShare : Share;
/// <summary>The status that is the bleed's look. Red, no light, no damage.</summary>
public const string Status = "bleed";
/// <summary>A Bleeder bullet hit this zombie for <paramref name="amount"/>. HOST — `Health.OnDamage`.</summary>
public static void Start( Health hp, float amount, GameObject attacker )
{
if ( !hp.IsValid() || hp.IsDead || amount <= 0f ) return;
// ⛔ THE SHOOTER'S LEVEL, READ HERE ON THE HOST AND KEPT BY THIS BLEED (2026-10-05). `attacker` is the shooter's
// body — the host's own, or the one a client's hit names (`NZNet.HurtRemote`), whose levels the host reads off the
// wire (`NZPlayer.AmmoUpgradeNet`). The share, the length and the level (which sets the zombie's cap and pay, `Bleeding`)
// go into the stack as it is made, so a level bought mid-bleed changes the next bleed, never one already running. No
// player found reads as level 0: the bleed as it was.
var player = attacker.IsValid() ? attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;
var total = amount * MathF.Max( 0f, ShareFor( player ) );
if ( total <= 0f ) return;
var seconds = MathF.Max( 0.5f, SecondsFor( player ) );
var bleed = hp.Components.GetOrCreate<Bleeding>();
if ( !bleed.IsValid() ) return;
bleed.Add( total, seconds, attacker, LevelOf( player ) );
// ⚠️ THE TINT RUNS AS LONG AS THIS BLEED: 4s with Fast Bleed, 1s with Exsanguinate. A refresh never shortens it
// (`StatusEffects` keeps the later expiry), so a 4s bleed landing on an 8s one leaves the 8s.
StatusEffects.Apply( hp.GameObject, Status, attacker, seconds: seconds );
}
/// <summary>
/// `nz_bleeder [share] [seconds] [stacks] [share III] [seconds II] [stacks I] [stacks IV] [seconds V] [pay V]` — the
/// resolved numbers, and retune them live; the last six are the upgrades' (IV and V's three since 2026-10-06).
/// </summary>
[ConCmd( "nz_bleeder" )]
public static void Cmd( float share = -1f, float seconds = -1f, int stacks = -1,
float deepShare = -1f, float fastSeconds = -1f, int deepStacks = -1,
int lacerateStacks = -1, float exsanguinateSeconds = -1f, float exsanguinateInterval = -1f )
{
if ( share >= 0f ) Share = share;
if ( seconds > 0f ) Seconds = seconds;
if ( stacks > 0 ) MaxStacks = stacks;
if ( deepShare >= 0f ) DeepShare = deepShare;
if ( fastSeconds > 0f ) FastSeconds = fastSeconds;
if ( deepStacks > 0 ) DeepCutsStacks = deepStacks;
if ( lacerateStacks > 0 ) LacerateStacks = lacerateStacks;
if ( exsanguinateSeconds > 0f ) ExsanguinateSeconds = exsanguinateSeconds;
if ( exsanguinateInterval > 0f ) ExsanguinateInterval = exsanguinateInterval;
var bleeding = 0;
var most = 0;
foreach ( var b in Game.ActiveScene?.GetAllComponents<Bleeding>() ?? Array.Empty<Bleeding>() )
{
if ( !b.IsValid() || b.Count <= 0 ) continue;
bleeding++;
most = Math.Max( most, b.Count );
}
// ⚠️ THE MOST STACKS ON ONE ZOMBIE (2026-10-05): Deep Cuts' 8 and Lacerate's 12 show here, and nowhere else.
Log.Info( $"[nz-ammo] BLEEDER · every bullet hit bleeds {Share * 100f:0.#}% of its damage over {Seconds:0.#}s"
+ $" · {MaxStacks} stacks, a new one replaces the oldest · pays every {Interval:0.##}s, unpaid"
+ $" · {bleeding} zombie(s) bleeding, at most {most} stacks (host only)" );
// ⚠️ 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 {DeepCutsStacks} stacks · II {FastSeconds:0.#}s · III {DeepShare * 100f:0.#}%"
+ $" · IV {LacerateStacks} stacks · V {ExsanguinateSeconds:0.##}s, paid every {ExsanguinateInterval:0.##}s"
+ (me.IsValid()
? $" · yours at level {AmmoModUpgrades.Level( me, ModId )}:"
+ $" {ShareFor( me ) * 100f:0.#}% over {SecondsFor( me ):0.##}s, {StacksFor( LevelOf( me ) )} stacks,"
+ $" paid every {IntervalFor( LevelOf( me ) ):0.##}s"
: "") );
}
}
/// <summary>One zombie's bleeds. Created on demand by <see cref="Bleeder.Start"/>, on the host.</summary>
public sealed class Bleeding : Component
{
struct Stack
{
public float PerSecond;
public float Left;
public float Born;
/// <summary>
/// Its shooter's Bleeder level when it began (`Bleeder.LevelOf`; a Deep Cuts flag until 2026-10-06): the zombie's cap and
/// pay go by the highest among its bleeds. See `Add`.
/// </summary>
public int Level;
}
readonly List<Stack> _stacks = new();
/// <summary>
/// Who bled it last. ⚠️ ONE FIELD, NOT ONE PER STACK, for Perforator's reason: `Health.LastAttacker` is a single field
/// and a kill pays one player, so the most recent shooter is the answer every damage source here gives.
/// </summary>
GameObject _from;
float _since;
/// <summary>The bleeds on this zombie right now.</summary>
public int Count => _stacks.Count;
/// <summary>
/// The highest level among the bleeds on it now, 0 with none: what its cap and its pay go by (`Bleeder.StacksFor`,
/// `Bleeder.IntervalFor`). Twelve bleeds at most to look through.
/// </summary>
int Top
{
get
{
var top = 0;
foreach ( var s in _stacks ) top = Math.Max( top, s.Level );
return top;
}
}
/// <summary>
/// One more bleed: <paramref name="total"/> over <paramref name="seconds"/>, and its shooter's Bleeder level.
///
/// ⚠️ DEEP CUTS (I, 2026-10-05) RAISES THE CAP WHILE ANY OF ITS BLEEDS IS ON THE ZOMBIE, the new one or one already
/// there. The stacks are the zombie's, shared by every shooter, so a cap taken from the new bleed alone would let a
/// teammate without the upgrade trim an upgraded player's eight back to five with every hit. With no Deep Cuts bleed on
/// it, the cap is `MaxStacks`, read live, as it always was.
///
/// ⚠️ AND LACERATE (IV, 2026-10-06) THE SAME WAY: each bleed keeps its shooter's level, and the highest among them, the new
/// one included, sets the cap (`Bleeder.StacksFor`), so a level-IV player's bleeds hold the zombie at 12 against a
/// teammate's hits. A flag per bleed could only say "I or not"; a level says which step.
/// </summary>
public void Add( float total, float seconds, GameObject from, int level )
{
if ( total <= 0f || seconds <= 0f ) return;
// ⚠️ FULL: THE OLDEST GOES, the user's rule. Oldest by when it began, not by what it has left — a retune of the
// length mid-fight would otherwise make "oldest" mean something else.
var cap = Math.Max( 1, Bleeder.StacksFor( Math.Max( level, Top ) ) );
while ( _stacks.Count >= cap )
{
var oldest = 0;
for ( var i = 1; i < _stacks.Count; i++ )
if ( _stacks[i].Born < _stacks[oldest].Born ) oldest = i;
_stacks.RemoveAt( oldest );
}
_stacks.Add( new Stack { PerSecond = total / seconds, Left = seconds, Born = Time.Now, Level = level } );
if ( from.IsValid() ) _from = from;
Enabled = true;
}
protected override void OnUpdate()
{
if ( _stacks.Count == 0 )
{
// ⚠️ IT SLEEPS RATHER THAN BEING DESTROYED, as `Perforation` does: a zombie bled once is shot again.
Enabled = false;
return;
}
_since += Time.Delta;
// ⚠️ FASTER WHILE AN EXSANGUINATE BLEED IS ON IT (V, 2026-10-06): its 1s is four payments, not two lumps. Each payment is
// for the time that passed (below), so the other bleeds pay the same totals in smaller pieces.
if ( _since < MathF.Max( 0.05f, Bleeder.IntervalFor( Top ) ) ) return;
// ⚠️ PAID FOR THE TIME THAT PASSED, so every bleed pays exactly its total over its life, whatever the frame rate.
var dt = _since;
_since = 0f;
var hp = Components.Get<Health>( FindMode.EverythingInSelf );
if ( !hp.IsValid() || hp.IsDead )
{
_stacks.Clear();
Enabled = false;
return;
}
var total = 0f;
for ( var i = _stacks.Count - 1; i >= 0; i-- )
{
var s = _stacks[i];
var ran = MathF.Min( dt, s.Left );
total += s.PerSecond * ran;
s.Left -= ran;
if ( s.Left <= 0.0001f ) _stacks.RemoveAt( i );
else _stacks[i] = s;
}
// ⛔ UNPAID — the hit that started each bleed already paid its points (see `Bleeder`).
if ( total > 0f ) hp.ApplyUnpaid( total, _from );
}
}