Static helper for published-map gamemodes. It lists shipped config files for a map, produces display labels, tracks the currently active/selected gamemode, starts a map on a gamemode, and exposes a console command to list or pick gamemodes.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace NZombies;
/// <summary>
/// THE PUBLISHED LOBBY'S GAMEMODES: each config a map ships is one way to play it (the user, 2026-10-05: *"Each config will
/// be a different way to play the map basically"*). Map select lists them once a map is chosen (`MapBrowserState.Chosen`, since
/// 2026-10-05; the lobby's Gamemode row went), where the editor's lobby has Load and Save config (`Edition`). The host picks one,
/// and it travels to everyone as any config does (`NZNet.SendConfig`).
///
/// ⚠️ A GAMEMODE IS A FILE IN `Assets/configs/<map>/`, NOTHING MORE. Its name and its line in the list are the config's
/// own <see cref="MapConfig.Gamemode"/> and <see cref="MapConfig.GamemodeDescription"/> (Settings → Gameplay); one with no
/// name goes by its file's. ⛔ ONLY WHAT SHIPS, never the player's own saved files, which a published copy cannot write and
/// does not read (`MapConfig.Readers`). So a config saved in the editor becomes a gamemode once `ship_data.py --apply` has
/// copied it into Assets.
///
/// ⛔ THE MAP STARTS ON ONE. A map change and the game's start both leave no config loaded (`ActiveConfig.Reset`), and a
/// lobby with nothing to ready up on would send a player looking for a menu first. So <see cref="Tick"/> loads the map's
/// first gamemode (`default`, where there is one) whenever nothing playable is loaded.
/// </summary>
public static class Gamemodes
{
/// <summary>One way to play a map: the config's file name, what the list calls it, and the line under that.</summary>
public record Mode( string Name, string Label, string Description );
/// <summary>
/// Every gamemode a map ships, `default` first and the rest by name.
///
/// ⚠️ EACH FILE'S NAME AND LINE ARE READ ONCE AND KEPT (`MapConfig.GamemodeOf`), because the lobby asks on every redraw.
/// </summary>
public static List<Mode> For( string map )
=> MapConfig.ListFor( map, shippedOnly: true )
.Select( n =>
{
var h = MapConfig.GamemodeOf( map, n );
return new Mode( n, LabelFor( n, h.Name ), h.Description );
} )
.OrderBy( m => m.Name.Equals( "default", StringComparison.OrdinalIgnoreCase ) ? 0 : 1 )
.ThenBy( m => m.Label, StringComparer.OrdinalIgnoreCase )
.ToList();
/// <summary>What a config is called in the list: its <see cref="MapConfig.Gamemode"/>, or its file's name made readable ("default" → "Default").</summary>
public static string LabelFor( string file, string gamemode )
{
if ( !string.IsNullOrWhiteSpace( gamemode ) ) return gamemode.Trim();
var words = (file ?? "").Replace( '_', ' ' ).Replace( '-', ' ' )
.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
return words.Length == 0
? "Unnamed"
: string.Join( " ", words.Select( w => char.ToUpperInvariant( w[0] ) + w[1..] ) );
}
/// <summary>
/// The gamemode being played, or null when no config is loaded.
///
/// ⚠️ FROM THE LOADED CONFIG ITSELF, NOT THE FILE LIST. A client holds the host's config (`NZNet.ConfigLoaded`), and
/// that is all it needs to say what the host chose.
///
/// ⚠️ "LOADED" IS A <see cref="MapConfig.Map"/>. A config is stamped with its map when it is saved, so every file has one,
/// and the blank config a reset leaves behind has none. Its <see cref="MapConfig.Name"/> would read "default" all the
/// same, which is the trap `ActiveConfig.Set`'s log describes.
/// </summary>
public static Mode Active
{
get
{
var c = ActiveConfig.Current;
if ( c is null || string.IsNullOrEmpty( c.Map ) ) return null;
return new Mode( c.Name ?? "", LabelFor( c.Name, c.Gamemode ), c.GamemodeDescription?.Trim() ?? "" );
}
}
/// <summary>Is this the gamemode being played?</summary>
public static bool IsActive( Mode m )
=> m is not null && Active is { } a && a.Name.Equals( m.Name, StringComparison.OrdinalIgnoreCase );
/// <summary>
/// Why the published lobby cannot be readied, in a player's words, or "" when it can.
///
/// ⚠️ NOT `ActiveConfig.PlayableProblem`, which speaks to the person building the map ("No player spawns or zombie
/// spawns"). A player cannot place spawns; what a player can do is choose a gamemode, or wait for the host to.
/// </summary>
public static string ReadyProblem
{
get
{
if ( Active is null )
{
if ( NZGame.IsClient ) return "Waiting for the host to choose a gamemode";
// ⚠️ "IN CHANGE MAP": the Gamemode row went (2026-10-05), and a gamemode is chosen with its map in Map select
return For( NZMap.Current ).Count == 0
? "This map has no gamemode yet"
: "Choose a gamemode in Change map";
}
var missing = ActiveConfig.PlayableProblem;
return string.IsNullOrEmpty( missing ) ? "" : $"This gamemode has {missing.ToLowerInvariant()}";
}
}
/// <summary>The config the last pick loaded. <see cref="Tick"/> never replaces it, playable or not: it was chosen.</summary>
static MapConfig _chosen;
/// <summary>The config <see cref="Tick"/> last looked at, so a map with no gamemode is reported once, not every frame.</summary>
static MapConfig _triedOn;
/// <summary>
/// Play a gamemode: load its shipped config and hand it to everyone. False when that is not possible.
///
/// ⛔ THE HOST'S, AS EVERY CONFIG IS. ⛔ AND NOT WHILE A GAME RUNS: taking a config rebuilds the whole map
/// (`NZGame.ShowConfig`) under the players. Back in the lobby it can change.
/// </summary>
public static bool Select( string name )
{
if ( NZGame.IsClient ) { Log.Info( "[nz-gamemode] the host chooses the gamemode" ); return false; }
if ( NZGame.IsSurvival || MapLoading.Active ) { Log.Info( "[nz-gamemode] not during a game — the gamemode changes in the lobby" ); return false; }
if ( NZMap.Busy ) { Log.Info( "[nz-gamemode] the map is still loading" ); return false; }
var map = NZMap.Current;
var all = For( map );
var want = (name ?? "").Trim();
// ⚠️ BY FILE NAME FIRST, THEN BY WHAT THE LIST CALLS IT, so `nz_gamemode` takes either
var mode = all.FirstOrDefault( m => m.Name.Equals( want, StringComparison.OrdinalIgnoreCase ) )
?? all.FirstOrDefault( m => m.Label.Equals( want, StringComparison.OrdinalIgnoreCase ) );
if ( mode is null )
{
Log.Warning( $"[nz-gamemode] no gamemode '{want}' on {map} — "
+ (all.Count == 0 ? "it ships none" : "it has " + string.Join( ", ", all.Select( m => m.Name ) )) );
return false;
}
// ⚠️ THE ONE ALREADY PLAYED IS NOT TAKEN AGAIN: taking it would rebuild the map and send it to everyone for nothing
if ( IsActive( mode ) && ReferenceEquals( ActiveConfig.Current, _chosen ) )
{
Log.Info( $"[nz-gamemode] {map}: already playing '{mode.Label}'" );
return true;
}
var cfg = MapConfig.Load( map, mode.Name, shippedOnly: true );
if ( cfg is null ) return false;
// ⚠️ NAMED AFTER ITS FILE, which is what the list matches it by. A file copied by hand can carry another name inside
// it: gm_grid_d's `a.json` says "A". And stamped with its map where it has none, which is how `Active` knows it loaded.
cfg.Name = mode.Name;
if ( string.IsNullOrEmpty( cfg.Map ) ) cfg.Map = map;
ActiveConfig.Set( cfg );
_chosen = ActiveConfig.Current;
// ⚠️ AND TO EVERYONE, as `nz_load` does. Only once it has loaded, or a client is handed whatever was there before.
NZNet.SendConfig();
Log.Info( $"[nz-gamemode] {map}: playing '{mode.Label}' ({mode.Name}.json)" );
return true;
}
/// <summary>The map Map select is loading for a gamemode it was asked for, and that gamemode (`PlayOn`). `Tick` starts that
/// one there rather than the map's first. Null otherwise.</summary>
static string _wantMap, _wantMode;
/// <summary>
/// Play a map on a gamemode: Map select's one click on the published side (2026-10-05). The user: *"When I choose a map, it
/// should then replace the maps on the left with the gamemodes available for that map, these being the config games, when I
/// click one it loads that gamemode"*. The map is loaded first when it is not the one on, then the gamemode is taken
/// (`Select`, which hands it to everyone), then a changed map goes to everyone.
///
/// ⚠️ THE WANTED GAMEMODE IS SAID BEFORE THE LOAD. `Tick` starts a map on its first gamemode as soon as the load leaves
/// nothing playable, and it can run between the load finishing and this method resuming. Told what is wanted, it starts that
/// one, and the `Select` below finds it already playing.
///
/// ⚠️ MAP SELECT'S ORDER FOR A MAP CHANGE, AS IT WAS: the host loads, the config goes out, then the clients rejoin onto the new
/// map (`NZNet.ChangeMapForEveryone`), which is the only way a map reaches them.
/// </summary>
public static async Task<bool> PlayOn( string mapName, string mode )
{
if ( NZGame.IsClient ) { Log.Info( "[nz-gamemode] the host chooses the map and the gamemode" ); return false; }
if ( NZGame.IsSurvival || MapLoading.Active ) { Log.Info( "[nz-gamemode] not during a game — the gamemode changes in the lobby" ); return false; }
if ( NZMap.Busy ) { Log.Info( "[nz-gamemode] the map is still loading" ); return false; }
if ( string.IsNullOrWhiteSpace( mapName ) ) return false;
var changing = !NZMap.CurrentRaw.Equals( mapName, StringComparison.OrdinalIgnoreCase );
if ( changing )
{
_wantMap = NZMap.KeyFor( mapName );
_wantMode = mode;
var loaded = await NZMap.Load( mapName );
if ( !loaded )
{
_wantMap = _wantMode = null;
return false;
}
}
var picked = Select( mode );
_wantMap = _wantMode = null;
if ( changing )
{
// ⚠️ THE CONFIG GOES OUT EVEN WHEN THE PICK FAILED, as it always did after a map change: the load reset it, and a client
// must not be left holding the old map's.
if ( !picked ) NZNet.SendConfig();
NZNet.ChangeMapForEveryone( mapName );
}
return picked;
}
/// <summary>
/// Start the map on its first gamemode whenever nothing playable is loaded: on the host of a published copy, outside a
/// game. Called every frame from `LobbyMenu.OnUpdate`, the one update that runs in every mode.
///
/// ⚠️ IT NEVER REPLACES A CONFIG THAT CAN BE PLAYED, nor one that was picked. On the editor's player side (`nz_side`),
/// the editor's own config stays loaded, unsaved edits and all, until a gamemode is picked.
///
/// ⚠️ AND IT WAITS FOR THE MAP, because taking a config rebuilds the nav mesh over whatever geometry is there
/// (`NZGame.ShowConfig`). The same test `NZNet` uses: the instance says it is loaded and has a world under it.
/// </summary>
public static void Tick()
{
if ( !Edition.IsPublished || NZGame.IsClient ) return;
if ( NZMap.Busy || MapLoading.Active || NZGame.IsSurvival ) return;
// ⛔ NEVER ON A MAP THAT ISN'T READY (2026-10-05). The gamemode scene opens on countdown, which a published copy doesn't
// offer: the host moves to the first ready map (`MapLibrary.Offered`, Ignis Aeternus) and starts its first gamemode, as
// Map select would (`PlayOn`). Once per map it starts from, so a load that fails isn't retried every frame.
if ( !MapLibrary.IsOffered( NZMap.CurrentRaw ) )
{
var here = NZMap.Instance;
if ( here.IsValid() && (!here.IsLoaded || here.GameObject.Children.Count == 0) ) return;
var onMap = NZMap.CurrentRaw;
if ( _redirectedFrom == onMap ) return;
_redirectedFrom = onMap;
var target = MapLibrary.Offered.FirstOrDefault();
if ( target is null )
{
Log.Warning( "[nz-gamemode] no map is marked ready in maps/manifest.json — players have nothing to play" );
return;
}
var startOn = For( NZMap.KeyFor( target.MapName ) ).FirstOrDefault()?.Name ?? "default";
Log.Info( $"[nz-gamemode] '{NZMap.Current}' isn't ready for players — opening {target.Name} instead" );
_ = PlayOn( target.MapName, startOn );
return;
}
var cur = ActiveConfig.Current;
if ( ActiveConfig.IsPlayable || ReferenceEquals( cur, _chosen ) || ReferenceEquals( cur, _triedOn ) ) return;
var inst = NZMap.Instance;
if ( inst.IsValid() && (!inst.IsLoaded || inst.GameObject.Children.Count == 0) ) return;
_triedOn = cur;
var map = NZMap.Current;
var all = For( map );
// ⚠️ THE ONE MAP SELECT ASKED FOR, while it loads this map for it (`PlayOn`); else the map's first
var first = (_wantMap == map ? all.FirstOrDefault( m => m.Name.Equals( _wantMode, StringComparison.OrdinalIgnoreCase ) ) : null)
?? all.FirstOrDefault();
if ( first is null )
{
Log.Warning( $"[nz-gamemode] {map} ships no gamemode, so there is nothing to play on it. A config in "
+ $"Assets/configs/{map}/ is one (ship_data.py --apply copies the editor's)" );
return;
}
Log.Info( $"[nz-gamemode] {map}: no gamemode loaded — starting on '{first.Label}'" );
Select( first.Name );
}
/// <summary>Forget what was picked and tried — `nz_side` switching lobbies.</summary>
public static void Forget()
{
_chosen = null;
_triedOn = null;
_redirectedFrom = null;
}
/// <summary>The unready map <see cref="Tick"/> last moved off, so it moves once rather than every frame.</summary>
static string _redirectedFrom;
/// <summary>
/// `nz_gamemode [name]` — the map's gamemodes, or (host, published lobby) play one on the map that is on: Map select's gamemode
/// list as a command.
///
/// ⚠️ IN THE EDITOR IT LISTS AND STOPS. The list is exactly what a published copy will offer, which is worth seeing before
/// a publish; loading belongs to `nz_load` there, and `nz_side` is the way to try picking one.
/// </summary>
[ConCmd( "nz_gamemode" )]
public static void GamemodeCmd( string name = "" )
{
if ( !string.IsNullOrWhiteSpace( name ) )
{
if ( Edition.CanBuild )
{
Log.Info( "[nz-gamemode] the editor loads configs with nz_load. nz_side switches to the player's lobby, where this picks one" );
return;
}
Select( name );
return;
}
var map = NZMap.Current;
var all = For( map );
Log.Info( $"[nz-gamemode] {map}: {all.Count} gamemode(s)"
+ (Edition.IsPublished ? "" : " — what a published copy lists (Assets/configs)") );
foreach ( var m in all )
Log.Info( $"[nz-gamemode] {(IsActive( m ) ? "▶" : " ")} {m.Label,-24} {m.Name}.json"
+ (string.IsNullOrWhiteSpace( m.Description ) ? "" : " · " + m.Description) );
if ( Active is { } a && !all.Any( IsActive ) )
Log.Info( $"[nz-gamemode] ▶ loaded now: '{a.Label}' ({a.Name}), which this map does not ship" );
}
}