Static helper that implements the Zombies difficulty and scaling curves used by the gamemode. It computes per-round values for health, speed, damage, attack scaling, wave sizes, spawn delays and point awards, and caches health results. It reads from an ActiveConfig to allow runtime tuning and documents many design decisions.
using System;
namespace NZombies;
/// <summary>
/// ZOMBIE/CURVES — per-round scaling, ported 1:1 from the GMod gamemode.
///
/// Source: gamemodes\nzombies\gamemode\curves\sh_constructor.lua
/// These are pure arithmetic and were verified line-by-line against the Lua,
/// so they should behave identically. Do not "improve" them without a balance
/// decision — they're the CoD Zombies curves the whole game is tuned around.
///
/// See Docs/NZOMBIES_REFERENCE.md §2 and §9.5.
/// </summary>
public static class ZombieStats
{
// ── CURVES/HEALTH ────────────────────────────────────────────────────────
// ⛔ RETUNED AWAY FROM THE ORIGINAL CURVE, WHICH WAS BUILT FOR A x97 PACK-A-PUNCH. The original
// (75 base, +50/round, +10% compounding) gave R20 1348 — and against the damage a normal
// round-20 loadout produces, a zombie died to a single body shot before the perks were even
// involved. Weapons stopped mattering because health had stopped keeping up.
//
// ⚠️ ANCHORED ON ROUND 20 = 3,000 HP. Against the reference loadout — 30 damage, Rare, MK2,
// Vigor Rush — that is 2.3 bullets to the head, or a little over one trigger pull once Double
// Tap's second projectile is counted. Every number below is fitted through that point.
//
// ⛔ THE MULTIPLIER IS NOT INDEPENDENT OF THE FLAT PHASE, and that is the easy mistake here.
// The compounding runs from whatever round 9 leaves behind, so changing base or increment
// changes what the multiplier has to be — raising the flat phase and keeping the old 0.229
// overshot round 20 by 26%. Change either half and this has to be re-solved.
//
// R1 80 · R9 600 · R10 694 · R15 1440 · R20 2994 · R25 6232 · R30 12974
public const int BaseHealth = 80;
public const int HealthIncrement = 65;
public const float HealthMultiplier = 0.158f;
/// <summary>
/// Zombie health on <see cref="HealthCapRound"/> (round 100): 6,000,000. NOT A CEILING ANY MORE: a point the line from
/// round 59 passes through, which with that round sets its slope. The name stays because every config persists it.
///
/// ⛔ NO CAP SINCE 2026-10-06: THE STRAIGHT LINE FROM ROUND 59 NEVER STOPS. The user, after a run to round 116 that sat at
/// 6,000,000 from round 100 on: *"I was thinking of keeping the linear increase it has from round 60 indefinitely, so they
/// always get more health over time, no cap"*. Every round past 59 adds the same ~124,073, and rounds 1-100 are unchanged:
/// 7,240,729 at round 110, 7,985,166 at 116, 12,203,643 at 150, 18,407,285 at 200. Bosses are multiples of this number,
/// so they climb with it (a round-150 Brutus, ×15, is 183M).
///
/// ⛔ 6,000,000 AT ROUND 100, CLIMBED TO IN A STRAIGHT LINE FROM ROUND 59 (2026-10-04). The user: *"lets increase the
/// cap to 6 million on round 100 / from round 59 to 100 the health will increase linearly"*. The compounding curve runs
/// to <see cref="HealthLinearFrom"/> exactly as before (round 59: 913,013), then rises about 124,073 a round: 1,037,086
/// at round 60, 2,277,814 at 70, 3,518,543 at 80, 4,759,271 at 90. It was 1,000,000 from round 60 on.
///
/// ⛔ IT WAS 60,000, AND IT BIT AT ROUND 41 — three rounds after the curve gets interesting.
/// A round-60 zombie should carry 1,057,269 by this curve and carried 60,000: seventeen times
/// less. Every zombie from round 41 to the end of a run was IDENTICAL, and so was every boss,
/// since a boss is a multiple of this number.
///
/// ⚠️ IT WAS NOT A BALANCE CHOICE, IT WAS AN UNEXAMINED CONSTANT. Nothing in this file or the
/// config explained the 60,000, and the compounding curve above it is documented in detail
/// and fitted through measured checkpoints — so the cap was quietly discarding the work the
/// rest of the numbers do.
///
/// ⚠️ 1,000,000 BIT AT ROUND 60, deliberately rather than "high enough not to matter": an
/// uncapped curve reaches 4.5M by round 70 and keeps compounding at 15.8%, which stops being a
/// difficulty curve and starts being an arithmetic one. The straight line is the answer to that
/// now: nothing compounds past round 59, and a line adds the same amount every round, so it can run on without end.
/// </summary>
public const int HealthCap = 6_000_000;
/// <summary>The last round on the compounding curve, 59: from the next, health climbs in a straight line through
/// <see cref="HealthCap"/> at <see cref="HealthCapRound"/>, and keeps climbing (no cap since 2026-10-06).</summary>
public const int HealthLinearFrom = 59;
/// <summary>The round health reaches <see cref="HealthCap"/>, 100. A point on the line, not where it stops: the line
/// runs on past it (2026-10-06; it held there from 2026-10-04).</summary>
public const int HealthCapRound = 100;
/// <summary>Zombie spawn health for a round. Memoized — the Lua learned the
/// hard way that an O(round) loop per damage pellet is expensive (curves:9-36).</summary>
public static int HealthForRound( int round )
{
if ( round < 1 ) round = 1;
if ( _healthCache.TryGetValue( round, out var cached ) ) return cached;
// Read from the active config rather than the constants. The constants
// remain the DEFAULTS (ZombieSettings initialises from them), so an
// untouched config behaves identically to before.
var s = ActiveConfig.Zombies;
// ⚠️ THE LINE NEEDS BOTH ENDS, IN ORDER. A cap round at or before the line's start — or 0 in both, which is what a
// config object hotloaded from before these fields existed may read — keeps the old shape: compound, clamp at the cap.
bool linear = s.HealthLinearFrom >= 1 && s.HealthCapRound > s.HealthLinearFrom;
int compoundTo = linear ? Math.Min( round, s.HealthLinearFrom ) : round;
float hp = s.BaseHealth;
for ( int i = 2; i <= compoundTo; i++ )
{
if ( i >= 10 ) hp += MathF.Floor( hp * s.HealthMultiplier );
else hp += s.HealthIncrement;
if ( hp >= s.HealthCap ) { hp = s.HealthCap; break; }
}
// ⛔ PAST `HealthLinearFrom`, A STRAIGHT LINE THROUGH `HealthCap` AT `HealthCapRound`, AND ON WITHOUT END (2026-10-06;
// from 2026-10-04 it stopped at the cap round). The two points set the slope, ~124,073 a round by default, and every
// round after adds the same again. It starts from where the compounding left off, so that round is unchanged and the
// curve has no step; and it is worked in double, because a float lerp near six million moves in half points and would
// wobble from one round to the next, and past 16,777,216 (round 187) a float cannot hold every whole number at all.
// ⚠️ A CONFIG WHOSE COMPOUNDING MEETS ITS `HealthCap` BEFORE THE LINE STARTS STILL STOPS THERE (`hp < HealthCap`):
// a line from above its own target would run downhill. No config does that today.
double health = hp;
if ( linear && round > s.HealthLinearFrom && hp < s.HealthCap )
{
double t = (round - s.HealthLinearFrom) / (double)(s.HealthCapRound - s.HealthLinearFrom);
health = Math.Round( hp + (s.HealthCap - (double)hp) * t );
}
// ⚠️ THE ONE CEILING LEFT IS THE INT'S. The default line meets 2,147,483,647 past round 17,000, never in play, but a
// steep config (30,000,000 one round after the line starts) gets there within ~75 rounds, and an int cast past it
// is undefined.
int result = (int)Math.Min( health, int.MaxValue );
_healthCache[round] = result;
return result;
}
private static readonly System.Collections.Generic.Dictionary<int, int> _healthCache = new();
/// <summary>
/// Drop everything memoized from the round curves.
///
/// ⚠️ Must be called whenever the active config changes. The health cache is
/// keyed on ROUND ONLY, so after a settings change it would keep handing
/// back numbers computed from the previous config — health that silently
/// ignores what you just set. ActiveConfig.Set does this for you.
/// </summary>
public static void InvalidateCurves() => _healthCache.Clear();
// ── CURVES/SPEED ─────────────────────────────────────────────────────────
// GenerateCoDSpeedTable, curves:96-116. speed = clamp(round*4 - 4, 0, 300)
// Multiplier 4 = BO3+ pacing, 8 = pre-BO3.
// NOTE: each zombie then adds rand(0,35) jitter — see ZombieVariant. That
// width is deliberate: it matches the first animation-tier gap (36) so
// tiers smear across rounds instead of the whole horde flipping at once.
//
// ⛔ SLOWER SINCE 2026-10-05: THE WHOLE HORDE IN THE TOP TIER ON ROUND 60, NOT 40. The user: *"we need to reduce how fast
// zombie speed increases, I want their speed cap to be reached on round 60"*. The original's +4 a round put every zombie in
// super-sprint (155) on round 40, the first of them on 31. Now the rating climbs in a straight line from 0 on round 1 to 155
// on `SpeedCapRound`, about 2.63 a round, and keeps that rate up to `SpeedCap`:
//
// first of the horde all of it
// run (36) round 2 (was 2) round 15 (was 10)
// sprint (71) round 15 (was 10) round 29 (was 19)
// super (155) round 47 (was 31) round 60 (was 40)
//
// ⚠️ A RATING PAST 155 MOVES NO WALKER ANY FASTER: it only picks a tier, and super-sprint is the last. Hounds and donkeys
// start at 200 (`MinSpeedRating`), and bosses and the napalm zombie have speeds of their own, so none of them ride this.
// `nz_zspeed_curve` prints the horde's mix and speed per round.
public const int SpeedMultiplier = 4;
public const int SpeedCap = 300;
/// <summary>The round every zombie reaches the top speed tier, 60 (2026-10-05; the +4 curve reached it on 40).</summary>
public const int SpeedCapRound = 60;
public static int SpeedForRound( int round )
{
var s = ActiveConfig.Zombies;
// ⚠️ 0 OR 1 KEEPS THE OLD SHAPE, +`SpeedPerRound` a round: what a config object hotloaded from before the field existed
// reads, and the way back to the original's pace from Settings. Whole numbers, so the cap round lands on exactly 155.
if ( s.SpeedCapRound > 1 )
return Math.Clamp( (round - 1) * (int)WalkerAnimations.SuperSprintRating / (s.SpeedCapRound - 1), 0, s.SpeedCap );
return Math.Clamp( round * s.SpeedPerRound - s.SpeedPerRound, 0, s.SpeedCap );
}
// ── CURVES/DAMAGE ────────────────────────────────────────────────────────
// GenerateAttackDamage, curves:119-155
public static int AttackDamageForRound( int round )
{
if ( round <= 6 ) return 30;
if ( round <= 15 ) return 50;
if ( round <= 30 ) return 75;
// ⛔ IT RISES AGAIN FROM ROUND 31, TO DOUBLE BY ROUND 60 (2026-10-03). It used to sit at 90 for good, which with a
// tier-3 vest and Juggernog's armor minors was most of why a round-88 game had no danger in it. The user: *"i want
// zombie damage to scale up to round 60, like double what it is on 31"*. A straight line, 90 at round 31 to 180 at
// `DamageRampEndRound`, held after; both ends are config (`DamageRampEndScale` 1 switches it off).
//
// ⚠️ EVERY VARIANT MULTIPLIES THIS: Brutus and Shrek (x2.4) reach 432 a swing at round 60, napalm (x1.2) 216, a pest
// (x0.85) 153. Oberon still caps any one hit at 100. Armor's bar size is quoted at round 10 (`Armor.HitDamage`),
// so the vests themselves do not change size.
var s = ActiveConfig.Zombies;
int end = Math.Max( 32, s.DamageRampEndRound );
float t = Math.Clamp( (round - 31f) / (end - 31f), 0f, 1f );
return (int)MathF.Round( 90f * (1f + (s.DamageRampEndScale - 1f) * t) );
}
/// <summary>The round zombie damage stops rising (`AttackDamageForRound`), and how far it has risen by then from round
/// 31's 90. Config defaults.</summary>
public const int DamageRampEndRound = 60;
public const float DamageRampEndScale = 2f;
// ── CURVES/ATTACK PRESSURE ───────────────────────────────────────────────
// A SECOND DIFFICULTY AXIS, because the first one runs out. AttackDamageForRound WAS FLAT at 90
// from round 31 on (it rises to 180 by round 60 since 2026-10-03), and health was capped at round 60 (it climbs to round 100 since 2026-10-04) — so past round 31 the only thing that
// changed about a zombie's melee was how many of them there were, and that stops at the 240
// wave cap (round 49).
//
// ⛔ RAW DAMAGE IS NOT THE LEVER, THE IMMUNITY WINDOW IS. The player's Health carries
// `ImmunityAfterHit` (NZPlayer.VictimImmunity, 0.5s), which swallows every hit that lands
// inside it — from the WHOLE horde, not per zombie. 90 damage / 0.5s is a hard ceiling of
// 180 DPS no matter how many zombies are swinging or how fast they swing. Raising attack
// speed alone past that ceiling does nothing at all; the window has to come down with it.
//
// ⚠️ EVERYTHING HERE IS A MULTIPLIER ON THE AUTHORED VALUE, not a replacement for it. A
// variant that authors its own AttackSpeed keeps its character and scales the same relative
// amount — see ZombieAI.ScaledAttackSpeed and friends.
//
// ⚠️ LINEAR, ROUND 1 TO AttackScaleEndRound, FLAT AFTER. Round 1 is exactly the old numbers,
// so nothing about the early game moves. Print the whole curve with `nz_zombie_attack_curve`.
public const int AttackScaleEndRound = 60;
/// <summary>Swing playback at the end round. 1.8 -> 3.42, so one zombie swings every ~0.35s instead of ~0.67s
/// (round 1) — the average over the swing clips, which differ in length.
///
/// ⛔ WAS 1.25 (0.53s) TO ROUND 55 UNTIL 2026-10-03, and the user asked for more: *"damage cooldown down to 0.2s and
/// swing speed to 0.35s"*, peaking at round 60 with the damage. ZombieAI.AttackSpeed's own note warns that above ~2.5x
/// playback a swing reads as a twitch and its sound stops arriving early enough to warn; 3.42 is past that, on
/// purpose. If late swings look like a flinch rather than a strike, this is the number to bring back down.</summary>
public const float AttackSpeedEndScale = 1.9f;
/// <summary>Swing reach at the end round. 2x -> 2.3x, i.e. ~105u -> ~121u off the same 52.5u
/// trigger. The TRIGGER does not move — zombies still commit at a believable distance, they
/// just stop missing the player who backed off half a step.</summary>
public const float AttackReachEndScale = 1.15f;
/// <summary>How far into the swing the hit lands, at the end round. 0.10 -> 0.06.
///
/// ⚠️ THE SMALLEST OF THE FOUR IN PRACTICE, and it is in here for completeness rather than
/// for effect. At 0.10 of a swing that already runs in 0.67s the hit lands 67ms in; the end
/// of the curve moves that to 32ms. Both are "immediately" to a human. The floor on
/// ZombieAI.AttackDamagePoint is 0.05 and this must not drive it below that.</summary>
public const float AttackDamagePointEndScale = 0.60f;
/// <summary>The player's post-hit immunity window at the end round. 0.50s -> 0.20s, so the
/// horde's combined ceiling goes 2 hits/sec -> 5 hits/sec (60 DPS on round 1 -> 900 at round 60's 180 damage).
///
/// ⛔ WAS 0.70 (0.35s) UNTIL 2026-10-03; 0.20s at round 60 by request (see AttackSpeedEndScale). The note below was
/// written for 0.35 and its warning now applies: at 0.2s a swing every 0.35s means EVERY hit of a lone zombie lands,
/// and a surrounding crowd lands five a second.
///
/// ⚠️ THIS HAS ONLY EVER GATED A CROWD, NEVER A LONE ZOMBIE, at any point on the curve. One
/// zombie's swings are already spaced by its animation — 0.667s on round 1, 0.533s at the
/// fastest the curve gets — and both are LONGER than the window, so a solo attacker has never
/// had a hit swallowed. Shortening this does not make a single zombie hit harder; it raises
/// the ceiling on how much of a SURROUNDING crowd gets through, which is the intent.
///
/// ⛔ DO NOT TAKE THIS MUCH LOWER WITHOUT RE-READING NZPlayer.VictimImmunity. Its note is not
/// decoration: as the window approaches zero, every zombie in contact lands in the same tick
/// and being surrounded stops being survivable at all. At 0.35s a four-zombie ring still
/// lands 2.86 hits/sec between them rather than four at once.</summary>
public const float VictimImmunityEndScale = 0.40f;
/// <summary>0 at round 1, 1 at the end round, held at 1 after. The shape every attack knob
/// below rides on.</summary>
public static float AttackRampT( int round )
{
int end = Math.Max( 2, ActiveConfig.Zombies.AttackScaleEndRound );
if ( round <= 1 ) return 0f;
if ( round >= end ) return 1f;
return (round - 1f) / (end - 1f);
}
private static float Ramp( float endScale, int round )
=> 1f + (endScale - 1f) * AttackRampT( round );
public static float AttackSpeedScale( int round )
=> Ramp( ActiveConfig.Zombies.AttackSpeedEndScale, round );
public static float AttackReachScale( int round )
=> Ramp( ActiveConfig.Zombies.AttackReachEndScale, round );
public static float AttackDamagePointScale( int round )
=> Ramp( ActiveConfig.Zombies.AttackDamagePointEndScale, round );
public static float VictimImmunityScale( int round )
=> Ramp( ActiveConfig.Zombies.VictimImmunityEndScale, round );
// ── CURVES/TIER SPEED ────────────────────────────────────────────────────
// HOW FAST EACH TIER ACTUALLY TRAVELS. The round curve above picks a TIER; this decides what
// being in that tier is worth. Until these existed the answer was "whatever the animator's
// root motion happened to measure", which is why the ceiling sat at 221 u/s — 41% of a
// fully-augmented player's 538 sprint.
//
// ⛔ THESE MULTIPLY `_baseMoveSpeed`, NOT `_clipGroundSpeed`, AND THE DISTINCTION IS THE WHOLE
// REASON THEY WORK. The animation rate is `clamp( velocity / _clipGroundSpeed, 0.05, MaxAnimRate )`
// — so anything that raises the clip speed and the move speed TOGETHER (the baked
// WalkerGroundSpeeds table, `SpeedMultiplier`, `ExtraSpeedMultiplier`, a variant's
// `GroundSpeed`) leaves that ratio at 1.0 and the zombie slides with its legs cycling at the
// authored pace. Raising the move speed alone makes the legs cycle faster to match, exactly
// as `MinMoveSpeed` already does for the slow walk clips.
//
// ⚠️ WHICH MEANS `MaxAnimRate` IS THE REAL CEILING ON ALL OF THIS. Past it the legs stop
// keeping up and the zombie skates. Each tier's honest headroom is `MaxAnimRate` divided by
// the rate its SLOWEST clip is already running at:
//
// Walk 15 clips 35-60 u/s floored to 55 -> already at 1.56x, ~0.9x left
// Run 12 clips 57-121 u/s at 1.0x -> the full budget
// Sprint 8 clips 144-172 u/s at 1.0x -> the full budget
// SuperSprint 10 clips 197-234 u/s at 1.0x -> the full budget
//
// ⚠️ AT THE 2026-09-14 SCALES NOTHING COMES NEAR THAT CEILING. The largest is SuperSprint
// at 1.6x against a clamp of 2.5, so `MaxAnimRate` is now pure headroom rather than a
// constraint anything is pressed against. It is left at 2.5 rather than walked back to the
// original 2: the value is inert at these scales, and churning a clamp nothing touches would
// only cost a diff.
//
// ⚠️ BOSSES ARE NOT AFFECTED AND MUST NOT BE. `FixedSpeed`/`SpeedOverride` short-circuit the
// whole chain including the floor — a Brutus moves at his authored pace on round 11 and round
// 71 — so the scale is applied in the other branch only. See ZombieAI.ApplyGroundSpeed.
/// <summary>Rounds 1-14 (1-9 before 2026-10-05's slower curve). Left at 1: the opening shamble is the pacing, and `MinMoveSpeed`
/// has already spent most of this tier's animation budget flooring 14 of its 15 clips
/// up to 55.</summary>
public const float WalkSpeedScale = 1f;
/// <summary>Rounds ~15-28 (~10-18 before 2026-10-05). 57-121 -> 68-145 u/s, so the tier is felt without the fastest of
/// them beating a walking player (200) before the sprinters arrive.</summary>
public const float RunSpeedScale = 1.2f;
/// <summary>Rounds ~29-59 (~19-39 before 2026-10-05). 144-172 -> 202-241 u/s, just past walking pace — you can no longer
/// stroll away from the horde, but a sprint still clears it comfortably.</summary>
public const float SprintSpeedScale = 1.4f;
/// <summary>Round 60+ for the whole horde, the cap (40+ before 2026-10-05; the first arrive on 47). 197-234 -> 315-374 u/s.
///
/// ⛔ THIS WAS 2.5 AND IT WAS TOO MUCH — reported plainly as *"zombies are way too fast now"*.
/// At 2.5 the tier delivered 493-586 against a fully-augmented player's 538 sustained sprint,
/// so the fastest of them OUT-RAN a maxed sprinter. That was the stated goal at the time
/// ("an absolute panicking menace at the cap") and it turned out to be unplayable: a cap you
/// cannot break contact with at any movement build is not a difficulty curve, it is a wall.
///
/// ⚠️ 315-374 SITS EITHER SIDE OF THE UN-AUGMENTED SPRINT (340), which is the line worth
/// aiming at. A player with no Stamin-Up is caught by the faster half and escapes the slower
/// half; a fully-augmented one (538) always breaks contact but never casually. The movement
/// upgrades buy something real at the cap instead of being mandatory.</summary>
public const float SuperSprintSpeedScale = 1.6f;
/// <summary>Fastest the locomotion clip may be played to keep the feet planted. Was a
/// hardcoded 2 in two places.
///
/// ⚠️ NOTHING REACHES IT AT THE CURRENT SCALES — the largest is SuperSprint's 1.6x — so
/// this is headroom, not a constraint. Raise a tier past 2.5 and it becomes the real
/// ceiling again.</summary>
public const float MaxAnimRate = 2.5f;
/// <summary>The speed multiplier for a tier name, as WalkerAnimations.TierName spells it.</summary>
public static float TierSpeedScale( string tier )
{
var s = ActiveConfig.Zombies;
return tier switch
{
"Run" => s.RunSpeedScale,
"Sprint" => s.SprintSpeedScale,
"SuperSprint" => s.SuperSprintSpeedScale,
_ => s.WalkSpeedScale,
};
}
// ── CURVES/WAVE SIZE ─────────────────────────────────────────────────────
// GenerateMaxZombies, curves:38-70. This is the TOTAL for the round, not
// the concurrent cap — see MaxAlive below, they are unrelated.
public const int WaveBase = 24;
public const int WaveCap = 240;
private static readonly float[] EarlyRoundScale = { 0.25f, 0.3f, 0.5f, 0.7f, 0.9f };
public static int WaveTotal( int round, int players )
{
if ( round < 0 ) return 666; // endless, sv_round.lua:65
if ( players < 1 ) players = 1;
var s = ActiveConfig.Zombies;
float max = s.WaveBase;
float mult = MathF.Max( 1f, round / 5f );
if ( round > 10 ) mult *= round * 0.15f;
float perPlayer = players == 1 ? 0.5f : players - 1;
max += perPlayer * 6f * mult;
if ( round >= 1 && round <= EarlyRoundScale.Length )
max *= EarlyRoundScale[round - 1];
if ( max > s.WaveCap )
max = s.WaveCap + (players - 1) * 6;
return (int)max;
}
// ── CURVES/CONCURRENT CAP ────────────────────────────────────────────────
// sv_hooks.lua:932,938. FLAT — it does NOT scale with player count and does NOT grow per
// round (spawnperround defaults to 0). Player count scales the wave TOTAL only.
//
// ⛔ RAISED 35 -> 50 AS A DELIBERATE EXPERIMENT. User: *"I do not notice a performance change
// with the number of zombies."* 35 was never a measurement of what THIS engine can carry — it
// is the original's number, chosen for a single-threaded Lua server in 2010, and every perf
// note in this project that cites it inherited it rather than measured it.
//
// ⚠️ THE CAP IS A POPULATION TARGET WITH DEATH-TRIGGERED REFILL, NOT A RATE LIMIT — see the
// SPAWN RATE block below. While at the cap the spawn timer sits expired, so a kill produces an
// INSTANT replacement. Raising it therefore raises the standing crowd, not the trickle: late
// rounds go from "always exactly 35 on you" to "always exactly 50".
//
// ⚠️ AND IT COMPOUNDS WITH THE 18:05 SPEED CHANGE. Those 50 now arrive at up to 586 u/s where
// 35 arrived at 234. If anything is going to find the ceiling, it is this combination rather
// than either alone — `nz_zcap` moves it live so a bad number is one command, not a rebuild.
public const int MaxAlive = 50;
/// <summary>Concurrent cap. Per-map ramping exists in the original but
/// defaults to off, and is clamped to MaxAlive regardless.</summary>
/// <summary>Concurrent cap using the ACTIVE CONFIG's MaxAlive.
/// ⚠️ A separate overload rather than a default parameter — C# requires
/// defaults to be compile-time constants, so a config value cannot be
/// one.</summary>
public static int MaxAliveForRound( int round, int spawnsPerRound = 0 )
{
// ⚠️ THE MATCH'S "MAX AT ONCE" (the lobby's Difficulty, 2026-10-05) when it has one, else the config's
var cap = Difficulty.MaxAlive;
return MaxAliveForRound( round, cap, spawnsPerRound, cap );
}
public static int MaxAliveForRound( int round, int startingSpawns,
int spawnsPerRound, int cap )
=> Math.Min( startingSpawns + round * spawnsPerRound, cap );
// ── CURVES/SPAWN RATE ────────────────────────────────────────────────────
// sv_spawner.lua:15-31 — base 2s, ×0.95 per round, clamped [0.08, 2].
// R1 1.90s · R10 1.20s · R20 0.72s · R30 0.43s · R50 0.15s · R63+ 0.08s
//
// IMPORTANT (reference doc §1): this delay is only applied when a spawn
// actually happens. While at the concurrent cap the timer is never pushed
// forward, so it sits expired and a death triggers an INSTANT replacement.
// The cap is a population target with death-triggered refill, not a rate
// limiter. That's what produces the "horde is always exactly N" feel.
public const float SpawnDelayBase = 2f;
public const float SpawnDelayMin = 0.08f;
public static float SpawnDelayForRound( int round )
{
var s = ActiveConfig.Zombies;
return Math.Clamp( s.SpawnDelayBase * MathF.Pow( 0.95f, round ),
s.SpawnDelayMin, s.SpawnDelayBase );
}
// ── CURVES/POINTS ────────────────────────────────────────────────────────
// sv_hooks.lua — the scoring the whole economy is balanced against.
//
// ⛔ THE HIT AWARD IS HALVED FROM THE ORIGINAL 10, AND NOT TO SLOW THE ECONOMY DOWN. At 10 a
// SLOWER weapon earned MORE: eight shots with half the kills on the head paid 145 a zombie
// against 130 for four shots that all landed on the head. Shooting more times beat shooting
// better, which is precisely backwards in a mode whose whole skill loop is headshots. At 5 the
// same comparison is 110 against 115 and the faster, more accurate kill wins again.
//
// ⚠️ THE KILL AWARDS ARE UNTOUCHED, deliberately. They are what keeps a weak gun earning — a
// starting pistol still banks 100 for a headshot kill on round 1 — so the cut lands on volume
// of fire rather than on the early game.
public const int PointsHit = 5;
public const int PointsKillBody = 50;
public const int PointsKillHeadshot = 100;
public const int PointsKillMelee = 130;
}