Static game session manager for NZombies. Declares GameMode enum and NZGame class that tracks current mode, host/client predicates, preview and authoring visuals, commands for reporting host state, mode switching logic, and a ShowConfig method that ensures and rebuilds many scene managers and triggers cleanup and light baking.
using Sandbox;
using System.Linq;
namespace NZombies;
/// <summary>Creative builds the config; Survival plays it.</summary>
public enum GameMode
{
/// <summary>Sitting in the lobby, nothing running.</summary>
Lobby,
/// <summary>Building the map config. Tools work, markers are visible,
/// rounds only advance on command.</summary>
Creative,
/// <summary>Playing. Tools are locked out and markers are hidden.</summary>
Survival,
/// <summary>Watching, with no body in the world. Free-fly camera.
///
/// ⚠️ Appended, not inserted. Nothing persists this enum today, but the
/// habit is worth keeping — a mode written into a saved config that then
/// shifts value is a class of bug that only shows up much later.</summary>
Spectator,
}
/// <summary>
/// What the session is currently doing.
///
/// Small on purpose — this only answers "which mode", so the things that care
/// (tools, markers, the Q menu, the round manager) have one place to ask
/// instead of each keeping their own flag and drifting out of step.
/// </summary>
public static class NZGame
{
public static GameMode Mode { get; private set; } = GameMode.Lobby;
public static bool IsCreative => Mode == GameMode.Creative;
public static bool IsSurvival => Mode == GameMode.Survival;
public static bool IsSpectator => Mode == GameMode.Spectator;
/// <summary>
/// May THIS machine decide things — spend points, open doors, spawn zombies, author a map?
///
/// ⛔ IT DEFAULTS TO **TRUE** WHEN THERE IS NO SESSION, AND THAT IS THE WHOLE POINT.
/// `Networking.IsHost` is FALSE offline, so a bare `if ( Networking.IsHost )` gate locks a
/// single player out of their own editor and out of every purchase in the game — with no
/// error, because refusing is what the gate is for. `MULTIPLAYER.md` §5 states this as a
/// requirement before any gate is written; this is that accessor.
///
/// ⚠️ ASK THIS, NOT `Networking`. One predicate means the offline default is decided
/// once instead of at 40-60 command sites, and a site that gets it wrong is invisible until
/// somebody plays alone. The idiom it replaces lived at exactly one place in the codebase
/// (`NZPlayer.GiveWeapon`) and was already written correctly — which is why it is worth
/// having somewhere everything else can copy rather than re-derive.
/// </summary>
public static bool IsHost => !Networking.IsActive || Networking.IsHost;
/// <summary>
/// A connected machine that is NOT running the game.
///
/// ⚠️ NOT `!IsHost` FOR READABILITY ALONE — it reads better at a call site that wants to
/// hide a control, and it keeps "offline is host" stated in one place rather than inverted
/// by hand wherever someone needs the other sense.
/// </summary>
public static bool IsClient => !IsHost;
/// <summary>
/// `nz_host` — who is this machine, and what may it do?
///
/// ⛔ THE OFFLINE CASE IS PRINTED EXPLICITLY. "IsHost true, networking inactive" and
/// "IsHost true, and I am the host of a session" are different worlds that a single boolean
/// cannot tell apart — and the first bug this predicate can cause is being right for the
/// wrong reason.
/// </summary>
[ConCmd( "nz_host" )]
public static void HostReport()
{
Log.Info( $"[nz-net] networking {( Networking.IsActive ? "ACTIVE" : "inactive (solo)" )}"
+ $" · Networking.IsHost {Networking.IsHost}"
+ $" · NZGame.IsHost {IsHost}" );
Log.Info( $"[nz-net] mode {Mode} · {( IsHost ? "this machine decides" : "this machine asks the host" )}" );
if ( Networking.IsActive )
Log.Info( $"[nz-net] {Connection.All.Count} connection(s)" );
// ⚠️ WHERE THE SESSION CAME FROM, because "I am hosting" and "I am hosting because the
// game did it for me at startup" are different answers to the same question — and only one
// of them explains why `nz_host_start` refused. See NZStartup.
Log.Info( $"[nz-net] auto-host nz_autohost {NZStartup.AutoHost}"
+ $" → {(NZStartup.ShouldAutoHost ? "WILL host on start" : "will not host")}"
+ (Game.IsEditor ? " (editor)" : "")
+ $" · {(NZStartup.DidAutoHost ? "this session's lobby was made by it" : "it did not make this session")}" );
}
/// <summary>
/// Hide every authoring visual while STAYING in Creative — see the map as a
/// player will.
///
/// ⚠️ A view setting, not a mode. Tools keep working and the config is still
/// editable; only the overlays stop drawing. Switching to Survival to check
/// the look would start a game and reset the links, which is the opposite of
/// what you want mid-build.
///
/// ⚠️ THIS IS NOW THE ONLY WAY TO HIDE THEM. Markers used to vanish whenever
/// no tool was armed, which was a side effect of holstering rather than
/// something anyone asked for — and it hid the config exactly when you were
/// looking at the map rather than editing it. Holstering no longer hides
/// anything; see MapEditor.OnUpdate.
/// </summary>
public static bool PreviewMode
{
get => _previewMode;
set
{
if ( _previewMode == value ) return;
_previewMode = value;
// ⚠️ THE REBUILD FOLLOWS THE FLAG, FROM HERE -- the same reasoning SetMode gives for
// calling PlayerPresence.Apply itself. Two separate commands write this flag
// (nz_preview and nz_markers) and the one that got forgotten would leave preview mode
// half-applied.
//
// ⛔️ NEEDED ONLY BECAUSE NAV LINK MARKERS ARE REAL OBJECTS. Every other authoring
// visual is a DebugOverlay draw issued each frame under ShowAuthoringVisuals, so it
// vanishes on its own the moment this flips. Marker GameObjects do not -- they have to
// be destroyed, and Rebuild is what destroys them.
NavLinkManager.Instance?.Rebuild();
}
}
static bool _previewMode;
/// <summary>Should creative-only overlays draw right now?</summary>
/// <remarks>⚠️ AND NOT IN PHOTO MODE (`MapPhotoMode`, `nz_photo`): the map alone, for screenshots.</remarks>
public static bool ShowAuthoringVisuals => IsCreative && !PreviewMode && !MapPhotoMode.On;
public static void SetMode( GameMode mode )
{
// ⚠️ THE JOIN HOOK IS ENSURED HERE because `SetMode` is the one thing that runs on
// every machine, early, whatever it is doing — the console shows "[nz] mode -> Lobby" on
// the host and on every client. A listener that only exists on the machine that happened
// to open a menu is a listener that misses the join it was written for.
NZNetListener.Ensure();
// ⛔ NO CREATIVE IN A PUBLISHED COPY (`Edition`, 2026-10-05): *"in the editor I want to be able to edit maps as usual,
// but not in the published version"*. Refused here because every way in passes through here: the lobby's button,
// `nz_creative` and `nz_mode creative`.
// ⚠️ ONLY THIS MACHINE'S OWN CHOICE. A client following an editor host into Creative (`NZNet.ModeChanged`) still
// follows, or it would sit in the lobby while the host builds. Its build menu stays shut (`DevMenu`).
if ( mode == GameMode.Creative && IsHost && !Edition.CanBuild )
{
Log.Info( "[nz] no Creative in the published game — maps are built in the editor" );
return;
}
if ( Mode == mode ) return;
Mode = mode;
Log.Info( $"[nz] mode -> {mode}" );
// ⚠️ THE MATCH'S DIFFICULTY ENDS WITH THE GAME (2026-10-05): the lobby and the editor play the gamemode as it is, and the
// next game takes the lobby's choice afresh (`Difficulty.StartMatch`). Here because every machine passes: the host's mode
// reaches the clients just below. Spectator is still in the game, and keeps it.
if ( mode is GameMode.Lobby or GameMode.Creative ) Difficulty.Clear( $"mode -> {mode}" );
// ⛔ AND EVERY OTHER MACHINE FOLLOWS. The mode is a decision about the SESSION — whether
// a game is running — not about this pc, and until now it was purely local. That is why a
// client sat in the lobby while the host played:
//
// CLIENT ── 'Player (Cifosi)' ── 2 enabled … ⛔ NO — nothing below matters
//
// `RefreshBodies` shows other people's bodies only when THIS machine's mode says bodies
// should exist, and `Enabled` does not replicate (measured) — so a client still in Lobby
// hides everybody, including a host who is mid-round. The lobby countdown happened to
// broadcast a start, which hid this for as long as that was the only way in; `nz_round_start`,
// the dev menu and a creative session all bypass it.
//
// ⚠️ HOST DECIDES, EVERYONE FOLLOWS — the same shape as the round mirror. A client
// changing its own mode locally is still allowed and still works; it simply cannot tell
// anybody else to.
if ( IsHost && Networking.IsActive ) NZNet.ModeChanged( (int)mode );
// ⚠️ The BODY follows the mode, from here. Doing it at each call site
// means the one that gets forgotten leaves a player standing in the map
// during the lobby, taking mouse-look while a menu draws over the top —
// which is exactly the bug this was written for.
PlayerPresence.Apply();
// ⚠️ AFTER Apply, because it looks for the player Apply just put in the
// map.
if ( mode == GameMode.Creative ) ShowConfig();
// ⛔ THE LOBBY MENU OPENS HERE NOW, NOT IN `RoundManager.ReturnToLobby`, BECAUSE THAT
// METHOD RUNS ON THE HOST ALONE. Reported as *"não voltamos ao lobby automaticamente? ou
// pelo menos o host"* — and the host half always worked, which is what made it confusing.
//
// `ReturnToLobby` changes the mode (which DOES reach everyone, via `ModeChanged` above)
// and then calls `LobbyState.SetOpen` locally. So a client followed the host into Lobby
// mode and sat there with no lobby: its body was pulled from the map by `Apply` and no
// menu was ever drawn over the top. Mode-without-menu looks exactly like being stuck.
//
// ⚠️ THE MODE IS THE TRIGGER, NOT THE GAME-OVER. Every route into the lobby wants the
// menu — a finished run, `nz_mode lobby`, a host who quits to it — and putting it at the
// one call site that happened to be written first is what left the other routes bare. The
// same argument the `ModeChanged` block above makes about the countdown hiding the mode
// bug, one layer up.
//
// ⚠️ LEAVING the lobby is NOT the mirror of this and is deliberately left alone:
// `LobbyCommands` closes the menu when it sends you to Creative or Spectator, and the
// round start closes it by its own path. A blanket "close on any other mode" here would
// fight those.
//
// ⚠️ NULL-CONDITIONAL BECAUSE THE MENU MAY NOT EXIST YET. `LobbyState.SetOpen` is
// assigned by `LobbyMenu`'s own razor when it mounts; a machine that reaches Lobby mode
// before its UI has built simply has nothing to open, and `LobbyMenu` opens itself on
// start for that case.
if ( mode == GameMode.Lobby ) LobbyState.SetOpen?.Invoke( true );
// ⛔ THE FLOOR IS SWEPT HERE BECAUSE THIS METHOD RUNS ON EVERY MACHINE, which is the same
// argument the lobby menu two lines up already makes for itself. Pickups are
// `NetworkMode.Never` — each machine spawns its own copy and the network carries the event
// rather than the object — so there is no host that could destroy everybody's litter.
// `RoundManager.ReturnToLobby` is the host's alone and was the obvious place; a client
// would have walked into a lobby still holding a round of dropped plates and corpses.
//
// ⚠️ BOTH DIRECTIONS, AND SURVIVAL IS NOT REDUNDANT. `StartGame` sweeps too, but only on
// the host — this is what gives a CLIENT a clean map at the start of a run. Entering
// Creative is deliberately left alone: a leftover horde there is somebody testing.
if ( mode is GameMode.Lobby or GameMode.Survival )
WorldCleanup.Sweep( Game.ActiveScene, mode == GameMode.Lobby ? "lobby" : "new game" );
}
/// <summary>
/// Put the saved config into the world — markers, barriers and switches.
///
/// ⛔️ ENTERING CREATIVE USED TO SHOW AN EMPTY MAP. Every piece of this is
/// created on demand by whatever touches it first, and in practice that was
/// arming a tool from the Q menu: no MapEditor meant no markers, and no
/// DebrisManager meant the barriers themselves were never built. So a map
/// with a full config looked like a map with nothing saved — which is the
/// worst possible way to be wrong about a config, because the obvious next
/// move is to place everything again.
///
/// ⚠️ Rebuilds rather than merely ensuring. The managers are NotSaved and do
/// not survive a play restart, so "does one exist" is not the same question
/// as "is the config standing in the world".
///
/// ⚠️ Public because <see cref="SetMode"/> early-returns when the mode is
/// unchanged and Mode is STATIC — a play restart comes back already in
/// Creative, so the player has to be able to ask for this itself.
/// </summary>
public static void ShowConfig()
{
var scene = Game.ActiveScene;
if ( scene is null ) return;
MapEditor.Ensure();
DebrisManager.Ensure( scene )?.Rebuild();
PowerManager.Ensure( scene )?.Rebuild();
InvisibleWallManager.Ensure( scene )?.Rebuild();
// ⚠️ THE SAME TWO HOOKS AS THE INVISIBLE WALL, AND NO OTHERS. Adding a third in
// SetMode is what broke this the first time.
DamageWallManager.Ensure( scene )?.Rebuild();
// ⛔ BOTH OF THESE MUST BUILD BEFORE THE BAKE ON THE LAST LINE OF THIS METHOD. The probes
// capture whatever exists when they render, so a placed light created after the bake
// contributes nothing to the bounce — which is exactly the bug that made the lava's light
// vanish, from the other direction.
MapLightManager.Ensure( scene )?.Rebuild();
// ⚠️ AND THE MAP'S LIGHT STRIPS, each fixture a copy of their material of its own — dark until the power where the map asks
// (`StripLights`, 2026-09-28). ⚠️ Before the bake too: a map baked at runtime with its strips dark bakes them dark.
StripLights.Ensure( scene )?.Rebuild();
SoundSpotManager.Ensure( scene )?.Rebuild();
FogAreaManager.Ensure( scene )?.Rebuild();
AshParticles.Ensure( scene );
// ⚠️ AND EMBERS AMONG THE ASH, where the map asks for them (`Gameplay.FogEmbers`, 2026-09-28)
FogEmbers.Ensure( scene );
// ⚠️ AND OFF THE LAVA ITSELF, near the player, where the map asks (`Gameplay.LavaEmbers`, 2026-09-28)
LavaEmbers.Ensure( scene );
LavaFog.Ensure( scene )?.Rebuild();
// ⚠️ AND THE HEAT HAZE OVER THE LAVA, where the map asks (`Gameplay.LavaHaze`, 2026-09-28)
LavaHaze.Ensure( scene )?.Rebuild();
// ⚠️ AND BASALT'S CARVINGS ON ITS PILLARS (`Gameplay.Engravings`, 2026-09-28)
BasaltEngravings.Ensure( scene )?.Rebuild();
MiseryDeviceManager.Ensure( scene )?.Rebuild();
ClueManager.Ensure( scene )?.Rebuild();
HexSlotManager.Ensure( scene )?.Rebuild();
// ⚠️ AND BASALT'S TILE 1, plain until step 1 is done — or undressed, on any other map
HexPlatforms.OnConfigShown( scene );
PressableManager.Ensure( scene )?.Rebuild();
ShootableManager.Ensure( scene )?.Rebuild();
BarricadeManager.Ensure( scene )?.Rebuild();
NavLinkManager.Ensure( scene )?.Rebuild();
MysteryBoxManager.Ensure( scene )?.Rebuild();
PackAPunchManager.Ensure( scene )?.Rebuild();
WunderfizzManager.Ensure( scene )?.Rebuild();
PerkMachineManager.Ensure( scene )?.Rebuild();
TeleporterManager.Ensure( scene )?.Rebuild();
// ⛔️ THE NAV MESH IS PART OF PUTTING A CONFIG INTO THE WORLD. NZMap.RebuildNav only runs on a
// map SWITCH, so a cold start straight onto a map left whatever mesh the scene loaded with —
// which until now was countdown's baked one, hardcoded in nzombies.scene, on every map.
//
// ⚠️ EnsureFor, NOT Apply. This method runs on every mode change and config load; marking
// the mesh dirty each time would keep a canyon-sized map permanently regenerating.
NavBake.EnsureFor( NZMap.Current );
// ⚠️ AND THIS IS ALSO THE RESET. A soul box's fill lives on its component, so rebuilding
// them is what empties them for a new game — the same trick the trading table relies on.
SoulBoxManager.Ensure( scene )?.Rebuild();
ArsenalManager.Ensure( scene )?.Rebuild();
// ⛔️ THE AMMO BOX WAS MISSING FROM HERE and that was a real gap: placed boxes existed in the
// config and never appeared in the world, because nothing but their own console command ever
// called Rebuild. Every placeable has to be listed at all three rebuild sites — here,
// RoundManager.StartGame, and the map editor's own refresh.
AmmoBoxManager.Ensure( scene )?.Rebuild();
BuyableEndingManager.Ensure( scene )?.Rebuild();
// ⚠️ AND REBUILDING IS ALSO HOW A TRADING TABLE EMPTIES. Rebuild destroys the objects, and
// what a table holds is state on the component — so a new game gets bare tables without
// needing a reset path of its own.
TradeTableManager.Ensure( scene )?.Rebuild();
BuildTableManager.Ensure( scene )?.Rebuild();
BuildPartManager.Ensure( scene )?.Rebuild();
WallBuyManager.Ensure()?.Rebuild();
// ⛔ THE INDIRECT BAKE GOES LAST, AND IT HAS TO BE HERE RATHER THAN ON A TIMER, BECAUSE
// EVERYTHING IT CAPTURES IS BUILT ABOVE THIS LINE. `NZAtmosphere` used to trigger it the
// moment the map's AmbientLight appeared — which is map-LOAD time, before a single placeable
// exists. On Basalt the map's only real light source is a LAVA damage wall, created by
// `DamageWallManager.Rebuild()` twenty lines up, so the automatic bake was reliably
// capturing a room with no lava in it and binding black probes over a working ambient.
//
// ⚠️ THAT IS WHY IT KEPT "DECAYING". A hand-run `nz_light_bake` looked perfect and then the
// map went dark again later — not decay at all, but a second, earlier-ordered bake
// overwriting it with a pre-config snapshot.
//
// ⚠️ AND IT IS CHEAP TO PUT HERE: the bake is skipped entirely unless the map's config asks
// for it, and ShowConfig already runs exactly when the world has changed enough to need one.
LightBake.BakeIfConfigured();
}
}