LobbyFlow component driving the pre-game lobby state machine. Tracks browsing/countdown/launch states, selected game mode and map, per-connection ready and map votes, builds a map pool from resources, handles host/client input, starts a countdown and launches a scene.
using Machines.Resources;
using Machines.Systems;
namespace Machines.Components;
public enum LobbyState
{
Browsing,
Countdown,
Launching
}
/// <summary>
/// Drives the pre-game lobby state machine. Host-spawned, networked so clients see the selected mode/map and countdown.
/// </summary>
public sealed class LobbyFlow : Component
{
public static LobbyFlow Current { get; private set; }
/// <summary>
/// Current state of the lobby.
/// </summary>
[Sync] public LobbyState State { get; set; } = LobbyState.Browsing;
/// <summary>
/// Mode the host is currently browsing.
/// </summary>
[Sync] public GameModeType SelectedMode { get; set; } = GameModeType.Race;
/// <summary>
/// Resource path of the selected map; see <see cref="SelectedMap"/> for why we sync the path.
/// </summary>
[Sync] public string SelectedMapIdent { get; set; }
/// <summary>
/// Time until countdown hits zero; synced <see cref="TimeUntil"/> so it is clock-adjusted per client.
/// </summary>
[Sync] public TimeUntil CountdownEnds { get; set; }
/// <summary>
/// Countdown length in seconds.
/// </summary>
public const float CountdownSeconds = 3f;
/// <summary>
/// Ready state per connection, keyed by Connection.Id.
/// </summary>
[Sync] public NetDictionary<Guid, bool> Ready { get; } = new();
/// <summary>
/// True if the local connection is readied up.
/// </summary>
public bool IsLocalReady => Connection.Local is not null && Ready.TryGetValue( Connection.Local.Id, out var r ) && r;
/// <summary>
/// Set the calling connection's ready state.
/// </summary>
[Rpc.Host]
public void SetReady( bool ready )
{
Ready[Rpc.Caller.Id] = ready;
}
/// <summary>
/// Each connection's map vote, keyed by Connection.Id; the host's entry is the launch map.
/// </summary>
[Sync] public NetDictionary<Guid, string> BrowsedMaps { get; } = new();
/// <summary>
/// Set the caller's map vote; host's choice also becomes the launch map.
/// </summary>
[Rpc.Host]
public void SetBrowsedMap( string ident )
{
var caller = Rpc.Caller;
if ( caller is not null )
BrowsedMaps[caller.Id] = ident;
if ( caller is null || caller.IsHost )
SelectedMapIdent = ident;
}
// Map pool by mode, built once on host.
private Dictionary<GameModeType, List<MapResource>> _mapsByMode;
/// <summary>
/// True in single-player/editor or when hosting.
/// </summary>
private bool IsAuthority => !Sandbox.Networking.IsActive || Sandbox.Networking.IsHost;
protected override void OnEnabled()
{
Current = this;
if ( IsAuthority )
{
// Clear ready state so returning from a game doesn't re-trigger the countdown.
Ready.Clear();
BuildMapPool();
SelectFirstMap();
}
}
protected override void OnDisabled()
{
if ( Current == this )
Current = null;
}
/// <summary>
/// Reset to browsing and clear ready flags; call on return from a game.
/// </summary>
public void ResetForNewRound()
{
State = LobbyState.Browsing;
Ready.Clear();
}
private void BuildMapPool()
{
_mapsByMode = new Dictionary<GameModeType, List<MapResource>>();
foreach ( var map in ResourceLibrary.GetAll<MapResource>() )
{
if ( map.Hidden && !Game.IsEditor )
continue;
if ( !_mapsByMode.TryGetValue( map.Mode, out var list ) )
{
list = new List<MapResource>();
_mapsByMode[map.Mode] = list;
}
list.Add( map );
}
}
private void SelectFirstMap()
{
var maps = MapsForMode( SelectedMode );
SelectedMapIdent = maps.FirstOrDefault()?.ResourcePath;
}
public IReadOnlyList<MapResource> MapsForMode( GameModeType mode )
{
if ( _mapsByMode == null ) BuildMapPool();
return _mapsByMode.TryGetValue( mode, out var list ) ? list : System.Array.Empty<MapResource>();
}
/// <summary>
/// Selected map resolved from the pool; works before the client loads it.
/// </summary>
public MapResource SelectedMap => MapByPath( SelectedMapIdent );
private MapResource MapByPath( string ident )
{
if ( string.IsNullOrEmpty( ident ) )
return null;
if ( _mapsByMode == null ) BuildMapPool();
foreach ( var list in _mapsByMode.Values )
{
var match = list.FirstOrDefault( m => m.ResourcePath == ident );
if ( match != null )
return match;
}
return ResourceLibrary.Get<MapResource>( ident );
}
/// <summary>
/// Map the given connection is browsing (its vote); falls back to the host's pick if unset or invalid.
/// </summary>
public string BrowsedIdentFor( Connection c )
{
if ( c is not null && BrowsedMaps.TryGetValue( c.Id, out var p ) && IsInCurrentPool( p ) )
return p;
return SelectedMapIdent;
}
private bool IsInCurrentPool( string ident )
=> !string.IsNullOrEmpty( ident ) && MapsForMode( SelectedMode ).Any( m => m.ResourcePath == ident );
/// <summary>
/// Resource path of the local player's browsed map.
/// </summary>
public string LocalBrowsedMapIdent => BrowsedIdentFor( Connection.Local );
/// <summary>
/// Map the local player is browsing; the carousel centres on this.
/// </summary>
public MapResource LocalBrowsedMap => MapByPath( LocalBrowsedMapIdent );
/// <summary>
/// Connections currently browsing (voting for) the given map.
/// </summary>
public IReadOnlyList<Connection> VotersFor( string mapIdent )
=> Connection.All.Where( c => BrowsedIdentFor( c ) == mapIdent ).ToList();
protected override void OnUpdate()
{
// Only tick while /play is showing; prevent menu input bleeding in.
if ( Machines.UI.MainMenuPanel.Instance is { CurrentUrl: not "/play" } )
return;
// Map browsing is per-player; only the lobby state machine stays host-driven.
if ( State == LobbyState.Browsing )
HandleMapBrowseInput();
if ( Sandbox.Networking.IsHost )
{
TickHost();
}
else if ( Sandbox.Networking.IsActive )
{
TickClient();
}
}
private void HandleMapBrowseInput()
{
if ( Input.Pressed( "MenuLeft" ) ) CycleMap( -1 );
else if ( Input.Pressed( "MenuRight" ) ) CycleMap( +1 );
}
private void TickHost()
{
switch ( State )
{
case LobbyState.Browsing:
HandleBrowsingInput();
CheckHostReady();
break;
case LobbyState.Countdown:
HandleCountdownInput();
// Host unreadied or dropped; bail back to selection.
if ( State == LobbyState.Countdown && !HostReady() )
{
State = LobbyState.Browsing;
break;
}
if ( State == LobbyState.Countdown && CountdownEnds <= 0f )
Launch();
break;
}
}
/// <summary>
/// Client-side tick: handles ready/unready input for non-host players.
/// </summary>
private void TickClient()
{
if ( State != LobbyState.Browsing )
return;
if ( Input.Pressed( "MenuSelect" ) )
ToggleReady();
if ( Input.Pressed( "MenuBack" ) && IsLocalReady )
SetReady( false );
}
/// <summary>
/// Handle mode/map switching and ready-up while in browsing state.
/// </summary>
private void HandleBrowsingInput()
{
// Mode switching is host-only; map browsing is per-player via HandleMapBrowseInput.
if ( !IsLocalReady )
{
if ( Input.Pressed( "MenuSwitchLeft" ) ) CycleMode( -1 );
else if ( Input.Pressed( "MenuSwitchRight" ) ) CycleMode( +1 );
}
HandleReadyInput();
}
private void CycleMode( int direction )
{
var modes = System.Enum.GetValues<GameModeType>();
var idx = System.Array.IndexOf( modes, SelectedMode );
idx = (idx + direction + modes.Length) % modes.Length;
SelectedMode = modes[idx];
SelectFirstMap();
BrowsedMaps.Clear();
Sound.Play( "tab_switch" );
}
private void CycleMap( int direction )
{
var maps = MapsForMode( SelectedMode );
if ( maps.Count == 0 ) return;
var current = LocalBrowsedMap;
var idx = current == null ? 0 : maps.ToList().FindIndex( m => m.ResourcePath == current.ResourcePath );
if ( idx < 0 ) idx = 0;
idx = (idx + direction + maps.Count) % maps.Count;
SetBrowsedMapLocal( maps[idx].ResourcePath );
}
/// <summary>
/// Set the local player's map vote and play the switch sound.
/// </summary>
private void SetBrowsedMapLocal( string ident )
{
SetBrowsedMap( ident );
Sound.Play( "element_switch" );
}
/// <summary>
/// Select a mode directly (e.g. clicking a mode tab).
/// </summary>
public void SelectMode( GameModeType mode )
{
if ( !IsAuthority || IsLocalReady || mode == SelectedMode )
return;
SelectedMode = mode;
SelectFirstMap();
BrowsedMaps.Clear();
Sound.Play( "tab_switch" );
}
/// <summary>
/// Select a map directly (e.g. clicking a side card), sets the local vote.
/// </summary>
public void SelectMap( MapResource map )
{
if ( map == null || map.ResourcePath == LocalBrowsedMapIdent )
return;
if ( !MapsForMode( SelectedMode ).Any( m => m.ResourcePath == map.ResourcePath ) )
return;
SetBrowsedMapLocal( map.ResourcePath );
}
/// <summary>
/// Toggle the local ready state; unreadying during a countdown cancels it.
/// </summary>
public void ToggleReady()
{
if ( State == LobbyState.Launching )
return;
var wasReady = IsLocalReady;
SetReady( !wasReady );
if ( !wasReady )
Sound.Play( "button_accept" );
}
/// <summary>
/// Handle ready-up/cancel input; countdown starts once the host is ready.
/// </summary>
private void HandleReadyInput()
{
if ( Input.Pressed( "MenuSelect" ) )
ToggleReady();
// MenuBack cancels ready, re-enabling map switching.
if ( Input.Pressed( "MenuBack" ) && IsLocalReady )
SetReady( false );
}
/// <summary>
/// True when player count meets the map minimum and the host is ready.
/// The host readying up starts the countdown regardless of other players.
/// </summary>
private bool HostReady()
{
var connections = Connection.All;
if ( connections.Count == 0 ) return false;
var map = SelectedMap;
if ( map == null || connections.Count < map.MinPlayers ) return false;
var host = connections.FirstOrDefault( c => c.IsHost );
return host is not null && Ready.TryGetValue( host.Id, out var r ) && r;
}
private void CheckHostReady()
{
if ( HostReady() )
{
CountdownEnds = CountdownSeconds;
State = LobbyState.Countdown;
}
}
private void HandleCountdownInput()
{
if ( Input.Pressed( "MenuBack" ) )
{
Input.Clear( "MenuBack" );
// Clear ready so we settle back in selection rather than re-triggering the countdown.
Ready.Clear();
State = LobbyState.Browsing;
}
}
private void Launch()
{
State = LobbyState.Launching;
var map = SelectedMap;
if ( map?.Scene == null )
{
Log.Warning( "LobbyFlow.Launch: selected map has no scene." );
State = LobbyState.Browsing;
return;
}
var options = new SceneLoadOptions();
options.SetScene( map.Scene );
Game.ChangeScene( options );
}
}