A GameObjectSystem that pauses single-player gameplay when the s&box overlay (pause menu or settings) is open by setting Scene.TimeScale to 0 and holding power-up timers, and restores time when the menu closes or someone joins.
using Sandbox;
namespace NZombies;
/// <summary>
/// A SINGLE-PLAYER GAME STANDS STILL WHILE THE s&box MENU IS UP (2026-10-05). The user: *"i want to be able to pause the game by
/// pressing ESC / sbox already does this but i need the zombies to freeze too / only in singleplayer"*.
///
/// ⛔ ESC OPENS THE ENGINE'S PAUSE MENU, AND THE GAME WENT ON BEHIND IT. `Game.IsPaused`, the one flag the scene's tick asks
/// (`Scene.GameTick`), is set only by the editor's Pause button and its Stop, never by the pause menu (read out of the engine's
/// own assemblies). So zombies walked and bit, the round spawned, and a bleedout ran out while the menu was up.
///
/// ⚠️ `Scene.TimeScale = 0`, NOT `Game.IsPaused`. With `IsPaused` the scene skips its update, this system's included, so nothing
/// would be left awake to notice the menu close. At time scale 0 every update still runs, with `Time.Delta` 0 and scene time
/// standing still: zombies, nav agents, physics, animation, the spawner, and every `TimeUntil`/`TimeSince` clock (rounds,
/// bleedout, cooldowns) stop, and this goes on watching the menu. Nothing else in the project writes `TimeScale`.
///
/// ⚠️ POWER-UPS RUN ON REAL TIME (`ActivePowerups`, `RealTimeUntil`), which a time scale does not touch, so their clocks are
/// held for the pause (`ActivePowerups.Hold` / `Release`): a 30 s Insta-Kill is not spent behind the menu.
///
/// ⚠️ SINGLE PLAYER ONLY: no network, or a lobby with nobody else in it. Someone joining ends the pause on the spot.
///
/// ⚠️ ANY s&box OVERLAY, not just the pause menu (`MenuOpen`): Settings, opened from the pause menu, replaces it,
/// and the game must not start again underneath.
///
/// ⚠️ A `GameObjectSystem`, the engine menu's own pattern (`GameJamSystem`): it runs every frame of every scene with nothing to
/// keep alive, so a pause cannot be left on by a component being switched off. Its tick is a named method (INSTRUCTIONS: no
/// lambdas held across a hotload).
/// </summary>
public sealed class GamePause : GameObjectSystem<GamePause>
{
// ⚠️ NULLABLE-BACKED, INSTRUCTIONS §1: a static's value survives a hotload, its initialiser does not re-run.
static bool? _enabled;
/// <summary>Pause a single-player game behind the s&box menu. `nz_pause_solo 0` turns it off.</summary>
public static bool Enabled { get => _enabled ?? true; set => _enabled = value; }
/// <summary>Is a pause holding the game still right now?</summary>
public static bool IsPaused { get; private set; }
bool _paused;
float _scaleBefore = 1f;
RealTimeSince _since;
public GamePause( Scene scene ) : base( scene )
{
Listen( Stage.StartUpdate, -1000, Tick, nameof( GamePause ) );
}
/// <summary>Nobody else in the game: no network, or a lobby of one.</summary>
static bool Solo => !Networking.IsActive || Connection.All.Count <= 1;
/// <summary>
/// Is an s&box overlay up: the pause menu, or Settings opened from it?
///
/// ⛔ THROUGH AN INSTANCE (2026-10-05). In this engine build the overlay’s `IsOpen` is an INSTANCE property (its getter reads
/// the internal `IModalSystem.Current`), while `ShowPauseMenu` beside it is static, so reading it off the type does not compile.
/// The object holds nothing, so one is kept. If a later engine makes it static, this is the line to change.
/// </summary>
static bool MenuOpen => (_overlay ??= new Game.Overlay()).IsOpen;
static Game.Overlay _overlay;
void Tick()
{
// ⚠️ NOT IN THE EDITOR'S OWN SCENE, only in a game being played (the editor's play mode included: Shift+Esc there)
if ( Scene is null || Scene.IsEditor ) return;
var want = Enabled && MenuOpen && Solo;
if ( want == _paused )
{
// ⚠️ A ZERO TIME SCALE WITH NO PAUSE IS A PAUSE THAT LOST ITS OWNER, a hotload in the middle of one, say. Nothing else
// in the project sets it, so it is put back rather than leaving the game frozen with the menu closed.
if ( !_paused && Scene.TimeScale == 0f )
{
Scene.TimeScale = 1f;
ActivePowerups.Release();
Log.Warning( "[nz-pause] the game was frozen with no pause holding it: time runs again" );
}
return;
}
if ( want ) Pause();
else Resume();
}
void Pause()
{
_paused = true;
IsPaused = true;
_since = 0f;
// ⚠️ WHAT IT WAS, WHEN IT WAS SOMETHING: a zero here would be put back at the end and freeze the game for good
_scaleBefore = Scene.TimeScale > 0f ? Scene.TimeScale : 1f;
Scene.TimeScale = 0f;
ActivePowerups.Hold();
Log.Info( "[nz-pause] paused: the menu is open in single player, so zombies, clocks and power-ups stand still" );
}
void Resume()
{
_paused = false;
IsPaused = false;
Scene.TimeScale = _scaleBefore > 0f ? _scaleBefore : 1f;
ActivePowerups.Release();
Log.Info( $"[nz-pause] resumed after {(float)_since:0.0}s{(Solo ? "" : ", because someone joined")}" );
}
/// <summary>⚠️ A SCENE CLOSED MID-PAUSE (quitting from the menu) STILL HANDS THE POWER-UP CLOCKS BACK: they are statics, and
/// they would answer from the held copy in the next game.</summary>
public override void Dispose()
{
if ( _paused )
{
_paused = false;
IsPaused = false;
ActivePowerups.Release();
}
base.Dispose();
}
/// <summary>`nz_pause_solo [0|1]` — pause a single-player game behind the s&box menu, or not. With no argument, reports.</summary>
[ConCmd( "nz_pause_solo" )]
public static void Cmd( int on = -1 )
{
if ( on >= 0 ) Enabled = on != 0;
Log.Info( $"[nz-pause] pausing behind the menu in single player: {(Enabled ? "ON" : "off")}"
+ $" · paused now: {(IsPaused ? "yes" : "no")} · single player: {(Solo ? "yes" : "no")}"
+ $" · an s&box menu open: {(MenuOpen ? "yes" : "no")}" );
}
}