UI/LobbyMenu.razor

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.

NetworkingFile AccessHttp Calls
🐞 Lobby music is cached in the static LobbyCue at type init so changing map/config later does not switch the lobby track.
🐞 GamemodeLine and MapRowDesc are static one-time values so the labels do not update when the active gamemode or map summary changes.
🌐 none
@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 &lt;key&gt; [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 ) ) );
}