UI Lobby menu Razor component for the game's lobby overlay. Renders the full-screen lobby UI, player list and character preview, drives ready/start countdown, plays lobby music, manages preview stage objects and reacts to input and networked state.
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Collections.Generic;
@using System.Linq;
@using NZombies;
@inherits PanelComponent
@*
LOBBY — fullscreen overlay, laid out like the original's main menu.
Options run down the TOP LEFT, the player list sits on the RIGHT, over a
full-bleed background image. Open by default; L toggles it once in game.
Flow: pick a mode -> Creative drops you into the world as it is now, Q opens
the dev/build menu, L brings this back. So the lobby is never "exited",
only hidden — which is why it is an overlay rather than a separate scene.
⚠️ UI ONLY. Ready flips a local bool, the mode switch changes a label, and
config load/save log a line. Wiring comes after the layout is agreed.
*@
<root class="lobby @(Open ? "" : "hidden")">
@* ⚠️ COUNTED IN THE MARKUP BECAUSE THE MARKUP IS WHAT RUNS PER RENDER. A hook on the component
would need an engine callback this project has never used; this line executes exactly once
each time the tree is rebuilt, which is the number `LobbyProbe` wants. *@
@{ LobbyProbe.Renders++; }
<div class="bg" style="background-image: url( @CurrentBackground );"></div>
<div class="scrim"></div>
<div class="content">
<div class="left">
<div class="brand">nZombies</div>
@* ⚠️ WAS THE LITERAL "countdown", now the live map. A LABEL, not a control — making
the title clickable was tried and it is too quiet to find: the only hint was a word
that appeared on hover. The action belongs in the menu with the other actions. *@
@* ⚠️ THE MAP'S NAME AS PLAYERS READ IT (2026-10-05), not its key: `MapLabel`. *@
<div class="mapname">@MapLabel</div>
@* ⚠️ THE GAMEMODE BEING PLAYED, READ-ONLY, UNDER THE MAP NAME (published side, 2026-10-05). The Gamemode row went: a
gamemode is chosen with its map in Map select, and everyone reads here which one the host chose. *@
@if ( Edition.IsPublished && !string.IsNullOrEmpty( GamemodeLine ) )
{
<div class="modename">@GamemodeLine</div>
}
@* ⚠️ THE EDITOR ON THE PLAYER'S SIDE SAYS SO (`nz_side`, 2026-10-05). The switch outlives a play restart, and a
lobby with no Creative and no Save config, with nothing to say why, reads as broken. Never drawn in a
published copy, which has no other side. *@
@if ( Edition.Previewing )
{
<div class="sidetag">PLAYER SIDE · what the published game shows · nz_side to switch back</div>
}
@* ⛔ ITS OWN GROUP, AND FIRST. Which map you are on decides what every other row in
this menu acts on — Creative edits that map's config, Load config lists that map's
configs, Ready up starts a round on it. Putting it below them would have you choose
a setup before choosing the thing it belongs to. *@
@* ⛔ HIDDEN FROM A CLIENT, along with Creative and both config rows. §3: the client's
lobby is three controls — character, ready, spectate. Hiding is the COSMETIC half;
each of these also refuses in its command, because the console reaches them
directly and a rule enforced only in the UI is enforced on one path. *@
@if ( NZGame.IsHost )
{
<div class="group">Map</div>
<div class="opt" onmouseover=@Hover
onclick=@( () => Click( "Map", () => MapBrowserState.OpenCmd( "auto" ) ) )>
<div class="opt-label">Change map</div>
<div class="opt-desc">@MapRowDesc</div>
</div>
}
@* ⚠️ THE DIFFICULTY, AFTER THE MAP AND ITS GAMEMODE (2026-10-05): those decide what is played, this decides how hard.
Host-only, like the Map row: the host decides it (the user, 2026-10-05), and a game started takes it for everyone
(`Difficulty.StartMatch`). *@
@if ( NZGame.IsHost )
{
<div class="group">Difficulty</div>
<div class="opt" onmouseover=@Hover
onclick=@( () => Click( "Difficulty", DifficultyState.Open ) )>
<div class="opt-label">@DifficultyState.Label</div>
<div class="opt-desc">Zombie health, damage, horde size and more · click to change</div>
</div>
}
@* No Survival button — readying up IS starting survival. A mode
picker with one real choice in it is just a button wearing a
costume, and it would imply you can be "in survival" without
having readied, which is not a state that exists. *@
<div class="group">Session</div>
@* ⚠️ The click handler is still wired when disabled — LobbyCommands.Ready
refuses on its own. Removing the handler here would leave the console
path unguarded, and a rule enforced in two places disagrees eventually.
The class only makes the refusal VISIBLE. *@
<div class="opt strong @(LocalReady ? "on" : "") @(CanReady ? "" : "disabled")"
onmouseover=@Hover
onclick=@( () => Click( "Ready", () => LobbyCommands.Ready( !LocalReady ) ) )>
<div class="opt-label">@(LocalReady ? "Unready" : "Ready up")</div>
<div class="opt-desc">@GateText</div>
</div>
@* ⚠️ ONE ROW THAT OPENS A LIST, not four rows always on screen. Four names permanently
in a seven-item menu makes the character the loudest thing in the lobby, and it is a
once-a-session choice. Collapsed it reads as one decision; expanded it is the only
thing you are looking at. *@
<div class="group">Character</div>
<div class="opt @(CharacterOpen ? "on" : "")"
onmouseover=@Hover
onclick=@( () => Click( "Character", () => CharacterOpen = !CharacterOpen ) )>
<div class="opt-label">@(MyCharacter?.Name ?? "Choose character")</div>
<div class="opt-desc">@(MyCharacter is null
? "Sets your body, hands and voice"
: "Click to change")</div>
</div>
@if ( CharacterOpen )
{
<div class="charlist">
@* ⚠️ GROUPED, NOT ONE LIST OF TWELVE. The row above collapses to a single
decision; twelve names under it would undo that. Each set header names the
four under it, so the "(Ultimis)" the id-level names carry is dropped here —
it disambiguates in the collapsed row, where there is no header to do it.
⛔ THE GROUPS THIS MAP OFFERS, NOT ALL THREE (2026-10-05): a Primis map offers the Primis four only
(`PlayerCharacters.SetsHere`). *@
@foreach ( var set in PlayerCharacters.SetsHere() )
{
<div class="charset">@set.Name</div>
@foreach ( var c in set.Members )
{
<div class="charopt @(IsMe( c ) ? "on" : "")"
onmouseover=@Hover
onclick=@( () => Click( c.Name, () => PickCharacter( c ) ) )>
@c.Name.Replace( $" ({set.Name})", "" )
</div>
}
}
@* ⚠️ AN EXPLICIT WAY BACK, now that clicking the chosen one no longer has to
double as "clear" — the list has room for it and a hidden toggle does not
announce itself. Not on a Primis map, where the four are the whole list
(`PlayerCharacters.AvatarChoosableHere`). *@
@if ( MyCharacter is not null && PlayerCharacters.AvatarChoosableHere )
{
<div class="charopt clear"
onmouseover=@Hover
onclick=@( () => Click( "None", () => PickCharacter( null ) ) )>
@* ⚠️ IT IS YOUR OWN s&box AVATAR NOW, NOT THE CITIZEN (2026-10-05, `PlayerCharacters.AvatarBody`). *@
Your own avatar
</div>
}
</div>
}
@* ⚠️ THE INFO BOOKLET (2026-10-05): what every perk, augment and Arsenal page does, for new players. EVERYONE'S, host or
client, and in a game too, where L opens this menu. `BookletState`, `BookletPage`. *@
<div class="group">Info</div>
<div class="opt" onmouseover=@Hover onclick=@( () => Click( "Info booklet", BookletState.Open ) )>
<div class="opt-label">Info booklet</div>
<div class="opt-desc">Perks and their augments, armor, rarity, ammo mods and weapon tech</div>
</div>
@* ⚠️ THE GROUP HEADER STAYS — Spectate lives under it and a client keeps that one. *@
<div class="group">Build</div>
@* ⛔ AND THE EDITOR'S ALONE (`Edition`, 2026-10-05): the published game builds nothing *@
@if ( NZGame.IsHost && Edition.CanBuild )
{
<div class="opt" onmouseover=@Hover onclick=@( () => Click( "Creative", LobbyCommands.Creative ) )>
<div class="opt-label">Creative</div>
<div class="opt-desc">Build and edit the map config</div>
</div>
}
<div class="opt" onmouseover=@Hover onclick=@( () => Click( "Spectate", LobbyCommands.Spectate ) )>
<div class="opt-label">Spectate</div>
<div class="opt-desc">Fly the map with no body in it</div>
</div>
@* The whole group goes on a client — both rows are host-only, so an empty
"Config" header would just be a heading over nothing. *@
@* ⛔ AND IN THE PUBLISHED GAME, where a gamemode is chosen with its map in Map select (`Edition`, 2026-10-05) *@
@if ( NZGame.IsHost && Edition.CanBuild )
{
<div class="group">Config</div>
<div class="opt" onmouseover=@Hover onclick=@( () => Click( "Load config",
() => ConfigBrowserState.Open( ConfigBrowserState.Mode.Load ) ) )>
<div class="opt-label">Load config</div>
<div class="opt-desc">Browse, load or delete saved setups</div>
</div>
<div class="opt" onmouseover=@Hover onclick=@( () => Click( "Save config",
() => ConfigBrowserState.Open( ConfigBrowserState.Mode.Save ) ) )>
<div class="opt-label">Save config</div>
<div class="opt-desc">Write this map's setup under a name</div>
</div>
}
@* ⚠️ NO BUILD MENU IN THE PUBLISHED GAME, so no Q in its hint *@
<div class="hint">@(Edition.CanBuild ? "L to close · Q for build menu" : "L to close")</div>
</div>
<div class="middle">
<ScenePanel @ref=Preview class="preview"></ScenePanel>
</div>
<div class="right">
<div class="group">Players <span class="dim">@ReadyCount/@Players.Count</span></div>
@foreach ( var p in Players )
{
<div class="player @(p.Ready ? "ready" : "") @(p.IsLocal ? "you" : "")">
<div class="tick">@(p.Ready ? "●" : "○")</div>
@* ⛔ A LONG NAME GETS SMALLER UNTIL IT FITS (2026-10-05): *"if the name is too long, the font size decreases so it
always fits"*. Sized before it is drawn, from Inter's own widths (`NameFit`), never cut short. *@
<div class="pname" style="font-size: @(NameFit.Size( p.Name, NameRoom ))px;">@p.Name</div>
@* ⛔ READY OR NOT, AND NOTHING ELSE (2026-10-05): *"they must not have the character name when they select it"*. The
character's name beside the state squeezed the player's own name into two or three lines. *@
<div class="pstate">@(p.Ready ? "Ready" : "Not ready")</div>
</div>
}
</div>
</div>
@* Save/load modal. A child Panel so it needs no GameObject — and inside the
lobby's root so its scrim covers the menu behind it. *@
<ConfigBrowser />
@* Map picker, same arrangement. ⚠️ AFTER ConfigBrowser so that if both were somehow open the
map browser wins — it is the one that changes what every other button in this menu acts on. *@
<MapBrowser />
@* The difficulty page (2026-10-05), the same arrangement as the map picker. *@
<DifficultyPage />
@* The info booklet (2026-10-05), the same arrangement again. *@
<BookletPage />
</root>
@code
{
/// <summary>The lobby's music: the loaded map's own (`Gameplay.LobbyMusic`), or the game's tracks.</summary>
static string LobbyCue => NZSound.MapCue( ActiveConfig.Current?.Gameplay?.LobbyMusic, NZSound.MusicLobby );
/// <summary>Shown by default — the lobby is what you land on.</summary>
[Property] public bool Open { get; set; } = true;
/// <summary>Full-bleed background. Any wide image works — a [Property] so
/// it can be swapped without touching code.
///
/// ⚠️ THE FALLBACK NOW, not the background itself. A map may name its own in the manifest; see
/// <see cref="CurrentBackground"/>. This stays a [Property] because it is what a map WITHOUT one
/// gets, and that is still worth being able to swap from the inspector.</summary>
[Property] public string Background { get; set; } = "ui/lobby_bg.png";
/// <summary>
/// The map's name under the brand, as players read it (2026-10-05): the name the loading screen and the opening card
/// already give it (`MapLoading.Title`: the config's title, basalt's "Ignis Aeternus", else the manifest's name). It was
/// the map's KEY, "ttt_basalt_d". ⚠️ THE DEVELOPER SIDE KEEPS THE KEY BESIDE IT: configs are filed under it, and `nz_map`
/// and the tools take it.
/// </summary>
static string MapLabel
{
get
{
var name = MapLoading.Title;
var key = NZMap.Current;
if ( string.IsNullOrWhiteSpace( name ) ) return key;
return Edition.CanBuild && !string.Equals( name, key, StringComparison.OrdinalIgnoreCase )
? $"{name} · {key}"
: name;
}
}
/// <summary>
/// The background to actually draw: the loaded map's, or this panel's default.
///
/// ⚠️ RESOLVED PER RENDER RATHER THAN CACHED ON MAP CHANGE. BuildHash already includes
/// NZMap.Current, so the panel re-renders whenever the map changes anyway — caching it would add
/// a second thing that has to be invalidated at the same moment, and the two would eventually
/// disagree. Reading it here cannot go stale.
///
/// ⚠️ AN EMPTY MANIFEST VALUE FALLS BACK, so a map with no background of its own keeps the
/// lobby's rather than rendering an empty div.
/// </summary>
public string CurrentBackground
{
get
{
var forMap = MapLibrary.BackgroundFor( NZMap.Current );
return string.IsNullOrWhiteSpace( forMap ) ? Background : forMap;
}
}
/// <summary>
/// Am I ready? Reads and writes the SHARED table, so there is one source of truth.
///
/// ⛔ NOT A STORED BOOL ANY MORE, AND THE SETTER IS WHY. Every existing
/// `LocalReady = false` — the lobby reset, the run starting — now announces itself for free.
/// A local mirror beside the networked table would have been two values that agree until one
/// of those resets forgets to send, and the symptom would be a button that says "Ready up"
/// while everyone else sees you ready.
/// </summary>
[Property]
public bool LocalReady
{
get => NZNet.IsReady( Connection.Local );
set => NZNet.SetReady( value );
}
/// <summary>The original's default (sh_lobby.lua, Lobby_Percent = 75).
/// A setting there, so a property here.</summary>
[Property, Range( 0, 100 )] public int ReadyPercent { get; set; } = 75;
record Slot( string Name, bool Ready, bool IsLocal, string CharacterId, Connection Conn )
{
/// <summary>The display name for this slot's character, or "" for the default body.</summary>
public string Character => PlayerCharacters.Find( CharacterId )?.Name ?? "";
}
/// <summary>
/// Everyone actually connected.
///
/// ⚠️ Was a hardcoded roster of eight — "Player 2" through "Player 8" —
/// added so the layout could be judged with content in it. That is fine
/// while designing and actively misleading afterwards: it showed a full
/// lobby on an empty server, and the ready gate counted fake players, so
/// "6 more needed" was arithmetic on people who did not exist.
///
/// Connection.All returns just Connection.Local when not on a server, so
/// singleplayer correctly shows exactly one row.
///
/// ⚠️ Ready state now REPLICATES — see `NZNet.SetReady`. This list is still built from
/// `Connection.All`, which is the engine's own truth about who is here.
/// </summary>
List<Slot> Players => Connection.All
.Select( c => new Slot( c.DisplayName, ReadyOf( c ), c == Connection.Local,
// ⚠️ THE SHARED TABLE FIRST, THE LOCAL PLAYER'S OWN FIELD AS A FALLBACK. A character
// set through the console rather than the menu never went through `SetCharacter`, so
// the table would not know about it — for yourself, `CharacterId` still does.
NZNet.CharacterOf( c ) is string id && !string.IsNullOrEmpty( id ) ? id
: c == Connection.Local ? Me()?.CharacterId ?? "" : "",
// ⚠️ AND THE CONNECTION ITSELF (2026-10-05), so each preview can wear that player's own s&box avatar.
c ) )
.ToList();
/// <summary>Ready state for any connection — the shared table, filled by
/// `NZNet.SetReady` broadcasts.</summary>
bool ReadyOf( Connection c ) => NZNet.IsReady( c );
int ReadyCount => Players.Count( p => p.Ready );
/// <summary>How many must be ready before the game starts.
/// (sh_lobby.lua, Lobby_Percent = 75.)</summary>
int NeededReady => (int)MathF.Ceiling( Players.Count * (ReadyPercent / 100f) );
bool GateMet => Players.Count > 0 && ReadyCount >= NeededReady;
/// <summary>
/// The px a player's name has in its row (`NameFit`). The list is 400 wide (`.right`), less:
/// - the row's 16 px padding on each side;
/// - the 18 px tick;
/// - the name's 6 px margin;
/// - the state's 12 px margin and its widest word ("Not ready" at 13 px is 61 px);
/// - your own row's 2 px rule.
/// ⚠️ CHANGE IT WITH THE STYLESHEET, or a long name is sized for a row that no longer exists.
/// </summary>
const float NameRoom = 400f - 32f - 18f - 6f - 12f - 61f - 2f;
/// <summary>
/// Seconds until the game starts, or null when no countdown is running.
///
/// ⚠️ Nullable rather than a sentinel like -1. "Not counting down" and
/// "counting down, 0 left" are genuinely different states and the second one
/// exists for a frame — collapsing them starts the game twice.
/// </summary>
TimeUntil? _startsIn;
/// <summary>
/// Last whole second the countdown announced, or -1 when not counting.
///
/// ⚠️ THE TICK IS DRIVEN OFF THIS, NOT OFF A TIMER. TickCountdown runs every
/// frame, so "play a tick" has to fire on the SECOND CHANGING — anything
/// based on elapsed time drifts against the number actually on screen, and
/// the sound would land off the digit the player is reading.
/// </summary>
int _lastTick = -1;
/// <summary>The grace period before a lobby that has hit its threshold
/// actually starts. Long enough to un-ready if someone mis-clicked.</summary>
[Property] public float CountdownSeconds { get; set; } = 5f;
/// <summary>
/// Can this lobby be readied up at all?
///
/// ⚠️ Asked of ActiveConfig rather than tracked here — the lobby is not the
/// only way into a game (nz_start, the console, a future map vote) and a
/// check that lives in the UI only guards the one path it can see.
/// </summary>
static bool CanReady => ActiveConfig.IsPlayable;
string GateText
{
get
{
// ⚠️ FIRST, ahead of the countdown and the ready count. Those describe
// progress toward a start that cannot happen — telling someone "2 more
// needed" when the map has no zombie spawns sends them to find another
// player instead of to the editor.
// ⚠️ IN A PLAYER'S WORDS IN THE PUBLISHED LOBBY (`Edition`, 2026-10-05), which has no editor to send them to
if ( !CanReady )
return Edition.IsPublished ? Gamemodes.ReadyProblem : ActiveConfig.PlayableProblem;
if ( _startsIn is TimeUntil t )
return $"Starting in {MathF.Ceiling( t ):0}…";
var missing = Math.Max( 0, NeededReady - ReadyCount );
return missing == 0
? "Ready"
: $"{missing} more needed ({ReadyPercent}%)";
}
}
/// <summary>
/// Drive the countdown, and start the game when it runs out.
///
/// ⚠️ CANCELS IF THE GATE STOPS BEING MET. Un-readying during the countdown
/// has to call it off, or the lobby starts a game nobody is waiting for —
/// and the only way out would be to let it start and then stop it.
/// </summary>
void TickCountdown()
{
// ⛔ NOT UNDER THE LOADING SCREEN. `GameStarting` cleared the ready table, so the gate reads as broken and this would call
// off a start that has already been given — and lift the black the loading screen goes to.
if ( MapLoading.Active ) return;
// ⚠️ GATED ON THE PANEL BEING OPEN, NOT ON GameMode.Lobby.
//
// The lobby is an OVERLAY, not a mode — entering Creative sets the mode
// and L brings the panel back without changing it. Checking the mode
// meant that build-then-play (creative → L → ready) could never start
// a game: the countdown early-returned forever and the button just sat
// there saying "Ready".
//
// Survival is the one mode that must not re-trigger it — TAB-ing the
// lobby up mid-game and being still marked ready would restart round 1
// underneath you.
if ( !Open || NZGame.Mode == GameMode.Survival )
{
// ⚠️ A COUNTDOWN CUT SHORT — the lobby shut on it — calls off its fade to black. The spawn-in is never one: StartGame
// clears the countdown before it closes the lobby.
if ( _startsIn is not null ) ScreenFade.CallOff();
_startsIn = null; _lastTick = -1; return;
}
if ( !GateMet )
{
if ( _startsIn is not null )
{
_startsIn = null;
_lastTick = -1;
ScreenFade.CallOff();
Log.Info( "[nz] start cancelled — not enough players ready" );
}
return;
}
if ( _startsIn is null )
{
_startsIn = CountdownSeconds;
Log.Info( $"[nz] {ReadyCount}/{Players.Count} ready — starting in "
+ $"{CountdownSeconds:0}s" );
return;
}
// ⚠️ CEILING, TO MATCH THE DIGIT ON SCREEN. The panel renders
// `(int)MathF.Ceiling( t )`, so anything else here would tick on a
// different boundary than the number the player is watching.
int secs = (int)MathF.Ceiling( _startsIn.Value );
if ( secs != _lastTick && secs >= 1 )
{
_lastTick = secs;
NZSound.PlayUi( NZSound.UiCountdown );
}
// ⛔ THE SPAWN-IN BEGINS HERE — *"the screen to fade to black and then slowly fade into the game"*. The countdown's last
// moments take the screen to black, on every machine from its own countdown, so the lobby closes and everyone is placed
// behind it; `StartGame` then holds the black and fades up (`ScreenFade`).
if ( _startsIn.Value <= ScreenFade.SpawnOutSeconds ) ScreenFade.ToBlack( MathF.Max( 0f, _startsIn.Value ) );
if ( _startsIn.Value > 0f ) return;
// ⛔ THE HOST ANNOUNCES IT; NOBODY STARTS THEMSELVES. Every machine runs this
// countdown so everyone SEES the same digits, but only one of them is allowed to act on
// reaching zero — see `NZNet.GameStarting`. Acting locally is what let the fastest
// machine start alone and strand everyone else in the lobby.
if ( NZGame.IsHost ) NZNet.GameStarting( MapLoading.SecondsFor() );
}
/// <summary>
/// The host's word that the game starts (`NZNet.GameStarting`): the loading screen first, if this map has one
/// (`Gameplay.LoadingSeconds`), and the game when it is done (`OnUpdate`) — *"after the players are ready and before the
/// players spawn in, theres 10 seconds of loading screen"* (2026-09-28).
///
/// ⚠️ THE COUNTDOWN IS CLEARED HERE, as StartGame clears it, so nothing counts on under the screen; and the countdown's fade
/// to black is lifted onto it.
/// </summary>
void BeginLoading()
{
var seconds = MapLoading.TakeSeconds();
if ( seconds <= 0f )
{
StartGame();
return;
}
_startsIn = null;
_lastTick = -1;
MapLoading.Begin( seconds );
LoadingScreenHud.EnsureHost();
ScreenFade.CallOff( 0.6f );
}
/// <summary>
/// Close the lobby and hand over to the round loop.
///
/// ⚠️ Clears the countdown FIRST. StartGame changes the mode, which this
/// panel also watches — and a second call while the first was still in
/// flight would restart round 1 on top of itself.
/// </summary>
void StartGame()
{
// ⛔ THE SPAWN-IN: black already, from the countdown's last moments — held while the game is set up behind it, then faded
// up slowly into the game (`ScreenFade.SpawnIn`). Every machine comes here, on the host's `GameStarting`.
ScreenFade.SpawnIn();
_startsIn = null;
_lastTick = -1;
// ⚠️ Un-ready on start. Ready is a request to begin, and it has been
// granted — leaving it set means TAB-ing the lobby back up after the
// game has ended immediately counts down and starts another one, with
// no way to look at the menu without triggering it.
//
// ⛔ CLEARED LOCALLY, NOT THROUGH `LocalReady`. That property broadcasts now, so
// un-readying here would have told every other machine the gate had broken — which is
// precisely how the fastest machine used to strand the rest. `NZNet.GameStarting` already
// cleared this machine's table before calling in; this line is belt and braces for the
// solo path, where no broadcast happened at all.
NZNet.ClearReady();
Open = false;
_userOpened = false;
Mouse.Visibility = MouseVisibility.Auto;
// ⚠️ THE ROUND LOOP IS THE HOST'S. A client entering survival needs the lobby closed
// and a body; it must not start a second round manager counting its own zombies.
if ( NZGame.IsHost ) RoundCommands.Start();
else NZGame.SetMode( GameMode.Survival );
}
/// <summary>
/// Every lobby button goes through here so a click ALWAYS prints, before
/// anything else can go wrong.
///
/// ⚠️ This is the difference between "the button is broken" and "the click
/// never arrived", which look identical from the outside and have nothing
/// in common as bugs. Without it, a button whose handler silently no-ops
/// (Load config, which only logs) is indistinguishable from a panel that is
/// not receiving input at all.
/// </summary>
void Click( string what, System.Action then )
{
Log.Info( $"[nz-ui] clicked '{what}'" );
// Every lobby button comes through here, so the click sound is one line
// rather than one per button — and cannot be forgotten on a new button.
NZSound.PlayUi( NZSound.UiClick );
try { then?.Invoke(); }
catch ( System.Exception e ) { Log.Warning( $"[nz-ui] '{what}' threw — {e.Message}" ); }
}
/// <summary>
/// Hover sound for an option.
///
/// ⚠️ ON THE ROW, NOT ON ITS CHILDREN. `onmouseover` fires again for every
/// nested element the pointer crosses, so putting this on the label as well
/// as the row would retrigger mid-hover and machine-gun the cue.
/// </summary>
void Hover()
{
NZSound.PlayUi( NZSound.UiHover );
}
/// <summary>Is this the character the local player has chosen?</summary>
bool IsMe( NZCharacter c )
=> c is not null
&& Me()?.CharacterId is string id
&& id.Equals( c.Id, StringComparison.OrdinalIgnoreCase );
// ⚠️ `PlayerCharacters.Local`, NOT a scene query. The lobby runs while the player object is
// DISABLED, and `GetAllComponents` does not see it there — which is why selecting a character
// from this menu reported "no player".
static NZPlayer Me() => PlayerCharacters.Local();
/// <summary>
/// Choose a character, or unchoose the one already selected.
///
/// ⛔ IT GOES THROUGH THE SAME PATH THE CONSOLE COMMAND USES. Setting `CharacterId` here and
/// calling the appliers by hand would be a second author for "what picking a character means" -
/// and the moment a fourth thing hangs off it (a HUD portrait, a name tag) only one of them
/// would learn about it.
///
/// ⚠️ CLICKING THE SELECTED ONE CLEARS IT, so there is a way back to the Citizen without a
/// separate "none" row taking up space in a four-item list.
/// </summary>
/// <summary>
/// Preview light strength for a ported body, against 40/18 for the Citizen.
///
/// ⚠️ STATIC AND TUNABLE BECAUSE THIS IS A LOOK, NOT A DERIVED VALUE. It compensates for an
/// approximated roughness rather than measuring anything, so the only test is the screen —
/// `nz_lobby_light 6 3` walks it in without a recompile.
/// </summary>
public static float CharacterKey { get; set; } = 1f;
public static float CharacterFill { get; set; } = 2.5f;
/// <summary>`nz_lobby_light <key> [fill]` — retune the character preview lights.</summary>
[ConCmd( "nz_lobby_light" )]
public static void LightCmd( float key = -1f, float fill = -1f )
{
if ( key >= 0f ) CharacterKey = key;
if ( fill >= 0f ) CharacterFill = fill;
Log.Info( $"[nz-lobby] character preview lights: key {CharacterKey:0.#}, fill {CharacterFill:0.#}" );
Log.Info( "[nz-lobby] reopen the picker (or pick again) to restage" );
}
/// <summary>
/// Is a ported character body on the stage right now?
///
/// ⚠️ ONLY THE LOCAL PLAYER CAN BE ONE — `CharacterId` is not networked — so this is the same
/// question `BuildStage` asks per slot, hoisted so the LIGHTS can be set before the bodies are
/// created. They are, which is why it cannot simply be read off the loop.
/// </summary>
bool HasCharacterOnStage => PlayerCharacters.Find( Me()?.CharacterId ) is not null;
/// <summary>Is the picker list showing?</summary>
bool CharacterOpen { get; set; }
/// <summary>Since my pick was last checked against this map's cast (`OnUpdate`).</summary>
TimeSince _castCheck;
/// <summary>
/// The read-only line under the map name on the published side: the gamemode being played (`Gamemodes.Active`), or "" when none
/// is loaded. ⚠️ THE GAMEMODE ROW WENT (2026-10-05): a gamemode is chosen with its map, in Map select.
/// </summary>
static string GamemodeLine => Gamemodes.Active?.Label ?? "";
/// <summary>The Change map row, second line. On the published side a map is chosen with a gamemode (2026-10-05).</summary>
static string MapRowDesc => Edition.IsPublished ? MapBrowserState.Summary + ", then a gamemode" : MapBrowserState.Summary;
/// <summary>Who the local player currently is, or null.</summary>
NZCharacter MyCharacter => PlayerCharacters.Find( Me()?.CharacterId );
void PickCharacter( NZCharacter c )
{
PlayerCharacters.CharacterCmd( c?.Id ?? "none" );
// ⚠️ AND TELL EVERYONE. The body and the voice are things OTHER players look at, so
// a choice that stays on the machine that made it is a choice nobody else can see —
// `MULTIPLAYER.md` §6 lists this beside ready for exactly that reason.
NZNet.SetCharacter( c?.Id ?? "" );
// ⚠️ THE LIST CLOSES ON PICK. It is a one-decision menu; leaving it open after a choice
// means the player has to dismiss something they already finished with.
CharacterOpen = false;
// ⚠️ THE PREVIEW IS REBUILT, NOT NUDGED. It stages one body per player and the model is
// chosen when that body is created; nothing re-reads it afterwards.
_stagedCount = -1;
}
ScenePanel Preview;
GameObject PreviewRoot;
/// <summary>
/// The map's sky stays out of the character preview (see OnTreeFirstBuilt): its camera skips
/// "skybox", and a map sky object placed without that tag gets it — live only, the map scene is
/// not touched.
///
/// ⚠️ WHILE THE LOBBY IS OPEN, NOT ONCE: a hotload keeps this panel without building it again, and
/// changing the map brings a new sky.
/// </summary>
void KeepSkyOutOfPreview()
{
#pragma warning disable CS0618
if ( Preview?.Camera is { } cam && !cam.ExcludeTags.Has( "skybox" ) )
cam.ExcludeTags.Add( "skybox" );
#pragma warning restore CS0618
if ( LobbyState.UpdateTicks % 30 != 0 ) return;
foreach ( var sky in Scene.GetAllComponents<SkyBox2D>() )
if ( !sky.Tags.Has( "skybox" ) )
sky.GameObject.Tags.Add( "skybox" );
}
/// Far off the map so the preview cast cannot be seen, shot or walked into
/// during a round. The map is nowhere near here.
static readonly Vector3 Stage = new( 0f, 0f, 20000f );
/// <summary>
/// Build the lobby's character line-up.
///
/// ⛔ These are REAL GameObjects in the REAL scene, not SceneModels in a
/// private SceneWorld — because ClothingContainer.Apply only accepts a
/// SkinnedModelRenderer. Every overload does. A SceneModel cannot be
/// dressed, so a private world would leave the cast permanently in its
/// underwear. The ScenePanel just points its camera at them instead.
///
/// ⚠️ Built in OnTreeFirstBuilt, not OnStart: the ScenePanel does not exist
/// until the razor tree has rendered once, and assigning World before then
/// quietly does nothing.
/// </summary>
protected override void OnTreeFirstBuilt()
{
if ( Preview is null ) return;
BuildStage();
// ⚠️ SUPPRESSED, NOT MIGRATED — deliberately, and here is why.
//
// ScenePanel.World/.Camera are deprecated in favour of
// ScenePanel.RenderScene ("the Scene this panel renders"). But that
// takes a whole Scene and renders it from ITS camera — there is no
// "render this scene from this transform" — so the migration means
// building the cast in a SEPARATE scene with its own CameraComponent,
// not a two-line swap. Point RenderScene at the game scene as-is and it
// renders the game view instead of the line-up.
//
// The five warnings are noise; the working lobby preview is not. Left
// suppressed until it can be reworked AND tested — see TO_TEST.md.
#pragma warning disable CS0618
Preview.World = Scene.SceneWorld;
Preview.Camera.Rotation = Rotation.FromYaw( 180f );
Preview.Camera.FieldOfView = 34f;
Preview.Camera.BackgroundColor = Color.Transparent;
// ⛔ NOT THE MAP'S SKY. This camera renders the map's own SceneWorld, and the first map with a
// sky object — Laketown's aurora, a SkyBox2D (2026-10-02) — had it drawn behind the cast: a dark
// teal box over the lobby background, where every other map shows its picture. SkyBox2D copies
// its object's tags onto the sky it draws and onto its reflection probe (OnEnabled and
// OnTagsChanged), and a map's sky object is tagged "skybox" — KeepSkyOutOfPreview sees to it.
Preview.Camera.ExcludeTags.Add( "skybox" );
// ⚠️ A FAR PLANE THE STAGE NEVER NEEDED TO BE WITHOUT. The camera sits 300-600 units from
// the cast, and everything else this camera can see is the real map — it renders the
// SHARED SceneWorld, not a private one. The map is 20,000 units below and outside the view
// cone today, so this is insurance rather than the measured fix: it makes "the stage
// camera sees only the stage" true for any map, including one built high enough to reach
// into shot.
Preview.Camera.ZFar = 3000f;
FrameCast();
#pragma warning restore CS0618
}
/// <summary>
/// Point the camera at the cast so the whole body is in shot.
///
/// ⚠️ THE FRAMING WAS HARDCODED FOR EIGHT. Position (300, 0, 40) was picked
/// against a placeholder roster of eight spread over two rows; with a real
/// roster of one, that lone player stands at the FRONT row's depth — closer,
/// and much taller in frame — and their legs ran off the bottom of the
/// panel. A fixed camera cannot frame a cast whose size is not fixed.
///
/// Height aims at mid-body rather than the eye line, so the feet cannot fall
/// out of the bottom however many there are. Distance grows with the row so
/// a full lobby still fits sideways.
/// </summary>
void FrameCast()
{
if ( Preview is null ) return;
var count = Math.Max( 1, Players.Count );
// Half the widest row, plus the back row's depth for a two-row layout.
var perRow = count <= 4 ? count : (count + 1) / 2;
var halfWidth = (perRow - 1) * SPACING * 0.5f;
// Enough distance to fit the row across, floored so a single player is
// a portrait rather than a close-up, and with the back row's depth
// added so nobody is framed out when the layout splits.
var distance = Math.Max( 300f, 150f + halfWidth * 3.2f )
+ (count > 4 ? DEPTH : 0f);
#pragma warning disable CS0618
Preview.Camera.Position = Stage + new Vector3( distance, 0f, DROP + BODY * 0.5f );
#pragma warning restore CS0618
}
void BuildStage()
{
LobbyProbe.StageBuilds++;
PreviewRoot?.Destroy();
PreviewRoot = Scene.CreateObject();
PreviewRoot.Name = "Lobby Preview";
PreviewRoot.WorldPosition = Stage;
PreviewRoot.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;
// ⛔ LOCAL ONLY, AND WITH TWO PLAYERS THIS IS VISIBLE FROM THE FIRST SECOND. The stage
// draws through `Preview.World = Scene.SceneWorld` — it is not a private world, it is a
// camera pointed at a corner of the REAL scene. So every client that builds its own
// preview body puts that body in the shared world, and the lobby shows one character per
// connected player standing in a row. Seen the first time two clients connected: two
// identical bodies side by side. Same rule the 18 effect objects already follow.
PreviewRoot.NetworkMode = NetworkMode.Never;
// Own lighting — the stage is far from the map, so nothing lights it.
// Two angled sources rather than one flat fill, or the cast reads as
// grey cutouts, which is the one thing a character select must not do.
var key = PreviewRoot.Children.FirstOrDefault() ?? Scene.CreateObject();
key.Name = "key";
key.SetParent( PreviewRoot );
key.WorldPosition = Stage + new Vector3( 160f, -90f, 140f );
// ⛔ DIMMER FOR A CHARACTER, AND THE MATERIALS ARE WHY. 40 was tuned against the Citizen,
// whose materials are authored PBR. The ported bodies have an APPROXIMATED roughness —
// `vmt_to_vmat` writes `default_rough.tga` because Source 1 had no roughness map to convert
// — so they take a specular highlight far harder and blow out under the same key light.
//
// ⚠️ THIS IS A LIGHTING FIX FOR A MATERIAL PROBLEM, and worth naming as such. The real fix
// is authored roughness per material; until then the rig is turned down to suit them.
// Brutus wears the same approximation and looks correct in the world, where nothing shines
// a 40-intensity lamp at him from two feet away.
var lit = HasCharacterOnStage;
var kl = key.Components.Create<PointLight>();
kl.LightColor = Color.White * (lit ? CharacterKey : 40f);
kl.Radius = 600f;
// ⛔ NO SHADOWS. These two lamps exist to light a character PREVIEW on a stage, and
// `Light.Shadows` defaults to TRUE — so they were casting full shadow passes over the whole
// map for as long as the lobby object existed. Found by measurement, not by reading: after
// canyon's 70 map lights were switched off the perf census still reported 3 shadow-casters
// where the map scene has 1, and these were the other two.
//
// ⚠️ A PREVIEW STAGE NEEDS NO SHADOW ANYWAY. The bodies stand on a bare plinth with nothing
// to receive one, so this costs the look nothing at all.
kl.Shadows = false;
var fill = Scene.CreateObject();
fill.Name = "fill";
fill.SetParent( PreviewRoot );
fill.WorldPosition = Stage + new Vector3( 120f, 120f, 60f );
var fl = fill.Components.Create<PointLight>();
fl.LightColor = new Color( 0.45f, 0.6f, 0.95f ) * (lit ? CharacterFill : 18f);
fl.Radius = 600f;
// ⛔ NO SHADOWS — see the key light above. A fill light casting shadows is doubly wrong:
// filling is exactly the job of the light that must NOT add its own occlusion.
fl.Shadows = false;
var list = Players;
for ( int i = 0; i < list.Count; i++ )
{
var slot = SlotPosition( i, list.Count );
var go = Scene.CreateObject();
go.Name = $"preview {list[i].Name}";
go.SetParent( PreviewRoot );
go.WorldPosition = Stage + slot;
// ⚠️ THE CITIZEN FACES THE CAMERA AT YAW 0; A SOURCE MODEL DOES NOT. See
// `PlayerCharacters.PreviewYaw` — characters need a quarter turn the Citizen does not.
go.WorldRotation = Rotation.FromYaw( 0f );
// citizen_human_male, not citizen.vmdl - s&box ships two body
// types and citizen is the stylised "sausage" one.
var r = go.Components.Create<SkinnedModelRenderer>();
// ⚠️ ONLY THE LOCAL PLAYER'S CHOICE IS KNOWN HERE. `CharacterId` is not networked, so
// everyone else stays a Citizen rather than this guessing wrong about them.
// ⛔ EVERY SLOT'S OWN CHARACTER, NOT JUST THE LOCAL ONE. This read
// `list[i].IsLocal ? ... : null` — so every remote player was staged as the default
// Citizen and changing character was invisible to everybody else. The comment above
// it said "CharacterId is not networked", which was true until `NZNet.SetCharacter`
// existed and false afterwards; the code outlived its own reason by a few hours.
var mine = PlayerCharacters.Find( list[i].CharacterId );
// ⚠️ THE FALLBACK IS SILENT BY NATURE, so it says so. A Citizen appearing where a
// character was chosen is indistinguishable from never having chosen one, and this is
// the line that told us `Body` was null while the record resolved fine.
var wanted = mine?.Body;
if ( mine is not null && string.IsNullOrEmpty( wanted ) )
Log.Warning( $"[nz-lobby] '{mine.Id}' resolved but has no Body — falling back" );
// ⛔ NO CHARACTER = THAT PLAYER'S OWN s&box AVATAR (2026-10-05), always on the Human body, as in the game
// (`PlayerCharacters.AvatarBody`).
var avatar = mine is null ? PlayerCharacters.AvatarOf( list[i].Conn ) : null;
r.Model = wanted is not null ? Model.Load( wanted ) : PlayerCharacters.AvatarBody();
if ( mine is not null )
{
go.WorldRotation = Rotation.FromYaw( PlayerCharacters.PreviewYaw );
// ⛔ LEAVE THE ANIMGRAPH ALONE. A hand-added `idle` sequence was tried here and it
// EXPLODED THE MESH INTO SPIKES: the engine's animation FBX is read at a different
// unit scale than our Source-unit DMX, so the bones were driven to positions 39×
// out. `UseAnimGraph = false` then made it worse, because with the sequence gone the
// model has nothing to play at all and T-poses.
//
// ⚠️ THE GRAPH IDLES WITHOUT A PLAYERCONTROLLER — verified by spawning a rigged body
// in the editor beside `citizen_human_male`: same height, natural pose, no controller
// anywhere. So there is nothing to replace it with here.
}
// ⚠️ NO CLOTHING OVER A CHARACTER. The Citizen wardrobe is fitted to the Citizen
// skeleton; over a ValveBiped body it has nothing to attach to.
if ( mine is null )
{
// THE reason this is a GameObject and not a SceneModel.
// ⛔ THAT PLAYER'S CLOTHES, NOT YOURS (2026-10-05). This was `ClothingContainer.CreateFromLocalUser()` for every slot,
// so everybody in your lobby stood there wearing your outfit.
var clothes = avatar;
// ⚠️ THROUGH THE BODY'S `Dresser` (2026-10-05): `ClothingContainer.Apply` is obsolete, and its notice names these
// two. The appearance (height, skin, eyes, tints) first, then the outfit, put on at once with anything missing
// skipped, as the old call did.
if ( clothes is not null )
{
var dresser = Dresser.GetOrCreate( r );
dresser.UpdateAppearance( clothes );
// ⚠️ DOWNLOADING WHAT IS MISSING (2026-10-05): a teammate's workshop clothing is not on your disk until worn.
_ = dresser.ApplyAsync( clothes );
// ⚠️ AND SAID, with whose outfit and its print (`PlayerCharacters.DescribeOutfit`, 2026-10-05): on every
// machine, the same player's print should match, and nobody else's should match yours
//
// ⛔ THE "whose" IS WORKED OUT ABOVE THE STRING, NOT INSIDE IT (14:32). Written as a ternary inside the
// interpolation, with a string holding an apostrophe in it, it broke the EDITOR's razor compiler (41 errors from
// 14:28:43, members parsed as if inside a method) while `dotnet build` passed. Nested strings without an
// apostrophe compile in a dozen other .razor files here.
var whose = list[i].IsLocal ? "your own" : list[i].Name + "'s";
Log.Info( $"[nz-lobby] '{list[i].Name}' stands in {whose} "
+ $"avatar · {PlayerCharacters.DescribeOutfit( clothes, list[i].IsLocal )}" );
}
}
}
}
/// <summary>
/// Where the Nth of <paramref name="count"/> players stands.
///
/// Up to 4 is a single row. Beyond that it splits into two, with the back
/// row pushed away from the camera AND offset half a slot sideways so it
/// shows through the gaps rather than hiding directly behind the front row.
/// Eight fit comfortably; more just get tighter rather than breaking.
/// </summary>
const float SPACING = 42f;
const float DEPTH = 70f; // how far the back row sits from the camera
/// <summary>
/// Dropped below the camera's eye line. With nothing under their feet there
/// is no ground plane to read against, so sitting them centred in the panel
/// makes them look like they are hovering — sinking them toward the lower
/// edge reads as standing on something instead.
/// </summary>
const float DROP = -16f;
/// <summary>Roughly a citizen's height. Used to frame the shot, not to
/// place anything, so it only has to be close.</summary>
const float BODY = 72f;
static Vector3 SlotPosition( int index, int count )
{
if ( count <= 4 )
return new Vector3( 0f, (index - (count - 1) / 2f) * SPACING, DROP );
// Front row takes the ceiling so an odd count puts the extra in front,
// where there is more room and nobody is occluded.
var front = (count + 1) / 2;
var isBack = index >= front;
var n = isBack ? count - front : front;
var i = isBack ? index - front : index;
var y = (i - (n - 1) / 2f) * SPACING;
if ( isBack ) y += SPACING * 0.5f;
return new Vector3( isBack ? -DEPTH : 0f, y, DROP );
}
protected override void OnDestroy()
{
PreviewRoot?.Destroy();
}
// ── the seam's ends (`LobbyState`), as named methods so a hotload can find them (see OnStart) ──
void SetOpenFromOutside( bool v )
{
Open = v;
_userOpened = v;
Mouse.Visibility = v ? MouseVisibility.Visible : MouseVisibility.Auto;
}
void SetReadyFromOutside( bool v ) => LocalReady = v;
void SetCharacterListFromOutside( bool v ) => CharacterOpen = v;
bool IsOpenNow() => Open;
protected override void OnStart()
{
// Publish the seam so console commands can drive this panel without
// naming its (generated) type. See LobbyState.
// ⛔ NAMED METHODS, NOT LAMBDAS (2026-10-05). The editor's hotload finds a lambda by the ordinal of the method it sits in,
// so any member added to this file above OnStart left these pointing at nothing: "Unable to find scope method (Name:
// OnStart, Ordinal: 88)", then "Unable to find matching substitution for a lambda method" from RoomNameHud and SurvivalHud
// (both ask `LobbyState.IsOpen`) about 180 times a second until play was restarted. It happened at 13:32, 14:32 and 14:48
// that day, each time after an edit here. A named method is found by its name. `StartNow` was one already, and never broke.
LobbyState.SetOpen = SetOpenFromOutside;
LobbyState.SetReady = SetReadyFromOutside;
LobbyState.SetCharacterListOpen = SetCharacterListFromOutside;
// The host's start broadcast lands here on every machine.
// ⛔ THE LOADING SCREEN FIRST (`BeginLoading`, 2026-09-28) — the game starts when it is done
LobbyState.StartNow = BeginLoading;
LobbyState.IsOpen = IsOpenNow;
// ⚠️ NZGame.Mode is a STATIC and survives a play restart, so a session
// that ended in Survival starts the next one still in Survival — no
// lobby, round state carried over. Force it back, and forget the cached
// player object because the previous scene's one is gone.
PlayerPresence.Forget();
NZGame.SetMode( GameMode.Lobby );
PlayerPresence.Apply();
// ⛔ AND THE CONFIG IS A STATIC TOO — the same trap as NZGame.Mode above.
// Nothing auto-loads one at boot, but `ActiveConfig.Current` survives a
// play restart, so a config loaded in the last session was still active
// in the next one. That is a config "loaded by default" that nobody
// loaded: the lobby would let you ready up on spawns you never placed
// this session, and on a map that may not even be the one open.
//
// ⚠️ HERE, IN OnStart, NOT on every lobby open. L brings this panel
// back mid-session — resetting there would throw away the config the
// moment you tabbed out of Creative to look at it. OnStart runs once per
// play session, which is exactly the scope of the leak.
ActiveConfig.Reset();
}
protected override void OnUpdate()
{
LobbyState.UpdateTicks++;
// ⛔ ONLY THIS MAP'S CAST (2026-10-05, `PlayerCharacters.SetsHere`): a pick this map does not offer, made on another map
// before this one loaded or through the console, becomes the same person from a group it does offer, or no character.
// MY OWN PICK ONLY, each machine owning its player's choice, and through the same path as a click, so everyone hears
// of it. Four times a second: it is a manifest lookup, and nothing about it needs every frame.
if ( _castCheck > 0.25f )
{
_castCheck = 0;
if ( MyCharacter is NZCharacter mine && !PlayerCharacters.AllowedHere( mine ) )
{
var allowed = PlayerCharacters.AllowedVersionOf( mine );
Log.Info( $"[nz-char] {mine.Name} is not in this map cast, so now {allowed?.Name ?? "your own avatar"}" );
PickCharacter( allowed );
}
}
// ⛔ DRIVEN OFF THE STATE, NOT HOOKED INTO THE TRANSITIONS. `Open` is
// assigned in five separate places — ready-up, the L toggle, the escape
// path, OnStart and LobbyState.SetOpen — and hanging a Play/Stop on each
// means the one that gets added next week is silently missing it. Reading
// the state every frame cannot be forgotten.
//
// ⚠️ NZMusic.Play is idempotent precisely because it is called from here:
// a naive Play would stack a voice per frame and the menu would roar.
if ( Open )
{
// ⚠️ THE MAP'S OWN, IF ITS CONFIG NAMES ONE (`Gameplay.LobbyMusic`, 2026-09-27) — basalt's is made for it
NZMusic.Play( LobbyCue );
NZMusic.Tick(); // restart when it ends — there is no loop flag
KeepSkyOutOfPreview();
}
// ⛔ ONLY ITS OWN TRACK. This stopped ANY music whenever the lobby was shut — so the pest round's loop was started by the
// round and stopped here again on every frame, and all anyone heard was its first few milliseconds, over and over
// (2026-09-27). The special round's loop, and a preview through `nz_music`, belong to whoever started them.
else if ( NZMusic.Current == LobbyCue )
{
NZMusic.Stop();
}
// ⛔ 'Lobby' (L), NOT 'Score' (TAB). This used to ride on 'Score' because it was
// already bound and needed no new action — but 'Score' is titled "Scoreboard" and TAB is
// where players expect one, so the lobby was squatting on it. A dedicated `Lobby` action
// was added to ProjectSettings/Input.config to free TAB for the real scoreboard.
//
// ⚠️ L ONLY WORKS ONCE YOU HAVE LEFT THE LOBBY. While it is the only thing on screen
// there is nothing to toggle back to, and hiding it would leave you staring at the world
// with no way in.
if ( Input.Pressed( "Lobby" ) && NZGame.Mode != GameMode.Lobby && !MapLoading.Active )
{
Open = !Open;
_userOpened = Open; // pressing L is deliberate — do not auto-close it
Mouse.Visibility = Open ? MouseVisibility.Visible : MouseVisibility.Auto;
}
// ⚠️ The lobby follows the MODE, not just its own button. Entering
// creative any other way — a console command, another player starting
// the round — must close it too, or the world carries on behind a menu
// that thinks it is still in charge.
// ⚠️ NOT UNDER THE LOADING SCREEN (the co-op pass, 2026-09-28): the host's mode can land a moment before this machine's screen is
// done, and closing then cut the lobby's music short of its ease-out. This machine's own `StartGame` closes it.
if ( Open && NZGame.Mode != GameMode.Lobby && !_userOpened && !MapLoading.Active )
{
Open = false;
Mouse.Visibility = MouseVisibility.Auto;
}
// ⚠️ NO POINTER OVER THE LOADING SCREEN — nothing on it is clicked
if ( Open ) Mouse.Visibility = MapLoading.Active ? MouseVisibility.Hidden : MouseVisibility.Visible;
// Cheap and self-correcting: Apply() early-returns unless the body's
// enabled state actually disagrees with the mode. Belt and braces for
// anything that changes mode without going through SetMode.
PlayerPresence.Apply();
// ⛔ THE LOADING SCREEN, DRIVEN FROM HERE (`MapLoading`, 2026-09-28) — the one update that runs every frame in every mode.
// Its last moments go to black as the countdown's did, the lobby's music easing out with them, and the game starts when it
// is done: here the host's round loop, there a client's mode, each on its own clock from the host's `GameStarting`.
if ( MapLoading.Active )
{
LoadingScreenHud.EnsureHost();
if ( !MapLoading.IsPreview )
{
var left = MapLoading.Left;
if ( left <= ScreenFade.SpawnOutSeconds )
{
ScreenFade.ToBlack( left );
NZMusic.SetLevel( left / ScreenFade.SpawnOutSeconds );
}
}
if ( MapLoading.Done )
{
var preview = MapLoading.IsPreview;
MapLoading.End();
if ( !preview ) StartGame();
}
}
// ⛔ THE MAP'S AMBIENCE (`MapAmbience`, 2026-09-28) — in a game only: not in the lobby, not under the loading screen, not in
// creative. It fades in with the spawn-in and out as the lobby comes back.
// ⚠️ PHOTO MODE'S SWEEP (`nz_photo`), every frame while it is on, so what appears after the command is hidden too
MapPhotoMode.Tick();
MapAmbience.Tick( NZGame.Mode == GameMode.Survival && !Open && !MapLoading.Active
? NZSound.MapCue( ActiveConfig.Current?.Gameplay?.AmbientBed, null )
: null );
// ⛔ AND THE EASTER EGG'S MUSIC (`EggMusic`, 2026-09-28) — the defense's challenge, the boss fight's — each machine from the
// mirrored steps, as the bed is from the mode
EggMusic.Tick();
// ⛔ THE PUBLISHED LOBBY STARTS EACH MAP ON A GAMEMODE (`Gamemodes.Tick`, 2026-10-05): the host's first, whenever
// nothing playable is loaded. Ahead of the countdown, which reads what it loads.
Gamemodes.Tick();
TickCountdown();
// ⚠️ THE SPAWN-IN'S FADE DRAWS ON A SCREEN PANEL OF ITS OWN, ABOVE THIS ONE (`ScreenFadeHud`) — hosted from here, the one
// update that runs every frame in every mode, and only while a fade is on.
if ( ScreenFade.Active ) ScreenFadeHud.EnsureHost();
// ⚠️ THE STAGE'S A/B SWITCH — see `LobbyState.PreviewStage`. Read every frame and written
// only on a change, so the style is not dirtied sixty times a second and typing the
// command works whether or not the lobby is open at the time.
if ( Preview is not null )
{
var show = LobbyState.PreviewStage;
var want = show ? DisplayMode.Flex : DisplayMode.None;
if ( Preview.Style.Display != want ) Preview.Style.Display = want;
// ⚠️ THE CAST TOO, not only the panel. Hiding the panel stops the second render; the
// bodies and their two lights would still be standing in the shared world.
if ( PreviewRoot.IsValid() && PreviewRoot.Enabled != show ) PreviewRoot.Enabled = show;
}
// ⚠️ THE PUBLISHED CLIENT'S SELF-REPORT — see `LobbyProbe`. Every frame, open or not: the
// lobby every 10 s and the game every 30 s, so the comparison the complaint is about ends up
// in one log. This panel's OnUpdate runs whether the lobby is showing or hidden.
LobbyProbe.Frame( Open );
// ⚠️ The cast is built once in OnTreeFirstBuilt, which was fine for a
// hardcoded roster that could never change. With real connections it
// has to follow joins and leaves, or the stage keeps showing whoever
// was here when the lobby opened — and the camera stays framed for
// their number rather than the current one.
// ⚠️ AND ON A CHARACTER CHANGE, not only a join or a leave. The staged body's model is fixed
// when it is created, so picking a character has to rebuild the stage — see
// `PlayerCharacters.Revision`. Without this the console command changed everything EXCEPT
// what the player was looking at.
if ( Preview is not null
&& (_stagedCount != Players.Count || _stagedRevision != PlayerCharacters.Revision
// ⚠️ AND WHEN ANY SLOT'S CHARACTER CHANGES. Neither the player COUNT nor the
// catalogue REVISION moves when a remote player picks a different body, so the
// stage would have kept whatever it built first.
|| _stagedChars != CharacterSignature) )
{
_stagedCount = Players.Count;
_stagedRevision = PlayerCharacters.Revision;
_stagedChars = CharacterSignature;
BuildStage();
FrameCast();
}
}
/// <summary>How many the preview stage was last built for. -1 so the first
/// tick after the tree exists always rebuilds.</summary>
int _stagedCount = -1;
/// <summary>Every slot's character, in order — what the stage was last built from.</summary>
string _stagedChars = "";
string CharacterSignature => string.Join( "|", Players.Select( p => p.CharacterId ) );
/// <summary>`PlayerCharacters.Revision` the stage was last built at.</summary>
int _stagedRevision = -1;
/// <summary>
/// Drop into the world to build.
///
/// Closing the lobby IS entering creative — there is no separate "leave
/// menu" step, because a hidden lobby with the game not started would be a
/// state with nothing in it. TAB brings it back.
/// </summary>
void EnterCreative()
{
NZGame.SetMode( GameMode.Creative );
Open = false;
_userOpened = false;
Mouse.Visibility = MouseVisibility.Auto;
}
/// <summary>Did the player open this with TAB? If so it stays open until
/// they close it, rather than being auto-dismissed by the mode rule.</summary>
bool _userOpened;
protected override int BuildHash()
// ⚠️ Connection count is in here so the list redraws when somebody joins
// or leaves. With a hardcoded roster it could never change, so nothing
// ever needed to trigger a rebuild.
// ⚠️ The countdown is hashed as WHOLE SECONDS, not raw. The raw value
// changes every frame, which would rebuild the whole panel — including
// the character stage — 60 times a second. Ceiling gives exactly the
// five rebuilds the visible text actually needs.
// ⚠️ CanReady is hashed, or the button stays greyed out after you place
// the missing spawns and come back — the config can change while this
// panel is open, and nothing else here would notice.
=> System.HashCode.Combine( Open, LocalReady, ReadyPercent, NZGame.Mode,
// ⚠️ WHICH SET OF ROWS WAS DRAWN. A machine becomes a client the moment it joins,
// and without this the lobby would keep showing host-only rows until something else
// happened to dirty the panel.
//
// ⛔ ON ITS OWN, NOT `CanReady && NZGame.IsHost`, AND THE CONJUNCTION WAS THE BUG.
// Two independent facts ANDed together give one boolean that cannot report a change
// in either of them: with `CanReady` false the term is false whatever the role does,
// so a machine that became a client redrew nothing and kept showing Change map,
// Creative and both config rows. It stayed wrong until an unrelated click dirtied the
// panel, which is why it was reported as *"when a player joins and clicks anywhere
// they suddenly become the one with the host version of the lobby"* — the click was
// the REVEAL, not the cause.
//
// ⚠️ AND IT COST `CanReady` ITS OWN REDRAW TOO, in the mirror-image case: on a client
// `NZGame.IsHost` is false, so the term was pinned false and the ready button never
// un-greyed when the map became playable. The comment two lines up claimed `CanReady`
// was hashed; it was hashed in a way that only worked on the host. It has its own
// entry in the nested call below now.
NZGame.IsHost,
Connection.All.Count,
_startsIn is TimeUntil t ? (int)MathF.Ceiling( t ) : -1,
// The modal lives inside this panel's tree, so its state has to
// reach this hash or opening it would not redraw anything.
//
// ⛔ THE MAP NAME IS DRAWN BY THIS PANEL TOO, so a map change has to reach here.
// Without it the label under the brand kept saying "countdown" while the browser's own
// header correctly read "thieves.dolls" — the exact staleness this note was written
// about, one field later.
//
// ⚠️ NESTED because HashCode.Combine takes at most EIGHT arguments; a ninth is CS1501
// rather than anything that degrades quietly.
System.HashCode.Combine( ConfigBrowserState.Showing,
ConfigBrowserState.Selected, ConfigBrowserState.DeleteMode,
ConfigBrowserState.TypedName, NZMap.Current,
// ⚠️ `CanReady` LIVES HERE NOW rather than ANDed onto the role above — see the
// note there. The outer call was already at HashCode.Combine's eight-argument
// limit, which is presumably how the two came to be conjoined in the first place.
CanReady,
// ⚠️ THE ROSTER AS TEXT — names, ready and character together, and it goes in
// the NESTED call because the outer one was already at its eight. Hashing
// `Connection.All.Count` alone meant a remote player readying up or changing
// body redrew nothing at all: the count had not moved.
string.Join( "|", Players.Select( p => $"{p.Name}{p.Ready}{p.Character}" ) ),
// ⛔ THE CHARACTER LIST IS INSIDE THIS PANEL'S TREE AND WAS NOT HASHED, which is
// the same fault the ConfigBrowser note five fields up was written about — "the
// modal lives inside this panel's tree, so its state has to reach this hash or
// opening it would not redraw anything." `Click` does not call StateHasChanged,
// so flipping `CharacterOpen` changed a bool and nothing else: the list appeared
// only once something UNRELATED dirtied the panel, which in a quiet lobby (no
// countdown running, nobody joining) is never.
System.HashCode.Combine( CharacterOpen,
// ⚠️ AND THE PUBLISHED LOBBY'S GAMEMODE LINE (`Gamemodes`, 2026-10-05): which lobby is drawn, and what the
// loaded config is called there. NESTED ONCE MORE, both calls above being at their eight.
Edition.IsPublished, ActiveConfig.Current?.Name, ActiveConfig.Current?.Map,
ActiveConfig.Current?.Gamemode, ActiveConfig.Current?.GamemodeDescription,
// ⚠️ AND THE DIFFICULTY ROW (2026-10-05): its edit counter, a plain int, not its label, which is worked out
// from every setting, and this hash runs every frame.
DifficultyState.Version ) ) );
}