RoundManager component controlling the game's round/wave loop and related round state. It manages phases (Waiting, Prep, Active, GameOver), spawning bookkeeping, special rounds, boss spawning, player resets on run start/end, mirroring state to clients, fog/announcements, and multiple map/manager rebuilds needed when a game starts or ends.
using Sandbox;
using Sandbox.Navigation;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
public enum RoundState
{
/// <summary>Nothing running — lobby, or creative.</summary>
Waiting,
/// <summary>Between rounds. The breather before the next wave.</summary>
Prep,
/// <summary>Wave in progress: spawning, and waiting for it to be cleared.</summary>
Active,
/// <summary>
/// Everyone is down. The run is over and the score is on screen.
///
/// ⚠️ A SEPARATE STATE, not Waiting. Waiting means "no game running" — the
/// lobby and creative sit in it — so folding game-over into it would make
/// "you just died on round 14" indistinguishable from "you have not started
/// yet", and the score screen would have nothing to key off.
/// </summary>
GameOver,
}
/// <summary>
/// The wave loop — Waiting → Prep → Active → Prep → Active…
///
/// ⚠️ THIS IS WHAT SWITCHES ON THE ROUND CURVES. ZombieStats has had
/// HealthForRound, SpeedForRound, WaveTotal, MaxAliveForRound and
/// SpawnDelayForRound since the port, all unused — ZombieAI hardcoded
/// `int round = 1`. Every zombie was permanently round 1 and none of the
/// scaling did anything. Nothing else had to change for it to start working.
///
/// Modelled on the original's state names and timings (sv_round.lua): 15s
/// between rounds, but only 1s before round 1 — you should be fighting almost
/// immediately on starting, and get a real breather thereafter.
/// </summary>
public sealed class RoundManager : Component
{
public static RoundManager Instance { get; private set; }
[Property, ReadOnly] public RoundState State { get; private set; } = RoundState.Waiting;
[Property, ReadOnly] public int Round { get; private set; }
/// <summary>
/// Seconds between rounds. **15** — doubled from 7.5 by request.
///
/// ⚠️ WHICH PUTS IT BACK ON THE ORIGINAL'S NUMBER (`Settings.roundwaittime = 15`). It had been
/// halved here with this reasoning: *"GMod's value is tuned for a lobby of players who need to
/// walk to a box, a wall buy and a Pack-a-Punch between waves; solo it is mostly standing
/// still."* That argument was about SOLO, and this project is now built and played
/// multiplayer — which is the case the original's 15 was chosen for in the first place.
///
/// ⚠️ THIS IS THE WHOLE TRANSITION. `Prep` is a single state and `_nextPhase` is assigned in
/// exactly one place (`BeginPrep`), so there is no second delay stacked either side of it —
/// changing this number is the entire change.
///
/// ⛔ NOT SERIALISED ANYWHERE, so this default IS the live value. `RoundManager` is created at
/// runtime rather than saved in the scene, and neither the scene nor any map config carries a
/// `PrepTime` — s&box omits a property still holding its default. Checked before editing,
/// because a `[Property]` that a scene HAS stored ignores the default silently.
///
/// ⚠️ Still a [Property] so a map can override it, and `nz_round_prep` moves it live.
///
/// ⚠️ **15 → 13 → 10 ON 2026-09-23**, by request, in two steps within the hour. Recorded as a
/// trim rather than a re-argument — the note above is about 7.5 vs 15, and 10 is neither of
/// those. It is a number arrived at by playing it, which is the only way this one has ever been
/// settled; the two steps are kept because "13 was still too long" is the useful part.
///
/// ⚠️ `FirstPrepTime` IS NOT TOUCHED. It is the pre-round-1 wait, deliberately 1 second, and
/// "the round change time" is the gap BETWEEN rounds.
/// </summary>
[Property] public float PrepTime { get; set; } = 10f;
/// <summary>Seconds before round 1 — deliberately short. (firstroundwaittime = 1)</summary>
[Property] public float FirstPrepTime { get; set; } = 1f;
/// <summary>
/// How many stragglers at the end of a round get pushed to a sprint. 4.
///
/// ⚠️ COUNTED AS `Remaining + AliveBlocking`, i.e. still-to-spawn PLUS on-the-map. Counting
/// only what is alive would fire the moment a round throttled down to four on screen with
/// thirty still queued -- which is most of a late round, not the end of one.
///
/// ⚠️ MONOTONIC, SO IT NEVER NEEDS UNDOING. That sum only ever falls within a round, so once
/// a zombie qualifies it stays qualified, and there is no "slow them back down" case to get
/// wrong. 0 disables the whole behaviour.
/// </summary>
public static int LastSprintCount { get; set; } = 4;
/// <summary>
/// First round the straggler sprint applies to. 4.
///
/// ⛔️ ROUNDS 1-3 ARE THE TUTORIAL AND MUST STAY SLOW. They are where a player learns the map
/// and buys the first door; a sprinting straggler on round 1 is not tension, it is an ambush
/// aimed at someone with a starting pistol.
/// </summary>
public static int LastSprintFromRound { get; set; } = 4;
/// <summary>Still to spawn this wave.</summary>
public int Remaining { get; private set; }
/// <summary>Spawned and still alive — bosses included.</summary>
public int Alive => ZombieAI.All.Count( z => z.State != ZombieState.Dead );
/// <summary>
/// Alive and still holding the round open. BOSSES DO NOT.
///
/// ⛔️ THE ROUND MUST NOT WAIT ON A BOSS, AND THIS IS THE ONLY PLACE THAT KNOWS IT. A boss is not
/// part of the wave: it is not drawn from WaveTotal, killing it is optional, and it is meant to
/// chase you across round boundaries the way Brutus and the Panzer do. Counting it in the
/// round-clear test would stall the game forever on any boss the player chose not to fight —
/// which, for a boss with hundreds of hit points and no obligation attached, is most of them.
///
/// ⚠️ IT IS `Alive` THAT STAYS HONEST, not this. Anything asking "how many things are on the
/// map" — the HUD, max-alive throttling, a log line — still wants the boss counted, so the
/// distinction is which QUESTION is being asked rather than a correction to the old number.
/// </summary>
public int AliveBlocking => ZombieAI.All.Count(
z => z.State != ZombieState.Dead && !(z.Variant?.IsBoss ?? false) );
/// <summary>Bosses alive right now, for the HUD and for logs.</summary>
public int BossesAlive => ZombieAI.All.Count(
z => z.State != ZombieState.Dead && (z.Variant?.IsBoss ?? false) );
/// <summary>Total for this wave, for the HUD and for logs.</summary>
public int WaveTotal { get; private set; }
/// <summary>
/// This round's zombies killed so far — those that hold the round open, not bosses — for the round bar (`RoundBarHud`).
/// HOST: counted as each dies (<see cref="OnZombieDied"/>); a client holds the host's, mirrored (`NZNet.RoundNow`).
///
/// ⚠️ KILLS, NOT DESPAWNS: the round's zombies cleared by a skip, or by basalt's boss fight as it begins, never count.
/// </summary>
public int RoundKills { get; private set; }
/// <summary>
/// This round's zombies not yet dead: still to spawn, and alive holding the round open — the round ends as it reaches 0.
/// With <see cref="RoundKills"/> it is the round bar: killed of killed-and-left, which counts the zombies that join a round
/// without being in its wave (an ambient Shrieker) where <see cref="WaveTotal"/> does not.
///
/// ⚠️ A CLIENT HOLDS THE HOST'S, MIRRORED: its own zombies are puppets, and a death reaches it only as the body falls.
/// </summary>
public int ZombiesLeft => NZGame.IsClient ? _leftMirror : Remaining + AliveBlocking;
int _leftMirror;
/// <summary>
/// A zombie died: one of this round's kills, if it held the round open. HOST — `ZombieAI.Die`, which runs there alone.
/// ⚠️ NOT IN BASALT'S BOSS FIGHT: the round stands frozen, and the fight's zombies are the fight's, not the round's.
/// </summary>
public static void OnZombieDied( ZombieAI z )
{
var rm = Instance;
if ( !rm.IsValid() || !z.IsValid() || NZGame.IsClient ) return;
if ( rm.State != RoundState.Active || (z.Variant?.IsBoss ?? false) || HexPlatforms.FreezesRound ) return;
rm.RoundKills++;
}
TimeUntil _nextPhase;
TimeUntil _nextSpawn;
/// <summary>Counts down to Samantha's special-round line. See
/// NZSound.AnnouncerSpecial for why it is not played immediately.</summary>
TimeUntil _specialAnnounce;
/// <summary>⛔️ STARTS TRUE. `TimeUntil` defaults to 0, i.e. ALREADY ELAPSED, so
/// a false default fires Samantha on the very first frame of the very first
/// update — before any round has begun, on a normal round, from the lobby.
/// It is set false only when a special round actually starts.</summary>
bool _specialAnnounced = true;
/// <summary>Rate-limits the "everything is gated" warning — TickSpawning
/// runs every frame and would otherwise flood the console.</summary>
TimeSince _sinceBlockedWarning;
/// <summary>How many zombies each config spawn index produced this wave.
/// A tally rather than a log line per spawn — round 25 spawns 80.</summary>
readonly Dictionary<int, int> _spawnUse = new();
/// <summary>"0×6, 2×3" — which spawns actually fed the current wave.</summary>
public string SpawnUsage => _spawnUse.Count == 0
? "none yet"
: string.Join( ", ", _spawnUse.OrderBy( kv => kv.Key )
.Select( kv => $"#{kv.Key}×{kv.Value}" ) );
/// <summary>
/// The scene's round manager, created if there is not one yet.
///
/// ⛔️ IT EXISTS BECAUSE THE DEV MENU RUNS IN CREATIVE, WHERE THERE IS NO ROUND MANAGER. Pressing
/// "Spawn boss" there reported "no round manager — press Play first" while the player WAS in play,
/// because a manager is only created when the wave loop is first started. Anything a dev button
/// can reach has to cope with that.
///
/// ⚠️ CREATING ONE IS HARMLESS. A fresh manager sits in `RoundState.Waiting`, and the `OnUpdate`
/// switch has no case for it — no wave, no spawning, no timers. It starts doing things only when
/// `StartGame` is called.
///
/// ⚠️ ITS OWN GAMEOBJECT, matching what `RoundCommands` already did: the manager must not hang
/// off anything that gets destroyed, or the wave loop stops mid-game. That file's `Manager`
/// property now delegates here so there is one author (§3).
/// </summary>
public static RoundManager Ensure( Scene scene = null )
{
if ( Instance.IsValid() ) return Instance;
scene ??= Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Round Manager";
return go.Components.Create<RoundManager>();
}
protected override void OnAwake()
{
Instance = this;
// ⚠️ CREATED HERE AND NOWHERE ELSE, `GetOrCreate` so it is idempotent across a hotload —
// the same shape `NZPlayer` uses for `PowerupMusic` and `SurvivalHud` for its overlays. A
// ticking component that nothing creates is not a disabled feature, it is an absent one,
// and this project has already shipped one of those this session.
Components.GetOrCreate<AmbientSpecials>();
}
protected override void OnDestroy() { if ( Instance == this ) Instance = null; }
// ── control ──────────────────────────────────────────────────────────────
/// <summary>Start from round 1.</summary>
public void StartGame()
{
Round = 0;
NZGame.SetMode( GameMode.Survival );
// ⛔ THE LOBBY'S DIFFICULTY, TAKEN NOW AND SENT TO EVERYONE, BEFORE ANYTHING BELOW READS IT (2026-10-05). After the mode,
// whose change drops a difficulty on the way to the lobby, and before every player's reset, which starts the wallets and
// the health from it: a client hears it before `RunStarted` resets its own. `Difficulty.StartMatch`.
Difficulty.StartMatch();
// ⛔ AND EVERY CLIENT'S WORLD WITH IT, SENT BEFORE ANYTHING BELOW (the co-op audit, 2026-09-27 — *"fix it so all players
// get it reset"*). What follows rebuilds on the host only; a client reset its own player and nothing else, so a second
// game kept the first one's bench, soul boxes, parts and doors. First, so the host's own announcements below land on
// the rebuilt world. `NZNet.NewGameWorld`.
if ( Networking.IsActive ) NZNet.NewGameWorld();
// ⚠️ A new game starts with the map LOCKED. Links are runtime state and
// survive as a static, so without this a second game would begin with
// everything the last one opened still open.
DoorLinks.Reset();
// ⚠️ RESETTING THE LINKS IS ONLY HALF OF IT — THE WALLS HAVE TO COME
// BACK TOO.
//
// DoorLinks.Reset says "the map is locked again", but the barriers are
// GameObjects that were destroyed when they were bought. Without this
// rebuild the flags were locked and the doorways were still standing
// open: no wall to see, nothing to buy, and no navmesh blocker, so
// zombies walked straight through a door the game believed was shut.
//
// Ensure(), not Instance?., because the manager is created on demand —
// a map whose barriers were only ever placed by the tool may not have
// one yet, and a null-conditional call would silently do nothing.
DebrisManager.Ensure( Scene )?.Rebuild();
// Same reasoning for the power — Power.Switched is a static and would
// otherwise carry a previous game's flipped switch into this one. On a
// map with no switch placed this changes nothing, because there IsOn is
// derived as always-true.
Power.Reset();
PowerManager.Ensure( Scene )?.Rebuild();
// ⚠️ NOTHING TO RESET, BUT IT STILL HAS TO EXIST. Invisible walls have no
// runtime state — no link, no purchase, nothing a game can change — so
// this is purely "build them", the same as entering creative does. Left
// out, a map's boundaries simply are not there in Survival while being
// perfectly present in the editor, which is the worst way round.
InvisibleWallManager.Ensure( Scene )?.Rebuild();
// ⛔ MISERY IS CLEARED FOR A NEW RUN. `MiseryDevice.Running` is STATIC and would
// otherwise survive into the next game — a run that began already miserable, with
// nothing on screen saying why.
MiseryDevice.ClearForNewRun();
MiseryDeviceManager.Ensure( Scene )?.Rebuild();
ClueManager.Ensure( Scene )?.Rebuild();
PressableManager.Ensure( Scene )?.Rebuild();
ShootableManager.Ensure( Scene )?.Rebuild();
// ⚠️ AND THE DAMAGE WALLS, for exactly the reason the note above gives for the
// invisible ones: no runtime state to reset, but they still have to EXIST in
// Survival. Leaving this out is precisely how the wall came to be present in the
// editor and absent in a round.
DamageWallManager.Ensure( Scene )?.Rebuild();
// ⚠️ Rebuilt AND re-boarded. Barricades carry runtime state (torn boards)
// that a previous run left behind, so unlike invisible walls "build them"
// is not enough — a new game must start with every window whole.
BarricadeManager.Ensure( Scene )?.Rebuild();
// ⛔️ THE JUMP/DROP LINKS WERE LEFT EXACTLY AS CREATIVE BUILT THEM, and that was wrong
// twice over.
//
// VISUALLY: Rebuild is what applies the creative-only marker rule, and it was never called
// on the way into a round -- SetMode only calls ShowConfig when entering CREATIVE, and this
// method rebuilt seven managers without this one. So the solid boxes sitting over every
// ledge stayed standing in survival, in front of the player, as scenery no map has.
//
// ⚠️ AND FUNCTIONALLY, which is the worse half: the creative build deliberately ignores
// the door flags, so every link was ALREADY ACTIVE at round 1. Zombies had routes into
// areas nobody had bought. DoorLinks.Reset above is what makes this correct -- rebuilding
// AFTER it means each link is re-tested against a freshly locked map.
NavLinkManager.Ensure( Scene )?.Rebuild();
// ⚠️ Same shape as Power above — clear the STATIC, then rebuild the objects. The box's
// use count, whether it has ever moved and its post-move grace window all live as statics
// (MysteryBox.ResetRun says why), so a rebuild alone would put a fresh crate on the map
// with the previous game's teddy odds still attached to it.
MysteryBox.ResetRun();
MysteryBoxManager.Ensure( Scene )?.Rebuild();
// ⛔ AND EVERY PERK MACHINE'S LOOSE CHANGE IS BACK (2026-09-29). It was reset only by `PerkMachineManager.Rebuild`, which
// runs when a config is shown and on no new game — so after a game over nobody, host or client, could crouch for a coin
// again. User: *"perk machines do not reset the change you can get by crouching"*. Clients do the same in
// `NZNet.NewGameWorld`.
LooseChange.ResetForNewGame();
// ⛔️ BOTH LISTED HERE TOO. The ammo box was absent and never appeared on a real game start;
// the trading table relies on this call to come back EMPTY, since Rebuild destroys the
// objects holding the stored weapon. See the matching block in NZGame.
AmmoBoxManager.Ensure( Scene )?.Rebuild();
BuyableEndingManager.Ensure( Scene )?.Rebuild();
TradeTableManager.Ensure( Scene )?.Rebuild();
BuildTableManager.Ensure( Scene )?.Rebuild();
BuildPartManager.Ensure( Scene )?.Rebuild();
// ⚠️ AND BASALT'S HEX SLOTS ROLL AGAIN — a new number and colour on each, every game. `NewGame` is their rebuild
// here as well: the roll goes to every machine, and each builds its slots from it.
HexSlotManager.Ensure( Scene )?.NewGame();
// ⛔ AND BASALT'S EASTER EGG STARTS OVER, ITS BOSS FIGHT WITH IT — BEFORE THE FIRST ROUND. The fight freezes the round
// (`HexPlatforms.FreezesRound`), and round 1 is where the egg has always reset: a fight still on would keep the new
// game's first round from ever beginning, and so from ever resetting it.
HexPlatforms.OnGameReset( "a new game" );
// ⛔ THE SIXTEENTH MANAGER, AND IT WAS SIMPLY MISSING FROM THIS LIST. Reported as *"the map
// is not properly cleaned up on game over … and maybe soul boxes too"* — and unlike the
// drops, this was not a cleanup problem at all: nothing rebuilt the boxes for a new run, so
// a second game started with the first game's souls still counted and a set that may
// already have been completed. `Rebuild` destroys and re-reads the config, which is what
// resets them; every other manager on this list is here for exactly that reason.
SoulBoxManager.Ensure( Scene )?.Rebuild();
// ⚠️ AND THE FLOOR, on the host's path. `NZGame.SetMode` sweeps when the mode actually
// changes — but `Mode` is static and survives a play restart, so a second game started
// without leaving Survival early-returns out of SetMode and never reaches it. The same
// reason `PlayerSpawner.PlaceAll` is called from here rather than from there.
WorldCleanup.Sweep( Scene, "new game" );
// ⚠️ Placed here rather than inside NZGame.SetMode, which early-returns
// when the mode is unchanged — and NZGame.Mode is STATIC, so it survives
// a play restart. Starting a second game without leaving Survival would
// silently skip the spawn and leave everyone where they died.
PlayerSpawner.PlaceAll();
// ⛔️ RE-EQUIP, because game over DESTROYED the weapon objects. OnStart is
// the only other place that arms a player, and it does not run again for
// a body that survived the trip to the lobby — so without this the second
// run starts empty-handed with no error to say why.
//
// ⚠️ force:true — the normal call early-returns in Creative, and a run
// started from a creative session would otherwise be unarmed.
foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
{
if ( !p.IsValid() ) continue;
ResetPlayerForRun( p );
}
// ⛔ AND EVERY CLIENT RESETS ITS OWN PLAYER (2026-09-27). The loop above reset the HOST'S
// copy of each client, which is not where a client's points, perks or guns live — see
// `NZNet.RunStarted`. Sent last, after the mode change and the spawns it depends on.
if ( Networking.IsActive ) NZNet.RunStarted();
BeginPrep();
}
/// <summary>
/// One player back to the start of a run: standing, the loadout pistol, the starting wallet
/// and nothing bought. The body of `StartGame`'s per-player loop.
///
/// ⛔ A METHOD BECAUSE A CLIENT HAS TO RUN IT TOO (2026-09-27). `StartGame` runs on the host
/// alone and every line here is local state on whoever owns the body, so for a client it only
/// ever reset the host's copy. `NZNet.RunStarted` runs this on each client for its own player.
/// </summary>
public static void ResetPlayerForRun( NZPlayer p )
{
if ( !p.IsValid() ) return;
p.Revive();
// ⛔️ EMPTIED FIRST, OR THE LAST RUN'S GUNS COME WITH YOU. EquipStartingWeapon ADDS the
// loadout; it does not remove anything, so a player who died holding a bought M14 kept it
// in the second slot even once the pistol was correctly restored to the first. A new game
// has to start with the inventory a new game is supposed to have.
//
// ⚠️ BEFORE the equip, obviously — clearing afterwards would throw away the pistol that
// was just handed over and leave the player unarmed.
//
// ⚠️ ClearWeapons, NOT Inventory.Clear. The inventory only knows about what it was told
// about, and clearing it leaves the weapon OBJECTS parented until the end of the frame —
// which the equip guard then mistook for "already armed" and skipped, starting the run
// with nothing at all. See NZPlayer.ClearWeapons and LiveWeapons.
var taken = p.ClearWeapons();
// ⛔ AND EVERYTHING THE LAST GAME HUNG ON A GUN, BEFORE THE PISTOL IS HANDED OVER (2026-09-29). Pack-a-Punch levels,
// rarity, tech and ammo mods are all kept per PREFAB, and the equip below reads them straight onto the gun it spawns
// (`ApplyStoredUpgrades`), so whatever is still stored at that moment is what the new game's pistol starts with.
//
// ⚠️ PACK-A-PUNCH (user: *"reset pap levels too yes"*): nothing but `nz_pap_level` cleared the levels, so a new game
// began with the last one's — the starting pistol packed from round 1, any wall gun packed last time bought packed.
//
// ⛔ RARITY AND TECH WERE CLEARED, BUT BELOW THE EQUIP. Both are bought with SALVAGE, which a new game zeroes, so
// keeping them meant a fresh game holding a Legendary, fully teched weapon bought with a currency it does not have
// (Rarity shipped with a ClearRarity() that nothing ever called). Cleared after the equip, they left the tables empty
// but not the pistol already built from them: it kept last game's rarity damage and tech until its next re-equip.
//
// ⚠️ AMMO MODS, bought at the Arsenal with salvage too, were cleared nowhere. `AmmoMods.On` reads them live, so for
// them the order does not matter; they go with the rest.
//
// Here, on the host for its own player and on each client through `NZNet.RunStarted` — all of it lives on the
// owner's machine. `ClearTech` republishes the emptied tree itself.
p.ClearPap();
p.ClearRarity();
p.ClearTech();
p.AmmoModIds.Clear();
p.AmmoModReady.Clear();
// ⚠️ AND THE MODS' UPGRADE LEVELS (2026-10-05), bought with salvage like the rest and kept per MOD, not per gun
// (`AmmoModUpgrades`): "permanent" is the rest of the game, not the next one.
p.ClearAmmoModLevels();
// ⚠️ AND LEECH'S OVERHEAL (2026-10-04), which nothing else takes away but damage (`Health.Over`).
p.Hp?.ClearOver();
p.EquipStartingWeapon( true );
if ( taken > 0 )
Log.Info( $"[nz] cleared {taken} weapon(s) from the last run" );
// ⛔️ THE WALLET IS RESET AT THE START, NOT ONLY AT GAME OVER. Game over
// already clears these, but a run begun from a CREATIVE session never
// passed through game over — and Creative tops both currencies up to
// 100,000 every frame. Without this, testing a map and then pressing play
// starts round 1 with 100,000 points and 100,000 salvage.
//
// ⚠️ SET, not added. StartGame can run twice without leaving Survival.
// ⚠️ THE MATCH'S STARTING POINTS (the lobby's Difficulty, 2026-10-05), taken before this reset on every machine
p.SetPoints( Difficulty.StartingPoints );
Salvage.Reset( p );
// ⚠️ THE SCOREBOARD IS PER GAME, NOT PER SESSION. Same reason the points above are SET
// rather than added: StartGame can run twice without leaving Survival, and a run that
// opened holding the previous run's kill count would make the whole panel meaningless.
PlayerStats.For( p )?.Reset();
// ⚠️ RARITY AND TECH ARE RESET ABOVE NOW, before the equip, with Pack-a-Punch and the ammo mods (2026-09-29).
// ⚠️ ARMOR GOES TOO, and this is an inference rather than a stated
// requirement: resetting the salvage but keeping the vest bought with it
// would start a run in tier-3 armor with an empty wallet, which is a
// stranger state than either end of the choice. Say so if a run should
// inherit a vest fitted in Creative.
Armor.Reset( p );
// ⛔️ AND THE AUGMENTS, FOR THE SAME REASON AS RARITY AND TECH: bought with salvage,
// which the line above zeroed. This is one of only two places they are cleared — going
// down deliberately keeps them now (see `NZPlayer.LosePerksOnDown`) — and it is the one
// that catches a run started from Creative, which never passes through `EndGame` at all.
//
// ⚠️ AFTER `p.Revive()` ABOVE, WHICH MATTERS. `Revive` runs `LosePerksOnDown` for anyone
// who was down, and that path reads augments to decide what survives — clearing them
// first would silently switch off Grave Keeper for a player who began the restart downed.
PerkAugments.ClearAll( p );
// ⛔ AND MULE KICK'S INSURANCE ESCROW (2026-09-29). M4 keeps each gun a slot loss destroyed, to hand back the next
// time Mule Kick is bought (`MuleKickAugments.Restore`), and nothing ever emptied it: a gun lost with M4 held and not
// reclaimed before the game ended came back in a LATER game, the next time Mule Kick was bought with M4 held.
p.InsuredWeapons.Clear();
// ⛔ AND THE SELF-REVIVES COME BACK (2026-09-27). The count was never reset, so three
// self-revives lasted the whole session and Quick Revive stopped working in every later game.
p.SelfRevivesUsed = 0;
}
public void Stop()
{
State = RoundState.Waiting;
Remaining = 0;
InSpecialRound = false;
Fog?.SetSpecial( false );
Log.Info( "[nz] rounds stopped" );
}
// ── game over ────────────────────────────────────────────────────────────
/// <summary>Round reached when the run ended — the number on the score screen.</summary>
public int FinalRound { get; private set; }
/// <summary>Points held at the end.</summary>
public int FinalPoints { get; private set; }
/// <summary>Why it ended, shown under the title.</summary>
public string GameOverReason { get; private set; } = "";
/// <summary>How long the run lasted.</summary>
public TimeSince SinceGameOver { get; private set; }
/// <summary>
/// End the run. The original's `nzRound:GameOver` (round/sv_round.lua:483),
/// reached from its round think when nobody is left up:
///
/// if #player.GetAllPlayingAndAlive() < 1 then self:End()
///
/// ⚠️ Zombies are deliberately NOT cleared. The original leaves them, and it
/// matters: the score screen over an empty map reads as a level transition,
/// over a horde still clawing at you it reads as losing. Stopping the SPAWNER
/// is enough to end the run.
/// </summary>
public void EndGame( string reason = "" )
{
if ( State == RoundState.GameOver ) return;
FinalRound = Round;
FinalPoints = NZPlayer.Local?.Points ?? 0;
GameOverReason = reason;
SinceGameOver = 0f;
// ⚠️ SET BEFORE the players are touched. ForceDown leaves them with a
// bleedout that TickBleedout will look at, and its expiry calls EndGame —
// which now finds the state already GameOver and returns instead of
// recursing.
State = RoundState.GameOver;
Remaining = 0;
// ⚠️ Dying DURING a dog round must not leave the mist over the score
// screen. The original clears fog on ROUND_WAITING; game over reaches
// that state too, but only after the ten second delay, and the fog would
// sit there for all ten of them.
InSpecialRound = false;
Fog?.SetSpecial( false );
// Everyone ends the run on the floor, weaponless. The score screen reads
// as a defeat rather than a pause because the world behind it shows one.
foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
{
if ( !p.IsValid() ) continue;
EndRunFor( p );
}
// ⛔ AND EVERY CLIENT FLOORS ITS OWN PLAYER (2026-09-27), for the reason the new-run reset
// gives — the loop above only reached the host's copies. See `NZNet.RunEnded`.
if ( Networking.IsActive ) NZNet.RunEnded();
// ⛔ BASALT'S BEAST GOES AND ITS EASTER EGG STARTS OVER — *"oberon must despawn on gameover, and all ester egg steps must
// reset back to the start"* (2026-09-27). Left on, the fight went on behind the score screen, spawning him again, and
// froze the next game's first round. Its zombies stay, as every zombie does here (see the summary): only he goes.
HexPlatforms.OnGameReset( "game over", keepZombies: true );
// ⚠️ SHARED — the run ends on the host and every other screen heard nothing.
// ⚠️ THE MAP'S OWN, IF IT HAS ONE (`Gameplay.GameOverSound`, 2026-09-27) — basalt's is Ancient Evil's.
NZSound.PlayShared( NZSound.MapCue( ActiveConfig.Gameplay.GameOverSound, NZSound.GameOver ) );
Log.Warning( $"[nz] GAME OVER — round {FinalRound}, {FinalPoints} points"
+ (string.IsNullOrEmpty( reason ) ? "" : $" ({reason})")
+ $" — lobby in {GameOverHold:0}s" );
}
/// <summary>
/// One player at the end of a run: on the floor, weaponless, and everything the run bought gone.
/// The body of `EndGame`'s per-player loop, which a client runs for its own player through
/// `NZNet.RunEnded` (2026-09-27) — see `ResetPlayerForRun`.
/// </summary>
public static void EndRunFor( NZPlayer p )
{
if ( !p.IsValid() ) return;
p.ForceDown();
p.StripWeapons();
// ⛔️ BOUGHT PERK SLOTS DIE WITH THE RUN. They are a purchase, not a
// setting — carrying them into the next game would mean every run
// after the first starts richer than the map was configured for, and
// the config's allowance would only ever apply once.
p.BonusPerkSlots = 0;
// ⛔️ SO DO ARMOR AND SALVAGE, for the same reason. A tier bought this
// run, the plates carried and the salvage banked are all purchases and
// pickups, not configuration — surviving into the next game would mean
// run two starts in a tier-3 vest with a full wallet.
//
// ⚠️ Armor.Reset clears the TIER as well as the points. Leaving the tier
// while zeroing the armor would look tidy and hand every later run a free
// vest that only needs plating.
Armor.Reset( p );
Salvage.Reset( p );
// ⛔️ AND THE AUGMENTS, WHICH IS NOW THE *ONLY* PLACE THEY DIE. Going down used to clear
// the augments of every perk it took (`NZPlayer.LosePerksOnDown`); by request it no
// longer does, so this line and its twin in `StartGame` are the whole of their
// lifetime. Miss one and augments bought in run one are still equipped in run five.
//
// ⚠️ `ClearAll`, NOT PER-PERK, because the perks themselves are not cleared here — so
// iterating the perk list would leave augments belonging to perks the player never
// dropped. The run is over; everything hung on it goes.
PerkAugments.ClearAll( p );
}
/// <summary>
/// How long the score screen stays up before the map clears and everyone
/// goes back to the lobby.
///
/// ⚠️ The original splits this in two — `gameovertime` (15) plus
/// `gocamerawait` (5) — because it flies a camera around the map first. We
/// have no death camera, so it is one number.
/// </summary>
[Property] public float GameOverHold { get; set; } = 10f;
/// <summary>
/// Tear the run down and hand everyone back to the lobby.
///
/// ⚠️ Zombies are cleared HERE rather than in EndGame. They are deliberately
/// left alive under the score screen — a horde still clawing at you reads as
/// losing where an empty map reads as a level transition — so the clear
/// belongs at the moment the screen goes away, not the moment it appears.
/// </summary>
public void ReturnToLobby()
{
ClearZombies();
// ⛔ AND BASALT'S EASTER EGG BACK AT ITS START, whatever brought the game here (`HexPlatforms.OnGameReset`).
HexPlatforms.OnGameReset( "back to the lobby" );
// ⚠️ Revive before the mode change, not after. A player still flagged
// IsDown is crouched, crawling and invisible to zombies; carrying that
// into the lobby means the next run starts from it.
foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
if ( p.IsValid() ) p.Revive();
State = RoundState.Waiting;
Round = 0;
InSpecialRound = false;
Fog?.SetSpecial( false );
// ⚠️ `SetMode` OPENS THE MENU ITSELF NOW, on every machine — this line used to be the
// only thing that did, which is why a client followed the host into Lobby mode and got no
// lobby. Kept rather than deleted because it is harmless (the setter is idempotent) and
// because removing it would make this method's behaviour depend entirely on a side effect
// two files away.
NZGame.SetMode( GameMode.Lobby );
LobbyState.SetOpen?.Invoke( true );
Log.Info( "[nz] returned to lobby" );
}
/// <summary>Skip to the next round, abandoning this wave.</summary>
public void NextRound()
{
ClearZombies();
BeginPrep();
}
/// <summary>
/// Step back a round.
///
/// ⚠️ Rewinds to the round BEFORE the one we are on, so BeginPrep lands on
/// the previous number. Going back from round 1 stays at 1 — there is no
/// round 0 to test.
/// </summary>
public void PreviousRound()
{
Round = Math.Max( 0, Round - 2 );
ClearZombies();
BeginPrep();
}
/// <summary>Jump straight to a round, for testing a late-game curve without
/// playing thirty waves to reach it.</summary>
public void SetRound( int round )
{
Round = Math.Max( 0, round - 1 );
ClearZombies();
BeginPrep();
}
// ── phases ───────────────────────────────────────────────────────────────
void BeginPrep()
{
State = RoundState.Prep;
var next = Round + 1;
// ⛔ MISERY REMOVES THE GAP ENTIRELY. Asked here rather than pushed in by the device,
// so switching it off needs no undo — see MiseryDevice's remarks.
_nextPhase = MiseryDevice.Running
? 0f
: ( next <= 1 ? FirstPrepTime : PrepTime );
Log.Info( $"[nz] round {next} starting in {(float)_nextPhase:0.0}s" );
BringBackTheBledOut();
}
/// <summary>
/// Anyone who bled out is back on their feet for the new round.
///
/// ⚠️ THE OTHER HALF OF THE CO-OP RULE. `NZPlayer.TickBleedout` stops ending the game
/// while somebody is still up; without this the player it spared would simply stay on the
/// floor for the rest of the run, which is worse than the game-over it replaced. The stated
/// rule is *"the player respawns next round"* — both halves or neither.
///
/// ⛔ BLED OUT ONLY, NOT MERELY DOWNED. A downed player can still be picked up, and that is
/// a live situation with real stakes; sweeping them up here would quietly delete reviving from
/// the game the moment a round happened to end. Bleeding out is the state that has already
/// cost its player the round.
///
/// ⚠️ AND THEY COME BACK AT A SPAWN POINT, not where they fell — which is somewhere a
/// horde was, a round ago.
/// </summary>
void BringBackTheBledOut()
{
// ⛔ CAPTURED BEFORE THE REVIVE, BECAUSE THE REVIVE IS WHAT CLEARS THE FLAG. Asking
// `HasBledOut` again afterwards to decide who to move would find nobody.
//
// ⛔ `IsOutOfRound` TOO, AND WITHOUT IT A CLIENT NEVER CAME BACK AT ALL. `HasBledOut`
// reads `_bledOut`, a plain field written on the machine that did the bleeding — so on the
// HOST a client's proxy has it false forever and this sweep could not even see them.
// `IsOutOfRound` is the half that replicates (`OutOfRoundNet`, published by the owner in
// `TickDownedMirror`), so it is the only one of the two the host can trust about somebody
// else. User: *"the player that bleeds out never respawns."*
//
// ⛔ AND FOR SOMEBODY ELSE'S BODY, `IsOutOfRound` ALONE (2026-09-29). `HasBledOut` on a copy is this machine's own latch —
// game over's `ForceDown` set it on the host's copy of every client and nothing cleared it — so after the first game
// over EVERY round "brought back" every client: a teleport to a spawn, alive, at the start of each round. User: *"the
// client is always respawning at the start of the round, even when it is alive, so it always gets teleported to
// spawn"*. The host log had it plainly: "1 player(s) back … (0 here, 1 asked)" at every round after each restart.
var back = PlayerSpawner.AllBodies()
.Where( go => go.Components.Get<NZPlayer>( FindMode.EverythingInSelf )
is { IsValid: true } p
&& (p.IsOutOfRound || (p.HasBledOut && !(Networking.IsActive && PlayerPresence.Theirs( go )))) )
.ToList();
if ( back.Count == 0 ) return;
// ⛔ AND THE REVIVE ITSELF HAS TO HAPPEN ON THE OWNER'S MACHINE. That was the second half
// of the same bug: even once the host could see them, `Revive()` on a proxy writes health,
// perks, the crouch release and `IsOutOfRound` into the host's copy of a body it does not
// drive. The client went on sitting out, invisible and frozen, for the rest of the game.
//
// ⚠️ ONLY THE REVIVE IS RELAYED. Placement is untouched and stays below: `PlaceAll` →
// `MoveTo` already sends a remote body's spot through `PlaceAt`, so the host goes on
// deciding where everyone stands and there is still one author for it.
var asked = 0;
foreach ( var go in back )
{
if ( Networking.IsActive && PlayerPresence.Theirs( go )
&& NZPlayers.OwnerOf( go ) is { Length: > 0 } owner )
{
NZNet.ComeBack( owner );
asked++;
continue;
}
go.Components.Get<NZPlayer>( FindMode.EverythingInSelf ).Revive();
}
Log.Info( $"[nz] {back.Count} player(s) back for round {Round + 1}"
+ $" ({back.Count - asked} here, {asked} asked)" );
// ⛔ ONLY THE PLAYERS COMING BACK. This was a bare `PlaceAll()`, which moves EVERY body
// in the scene — so one player returning from a bleedout teleported the whole team to the
// spawn points, mid-game, wherever they happened to be fighting. User: *"when a player
// respawns all players are teleported to the player spawns, this should not happen."*
//
// ⚠️ THE OTHER FOUR CALLERS STILL PASS NOTHING and still move everybody, which is right:
// they start a game or load a map, where everyone SHOULD be at a spawn.
PlayerSpawner.PlaceAll( only: back.Contains );
}
/// <summary>
/// Whether a given round is a special round.
///
/// ⚠️ REQUIRES A PLACED SPAWNER. Without one there is nowhere to put a hound,
/// and a "special round" that spawns nothing is an unwinnable round — the wave
/// never empties, so it never ends. Falling back to a normal wave is the only
/// safe answer, and it is what an unconfigured map gets.
/// </summary>
/// <remarks>
/// ⚠️ DELEGATED TO `MapConfig` SINCE THE BOSS SCHEDULE NEEDED THE SAME ANSWER. The arithmetic
/// and the placed-spawner rule both live there now, so the two callers cannot drift (§3).
/// </remarks>
public bool IsSpecialRound( int round ) => ActiveConfig.Current.IsSpecialRound( round );
/// <summary>True while the round being fought is a special one.</summary>
public bool InSpecialRound { get; private set; }
void BeginRound()
{
Round++;
State = RoundState.Active;
RoundKills = 0;
_spawnUse.Clear();
// The howl. 2D on purpose — it is not coming from anywhere in the map.
// ⚠️ SHARED — the wave loop runs on the host alone; a client is told the NUMBER
// through `RoundNow` and never heard the round begin.
// ⚠️ NOT IN A SPECIAL ROUND THAT TURNS IT OFF (`Specials.RoundSounds`) — asked of the round number, since
// `InSpecialRound` is only set further down
// ⚠️ THE MAP'S OWN, IF IT HAS ONE (`Gameplay.RoundStartSound`, 2026-09-27) — basalt's is Ancient Evil's.
if ( !IsSpecialRound( Round ) || SpecialRoundSounds )
NZSound.PlayShared( NZSound.MapCue( ActiveConfig.Gameplay.RoundStartSound, NZSound.RoundStart ) );
// ⚠️ BEFORE the wave is sized, not after, so an augment cannot be skipped by any
// of the early returns further down — the special-round branch returns before the
// end of this method, which is exactly the §4 shape (an early-out gating
// everything below it) that has caught this project out before.
AugmentEffects.OnRoundStart();
// ⚠️ BESIDE THE AUGMENT HOOK AND FOR THE SAME REASON — above every early return further
// down, including the special-round branch. The ammo box's price escalation is per round,
// so a round that skipped this would carry the previous round's prices forward and read as
// the box simply becoming unaffordable.
AmmoBox.OnRoundStart();
EggInteractable.OnRoundStart();
// ⚠️ BASALT SEAL 1 — round 1's slam platforms are picked here (every later round was reset when the one
// before it was cleared). Above every early return with the hooks around it.
HexPlatforms.OnRoundStart( Round );
// ⚠️ BESIDE THE OTHERS, ABOVE EVERY EARLY RETURN. The player's post-hit immunity window
// shrinks with the round, and it is the one number that sets how much damage the whole
// horde can land per second — a round that skipped this would run at the previous
// round's ceiling with no sign anything was wrong.
NZPlayer.OnRoundStart();
// ⚠️ THE SPAWN COST TABLE IS KEYED BY POSITION AND THE ELIGIBLE SET CHANGES WITH THE ROUND.
// A newly-eligible spawner would miss the dictionary and quietly fall back to flat distance
// until the next refresh — correct, but it would be the old behaviour on exactly the
// spawners a new round just opened up.
InvalidateSpawnCosts();
// ⚠️ AFTER the per-step reset, not before. A step group's deadline is the harder rule
// — it wipes every member regardless of their own "Reset each round" setting — so it runs
// last and has the final say on what survives the turn.
EggGroups.OnRoundStart();
var players = Math.Max( 1, Game.ActiveScene.GetAllComponents<NZPlayer>().Count() );
InSpecialRound = IsSpecialRound( Round );
var sp = ActiveConfig.Current.Specials;
// ⚠️ THE MATCH'S HORDE SIZE (the lobby's Difficulty, 2026-10-05), a special round's count too
WaveTotal = Math.Max( 1, (int)MathF.Round( (InSpecialRound
? Math.Max( 1, sp.CountPerRound )
: ZombieStats.WaveTotal( Round, players )) * Difficulty.HordeSize ) );
Remaining = WaveTotal;
// ⚠️ ROUND 1 WAITS (`Gameplay.FirstRoundDelay`) — the game has only just faded up out of the black, and the first zombie
// should not be through a window before anyone has looked round. A special round sets its own wait below.
_nextSpawn = Round == 1 ? ActiveConfig.Gameplay.FirstRoundDelay : 0f;
// ⚠️ The fog and the announcement are driven from HERE rather than from
// the state change, because both are specific to the round being special
// and the state change knows only that a round started.
Fog?.SetSpecial( InSpecialRound && SpecialFogOn );
// ⚠️ A MAP CAN SILENCE THE CALL (`Specials.Announce`) — marked as made, so the timer below never fires it
_specialAnnounced = !InSpecialRound || !(ActiveConfig.Current?.Specials?.Announce ?? true);
if ( InSpecialRound )
{
// The original's two timers, both from sv_round.lua:298-306.
_specialAnnounce = 3f; // Samantha, into silence
_nextSpawn = 6f; // first hound, well after her
// ⚠️ THE SET IT WILL ACTUALLY DRAW FROM. Printing the special spawner count on a map
// that fields its special round through the ordinary windows reports a number with no
// bearing on what is about to happen — and usually the number zero.
var fromNormal = sp.UseZombieSpawns;
Log.Info( $"[nz] ROUND {Round} — SPECIAL — {WaveTotal} from "
+ $"{( fromNormal ? ActiveConfig.Current.ZombieSpawns.Count
: ActiveConfig.Current.SpecialSpawns.Count )}"
+ $" {( fromNormal ? "zombie" : "special" )} spawner(s), "
+ $"hp x{sp.HealthMultiplier:0.##}, speed x{sp.SpeedMultiplier:0.##}, "
+ $"max alive {sp.MaxAlive}, every {sp.SpawnDelay:0.00}s" );
return;
}
Log.Info( $"[nz] ROUND {Round} — {WaveTotal} zombies, "
+ $"{ZombieStats.HealthForRound( Round )} hp, "
+ $"speed rating {ZombieStats.SpeedForRound( Round )} "
+ $"({WalkerAnimations.TierName( ZombieStats.SpeedForRound( Round ) )}), "
+ $"max alive {ZombieStats.MaxAliveForRound( Round )}, "
+ $"every {ZombieStats.SpawnDelayForRound( Round ):0.00}s" );
SpawnScheduledBosses();
}
/// <summary>
/// Put out this round's bosses, if it is a boss round.
///
/// ⛔️ THE STEP THAT WAS NEVER BUILT, AND THE FILE SAID SO. BossCommands' own header read "THE
/// SCHEDULE EXISTS AS DATA; NOTHING CONSULTS IT YET … a boss still only appears via
/// nz_boss_spawn or the dev menu button", and listed the intended order: spawn points, then the
/// boss, then the schedule as data, then the round loop that reads it. This is that last step.
/// Boss rounds were not failing to spawn bosses; nothing had ever asked them to.
///
/// ⛔️ BossArrivesOn IS THE TEST, NOT Bosses.DueOn. The config's version also excludes special
/// rounds and requires at least one boss spawner to exist — both of which BossPreview already
/// reports, so using the same call is what stops the dev menu's preview from disagreeing with
/// what actually happens.
///
/// ⚠️ BOSSES ARE EXTRA, NOT PART OF THE WAVE. Remaining and WaveTotal are untouched, so a boss
/// round still contains its full complement of ordinary zombies and the round-clear test still
/// waits for all of them. Drawing bosses from the wave budget would make a boss round EASIER
/// than the round before it.
///
/// ⚠️ NOT CALLED FOR A SPECIAL ROUND, because this sits after the special-round early return
/// above — and BossArrivesOn would refuse anyway. Two guards agreeing is deliberate here: the
/// early return is about control flow and could be moved, the test is about the rule.
/// </summary>
void SpawnScheduledBosses()
{
var cfg = ActiveConfig.Current;
if ( cfg is null || !cfg.BossArrivesOn( Round ) ) return;
var want = Math.Max( 1, ActiveConfig.Bosses.CountForRound( Round ) );
// ⚠️ Nearest player first, same level preferred — the round-robin below then hands the
// first boss the closest point rather than whichever one the mapper happened to place
// first. See BossSpawnsNearestFirst.
var points = BossSpawnsNearestFirst();
if ( points.Count == 0 )
{
// ⚠️ NAMES BOTH REASONS, matching nz_bosses. "None placed" and "all gated behind a door
// you have not opened" need opposite fixes and read identically as an empty list.
Log.Warning( $"[nz-boss] round {Round} is a boss round but no boss spawn is usable"
+ $" — {cfg.BossSpawns.Count} placed, all gated by round or power (nz_bosses)" );
return;
}
var made = 0;
var picked = new List<string>();
for ( var i = 0; i < want; i++ )
{
// ⚠️ THE MAP'S POOL FIRST, when it has one (`BossSettings.Pool`, 2026-10-07): a random boss, never one picked this
// round or still alive. Null without a pool — the point's own boss, as before.
var name = PickPooledBoss( picked );
if ( name is not null ) picked.Add( name );
// ⚠️ ROUND-ROBIN THROUGH THE POINTS rather than one boss per point. The count and the
// number of spawners are independent settings, and two bosses with one spawner placed is
// a legitimate configuration that should not silently drop one.
if ( SpawnBossAt( points[i % points.Count], name ).IsValid() ) made++;
}
Log.Info( $"[nz-boss] round {Round}: {made}/{want} boss(es) spawned"
+ $" from {points.Count} usable point(s)"
+ (picked.Count > 0 ? $" — from the map's pool: {string.Join( ", ", picked )}" : "") );
if ( made < want )
Log.Warning( $"[nz-boss] {want - made} boss(es) failed to spawn — nz_bosses for why" );
}
/// <summary>The fog, created on this object and self-healing. Null until the
/// first frame — every caller uses `?.`, which is why.</summary>
public SpecialFog Fog { get; private set; }
/// <summary>Does this map's special round play the round's start and end sounds? `Specials.RoundSounds`, on unless turned off.</summary>
static bool SpecialRoundSounds => ActiveConfig.Current?.Specials?.RoundSounds ?? true;
/// <summary>Does this map's special round bring its fog? `Specials.Fog`, on unless a map turns it off.</summary>
static bool SpecialFogOn => ActiveConfig.Current?.Specials?.Fog ?? true;
/// <summary>
/// The special round's own loop (`Specials.Loop`), on EVERY machine from the round it holds: started as the wave begins,
/// kept going, stopped the moment it is over — the break after it, a game over, the lobby. Through `NZMusic`, which plays one
/// track at a time and loops a sound by restarting it.
///
/// ⚠️ ONLY THE LOOP IT STARTED IS EVER STOPPED (`_loopCue`). The first version stopped the cue on every frame that was not
/// the special round, so a preview with `nz_music nz.music.pest` died the frame it began, and a loop cleared mid-round
/// never stopped at all. Anything else `NZMusic` plays — the lobby's, a preview — is left to whoever started it.
/// </summary>
void TickSpecialLoop()
{
var loop = ActiveConfig.Current?.Specials?.Loop?.Trim();
var want = InSpecialRound && State == RoundState.Active && !string.IsNullOrEmpty( loop ) ? loop : null;
// the round over, or its loop changed: the one this started stops
if ( _loopCue is not null && _loopCue != want )
{
if ( NZMusic.Current == _loopCue ) NZMusic.Stop();
_loopCue = null;
}
if ( want is null ) return;
NZMusic.Play( want );
NZMusic.Tick();
_loopCue = want;
}
/// <summary>The special round's loop this started and is keeping going, or null.</summary>
string _loopCue;
/// <summary>
/// Which announcement this map's special round gets.
/// </summary>
///
/// ⛔ READ FROM THE CONFIG'S OWN `Specials.Enemy`, NOT FROM A SECOND LIST. The map already
/// declares what its special round spawns; a parallel table mapping map to cue would be a
/// second answer to a question that is already answered, and the one that goes stale.
///
/// ⚠️ ANYTHING UNRECOGNISED FALLS BACK TO THE HELLHOUND LINE rather than to silence. A
/// special round with no warning at all is the failure this is fixing, so an approximate
/// warning beats none while a new enemy waits for its own recording.
static string SpecialAnnouncement()
{
var enemy = ActiveConfig.Current?.Specials?.Enemy;
return string.Equals( enemy, "pest", System.StringComparison.OrdinalIgnoreCase )
? NZSound.AnnouncerPest
: NZSound.AnnouncerSpecial;
}
protected override void OnUpdate()
{
// ⛔️ GetOrCreate FROM OnUpdate. A component created in OnStart does not
// survive a hotload, and a fog that quietly stops working after a code
// edit is a bug you chase in the wrong place. See SpecialFog.
Fog ??= Components.GetOrCreate<SpecialFog>();
// Samantha, three seconds in. Guarded by a bool as well as the timer so
// she cannot repeat: TimeUntil stays negative once it has elapsed.
if ( !_specialAnnounced && _specialAnnounce <= 0f )
{
_specialAnnounced = true;
// ⚠️ SHARED — the one cue that tells a player what is about to come through the
// door, so it has to name the right thing.
NZSound.PlayShared( SpecialAnnouncement() );
}
// ⚠️ AND THE SPECIAL ROUND'S LOOP, HERE ABOVE THE CLIENT LINE with the fog and the call: presentation, driven by the round
// this machine was told.
TickSpecialLoop();
// ⛔ A CLIENT DOES NOT RUN THE WAVE LOOP — IT IS TOLD THE ANSWER. Everything below
// decides things from the state of this machine's world, and a client's world has no
// zombies in it: the `Remaining <= 0 && AliveBlocking <= 0` test in the Active case is
// true on its very first frame, so its copy cleared round 1 immediately and then sprinted
// through the whole game in seconds. That is what "the rounds don't exist on the client"
// actually was — not a missing manager, a manager running against an empty map.
//
// ⚠️ THE FOG AND THE ANNOUNCER ARE DELIBERATELY ABOVE THIS LINE. They are presentation
// driven by state that has been handed to us, and a client should see and hear a special
// round like everyone else.
if ( NZGame.IsClient ) return;
// ⚠️ AND THE HOST TELLS EVERYONE, EVERY FRAME THE NUMBERS MOVE. Sent from here rather
// than from the twenty places that change them: one author, and no way to add a
// twenty-first that forgets.
PushRound();
// ⚠️ THE MAP'S OWN TREMORS, ON THE HOST'S CLOCK — every 5 to 15 minutes of a game (`AmbientTremor`). Above the fight's
// return below, so the clock runs on through it; one that comes due then waits for the fight to end.
AmbientTremor.Tick( this );
// ⛔ EVERYBODY DOWN IS GAME OVER, WHICHEVER MACHINES THE DOWNS LANDED ON (2026-09-27) — in the boss fight too,
// so above its return. See `NZPlayer.TickEverybodyDown`.
NZPlayer.TickEverybodyDown( this );
// ⛔ BASALT'S BOSS FIGHT FREEZES THE ROUND WHERE IT IS — *"the round freezes, stays where it is"*: no spawning, no clear,
// no countdown to the next (`HexPlatforms.FreezesRound`); the fight spawns its own. A game can still end.
if ( HexPlatforms.FreezesRound && State != RoundState.GameOver ) return;
switch ( State )
{
case RoundState.Prep:
if ( _nextPhase <= 0f ) BeginRound();
break;
// ⚠️ Driven off SinceGameOver, the same TimeSince the score screen
// reads. A separate timer could drift from the number on screen, and
// this one is already stamped in EndGame.
case RoundState.GameOver:
if ( SinceGameOver >= GameOverHold ) ReturnToLobby();
break;
case RoundState.Active:
TickSpawning();
// ⚠️ AFTER TickSpawning, deliberately: a zombie that spawned this very frame is
// then already on the map for the count below, so the last one out of a spawner
// is not missed for a whole tick.
TickLastSprint();
// The wave ends when everything has spawned AND been killed.
// Checking only one of those ends it early, either the instant
// the last one spawns or while a queue is still waiting.
//
// ⚠️ AliveBlocking, NOT Alive — a boss left standing does not hold the round open.
// See AliveBlocking for why. A boss that survives simply carries on into the next
// round, which is the whole point of it.
//
// ⛔ AND BASALT'S ALTAR DEFENSE DOES HOLD IT, however few are left: *"the round never ends during this
// time"* (`HexPlatforms.HoldsRound`). Its wave counts as alive too, but between two of its zombies there
// can be none.
if ( Remaining <= 0 && AliveBlocking <= 0 && !HexPlatforms.HoldsRound )
{
Log.Info( $"[nz] round {Round} cleared"
+ (BossesAlive > 0 ? $" — {BossesAlive} boss(es) still alive, carrying over" : "") );
// ⚠️ Before BeginPrep, not after. BeginPrep logs the NEXT
// round's countdown, and firing the end sting after it reads
// as the new round announcing itself.
// ⚠️ SHARED — see RoundStart above.
// ⚠️ AND NOT AT THE END OF A SPECIAL ROUND THAT TURNS IT OFF (`Specials.RoundSounds`)
// ⚠️ THE MAP'S OWN, IF IT HAS ONE (`Gameplay.RoundEndSound`, 2026-09-27)
if ( !InSpecialRound || SpecialRoundSounds )
NZSound.PlayShared( NZSound.MapCue( ActiveConfig.Gameplay.RoundEndSound, NZSound.RoundEnd ) );
// ⛔️ ONE GRENADE BACK, PER ROUND — the interim supply while there
// is no powerup system. The chosen rule was "Max Ammo refills",
// and Max Ammo does not exist; without something, grenades are two
// per run and nobody ever throws the second.
//
// ⚠️ ONE, NOT A TOP-UP TO FULL. Surviving a round should return a
// grenade you spent, not reward hoarding — a refill to `MaxCount`
// would mean the player who threw none and the player who threw
// four both start the next round identically.
//
// ⚠️ HERE, at the CLEAR, rather than in BeginPrep — prep also runs
// for round 1 and at a manual round set, either of which would hand
// out grenades for a round nobody fought.
foreach ( var nade in Scene.GetAllComponents<Grenade>() )
{
if ( !nade.IsValid() || nade.Count >= nade.MaxCount ) continue;
nade.Count++;
Log.Info( $"[nz] round bonus: grenade ({nade.Count}/{nade.MaxCount})" );
}
// ⛔ BASALT SEAL 1 — an unfinished step fully resets at every round END: every tile out, four new
// picks. Here at the clear, beside the grenade bonus, and not in BeginPrep, which also runs for round 1.
HexPlatforms.OnRoundEnd();
// …and its hex slots roll again: every slot's number and colour, and every colour's number, change from
// the round before.
HexSlotManager.OnRoundEnd( Round );
BeginPrep();
}
break;
}
}
// ── mirroring ─────────────────────────────────────────────────────
/// <summary>The last figures actually sent, so an unchanged frame sends nothing.</summary>
(int Round, RoundState State, int Remaining, int WaveTotal, bool Special, int Left, int Kills) _lastSent
= (-1, RoundState.Waiting, -1, -1, false, -1, -1);
/// <summary>
/// Tell every other machine what round it is, if it has changed since the last time.
///
/// ⚠️ CHANGE-DETECTED RATHER THAN RATE-LIMITED. A round is mostly still — these five
/// numbers move on a spawn, a kill and a phase change and are otherwise constant for
/// seconds at a time — so "send when different" is both cheaper than a timer and exactly
/// as prompt, with no interval to tune.
/// </summary>
void PushRound()
{
if ( !Networking.IsActive ) return;
// ⚠️ AND THE ROUND BAR'S TWO: a kill changes neither the wave nor what is still to spawn, so without them a client's bar
// would move only on spawns
var now = (Round, State, Remaining, WaveTotal, InSpecialRound, ZombiesLeft, RoundKills);
if ( now == _lastSent ) return;
_lastSent = now;
NZNet.RoundNow( Round, (int)State, Remaining, WaveTotal, InSpecialRound, ZombiesLeft, RoundKills );
}
/// <summary>
/// Take the host's figures. Client only — `NZNet.RoundNow` is the only caller.
///
/// ⚠️ IT WRITES THE FIELDS AND NOTHING ELSE. `BeginRound`, `BeginPrep` and `EndGame` all
/// have side effects — they rebuild managers, spawn bosses, reset wallets — and routing a
/// mirrored value through them would make every client re-run the host's game logic against
/// its own world. The phase CHANGES that a client genuinely needs to act on (the round sting,
/// the game-over screen) are separate messages, or will be.
/// </summary>
public void ApplyMirror( int round, RoundState state, int remaining, int waveTotal, bool special, int left, int kills )
{
// ⚠️ ANNOUNCED ONCE PER CHANGE, not per message, because the host sends on every
// spawn and kill — a line per message would be a line per zombie.
if ( round != Round || state != State )
Log.Info( $"[nz-net] round {round} · {state}" );
// ⛔ THE CLIENT'S ROUND-START AUGMENTS FIRE HERE, BECAUSE NOTHING ELSE EVER FIRES THEM.
// `BeginRound` runs on the host alone, so `AugmentEffects.OnRoundStart` never executed on a
// client — Juggernog's M2 Plated Up and Mule Kick's round hook were dead for everybody but
// the host. This is the client's equivalent of that line, and the hook itself now only
// touches the body this machine owns, so the two cannot overlap.
//
// ⚠️ ON THE ROUND NUMBER GOING UP, not on every message. `RoundNow` is sent on every
// spawn and every kill; refilling armour sixty times a round is a different augment.
var advanced = round > Round;
Round = round;
State = state;
Remaining = remaining;
WaveTotal = waveTotal;
InSpecialRound = special;
_leftMirror = left;
RoundKills = kills;
// ⛔ THE FLAG ARRIVED AND NOTHING EVER LOOKED AT IT. `InSpecialRound` was set here and
// read nowhere on a client — the host drives the fog from `BeginRound` (`Fog?.SetSpecial(
// InSpecialRound )`), which a client never runs. So a client fought a hellhound round in
// clear air, with no sign it was one. User: *"the hellhound rounds do not have the fog."*
//
// ⚠️ DRIVEN FROM THE MIRROR RATHER THAN RELAYED SEPARATELY. The round state already
// crosses on every change and the fog is a function of it; a second message would be a
// second thing that can disagree with the first.
//
// ⚠️ `SetSpecial` IS A TARGET, NOT A SWITCH — `SpecialFog` lerps toward it — so calling
// it on every mirror costs nothing and cannot flicker.
Fog?.SetSpecial( special && SpecialFogOn );
if ( advanced && !NZGame.IsHost )
{
AugmentEffects.OnRoundStart();
// ⚠️ MIRRORED ON THE CLIENT TOO even though the host is what applies the damage. A
// client reads its own Health for the HUD and for anything that asks "am I still in
// the window"; leaving it on round 1's value there would make the two disagree about
// a number they can both see.
NZPlayer.OnRoundStart();
}
}
// ── spawning ──────────────────────────────────────────────────────
/// <summary>
/// Push the last few zombies of a round up to a sprint.
///
/// ⛔️ RE-CHECKED EVERY TICK RATHER THAN FIRED ONCE. While the count is at or below the
/// threshold, `Remaining` can still be above zero -- so more zombies may yet spawn INTO the
/// last few, and a one-shot sweep would leave those walking while the ones already out sprint.
/// The work is trivial: it only runs once a round is nearly over, and RaiseSpeedRating is a
/// no-op on a zombie already at or above the tier.
///
/// ⛔️ BOSSES ARE EXCLUDED, matching AliveBlocking. A boss is not part of the wave and does not
/// hold the round open, so it is not one of "the last four" -- and its speed is authored on
/// its variant, which this has no business overruling.
/// </summary>
void TickLastSprint()
{
if ( LastSprintCount <= 0 || Round < LastSprintFromRound ) return;
if ( Remaining + AliveBlocking > LastSprintCount ) return;
var raised = 0;
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;
if ( z.Variant?.IsBoss ?? false ) continue;
if ( z.RaiseSpeedRating( WalkerAnimations.SprintRating ) ) raised++;
}
if ( raised > 0 )
Log.Info( $"[nz] last {LastSprintCount} of round {Round} — {raised} sprinting"
+ $" ({Remaining} still to spawn, {AliveBlocking} on the map)" );
}
/// <summary>
/// `nz_last_sprint [count] [fromRound]` — read or tune the straggler sprint.
///
/// ⛔️ EVERY BEHAVIOUR GETS A COMMAND, the standing rule this file already follows: nobody can
/// play a round over MCP, so a behaviour with no command cannot be demonstrated or ruled out
/// when something else looks wrong.
/// </summary>
[ConCmd( "nz_last_sprint" )]
public static void LastSprintCmd( int count = -1, int fromRound = -1 )
{
if ( count >= 0 ) LastSprintCount = count;
if ( fromRound >= 0 ) LastSprintFromRound = fromRound;
Log.Info( LastSprintCount <= 0
? "[nz] straggler sprint OFF (nz_last_sprint 4 to restore)"
: $"[nz] last {LastSprintCount} zombie(s) of a round sprint,"
+ $" from round {LastSprintFromRound}"
+ $" (rating {WalkerAnimations.SprintRating:0})" );
var rm = Game.ActiveScene?.GetAllComponents<RoundManager>().FirstOrDefault();
if ( rm.IsValid() )
Log.Info( $"[nz] round {rm.Round} — {rm.Remaining} to spawn +"
+ $" {rm.AliveBlocking} alive = {rm.Remaining + rm.AliveBlocking}"
+ $" {(rm.Remaining + rm.AliveBlocking <= LastSprintCount ? "<= ACTIVE NOW" : "> not yet")}" );
}
void TickSpawning()
{
if ( Remaining <= 0 ) return;
if ( _nextSpawn > 0f ) return;
// ⚠️ The cap is a POPULATION TARGET, not a rate limit. While at the cap
// the timer is deliberately NOT pushed forward, so it sits expired and a
// death triggers an instant replacement. That is what produces the
// "horde is always exactly N" feel rather than a trickle. Reference
// doc §1, from sv_spawner.lua.
var sp = ActiveConfig.Current.Specials;
// ⛔ A BURST IS A LOOP HERE, NOT A SMALLER DELAY. The wait below is floored at 0.05s so a
// misconfigured map cannot pin the frame, which means "fifty at once" is unreachable by
// tuning the delay alone. Spawning several per tick says what it means — and for the
// default `SpawnsPerTick` of 1 this loop runs exactly once, which is the old code.
var burst = InSpecialRound ? Math.Max( 1, sp.SpawnsPerTick ) : 1;
var made = 0;
for ( var i = 0; i < burst; i++ )
{
// ⚠️ THE CAP IS INSIDE THE LOOP. Testing it once before the burst would let a
// `SpawnsPerTick` of 50 blow straight through a `MaxAlive` of 8 on the first tick,
// which is the one thing the cap exists to prevent.
if ( Alive >= (InSpecialRound
? Math.Max( 1, sp.MaxAlive )
: ZombieStats.MaxAliveForRound( Round )) )
break;
if ( InSpecialRound ? !SpawnOneSpecial() : !SpawnOne() ) break;
made++;
Remaining--;
if ( Remaining <= 0 ) break;
}
// ⚠️ THE TIMER IS ONLY PUSHED WHEN SOMETHING ACTUALLY SPAWNED, which preserves the
// population-target behaviour the block above describes: at the cap the timer sits expired
// so a death triggers an instant replacement, rather than the horde trickling back.
if ( made == 0 ) return;
// ⛔ MISERY PINS THE DELAY TO THE CONFIGURED MINIMUM, for special rounds too — the
// device says "spawn as fast as this map allows", and a special round exempting
// itself would be the one place misery quietly did less.
// ⚠️ THE MATCH'S SPAWN RATE DIVIDES THE WAIT (the lobby's Difficulty, 2026-10-05), a special round's too, never under the
// 0.05s floor; misery's pinned minimum is misery's.
_nextSpawn = MiseryDevice.Running
? Math.Max( 0.05f, ActiveConfig.Zombies.SpawnDelayMin )
: Math.Max( 0.05f, (InSpecialRound
? Math.Max( 0.05f, sp.SpawnDelay )
: ZombieStats.SpawnDelayForRound( Round )) / MathF.Max( 0.01f, Difficulty.SpawnRate ) );
}
/// <summary>
/// The spawns usable right now — link open, power satisfied, round reached.
///
/// This is what makes buying a door change the game: a spawn behind locked
/// debris is simply not in this list, so the horde only comes from the part
/// of the map you have paid to open.
/// </summary>
public List<SpawnPoint> EligibleSpawns => EligibleSpawnsFor( Round );
/// <summary>
/// How strongly a nearer spawn is preferred. 0 is uniform — the old behaviour.
///
/// ⚠️ TUNABLE AND REVERSIBLE ON PURPOSE. Spawn pressure is a feel question, not a correctness
/// one, so `nz_spawn_bias 0` restores exactly what this replaced and there is a number to argue
/// about rather than a rewrite to undo.
/// </summary>
public static float NearSpawnBias
{
get => _nearSpawnBias ??= 1.5f;
set => _nearSpawnBias = value;
}
static float? _nearSpawnBias;
/// <summary>
/// Distance added to every spawn before weighting, in units.
///
/// ⛔️ WITHOUT THIS THE NEAREST SPAWN WOULD SWALLOW THE WAVE. Weighting by a raw 1/distance means
/// a spawner 60 units away outweighs one 3000 units away by fifty to one before the bias
/// exponent is even applied, so the horde would arrive from a single doorway and the other
/// spawners would effectively stop existing. Softening by ~a room's width keeps the preference a
/// preference: at the defaults, 100u beats 3000u by about 14x rather than 164x.
/// </summary>
public static float NearSpawnSoftening
{
get => _nearSpawnSoftening ??= 512f;
set => _nearSpawnSoftening = value;
}
static float? _nearSpawnSoftening;
/// <summary>
/// Pick a spawn, favouring ones near a player.
///
/// ⛔️ NEAREST PLAYER, NOT AVERAGE OR FIRST. With players split across the map an average sits
/// between them — often somewhere neither of them is, and behind a locked door as often as not —
/// so every spawner would read as equally far and the weighting would do nothing in exactly the
/// case it matters most. Distance to the closest player is what "close to the player" means when
/// there is more than one.
///
/// ⚠️ FALLS BACK TO UNIFORM if there are no players or the weights come out degenerate, rather
/// than biasing toward index 0. A silent shift to "always the first spawner" would look like the
/// weighting working.
///
/// ⛔ "DISTANCE" IS THE WALKED PATH NOW, NOT A STRAIGHT LINE — see NearestPlayerCost. This note
/// used to read "FLATTENED DISTANCE: a spawner one floor up is not further away in any sense the
/// player experiences", which was an APPROXIMATION OF REACHABILITY and failed in the case it was
/// meant to cover: the deck below the player is 100 flat units away and a lap of the ship on
/// foot. Flattening z only hid the axis the problem lived on. The path measures the thing the
/// flattening was standing in for, so the approximation is gone and so is its failure mode.
/// </summary>
/// <summary>
/// A spawn to put a zombie at, chosen the same way the wave chooses one.
///
/// ⚠️ THE SAME PICKER THE WAVE USES, not a second one. A relocated zombie that arrived somewhere
/// the wave would never have used would make the anti-stuck system visible as a different kind of
/// arrival, and any bias tuning would then only apply to half the zombies.
/// </summary>
public SpawnPoint PickWaveSpawn()
{
var spawns = EligibleSpawns;
return spawns.Count == 0 ? null : PickSpawnNearPlayers( spawns );
}
/// <summary>
/// Pick a spawn point the way the game picks spawn points — weighted toward the players.
///
/// ⚠️ PUBLIC SO `AmbientSpecials` CAN USE THE SAME ONE. A second notion of "where should a
/// zombie come from" is the thing this codebase keeps writing up as a mistake: the ambient
/// napalm and Shrieker must arrive the way the horde arrives, or they read as teleporting in
/// from somewhere the player was not watching.
/// </summary>
public SpawnPoint PickSpawnNearPlayers( List<SpawnPoint> spawns )
{
if ( spawns.Count == 1 ) return spawns[0];
SpawnPoint Uniform() => spawns[Game.Random.Int( 0, spawns.Count - 1 )];
if ( NearSpawnBias <= 0f ) return Uniform();
var players = Scene.GetAllComponents<NZPlayer>()
.Where( pl => pl.IsValid() && !pl.IsDown )
.Select( pl => pl.WorldPosition )
.ToList();
if ( players.Count == 0 ) return Uniform();
var weights = new float[spawns.Count];
var total = 0f;
for ( int i = 0; i < spawns.Count; i++ )
{
var nearest = NearestPlayerCost( spawns[i], players );
weights[i] = MathF.Pow( 1f / (nearest + NearSpawnSoftening), NearSpawnBias );
total += weights[i];
}
if ( total <= 0f || float.IsNaN( total ) || float.IsInfinity( total ) ) return Uniform();
// ⚠️ Walks the cumulative weights rather than sorting — one pass, no allocation beyond the
// weight array, and it runs once per zombie spawned rather than per frame.
var roll = Game.Random.Float( 0f, total );
for ( int i = 0; i < spawns.Count; i++ )
{
roll -= weights[i];
if ( roll <= 0f ) return spawns[i];
}
// Floating-point slack only — the loop above should always have returned.
return spawns[^1];
}
// ── how far a spawn is from a player ─────────────────────────────────────
/// <summary>
/// Measure spawn distance along the NAVMESH rather than through walls.
///
/// ⛔ STRAIGHT-LINE DISTANCE IS WRONG ON ANY MAP WITH MORE THAN ONE FLOOR, and it was wrong in
/// the exact case the bias exists to handle. A spawner on the deck below the player is ~100
/// units away and wins the roll outright — but the zombie that arrives there may have to walk
/// the length of the ship to find a staircase. The player gets a spawn flagged as "right next
/// to you" and then forty seconds of nothing, which is worse than uniform: uniform at least
/// picks somewhere that might be connected.
///
/// ⚠️ THIS SUPERSEDES THE "FLATTENED DISTANCE" RULE THAT USED TO LIVE ON THE PICKER. That note
/// argued z should be ignored because "a spawner one floor up is not further away in any sense
/// the player experiences" — true of the STAIRWELL one floor up, false of the one that needs a
/// lap of the map. Flattening was an approximation of reachability; the path IS reachability,
/// so the approximation is not needed and its failure mode goes with it.
///
/// ⚠️ FALLS BACK TO FLAT DISTANCE, NOT TO EXCLUSION, when a path cannot be found. An unreachable
/// spawn is usually a door that has not opened yet or a navmesh that has not finished baking —
/// both temporary — and a wave that refuses to use half its spawners starves rather than
/// degrades. `nz_spawn_bias` reports how many pairs fell back so a permanently disconnected
/// spawner is visible rather than silently down-weighted.
/// </summary>
public static bool PathWeighted
{
get => _pathWeighted ??= true;
set { _pathWeighted = value; InvalidateSpawnCosts(); }
}
static bool? _pathWeighted;
/// <summary>
/// How long a computed cost table stays good, in seconds.
///
/// ⛔ THE CACHE IS NOT AN OPTIMISATION, IT IS WHAT MAKES THIS POSSIBLE AT ALL. `CalculatePath`
/// documents itself as "not free" and `ZombiePathDebug` states plainly that nothing may call it
/// from a think. At round 63 the spawn delay floor is 0.08s — twelve spawns a second — and each
/// pick needs one query per spawn per player. Eight spawners and four players is 32 queries; at
/// twelve picks a second that is 384 path solves per second for a number that changes when
/// somebody walks across a room.
///
/// ⚠️ SO THE TABLE IS BUILT AT MOST ONCE PER INTERVAL AND REUSED BY EVERY PICK INSIDE IT,
/// which puts the real cost at 32 queries a second in the worst case and usually far less.
/// </summary>
public static float PathCostRefresh
{
get => _pathCostRefresh ??= 1f;
set => _pathCostRefresh = value;
}
static float? _pathCostRefresh;
/// <summary>
/// How far a player may move before the table is rebuilt early, in units.
///
/// ⚠️ TIME ALONE IS NOT ENOUGH. A player sprinting at 538 u/s covers half a map inside one
/// refresh interval, and a stale table would keep feeding the horde to where they were. This
/// is the cheap check — one distance compare per player per pick — that catches the case the
/// timer misses.
/// </summary>
public static float PathCostPlayerMove
{
get => _pathCostPlayerMove ??= 256f;
set => _pathCostPlayerMove = value;
}
static float? _pathCostPlayerMove;
/// <summary>Pairs in the last rebuild that had no navmesh route and used flat distance.</summary>
public static int PathCostFallbacks { get; private set; }
/// <summary>Spawn cost table, keyed by the spawn's position.</summary>
static readonly Dictionary<Vector3, float> _spawnCost = new();
/// <summary>
/// Where the players were when the cost table was last built. Flat x,y,z triples.
///
/// ⛔ A `float[]`, NOT A `List<Vector3>`, AND THAT IS A HOTLOAD FIX. s&box's upgrader
/// migrates statics by name and chokes on this one every single reload:
///
/// [hotload] Source array is too small. (Parameter 'src')
/// Path: NZombies.RoundManager::_costPlayers
/// at Sandbox.StructArrayConverter`2.OnBlockCopy
///
/// It is a CACHE, so losing it costs one rebuild - but the throw left it in an
/// indeterminate state instead, and it fired on every hotload of a live session.
/// A flat float array migrates without the struct converter.
/// </summary>
static float[] _costPlayers = System.Array.Empty<float>();
static TimeSince _costAge = 999f;
/// <summary>
/// Has any player moved far enough to be worth re-pathing every spawn.
///
/// ⛔ ORDER-INDEPENDENT, AND THE INDEXED COMPARISON IT REPLACES WAS NOT. The old test
/// walked both lists by index - `_costPlayers[i]` against `players[i]` - but `players` is
/// built from `GetAllComponents<NZPlayer>()`, whose order is not guaranteed stable. Two
/// players swapping places in that enumeration compared each against the other's old
/// position: a rebuild when nothing moved, or worse, NO rebuild when both did and each
/// happened to be near where the other had been.
///
/// ⚠️ NEAREST CACHED POSITION PER PLAYER, which is O(n²) on a co-op player count of at
/// most four - sixteen distance checks against a navmesh re-path of every spawn point.
/// </summary>
static bool PlayersMovedSince( List<Vector3> players )
{
if ( _costPlayers.Length != players.Count * 3 ) return true;
var limit = PathCostPlayerMove * PathCostPlayerMove;
for ( int i = 0; i < players.Count; i++ )
{
var best = float.MaxValue;
for ( int j = 0; j < players.Count; j++ )
{
var d = players[i].DistanceSquared( new Vector3(
_costPlayers[j * 3], _costPlayers[j * 3 + 1], _costPlayers[j * 3 + 2] ) );
if ( d < best ) best = d;
}
if ( best > limit ) return true;
}
return false;
}
/// <summary>
/// Both costs for every eligible spawn, for `nz_spawn_cost`.
///
/// ⚠️ MEASURES FRESH RATHER THAN READING THE CACHE. A report that showed the cached table would
/// be showing whatever the last rebuild happened to catch — up to a second old, and built for
/// wherever the players were standing then. A diagnostic that can disagree with the thing it
/// diagnoses is worse than no diagnostic.
/// </summary>
public List<(Vector3 at, float flat, float path, bool unreachable)> SpawnCostReport()
{
var outp = new List<(Vector3, float, float, bool)>();
var players = Scene.GetAllComponents<NZPlayer>()
.Where( pl => pl.IsValid() && !pl.IsDown )
.Select( pl => pl.WorldPosition )
.ToList();
if ( players.Count == 0 ) return outp;
var nav = Scene?.NavMesh;
foreach ( var s in EligibleSpawnsFor( Round ) )
{
var flat = FlatCost( s, players );
var best = float.MaxValue;
if ( nav is { IsEnabled: true } )
foreach ( var p in players )
{
var path = nav.CalculatePath( new CalculatePathRequest { Start = s.Position, Target = p } );
if ( !path.IsValid || path.Status != NavMeshPathStatus.Complete ) continue;
var len = PathLength( path );
if ( len > 0f && len < best ) best = len;
}
bool unreachable = best >= float.MaxValue;
outp.Add( (s.Position, flat, unreachable ? flat : best, unreachable) );
}
return outp;
}
/// <summary>Drop the table — on a round change, a door opening, or a bias retune.</summary>
public static void InvalidateSpawnCosts()
{
_spawnCost.Clear();
_costPlayers = System.Array.Empty<float>();
_costAge = 999f;
}
/// <summary>Flat XY distance to the closest player — the old measure, and the fallback.</summary>
static float FlatCost( SpawnPoint s, List<Vector3> players )
{
var at = s.Position.WithZ( 0 );
var nearest = float.MaxValue;
foreach ( var p in players )
{
var d = at.Distance( p.WithZ( 0 ) );
if ( d < nearest ) nearest = d;
}
return nearest;
}
/// <summary>Walked length of a path. Matches ZombiePathDebug.Length — `Points` holds
/// NavMeshPathPoint, so the position comes off `.Position`.</summary>
static float PathLength( NavMeshPath p )
{
var pts = p.Points;
if ( pts is null || pts.Count < 2 ) return 0f;
float d = 0f;
for ( int i = 1; i < pts.Count; i++ )
d += pts[i - 1].Position.Distance( pts[i].Position );
return d;
}
/// <summary>
/// Cost from a spawn to the nearest player, rebuilding the table when it has gone stale.
///
/// ⚠️ NEAREST BY PATH, NOT THE PATH TO THE NEAREST. Asking flat distance first and then pathing
/// only to that player would reintroduce the whole bug: the player 100u below through a locked
/// stairwell would still be chosen as "the nearest", and the path would then faithfully measure
/// the wrong route. Every player is queried.
/// </summary>
float NearestPlayerCost( SpawnPoint spawn, List<Vector3> players )
{
if ( !PathWeighted ) return FlatCost( spawn, players );
var nav = Scene?.NavMesh;
if ( nav is not { IsEnabled: true } ) return FlatCost( spawn, players );
if ( PlayersMovedSince( players ) || _costAge > PathCostRefresh || _spawnCost.Count == 0 )
RebuildSpawnCosts( players );
return _spawnCost.TryGetValue( spawn.Position, out var c )
? c
: FlatCost( spawn, players );
}
/// <summary>
/// Path-measure every eligible spawn against every player, once.
///
/// ⚠️ BUILT OVER `EligibleSpawnsFor`, NOT over the list handed to the picker. `PickWaveSpawn`
/// and the anti-stuck relocator both pass the same eligible list today, but a future caller
/// passing a subset would otherwise rebuild the table against that subset and then thrash it on
/// the next full pick — a cache that is rebuilt every call is a slow uncached path wearing a
/// dictionary.
/// </summary>
void RebuildSpawnCosts( List<Vector3> players )
{
// ⚠️ READ HERE RATHER THAN PASSED IN. Naming the navmesh's type in a signature pins this
// file to an engine type it otherwise never mentions; the caller has already checked it is
// enabled, so the only thing a parameter would buy is that coupling.
var nav = Scene.NavMesh;
_spawnCost.Clear();
// ⚠️ FLATTENED ON THE WAY IN - see `_costPlayers` for why it is not a Vector3 list.
_costPlayers = new float[players.Count * 3];
for ( int i = 0; i < players.Count; i++ )
{
_costPlayers[i * 3] = players[i].x;
_costPlayers[i * 3 + 1] = players[i].y;
_costPlayers[i * 3 + 2] = players[i].z;
}
_costAge = 0f;
PathCostFallbacks = 0;
foreach ( var s in EligibleSpawnsFor( Round ) )
{
var best = float.MaxValue;
var flat = FlatCost( s, players );
foreach ( var p in players )
{
var path = nav.CalculatePath( new CalculatePathRequest
{
Start = s.Position,
Target = p,
} );
// ⛔ `Complete` AND NOTHING LESS. A partial path is the pathfinder saying "I got as
// far as I could" — its length measures how far it gave up from, not how far the
// zombie has to walk, and treating that as a distance would rank a spawn that
// cannot reach the player at all as if it were the closest one on the map.
if ( !path.IsValid || path.Status != NavMeshPathStatus.Complete ) continue;
var len = PathLength( path );
if ( len > 0f && len < best ) best = len;
}
if ( best >= float.MaxValue ) { best = flat; PathCostFallbacks++; }
_spawnCost[s.Position] = best;
}
}
/// <summary>
/// The same list, for a caller that has no RoundManager to ask.
///
/// ⛔️ EXISTS BECAUSE `Instance` IS NULL WHENEVER NO ROUND HAS BEEN STARTED — this
/// component is created on demand, so in Creative, in the lobby, and before round one
/// there is nothing to read `Round` from. `ZombieAI`'s idle drift needs the list in
/// exactly those conditions, and `Instance?.EligibleSpawns` silently answered "no spawns"
/// instead, which presented as zombies walking to a stale destination and stopping.
///
/// ⚠️ ONE IMPLEMENTATION, with the instance property delegating to it. A second copy of
/// the `IsEligible( round, Power.IsOn )` filter is the §3 shape — and this one decides
/// where the horde comes from, so a divergence would be a gameplay bug, not a cosmetic
/// one.
/// </summary>
public static List<SpawnPoint> EligibleSpawnsFor( int round )
=> ActiveConfig.Current.ZombieSpawns
.Where( s => s.IsEligible( round, Power.IsOn ) )
.ToList();
bool SpawnOne()
{
var all = ActiveConfig.Current.ZombieSpawns;
if ( all.Count == 0 )
{
Log.Warning( "[nz] no zombie spawns placed — round cannot spawn anything" );
Remaining = 0;
return false;
}
var spawns = EligibleSpawns;
if ( spawns.Count == 0 )
{
// ⚠️ Do NOT zero Remaining here. Unlike "no spawns placed", this is
// recoverable — buying the debris opens a link and the wave carries
// on. Ending the wave would let a player skip a round by standing
// in a locked-off start area.
if ( _sinceBlockedWarning > 5f )
{
_sinceBlockedWarning = 0f;
Log.Warning( $"[nz] all {all.Count} zombie spawns are gated — "
+ $"open a link (nz_links) or the wave cannot continue" );
}
return false;
}
// ⚠️ WEIGHTED, AND ONLY FOR THE ORDINARY WAVE. SpawnOneSpecial keeps its uniform pick: a hound
// round has its own spawner set, its own pacing and only a handful of enemies, so biasing it
// would change a deliberately-authored arrival pattern rather than fix a feel problem.
var point = PickSpawnNearPlayers( spawns );
// Tally which CONFIG index it came from, not which eligible index —
// the eligible list is rebuilt as links open, so its indices shift.
// This is what proves a gated spawn never fed the wave, rather than
// merely reporting itself ineligible.
var configIndex = ActiveConfig.Current.ZombieSpawns.IndexOf( point );
_spawnUse[configIndex] = _spawnUse.GetValueOrDefault( configIndex ) + 1;
// ⚠️ AT ITS WINDOW, on the spawner's side, if it stands at one (`Barricade.SpawnSideFor`, 2026-10-01)
var z = ZombieCommands.SpawnAt( Scene, point.Position, atWindow: true );
if ( z is null ) return false;
// ⚠️ THE RIG'S CORRECTION IS NOT COMPOSED HERE, AND IT CANNOT BE. `ZombieAI.OnStart` has not
// run yet at this point, so the variant's offsets are still zero and `z.ModelTurn` is
// identity — an attempt to fix the spawn angle here multiplied by nothing and changed
// nothing. `ApplyVariantBody` does it, at the first moment those offsets exist.
z.WorldRotation = point.Rotation;
return true;
}
/// <summary>The special spawns usable right now. Same gating as the walker's
/// — link open, power satisfied, round reached — because a special spawner
/// behind a locked door should stay shut for the same reason.</summary>
public List<SpawnPoint> EligibleSpecialSpawns => ActiveConfig.Current.SpecialSpawns
.Where( s => s.IsEligible( Round, Power.IsOn ) )
.ToList();
/// <summary>
/// The boss spawns usable right now. Same gating as the other two lists.
///
/// ⚠️ READ BY `nz_boss_spawn` AND BY DEATH PERCEPTION'S TEST PATH, not by a scheduler -
/// nothing rolls boss rounds yet. That is deliberate: the spawn points and the boss itself are
/// worth having before the round type that uses them, and a scheduler with nowhere to put a
/// boss would be the wrong half to build first.
/// </summary>
public List<SpawnPoint> EligibleBossSpawns => ActiveConfig.Current.BossSpawns
.Where( s => s.IsEligible( Round, Power.IsOn ) )
.ToList();
/// <summary>
/// How far above or below a player a boss spawn can sit and still count as "the same level".
///
/// ⚠️ One storey, roughly. It has to be generous enough to cover a ramp, a step and the
/// difference between a spawn marker on the floor and a player's origin, and tight enough that
/// a balcony is a different level. `nz_boss_level_height` to argue with it.
/// </summary>
public static float BossSameLevelHeight
{
get => _bossSameLevelHeight ??= 128f;
set => _bossSameLevelHeight = value;
}
static float? _bossSameLevelHeight;
/// <summary>
/// The usable boss spawns, nearest player first, with same-level spawns ranked above any
/// spawn on another floor.
///
/// ⛔️ THE OPPOSITE RULE TO `PickSpawnNearPlayers`, ON PURPOSE. The wave picker deliberately
/// FLATTENS z — for a horde, a spawner one floor up is not meaningfully further away and
/// including z would make every multi-storey map favour whichever level the player stands on.
/// A boss is one arrival, not a stream: it should walk in at the doorway you can see, and a
/// boss materialising on the floor below to take the long way round reads as a broken spawn
/// rather than as pressure.
///
/// ⛔️ NEAREST PLAYER, NOT AVERAGE — the same reasoning the wave picker gives. An average
/// between two players split across the map sits where neither of them is.
///
/// ⚠️ SORTED, NOT PICKED. `SpawnScheduledBosses` round-robins this list, so with several
/// bosses and several spawners the first boss takes the nearest point, the second the next
/// nearest, and so on — they arrive together rather than stacking on one marker.
///
/// ⚠️ FALLS BACK TO CONFIG ORDER when nobody is up, rather than to an arbitrary sort. With no
/// live player there is no "nearest" to speak of and pretending otherwise would just hide that.
/// </summary>
public List<SpawnPoint> BossSpawnsNearestFirst()
{
var spawns = EligibleBossSpawns;
if ( spawns.Count <= 1 ) return spawns;
var players = Scene.GetAllComponents<NZPlayer>()
.Where( pl => pl.IsValid() && !pl.IsDown )
.Select( pl => pl.WorldPosition )
.ToList();
if ( players.Count == 0 ) return spawns;
// ⚠️ Scored once per spawn, then sorted — not scored inside the comparer, which would run
// the whole player loop O(n log n) times for a list this small but for no reason.
var scored = new List<(SpawnPoint Point, int OffLevel, float Flat)>( spawns.Count );
foreach ( var s in spawns )
{
var best = (OffLevel: 1, Flat: float.MaxValue);
foreach ( var p in players )
{
// ⚠️ The level test and the distance are measured against the SAME player. Taking
// the nearest player and then asking "is anyone on my level" separately would rank
// a spawn as same-level because of someone on the far side of the map.
var offLevel = MathF.Abs( s.Position.z - p.z ) <= BossSameLevelHeight ? 0 : 1;
var flat = s.Position.WithZ( 0 ).Distance( p.WithZ( 0 ) );
if ( offLevel < best.OffLevel || (offLevel == best.OffLevel && flat < best.Flat) )
best = (offLevel, flat);
}
scored.Add( (s, best.OffLevel, best.Flat) );
}
return scored
.OrderBy( x => x.OffLevel )
.ThenBy( x => x.Flat )
.Select( x => x.Point )
.ToList();
}
/// <summary>
/// Put a boss at one of its spawn points. Returns the object, or null.
///
/// ⚠️ IT GOES THROUGH THE SAME `SpawnOne`-style path a special does rather than creating a
/// zombie by hand, so a boss picks up the variant, the navmesh agent, the health scaling and
/// the death handling that everything else gets. A bespoke spawn would be a second author for
/// all of it.
///
/// ⛔️ AND IT REFUSES RATHER THAN GUESSING WHEN THERE IS NOWHERE TO PUT ONE. A boss dropped at
/// the world origin is worse than no boss, and the two reasons for an empty list - none placed
/// versus all gated - need different fixes, so both are named.
/// </summary>
/// <summary>
/// A boss from this map's pool (`BossSettings.Pool`, 2026-10-07: City Uprising's random Margwas), or null when the map has
/// none — the spawn point's own boss, then.
///
/// ⚠️ ALWAYS A DIFFERENT ONE (the user: *"when it spawns more than one it always spawns diferent ones"*): never one already
/// <paramref name="taken"/> this round, nor one alive now, while the pool has another; past that, never one taken this round;
/// past that (a round asking for more than the pool holds), any of them. THE HOST.
/// </summary>
public static string PickPooledBoss( IReadOnlyCollection<string> taken = null )
{
var pool = PoolBosses();
if ( pool.Count == 0 ) return null;
taken ??= Array.Empty<string>();
var choice = pool.Where( n => !taken.Contains( n ) && !BossAlive( n ) ).ToList();
if ( choice.Count == 0 ) choice = pool.Where( n => !taken.Contains( n ) ).ToList();
if ( choice.Count == 0 ) choice = pool;
return choice[Game.Random.Int( 0, choice.Count - 1 )];
}
/// <summary>The map's pool, cleaned: trimmed, once each, bosses only.</summary>
public static List<string> PoolBosses()
=> (ActiveConfig.Current?.Bosses?.Pool ?? new List<string>())
.Where( n => !string.IsNullOrWhiteSpace( n ) ).Select( n => n.Trim() ).Distinct()
.Where( SpecialEnemies.IsBossName ).ToList();
/// <summary>Is a boss of this id alive now (its variant's path, so the five Margwas are told apart)?</summary>
public static bool BossAlive( string name )
{
var path = SpecialEnemies.PathFor( name );
if ( string.IsNullOrEmpty( path ) ) return false;
return ZombieAI.All.Any( z => z.IsValid() && z.State != ZombieState.Dead
&& string.Equals( z.Variant?.ResourcePath, path, StringComparison.OrdinalIgnoreCase ) );
}
public GameObject SpawnBossAt( SpawnPoint point, string bossName = null )
{
if ( point is null ) return null;
var name = !string.IsNullOrWhiteSpace( bossName )
? bossName
: (!string.IsNullOrWhiteSpace( point.Special ) ? point.Special : SpecialEnemies.BossFallback);
if ( !SpecialEnemies.IsBossName( name ) )
{
Log.Warning( $"[nz] '{name}' is not a boss - bosses are: "
+ $"{string.Join( ", ", SpecialEnemies.BossNames )}" );
return null;
}
var variant = SpecialEnemies.VariantFor( name );
if ( variant is null )
{
Log.Warning( $"[nz] boss '{name}' has no variant asset at "
+ $"{SpecialEnemies.PathFor( name )}" );
return null;
}
// ⚠️ THE SAME `ZombieCommands.SpawnAt` PATH `SpawnOneSpecial` USES, so a boss picks up the
// variant, the agent, the health scaling and the death handling every other zombie gets. A
// bespoke spawn here would be a second author for all of it.
//
// ⚠️ AND IT TAKES NO MULTIPLIERS. Specials pass `Specials.HealthMultiplier` and
// `SpeedMultiplier` from the special-round settings; a boss has no round settings yet and its
// own `.zvar` already carries `HealthMultiplier` and `SpeedMultiplier`, so passing 1/1 keeps
// the variant as the single author of how tough it is.
var z = ZombieCommands.SpawnAt( Scene, point.Position, variant, 1f, 1f );
if ( z is null ) return null;
// ⚠️ THE RIG'S CORRECTION IS NOT COMPOSED HERE, AND IT CANNOT BE. `ZombieAI.OnStart` has not
// run yet at this point, so the variant's offsets are still zero and `z.ModelTurn` is
// identity — an attempt to fix the spawn angle here multiplied by nothing and changed
// nothing. `ApplyVariantBody` does it, at the first moment those offsets exist.
z.WorldRotation = point.Rotation;
z.GameObject.Name = name;
Log.Info( $"[nz] boss '{name}' spawned at {point.Position}" );
return z.GameObject;
}
/// <summary>One special, from a special spawner.</summary>
bool SpawnOneSpecial()
{
var cfg = ActiveConfig.Current.Specials;
// ⛔ A SPECIAL ROUND CAN COME THROUGH THE ORDINARY WINDOWS. The special spawner set is for
// enemies that arrive from their own places; a horde of sprinters is a wave of ordinary
// zombies that happen to be fast, and it belongs where the player has been watching all
// game. See `SpecialSettings.UseZombieSpawns`.
var normal = cfg.UseZombieSpawns;
var spawns = normal ? EligibleSpawns : EligibleSpecialSpawns;
if ( spawns.Count == 0 )
{
// ⚠️ NOT recoverable the way a gated walker spawn is. A special round
// draws only from special spawners, so if every one is gated the wave
// can never empty and the round never ends. End the wave instead of
// deadlocking the game, and say why.
if ( _sinceBlockedWarning > 5f )
{
_sinceBlockedWarning = 0f;
var n = normal
? ActiveConfig.Current.ZombieSpawns.Count
: ActiveConfig.Current.SpecialSpawns.Count;
Log.Warning( $"[nz] all {n} {( normal ? "zombie" : "special" )} spawns are gated"
+ " — ending the special wave rather than deadlocking it" );
}
Remaining = 0;
return false;
}
// ⚠️ WEIGHTED ON THE ORDINARY SET, UNIFORM ON THE SPECIAL ONE. The uniform pick is
// deliberate for a hound round — its arrival pattern is authored — but the normal spawner
// biases toward the player, and that bias is what makes a horde feel like a horde rather
// than fifty enemies spread evenly across a map you occupy one corner of.
var point = normal
? PickSpawnNearPlayers( spawns )
: spawns[Game.Random.Int( 0, spawns.Count - 1 )];
if ( point is null ) return false;
// ⛔ THE MAP'S CHOICE BEATS THE SPAWN POINT'S. `SpecialSettings.Enemy` is how a config says
// "this map's special round is pests" once, instead of repeating it at every point and
// keeping them in step forever. Blank leaves the old per-point behaviour untouched.
var name = !string.IsNullOrWhiteSpace( cfg.Enemy )
? cfg.Enemy
: string.IsNullOrWhiteSpace( point.Special )
? SpecialEnemies.Fallback
: point.Special;
var variant = SpecialEnemies.VariantFor( name );
if ( variant is null )
{
// A config naming a special that no longer exists. Spawn the fallback
// rather than nothing — see SpawnPoint.Special.
Log.Warning( $"[nz] special '{name}' is not in the roster — "
+ $"falling back to '{SpecialEnemies.Fallback}'" );
variant = SpecialEnemies.VariantFor( SpecialEnemies.Fallback );
}
var sp = ActiveConfig.Current.Specials;
var z = ZombieCommands.SpawnAt( Scene, point.Position, variant,
sp.HealthMultiplier, sp.SpeedMultiplier, atWindow: true );
if ( z is null ) return false;
// ⚠️ THE RIG'S CORRECTION IS NOT COMPOSED HERE, AND IT CANNOT BE. `ZombieAI.OnStart` has not
// run yet at this point, so the variant's offsets are still zero and `z.ModelTurn` is
// identity — an attempt to fix the spawn angle here multiplied by nothing and changed
// nothing. `ApplyVariantBody` does it, at the first moment those offsets exist.
z.WorldRotation = point.Rotation;
z.GameObject.Name = name;
return true;
}
static void ClearZombies()
{
foreach ( var z in ZombieAI.All.ToList() )
z.GameObject.Destroy();
}
/// <summary>One line of status, for the HUD and nz_round.</summary>
public string Summary => State switch
{
RoundState.Waiting => "waiting",
// ⚠️ Explicit arm. The catch-all below reports "round N — X alive, …",
// which after a game over would describe a wave that is no longer running.
RoundState.GameOver => $"game over — round {FinalRound}, {FinalPoints} points",
RoundState.Prep => $"round {Round + 1} in {(float)_nextPhase:0.0}s",
_ => $"round {Round} — {Alive} alive, {Remaining} to spawn, {WaveTotal} total",
};
}