s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
link Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=rwtrcsc.lobbysystem&take=20
Showing code results for query:
*
(22 total matches found)
Game
library
namespace LobbySystem;
/// <summary>
/// Auto-hosts a lobby so Steam friends can join, and keeps one networked pawn per connection plus optional
/// bots by cloning <see cref="PlayerPrefab"/>. The pawn only has to implement <see cref="ILobbyAgent"/>.
/// Spawning is de-duped and runs in OnUpdate so a join can't fire mid-enumeration.
/// </summary>
public sealed class LobbyNetworkManager : Component, Component.INetworkListener
{
[Property] public GameObject PlayerPrefab { get; set; }
[Property] public int BotCount { get; set; } = 1;
/// <summary>When true, bots only exist during an active round.</summary>
[Property] public bool BotsOnlyDuringRound { get; set; } = true;
[Property] public Color BotTint { get; set; } = new Color( 1f, 0.35f, 0.3f );
// Lobby spawn ring, used before a round map loads.
readonly Vector3[] _spawns =
{
new Vector3( 0f, -300f, 40f ), new Vector3( 300f, 0f, 40f ),
new Vector3( 0f, 300f, 40f ), new Vector3( -300f, 0f, 40f ),
new Vector3( 250f, 250f, 40f ), new Vector3( -250f, -250f, 40f ),
};
int _spawnIndex;
readonly Dictionary<Guid, GameObject> _pawns = new();
readonly List<GameObject> _bots = new();
bool _reconcileNow;
TimeUntil _nextReconcile;
TimeUntil _nextSweep;
protected override async Task OnLoad()
{
// When joining a friend the engine is mid-connect and IsActive is briefly false, so poll for a
// moment before hosting. Otherwise a joiner would spin up its own solo lobby.
if ( Networking.IsActive ) return;
for ( int i = 0; i < 6 && !Networking.IsActive; i++ )
await Task.DelayRealtimeSeconds( 0.1f );
if ( !Networking.IsActive )
Networking.CreateLobby( new() );
}
void INetworkListener.OnActive( Connection channel ) => _reconcileNow = true;
protected override void OnUpdate()
{
if ( !Networking.IsHost || PlayerPrefab is null ) return;
if ( !_reconcileNow && _nextReconcile > 0f ) return;
_reconcileNow = false;
_nextReconcile = 0.25f;
try
{
bool wantBots = !BotsOnlyDuringRound || (LobbyDirector.Current?.State == LobbyState.Active);
ReconcileBots( wantBots ? Math.Max( 0, BotCount ) : 0 );
foreach ( var conn in Connection.All.ToList() )
{
if ( conn is null || !conn.IsActive ) continue;
if ( _pawns.TryGetValue( conn.Id, out var pawn ) && pawn.IsValid() ) continue;
var id = conn.Id;
_pawns[id] = FindConnectionPawn( id ) ?? SpawnPawn( false, conn.DisplayName, conn );
}
Sweep();
}
catch
{
// Connection or scene list changed during the pass; retry next frame.
}
}
void ReconcileBots( int target )
{
_bots.RemoveAll( b => !b.IsValid() );
while ( _bots.Count > target )
{
var b = _bots[_bots.Count - 1];
_bots.RemoveAt( _bots.Count - 1 );
if ( b.IsValid() ) b.Destroy();
}
while ( _bots.Count < target )
_bots.Add( SpawnPawn( true, "Bot", null ) );
}
GameObject FindConnectionPawn( Guid id )
{
foreach ( var a in Scene.GetAllComponents<ILobbyAgent>() )
{
if ( !a.IsValid() || a.IsBot ) continue;
if ( a is Component c && c.Network.OwnerId == id ) return c.GameObject;
}
return null;
}
void Sweep()
{
if ( _nextSweep > 0f ) return;
_nextSweep = 1f;
foreach ( var key in _pawns.Where( kv => !kv.Value.IsValid() ).Select( kv => kv.Key ).ToList() )
_pawns.Remove( key );
}
GameObject SpawnPawn( bool isBot, string displayName, Connection owner )
{
var go = PlayerPrefab.Clone( NextSpawn() );
go.Name = isBot ? "Bot" : $"Player - {displayName}";
go.Enabled = true;
var agent = go.Components.Get<ILobbyAgent>() ?? go.Components.GetInChildren<ILobbyAgent>();
agent?.InitAgent( isBot, displayName );
if ( isBot )
{
var rend = go.Components.GetInChildren<SkinnedModelRenderer>();
if ( rend is not null ) rend.Tint = BotTint;
}
if ( owner is not null ) go.NetworkSpawn( owner );
else go.NetworkSpawn();
return go;
}
Vector3 NextSpawn()
{
int idx = _spawnIndex++;
var dir = LobbyDirector.Current;
if ( dir is not null && dir.UseRoundMap && dir.MapReady )
return dir.RoundSpawnPoint( idx );
return _spawns[idx % _spawns.Length];
}
}
Game
library
namespace LobbySystem;
/// <summary>Lifecycle of the lobby: Lobby, then Active, then Ended before looping back.</summary>
public enum LobbyState
{
/// <summary>Free roam before and after a round. The mode button works here.</summary>
Lobby,
/// <summary>A round is running.</summary>
Active,
/// <summary>Round finished; results show before returning to the lobby.</summary>
Ended
}
Game
library
namespace LobbySystem;
/// <summary>
/// In-world button that opens the mode menu for the host, or a local suggestion menu for a client. It needs
/// a ModelRenderer to be visible and is hidden while a round is live. When the local player is within
/// <see cref="UseRange"/> and presses Use, the menu opens.
/// </summary>
public sealed class LobbyModeButton : Component
{
[Property] public float UseRange { get; set; } = 130f;
[Property] public bool GlowWhenInRange { get; set; } = true;
[Property] public Color IdleTint { get; set; } = new Color( 0.85f, 0.4f, 0.15f );
[Property] public Color ActiveTint { get; set; } = new Color( 1f, 0.85f, 0.3f );
ModelRenderer _renderer;
ILobbyAgent _me;
protected override void OnStart()
{
_renderer = Components.Get<ModelRenderer>() ?? Components.GetInChildren<ModelRenderer>();
if ( _renderer is not null ) _renderer.Tint = IdleTint;
}
protected override void OnUpdate()
{
var dir = LobbyDirector.Current;
bool inLobby = dir is null || !dir.RoundLive;
if ( _renderer is not null && _renderer.Enabled != inLobby )
_renderer.Enabled = inLobby;
if ( !inLobby ) return;
var me = LocalPlayer();
bool inRange = me is not null && WorldPosition.Distance( me.WorldPosition ) <= UseRange;
if ( GlowWhenInRange && _renderer is not null )
_renderer.Tint = inRange ? ActiveTint : IdleTint;
if ( inRange && Input.Pressed( "Use" ) )
dir?.RequestModeMenu();
}
ILobbyAgent LocalPlayer()
{
if ( _me is not null && _me.IsValid() && !_me.IsBot && !_me.IsProxy ) return _me;
try { _me = Scene.GetAllComponents<ILobbyAgent>().FirstOrDefault( c => c.IsValid() && !c.IsBot && !c.IsProxy ); }
catch { _me = null; }
return _me;
}
}
Game
library
global using Sandbox; global using System; global using System.Collections.Generic; global using System.Linq; global using System.Threading.Tasks;
Game
library
@using Sandbox
@using Sandbox.UI
@using LobbySystem
@inherits PanelComponent
@namespace LobbySystem.Examples
<root>
@if ( Dir is null )
{
<text></text>
}
else if ( Dir.MenuOpen || Dir.SuggestMenuOpen )
{
<div class="menu">
<div class="title">MULTIPLAYER LOBBY</div>
<div class="sub">@(Dir.MenuOpen ? "Choose a game mode" : "Suggest a mode to the host")</div>
<div class="row">
@for ( int i = 0; i < Dir.Modes.Count; i++ )
{
var idx = i;
<button class="mode" onclick=@(() => Dir.PickMode( idx ))>
<div class="k">@(idx + 1)</div>
<div class="n">@Dir.Modes[idx].DisplayName</div>
</button>
}
</div>
<button class="back" onclick=@(() => Dir.RequestCloseMenu())>Back to lobby [E]</button>
</div>
}
else
{
<div class="top">
@if ( Dir.RoundLive )
{
<div class="mode">@(Dir.ActiveMode?.DisplayName ?? "")</div>
<div class="timer @(Dir.TimeLeftSeconds < 15 ? "urgent" : "")">@TimerText</div>
}
else
{
<div class="lobby">LOBBY</div>
}
</div>
@if ( !Dir.RoundLive && !string.IsNullOrEmpty( Dir.StatusMessage ) )
{
<div class="status">@Dir.StatusMessage</div>
}
@if ( Dir.ChatVisible )
{
<div class="chat">@Dir.ChatLine</div>
}
<div class="hints">
<span>[WASD] Move</span>
<span>[Space] Jump</span>
@if ( !Dir.RoundLive )
{
<span>Walk to the pad and press [E] to start a round</span>
}
</div>
}
</root>
@code
{
LobbyDirector Dir => LobbyDirector.Current;
string TimerText
{
get
{
int t = Dir?.TimeLeftSeconds ?? 0;
if ( t < 0 ) t = 0;
return $"{t / 60:D2}:{t % 60:D2}";
}
}
protected override int BuildHash() => System.HashCode.Combine(
Dir?.State, Dir?.MenuOpen, Dir?.SuggestMenuOpen, Dir?.RoundLive,
Dir?.TimeLeftSeconds, Dir?.StatusMessage, Dir?.ChatLine, Dir?.ChatVisible );
}
Game
library
using Sandbox;
using Sandbox.Citizen;
namespace LobbySystem.Examples;
/// <summary>
/// Self-contained networked third-person player: WASD with run and jump, a third-person camera created on
/// demand, Citizen animation and owner-synced facing, plus <see cref="ILobbyAgent"/> so the lobby can name,
/// spawn, place and reset it. Built on the standard s&box Citizen; copy or extend it for your own game.
/// </summary>
public sealed class LobbyPlayer : Component, ILobbyAgent
{
[Property] public float WalkSpeed { get; set; } = 130f;
[Property] public float RunSpeed { get; set; } = 260f;
[Property] public float JumpForce { get; set; } = 320f;
[Property] public GameObject Head { get; set; }
[Property] public GameObject Body { get; set; }
[Property] public SkinnedModelRenderer Renderer { get; set; }
[Sync( SyncFlags.FromHost )] public bool IsBot { get; set; }
[Sync( SyncFlags.FromHost )] public string DisplayName { get; set; } = "Player";
[Sync] public float LookYaw { get; set; }
CharacterController _cc;
CitizenAnimationHelper _anim;
CameraComponent _cam;
Vector3 _wish;
Vector3 _lastPos;
Vector3 _proxyVel;
float _jumpUntil;
bool _dressed;
TimeUntil _nextDressTry;
Vector3 _wanderDir = Vector3.Forward;
float _wanderTime;
// True on the peer that simulates this pawn: the owner, or anyone in single-player.
bool Controlled => !Networking.IsActive || !IsProxy;
public void InitAgent( bool isBot, string displayName ) { IsBot = isBot; DisplayName = displayName; }
public void ResetForRound() { if ( _cc is not null ) _cc.Velocity = Vector3.Zero; }
[Rpc.Broadcast]
public void TeleportTo( Vector3 position )
{
WorldPosition = position;
if ( _cc is not null ) _cc.Velocity = Vector3.Zero;
}
protected override void OnStart()
{
_cc = Components.Get<CharacterController>();
Body ??= GameObject.Children.FirstOrDefault( c => c.Name == "Body" );
Head ??= GameObject.Children.FirstOrDefault( c => c.Name == "Head" );
Renderer ??= Body?.Components.Get<SkinnedModelRenderer>();
if ( Renderer is not null ) _anim = Renderer.Components.GetOrCreate<CitizenAnimationHelper>();
_lastPos = WorldPosition;
}
protected override void OnUpdate()
{
TryDressFromAvatar();
if ( !Controlled )
{
_proxyVel = Time.Delta > 0f ? (WorldPosition - _lastPos) / Time.Delta : Vector3.Zero;
_lastPos = WorldPosition;
if ( Head is not null ) Head.WorldRotation = Rotation.FromYaw( LookYaw );
Animate();
return;
}
if ( !IsBot )
{
UpdateLookAndCamera();
if ( Input.Pressed( "Jump" ) ) _jumpUntil = Time.Now + 0.2f;
}
Animate();
}
protected override void OnFixedUpdate()
{
if ( _cc is null || !Controlled ) return;
_wish = IsBot ? WanderWish() : InputWish();
Move();
}
Vector3 InputWish()
{
if ( Head is null ) return Vector3.Zero;
var yaw = Head.WorldRotation.Angles().WithPitch( 0 ).ToRotation();
var move = Input.AnalogMove;
float speed = Input.Down( "Run" ) ? RunSpeed : WalkSpeed;
return yaw * new Vector3( move.x, move.y, 0f ) * speed;
}
// Bots stroll around and turn back when they drift too far from the centre.
Vector3 WanderWish()
{
_wanderTime -= Time.Delta;
if ( _wanderTime <= 0f )
{
_wanderDir = Rotation.FromYaw( Game.Random.Float( 0f, 360f ) ).Forward;
_wanderTime = Game.Random.Float( 2f, 4f );
}
var flat = WorldPosition.WithZ( 0 );
if ( flat.Length > 700f ) _wanderDir = (-flat).Normal;
return _wanderDir.WithZ( 0 ).Normal * WalkSpeed;
}
void Move()
{
var g = Scene.PhysicsWorld.Gravity;
if ( _cc.IsOnGround )
{
_cc.Velocity = _cc.Velocity.WithZ( 0 );
_cc.Accelerate( _wish );
_cc.ApplyFriction( 5f );
if ( Time.Now <= _jumpUntil ) { _cc.Punch( Vector3.Up * JumpForce ); _jumpUntil = 0f; }
}
else
{
_cc.Velocity += g * Time.Delta * 0.5f;
_cc.Accelerate( _wish.ClampLength( 60f ) );
_cc.ApplyFriction( 0.1f );
}
_cc.Move();
if ( _cc.IsOnGround ) _cc.Velocity = _cc.Velocity.WithZ( 0 );
else _cc.Velocity += g * Time.Delta * 0.5f;
}
void UpdateLookAndCamera()
{
if ( Head is null ) return;
var look = Input.AnalogLook;
var a = Head.WorldRotation.Angles();
a.pitch = MathX.Clamp( a.pitch + look.pitch, -89f, 89f );
a.yaw += look.yaw;
a.roll = 0f;
Head.WorldRotation = a.ToRotation();
LookYaw = a.yaw;
if ( !_cam.IsValid() )
{
_cam = Scene.GetAllComponents<CameraComponent>().FirstOrDefault();
if ( _cam is null )
{
var go = Scene.CreateObject();
go.Name = "Player Camera";
_cam = go.Components.Create<CameraComponent>();
}
}
var rot = Head.WorldRotation;
_cam.WorldPosition = WorldPosition + rot.Backward * 150f + rot.Right * 28f + Vector3.Up * 50f;
_cam.WorldRotation = rot;
}
void Animate()
{
if ( _anim is null || _cc is null ) return;
var vel = Controlled ? _cc.Velocity : _proxyVel;
_anim.WithVelocity( vel );
_anim.WithWishVelocity( Controlled ? _wish : _proxyVel );
_anim.IsGrounded = Controlled ? _cc.IsOnGround : MathF.Abs( _proxyVel.z ) < 50f;
if ( !IsBot && Head is not null )
_anim.WithLook( Head.WorldRotation.Forward, 1f, 0.75f, 0.5f );
FaceMovement( vel );
}
void FaceMovement( Vector3 velocity )
{
if ( Body is null ) return;
Rotation target;
if ( !IsBot && Head is not null )
{
target = Rotation.FromYaw( Head.WorldRotation.Yaw() );
}
else
{
var flat = velocity.WithZ( 0 );
target = flat.Length > 20f ? Rotation.LookAt( flat, Vector3.Up ) : Body.WorldRotation;
}
Body.WorldRotation = Rotation.Lerp( Body.WorldRotation, target, Time.Delta * 10f );
}
// Dress the Citizen in the player's own s&box avatar. Runs on every peer and retries until ownership has
// replicated. Bots keep the plain model.
void TryDressFromAvatar()
{
if ( _dressed || Renderer is null || _nextDressTry > 0f ) return;
_nextDressTry = 0.25f;
if ( IsBot ) { _dressed = true; return; }
ClothingContainer clothing;
try
{
var owner = Network.Owner;
if ( owner is not null )
{
var json = owner.GetUserData( "avatar" );
if ( string.IsNullOrEmpty( json ) ) return;
clothing = new ClothingContainer();
clothing.Deserialize( json );
}
else if ( !Networking.IsActive )
{
clothing = ClothingContainer.CreateFromLocalUser();
}
else return;
}
catch { _dressed = true; return; }
_dressed = true;
ApplyClothingAsync( clothing );
}
async void ApplyClothingAsync( ClothingContainer clothing )
{
try { await clothing.ApplyAsync( Renderer, System.Threading.CancellationToken.None ); }
catch { }
}
}
Game
library
$accent: #ffb13b;
root {
position: absolute;
width: 100%;
height: 100%;
font-family: "Poppins", sans-serif;
color: white;
pointer-events: none;
flex-direction: column;
}
.top {
flex-direction: column;
align-items: center;
width: 100%;
margin-top: 36px;
.mode { font-size: 20px; font-weight: 600; letter-spacing: 4px; color: $accent; text-shadow: 0 2px 6px black; }
.lobby { font-size: 40px; font-weight: 800; letter-spacing: 3px; text-shadow: 0 3px 10px black; }
.timer { font-size: 30px; font-weight: 700; text-shadow: 0 2px 6px black; &.urgent { color: #ff5a4d; } }
}
.status {
position: absolute; top: 48%; width: 100%; justify-content: center;
font-size: 24px; font-weight: 600; text-shadow: 0 2px 8px black;
}
.chat {
position: absolute; top: 70%; width: 100%; justify-content: center;
font-size: 18px; color: $accent; text-shadow: 0 2px 6px black;
}
.hints {
position: absolute; bottom: 24px; width: 100%; justify-content: center; gap: 18px;
span {
font-size: 15px; color: rgba(255,255,255,0.85);
background-color: rgba(0,0,0,0.35); padding: 5px 12px; border-radius: 6px;
}
}
.menu {
pointer-events: all;
position: absolute; width: 100%; height: 100%;
flex-direction: column; align-items: center; justify-content: center;
background-color: rgba(8, 10, 16, 0.72);
.title { font-size: 40px; font-weight: 800; letter-spacing: 6px; }
.sub { font-size: 18px; color: rgba(255,255,255,0.7); margin-bottom: 28px; }
.row { flex-direction: row; gap: 18px; }
.mode {
flex-direction: column; align-items: center; justify-content: center;
width: 220px; height: 150px; border-radius: 14px;
background: linear-gradient(160deg, rgba(40,46,66,0.95), rgba(22,26,40,0.95));
border: 2px solid rgba(255,255,255,0.08);
transition: all 0.12s ease-out; cursor: pointer;
&:hover { border-color: $accent; transform: scale(1.04); }
.k { font-size: 22px; color: $accent; font-weight: 700; }
.n { font-size: 24px; font-weight: 700; margin-top: 8px; }
}
.back {
margin-top: 30px; font-size: 16px; color: rgba(255,255,255,0.8);
background-color: rgba(255,255,255,0.08); padding: 10px 20px; border-radius: 8px; cursor: pointer;
&:hover { background-color: rgba(255,255,255,0.16); }
}
}
Game
library
namespace LobbySystem.Examples;
/// <summary>
/// The simplest reference mode: it runs the round timer and nothing else, so you can see the full lobby,
/// round and back-to-lobby loop. Replace the bodies with your own rules.
/// </summary>
public sealed class ExampleMode : IGameMode
{
public string DisplayName => "FREE PLAY";
public void OnRoundStart( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents )
{
}
public bool IsRoundOver( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents ) => false;
public string ResultText( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents ) => "Round over!";
}
/// <summary>
/// Declares which modes the menu offers and in what order. Put one on the Game Manager.
/// </summary>
public sealed class ExampleModeCatalog : Component, IGameModeCatalog
{
static readonly IGameMode[] _modes = { new ExampleMode() };
public IReadOnlyList<IGameMode> Modes => _modes;
}
Game
library
namespace LobbySystem;
/// <summary>
/// Implemented by a player or bot pawn so the lobby can count, place, reset and teleport it without knowing
/// anything about the gameplay. Keep roles (who is "it", score, team) on your own type and cast back to it
/// inside an <see cref="IGameMode"/>.
/// </summary>
public interface ILobbyAgent : IValid
{
/// <summary>True for AI pawns.</summary>
bool IsBot { get; }
/// <summary>True on peers that don't own this pawn.</summary>
bool IsProxy { get; }
/// <summary>Steam name for players, a label for bots.</summary>
string DisplayName { get; }
/// <summary>Used for spawn placement and proximity checks.</summary>
Vector3 WorldPosition { get; }
/// <summary>Host brands the pawn just after spawn. Network these fields.</summary>
void InitAgent( bool isBot, string displayName );
/// <summary>Return all gameplay state to a neutral pre-round condition.</summary>
void ResetForRound();
/// <summary>Move the pawn on every peer. Implement with <c>[Rpc.Broadcast]</c>.</summary>
void TeleportTo( Vector3 position );
}
Game
library
namespace LobbySystem;
/// <summary>
/// The rules of one gamemode. The <see cref="LobbyDirector"/> owns the lifecycle and calls into the active
/// mode for gameplay decisions. Keep implementations stateless so they can be shared as singletons. All
/// three methods run on the host.
/// </summary>
public interface IGameMode
{
/// <summary>Name shown on the HUD and in the menu.</summary>
string DisplayName { get; }
/// <summary>Called once when a round begins. Assign starting roles here.</summary>
void OnRoundStart( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents );
/// <summary>Return true to end the round before the timer expires.</summary>
bool IsRoundOver( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents );
/// <summary>Result line shown when the round ends.</summary>
string ResultText( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents );
}
/// <summary>
/// One component in the scene implements this to declare the available modes and their order. The menu
/// hotkeys 1, 2 and 3 map to the list indices.
/// </summary>
public interface IGameModeCatalog
{
IReadOnlyList<IGameMode> Modes { get; }
}
Game
library
namespace LobbySystem.Examples;
/// <summary>
/// The simplest reference mode: it runs the round timer and nothing else, so you can see the full lobby,
/// round and back-to-lobby loop. Replace the bodies with your own rules.
/// </summary>
public sealed class ExampleMode : IGameMode
{
public string DisplayName => "FREE PLAY";
public void OnRoundStart( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents )
{
}
public bool IsRoundOver( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents ) => false;
public string ResultText( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents ) => "Round over!";
}
/// <summary>
/// Declares which modes the menu offers and in what order. Put one on the Game Manager.
/// </summary>
public sealed class ExampleModeCatalog : Component, IGameModeCatalog
{
static readonly IGameMode[] _modes = { new ExampleMode() };
public IReadOnlyList<IGameMode> Modes => _modes;
}
Game
library
namespace LobbySystem;
/// <summary>
/// The rules of one gamemode. The <see cref="LobbyDirector"/> owns the lifecycle and calls into the active
/// mode for gameplay decisions. Keep implementations stateless so they can be shared as singletons. All
/// three methods run on the host.
/// </summary>
public interface IGameMode
{
/// <summary>Name shown on the HUD and in the menu.</summary>
string DisplayName { get; }
/// <summary>Called once when a round begins. Assign starting roles here.</summary>
void OnRoundStart( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents );
/// <summary>Return true to end the round before the timer expires.</summary>
bool IsRoundOver( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents );
/// <summary>Result line shown when the round ends.</summary>
string ResultText( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents );
}
/// <summary>
/// One component in the scene implements this to declare the available modes and their order. The menu
/// hotkeys 1, 2 and 3 map to the list indices.
/// </summary>
public interface IGameModeCatalog
{
IReadOnlyList<IGameMode> Modes { get; }
}
Game
library
using Sandbox;
using Sandbox.Citizen;
namespace LobbySystem.Examples;
/// <summary>
/// Self-contained networked third-person player: WASD with run and jump, a third-person camera created on
/// demand, Citizen animation and owner-synced facing, plus <see cref="ILobbyAgent"/> so the lobby can name,
/// spawn, place and reset it. Built on the standard s&box Citizen; copy or extend it for your own game.
/// </summary>
public sealed class LobbyPlayer : Component, ILobbyAgent
{
[Property] public float WalkSpeed { get; set; } = 130f;
[Property] public float RunSpeed { get; set; } = 260f;
[Property] public float JumpForce { get; set; } = 320f;
[Property] public GameObject Head { get; set; }
[Property] public GameObject Body { get; set; }
[Property] public SkinnedModelRenderer Renderer { get; set; }
[Sync( SyncFlags.FromHost )] public bool IsBot { get; set; }
[Sync( SyncFlags.FromHost )] public string DisplayName { get; set; } = "Player";
[Sync] public float LookYaw { get; set; }
CharacterController _cc;
CitizenAnimationHelper _anim;
CameraComponent _cam;
Vector3 _wish;
Vector3 _lastPos;
Vector3 _proxyVel;
float _jumpUntil;
bool _dressed;
TimeUntil _nextDressTry;
Vector3 _wanderDir = Vector3.Forward;
float _wanderTime;
// True on the peer that simulates this pawn: the owner, or anyone in single-player.
bool Controlled => !Networking.IsActive || !IsProxy;
public void InitAgent( bool isBot, string displayName ) { IsBot = isBot; DisplayName = displayName; }
public void ResetForRound() { if ( _cc is not null ) _cc.Velocity = Vector3.Zero; }
[Rpc.Broadcast]
public void TeleportTo( Vector3 position )
{
WorldPosition = position;
if ( _cc is not null ) _cc.Velocity = Vector3.Zero;
}
protected override void OnStart()
{
_cc = Components.Get<CharacterController>();
Body ??= GameObject.Children.FirstOrDefault( c => c.Name == "Body" );
Head ??= GameObject.Children.FirstOrDefault( c => c.Name == "Head" );
Renderer ??= Body?.Components.Get<SkinnedModelRenderer>();
if ( Renderer is not null ) _anim = Renderer.Components.GetOrCreate<CitizenAnimationHelper>();
_lastPos = WorldPosition;
}
protected override void OnUpdate()
{
TryDressFromAvatar();
if ( !Controlled )
{
_proxyVel = Time.Delta > 0f ? (WorldPosition - _lastPos) / Time.Delta : Vector3.Zero;
_lastPos = WorldPosition;
if ( Head is not null ) Head.WorldRotation = Rotation.FromYaw( LookYaw );
Animate();
return;
}
if ( !IsBot )
{
UpdateLookAndCamera();
if ( Input.Pressed( "Jump" ) ) _jumpUntil = Time.Now + 0.2f;
}
Animate();
}
protected override void OnFixedUpdate()
{
if ( _cc is null || !Controlled ) return;
_wish = IsBot ? WanderWish() : InputWish();
Move();
}
Vector3 InputWish()
{
if ( Head is null ) return Vector3.Zero;
var yaw = Head.WorldRotation.Angles().WithPitch( 0 ).ToRotation();
var move = Input.AnalogMove;
float speed = Input.Down( "Run" ) ? RunSpeed : WalkSpeed;
return yaw * new Vector3( move.x, move.y, 0f ) * speed;
}
// Bots stroll around and turn back when they drift too far from the centre.
Vector3 WanderWish()
{
_wanderTime -= Time.Delta;
if ( _wanderTime <= 0f )
{
_wanderDir = Rotation.FromYaw( Game.Random.Float( 0f, 360f ) ).Forward;
_wanderTime = Game.Random.Float( 2f, 4f );
}
var flat = WorldPosition.WithZ( 0 );
if ( flat.Length > 700f ) _wanderDir = (-flat).Normal;
return _wanderDir.WithZ( 0 ).Normal * WalkSpeed;
}
void Move()
{
var g = Scene.PhysicsWorld.Gravity;
if ( _cc.IsOnGround )
{
_cc.Velocity = _cc.Velocity.WithZ( 0 );
_cc.Accelerate( _wish );
_cc.ApplyFriction( 5f );
if ( Time.Now <= _jumpUntil ) { _cc.Punch( Vector3.Up * JumpForce ); _jumpUntil = 0f; }
}
else
{
_cc.Velocity += g * Time.Delta * 0.5f;
_cc.Accelerate( _wish.ClampLength( 60f ) );
_cc.ApplyFriction( 0.1f );
}
_cc.Move();
if ( _cc.IsOnGround ) _cc.Velocity = _cc.Velocity.WithZ( 0 );
else _cc.Velocity += g * Time.Delta * 0.5f;
}
void UpdateLookAndCamera()
{
if ( Head is null ) return;
var look = Input.AnalogLook;
var a = Head.WorldRotation.Angles();
a.pitch = MathX.Clamp( a.pitch + look.pitch, -89f, 89f );
a.yaw += look.yaw;
a.roll = 0f;
Head.WorldRotation = a.ToRotation();
LookYaw = a.yaw;
if ( !_cam.IsValid() )
{
_cam = Scene.GetAllComponents<CameraComponent>().FirstOrDefault();
if ( _cam is null )
{
var go = Scene.CreateObject();
go.Name = "Player Camera";
_cam = go.Components.Create<CameraComponent>();
}
}
var rot = Head.WorldRotation;
_cam.WorldPosition = WorldPosition + rot.Backward * 150f + rot.Right * 28f + Vector3.Up * 50f;
_cam.WorldRotation = rot;
}
void Animate()
{
if ( _anim is null || _cc is null ) return;
var vel = Controlled ? _cc.Velocity : _proxyVel;
_anim.WithVelocity( vel );
_anim.WithWishVelocity( Controlled ? _wish : _proxyVel );
_anim.IsGrounded = Controlled ? _cc.IsOnGround : MathF.Abs( _proxyVel.z ) < 50f;
if ( !IsBot && Head is not null )
_anim.WithLook( Head.WorldRotation.Forward, 1f, 0.75f, 0.5f );
FaceMovement( vel );
}
void FaceMovement( Vector3 velocity )
{
if ( Body is null ) return;
Rotation target;
if ( !IsBot && Head is not null )
{
target = Rotation.FromYaw( Head.WorldRotation.Yaw() );
}
else
{
var flat = velocity.WithZ( 0 );
target = flat.Length > 20f ? Rotation.LookAt( flat, Vector3.Up ) : Body.WorldRotation;
}
Body.WorldRotation = Rotation.Lerp( Body.WorldRotation, target, Time.Delta * 10f );
}
// Dress the Citizen in the player's own s&box avatar. Runs on every peer and retries until ownership has
// replicated. Bots keep the plain model.
void TryDressFromAvatar()
{
if ( _dressed || Renderer is null || _nextDressTry > 0f ) return;
_nextDressTry = 0.25f;
if ( IsBot ) { _dressed = true; return; }
ClothingContainer clothing;
try
{
var owner = Network.Owner;
if ( owner is not null )
{
var json = owner.GetUserData( "avatar" );
if ( string.IsNullOrEmpty( json ) ) return;
clothing = new ClothingContainer();
clothing.Deserialize( json );
}
else if ( !Networking.IsActive )
{
clothing = ClothingContainer.CreateFromLocalUser();
}
else return;
}
catch { _dressed = true; return; }
_dressed = true;
ApplyClothingAsync( clothing );
}
async void ApplyClothingAsync( ClothingContainer clothing )
{
try { await clothing.ApplyAsync( Renderer, System.Threading.CancellationToken.None ); }
catch { }
}
}
Game
library
namespace LobbySystem;
/// <summary>
/// Gamemode-agnostic round and lobby lifecycle. Drives the Lobby, Active and Ended states, the mode menu,
/// the synced countdown, optional map loading and agent tracking, then hands the actual rules to the active
/// <see cref="IGameMode"/>. Host-authoritative; clients read the synced values.
/// </summary>
public sealed class LobbyDirector : Component
{
public static LobbyDirector Current { get; private set; }
[Property] public float RoundDuration { get; set; } = 180f;
[Property] public float RestartDelay { get; set; } = 4f;
[Property] public int MinPlayers { get; set; } = 2;
/// <summary>Optional map cloned in when a round starts. Leave null to play in the lobby scene.</summary>
[Property] public GameObject MapPrefab { get; set; }
/// <summary>Lobby floor; its renderer hides once a round map has loaded.</summary>
[Property] public GameObject LobbyFloor { get; set; }
/// <summary>Turn on when you use <see cref="MapPrefab"/> so clients load it too.</summary>
[Property, Sync( SyncFlags.FromHost )] public bool UseRoundMap { get; set; }
[Sync( SyncFlags.FromHost )] public LobbyState State { get; set; } = LobbyState.Lobby;
[Sync( SyncFlags.FromHost )] public int ActiveModeIndex { get; set; }
[Sync( SyncFlags.FromHost )] public bool MenuOpen { get; set; }
[Sync( SyncFlags.FromHost )] public int TimeLeftSeconds { get; set; }
[Sync( SyncFlags.FromHost )] public string StatusMessage { get; set; } = "";
[Sync( SyncFlags.FromHost )] public string Banner { get; set; } = "";
[Sync( SyncFlags.FromHost )] public int EventPulse { get; set; }
/// <summary>Local flag: a client opened the menu to suggest a mode to the host.</summary>
public bool SuggestMenuOpen { get; set; }
float _timeRemaining;
float _restartAt;
TimeUntil _startGrace;
GameObject _mapInstance;
ModelRenderer _floorRenderer;
bool _floorResolved;
List<ILobbyAgent> _agents = new();
public IReadOnlyList<ILobbyAgent> Agents => _agents;
int LiveCount => _agents.Count;
IReadOnlyList<IGameMode> _modes;
public IReadOnlyList<IGameMode> Modes => _modes ?? ResolveModes();
public IGameMode ActiveMode => Modes.Count > 0 ? Modes[ Math.Clamp( ActiveModeIndex, 0, Modes.Count - 1 ) ] : null;
IReadOnlyList<IGameMode> ResolveModes()
{
try
{
var catalog = Scene.GetAllComponents<IGameModeCatalog>().FirstOrDefault();
_modes = catalog?.Modes ?? (IReadOnlyList<IGameMode>)Array.Empty<IGameMode>();
}
catch
{
_modes = Array.Empty<IGameMode>();
}
return _modes;
}
protected override void OnAwake() => Current = this;
protected override void OnDestroy() { if ( Current == this ) Current = null; }
bool _roundLive;
/// <summary>
/// True while a round is running. The RPC flag flips instantly for connected peers; OR-ing the synced
/// State also catches players who join mid-round. Drive lobby-vs-round UI off this, not State.
/// </summary>
public bool RoundLive => _roundLive || State == LobbyState.Active;
[Rpc.Broadcast] public void RpcSetRoundLive( bool live ) => _roundLive = live;
void SetRoundLive( bool live ) { if ( Networking.IsActive ) RpcSetRoundLive( live ); else _roundLive = live; }
public bool MapReady => _mapInstance.IsValid();
TimeUntil _nextScan;
void RefreshAgents()
{
if ( _nextScan > 0f && _agents.Count > 0 ) return;
_nextScan = 0.2f;
try
{
var fresh = new List<ILobbyAgent>();
foreach ( var a in Scene.GetAllComponents<ILobbyAgent>() )
if ( a.IsValid() ) fresh.Add( a );
_agents = fresh;
}
catch
{
// Scene changed during the scan; keep the previous list and retry next frame.
}
}
/// <summary>A spawn-point position spread by index. False until the map collision has loaded, so retry.</summary>
public bool TryRoundSpawn( int i, out Vector3 pos )
{
pos = default;
List<SpawnPoint> spawns;
try { spawns = Scene.GetAllComponents<SpawnPoint>().Where( s => s.IsValid() ).ToList(); }
catch { return false; }
if ( spawns.Count == 0 ) return false;
var sp = spawns[ ((i % spawns.Count) + spawns.Count) % spawns.Count ];
pos = sp.WorldPosition + Vector3.Up * 20f;
return true;
}
public Vector3 RoundSpawnPoint( int i ) => TryRoundSpawn( i, out var p ) ? p : Vector3.Up * 300f;
void SyncRoundMap()
{
if ( !UseRoundMap || MapPrefab is null ) return;
// Clone once. Toggling or destroying a MapInstance cancels its async load.
if ( RoundLive && !_mapInstance.IsValid() )
_mapInstance = MapPrefab.Clone( Vector3.Zero );
}
void SyncLobbyFloor()
{
if ( !_floorResolved )
{
_floorRenderer = LobbyFloor?.Components.Get<ModelRenderer>();
_floorResolved = true;
}
if ( _floorRenderer is not null )
{
bool show = !MapReady;
if ( _floorRenderer.Enabled != show ) _floorRenderer.Enabled = show;
}
}
public void RequestModeMenu()
{
bool host = !Networking.IsActive || Networking.IsHost;
if ( host ) { if ( MenuOpen ) RequestCloseMenu(); else RequestOpenMenu(); }
else SuggestMenuOpen = !SuggestMenuOpen;
}
public void RequestOpenMenu() { if ( !Networking.IsActive ) OpenMenu(); else RpcOpenMenu(); }
[Rpc.Broadcast] public void RpcOpenMenu() { if ( Networking.IsActive && !Networking.IsHost ) return; OpenMenu(); }
void OpenMenu() { MenuOpen = true; State = LobbyState.Lobby; }
public void RequestCloseMenu() { SuggestMenuOpen = false; if ( !Networking.IsActive ) CloseMenu(); else RpcCloseMenu(); }
[Rpc.Broadcast] public void RpcCloseMenu() { if ( Networking.IsActive && !Networking.IsHost ) return; CloseMenu(); }
void CloseMenu() { MenuOpen = false; State = LobbyState.Lobby; Banner = ""; }
/// <summary>Called by the HUD when a mode is clicked. The host starts it; a client suggests it.</summary>
public void PickMode( int index )
{
bool host = !Networking.IsActive || Networking.IsHost;
if ( host ) ChooseMode( index );
else { SuggestMenuOpen = false; SuggestMode( index ); }
}
public void ChooseMode( int index ) { MenuOpen = false; Banner = ""; StartRound( index ); }
public string ChatLine { get; private set; } = "";
TimeUntil _chatUntil;
public bool ChatVisible => !string.IsNullOrEmpty( ChatLine ) && _chatUntil > 0f;
public void SuggestMode( int index )
{
string who = Connection.Local?.DisplayName ?? "Someone";
string mode = (index >= 0 && index < Modes.Count) ? Modes[index].DisplayName : "a mode";
RpcSuggest( who, mode );
}
[Rpc.Broadcast] public void RpcSuggest( string who, string mode ) { ChatLine = $"{who} suggested starting {mode}"; _chatUntil = 6f; }
protected override void OnUpdate()
{
RefreshAgents();
SyncRoundMap();
SyncLobbyFloor();
// Only the host drives round state; clients render the synced values.
if ( Networking.IsActive && !Networking.IsHost ) return;
if ( MenuOpen ) { StatusMessage = "Select a game mode"; return; }
switch ( State )
{
case LobbyState.Lobby:
StatusMessage = "Waiting in the lobby";
break;
case LobbyState.Active:
_timeRemaining -= Time.Delta;
int secs = (int)MathF.Ceiling( _timeRemaining );
if ( secs != TimeLeftSeconds ) TimeLeftSeconds = secs;
bool graceOver = _startGrace <= 0f;
bool earlyOver = graceOver && (LiveCount < MinPlayers || (ActiveMode?.IsRoundOver( this, _agents ) ?? false));
if ( _timeRemaining <= 0f || earlyOver ) EndRound();
break;
case LobbyState.Ended:
if ( Time.Now >= _restartAt )
{
Banner = "";
State = LobbyState.Lobby;
SetRoundLive( false );
}
break;
}
}
/// <summary>Begin a round with the given mode index. Host only.</summary>
public void StartRound( int modeIndex )
{
if ( Modes.Count == 0 ) return;
ActiveModeIndex = Math.Clamp( modeIndex, 0, Modes.Count - 1 );
var live = _agents.Where( a => a.IsValid() ).ToList();
if ( live.Count < 1 ) { State = LobbyState.Lobby; return; }
State = LobbyState.Active;
SetRoundLive( true );
_startGrace = 1.5f;
_timeRemaining = RoundDuration;
TimeLeftSeconds = (int)RoundDuration;
foreach ( var a in live ) a.ResetForRound();
ActiveMode?.OnRoundStart( this, live );
StatusMessage = "";
Banner = ActiveMode?.DisplayName ?? "";
}
void EndRound()
{
State = LobbyState.Ended;
_timeRemaining = 0f;
TimeLeftSeconds = 0;
_restartAt = Time.Now + RestartDelay;
StatusMessage = ActiveMode?.ResultText( this, _agents ) ?? "Round over!";
Banner = "ROUND OVER";
}
/// <summary>Bump <see cref="EventPulse"/> to flash every HUD once.</summary>
public void PulseEvent() => EventPulse++;
}
Game
library
@using Sandbox
@using Sandbox.UI
@using LobbySystem
@inherits PanelComponent
@namespace LobbySystem.Examples
<root>
@if ( Dir is null )
{
<text></text>
}
else if ( Dir.MenuOpen || Dir.SuggestMenuOpen )
{
<div class="menu">
<div class="title">MULTIPLAYER LOBBY</div>
<div class="sub">@(Dir.MenuOpen ? "Choose a game mode" : "Suggest a mode to the host")</div>
<div class="row">
@for ( int i = 0; i < Dir.Modes.Count; i++ )
{
var idx = i;
<button class="mode" onclick=@(() => Dir.PickMode( idx ))>
<div class="k">@(idx + 1)</div>
<div class="n">@Dir.Modes[idx].DisplayName</div>
</button>
}
</div>
<button class="back" onclick=@(() => Dir.RequestCloseMenu())>Back to lobby [E]</button>
</div>
}
else
{
<div class="top">
@if ( Dir.RoundLive )
{
<div class="mode">@(Dir.ActiveMode?.DisplayName ?? "")</div>
<div class="timer @(Dir.TimeLeftSeconds < 15 ? "urgent" : "")">@TimerText</div>
}
else
{
<div class="lobby">LOBBY</div>
}
</div>
@if ( !Dir.RoundLive && !string.IsNullOrEmpty( Dir.StatusMessage ) )
{
<div class="status">@Dir.StatusMessage</div>
}
@if ( Dir.ChatVisible )
{
<div class="chat">@Dir.ChatLine</div>
}
<div class="hints">
<span>[WASD] Move</span>
<span>[Space] Jump</span>
@if ( !Dir.RoundLive )
{
<span>Walk to the pad and press [E] to start a round</span>
}
</div>
}
</root>
@code
{
LobbyDirector Dir => LobbyDirector.Current;
string TimerText
{
get
{
int t = Dir?.TimeLeftSeconds ?? 0;
if ( t < 0 ) t = 0;
return $"{t / 60:D2}:{t % 60:D2}";
}
}
protected override int BuildHash() => System.HashCode.Combine(
Dir?.State, Dir?.MenuOpen, Dir?.SuggestMenuOpen, Dir?.RoundLive,
Dir?.TimeLeftSeconds, Dir?.StatusMessage, Dir?.ChatLine, Dir?.ChatVisible );
}
Game
library
namespace LobbySystem;
/// <summary>
/// Implemented by a player or bot pawn so the lobby can count, place, reset and teleport it without knowing
/// anything about the gameplay. Keep roles (who is "it", score, team) on your own type and cast back to it
/// inside an <see cref="IGameMode"/>.
/// </summary>
public interface ILobbyAgent : IValid
{
/// <summary>True for AI pawns.</summary>
bool IsBot { get; }
/// <summary>True on peers that don't own this pawn.</summary>
bool IsProxy { get; }
/// <summary>Steam name for players, a label for bots.</summary>
string DisplayName { get; }
/// <summary>Used for spawn placement and proximity checks.</summary>
Vector3 WorldPosition { get; }
/// <summary>Host brands the pawn just after spawn. Network these fields.</summary>
void InitAgent( bool isBot, string displayName );
/// <summary>Return all gameplay state to a neutral pre-round condition.</summary>
void ResetForRound();
/// <summary>Move the pawn on every peer. Implement with <c>[Rpc.Broadcast]</c>.</summary>
void TeleportTo( Vector3 position );
}
Game
library
namespace LobbySystem;
/// <summary>Lifecycle of the lobby: Lobby, then Active, then Ended before looping back.</summary>
public enum LobbyState
{
/// <summary>Free roam before and after a round. The mode button works here.</summary>
Lobby,
/// <summary>A round is running.</summary>
Active,
/// <summary>Round finished; results show before returning to the lobby.</summary>
Ended
}
Game
library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Lobby System" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "lobbysystem" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "rwtrcsc" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "rwtrcsc.lobbysystem" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "6/15/2026 4:29:44 PM" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "25" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.112.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.112.0")]
Game
library
global using Sandbox; global using System; global using System.Collections.Generic; global using System.Linq; global using System.Threading.Tasks;
Game
library
namespace LobbySystem;
/// <summary>
/// In-world button that opens the mode menu for the host, or a local suggestion menu for a client. It needs
/// a ModelRenderer to be visible and is hidden while a round is live. When the local player is within
/// <see cref="UseRange"/> and presses Use, the menu opens.
/// </summary>
public sealed class LobbyModeButton : Component
{
[Property] public float UseRange { get; set; } = 130f;
[Property] public bool GlowWhenInRange { get; set; } = true;
[Property] public Color IdleTint { get; set; } = new Color( 0.85f, 0.4f, 0.15f );
[Property] public Color ActiveTint { get; set; } = new Color( 1f, 0.85f, 0.3f );
ModelRenderer _renderer;
ILobbyAgent _me;
protected override void OnStart()
{
_renderer = Components.Get<ModelRenderer>() ?? Components.GetInChildren<ModelRenderer>();
if ( _renderer is not null ) _renderer.Tint = IdleTint;
}
protected override void OnUpdate()
{
var dir = LobbyDirector.Current;
bool inLobby = dir is null || !dir.RoundLive;
if ( _renderer is not null && _renderer.Enabled != inLobby )
_renderer.Enabled = inLobby;
if ( !inLobby ) return;
var me = LocalPlayer();
bool inRange = me is not null && WorldPosition.Distance( me.WorldPosition ) <= UseRange;
if ( GlowWhenInRange && _renderer is not null )
_renderer.Tint = inRange ? ActiveTint : IdleTint;
if ( inRange && Input.Pressed( "Use" ) )
dir?.RequestModeMenu();
}
ILobbyAgent LocalPlayer()
{
if ( _me is not null && _me.IsValid() && !_me.IsBot && !_me.IsProxy ) return _me;
try { _me = Scene.GetAllComponents<ILobbyAgent>().FirstOrDefault( c => c.IsValid() && !c.IsBot && !c.IsProxy ); }
catch { _me = null; }
return _me;
}
}
Debug: View Raw JSON Response
{
"TotalCount": 22,
"Files": [
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "Code/LobbyNetworkManager.cs",
"FileName": "LobbyNetworkManager.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "namespace LobbySystem;\n\n/// <summary>\n/// Auto-hosts a lobby so Steam friends can join, and keeps one networked pawn per connection plus optional\n/// bots by cloning <see cref=\"PlayerPrefab\"/>. The pawn only has to implement <see cref=\"ILobbyAgent\"/>.\n/// Spawning is de-duped and runs in OnUpdate so a join can't fire mid-enumeration.\n/// </summary>\npublic sealed class LobbyNetworkManager : Component, Component.INetworkListener\n{\n\t[Property] public GameObject PlayerPrefab { get; set; }\n\t[Property] public int BotCount { get; set; } = 1;\n\n\t/// <summary>When true, bots only exist during an active round.</summary>\n\t[Property] public bool BotsOnlyDuringRound { get; set; } = true;\n\n\t[Property] public Color BotTint { get; set; } = new Color( 1f, 0.35f, 0.3f );\n\n\t// Lobby spawn ring, used before a round map loads.\n\treadonly Vector3[] _spawns =\n\t{\n\t\tnew Vector3( 0f, -300f, 40f ), new Vector3( 300f, 0f, 40f ),\n\t\tnew Vector3( 0f, 300f, 40f ), new Vector3( -300f, 0f, 40f ),\n\t\tnew Vector3( 250f, 250f, 40f ), new Vector3( -250f, -250f, 40f ),\n\t};\n\tint _spawnIndex;\n\treadonly Dictionary<Guid, GameObject> _pawns = new();\n\treadonly List<GameObject> _bots = new();\n\n\tbool _reconcileNow;\n\tTimeUntil _nextReconcile;\n\tTimeUntil _nextSweep;\n\n\tprotected override async Task OnLoad()\n\t{\n\t\t// When joining a friend the engine is mid-connect and IsActive is briefly false, so poll for a\n\t\t// moment before hosting. Otherwise a joiner would spin up its own solo lobby.\n\t\tif ( Networking.IsActive ) return;\n\t\tfor ( int i = 0; i < 6 && !Networking.IsActive; i++ )\n\t\t\tawait Task.DelayRealtimeSeconds( 0.1f );\n\t\tif ( !Networking.IsActive )\n\t\t\tNetworking.CreateLobby( new() );\n\t}\n\n\tvoid INetworkListener.OnActive( Connection channel ) => _reconcileNow = true;\n\n\tprotected override void OnUpdate()\n\t{\n\t\tif ( !Networking.IsHost || PlayerPrefab is null ) return;\n\n\t\tif ( !_reconcileNow && _nextReconcile > 0f ) return;\n\t\t_reconcileNow = false;\n\t\t_nextReconcile = 0.25f;\n\n\t\ttry\n\t\t{\n\t\t\tbool wantBots = !BotsOnlyDuringRound || (LobbyDirector.Current?.State == LobbyState.Active);\n\t\t\tReconcileBots( wantBots ? Math.Max( 0, BotCount ) : 0 );\n\n\t\t\tforeach ( var conn in Connection.All.ToList() )\n\t\t\t{\n\t\t\t\tif ( conn is null || !conn.IsActive ) continue;\n\t\t\t\tif ( _pawns.TryGetValue( conn.Id, out var pawn ) && pawn.IsValid() ) continue;\n\t\t\t\tvar id = conn.Id;\n\t\t\t\t_pawns[id] = FindConnectionPawn( id ) ?? SpawnPawn( false, conn.DisplayName, conn );\n\t\t\t}\n\n\t\t\tSweep();\n\t\t}\n\t\tcatch\n\t\t{\n\t\t\t// Connection or scene list changed during the pass; retry next frame.\n\t\t}\n\t}\n\n\tvoid ReconcileBots( int target )\n\t{\n\t\t_bots.RemoveAll( b => !b.IsValid() );\n\t\twhile ( _bots.Count > target )\n\t\t{\n\t\t\tvar b = _bots[_bots.Count - 1];\n\t\t\t_bots.RemoveAt( _bots.Count - 1 );\n\t\t\tif ( b.IsValid() ) b.Destroy();\n\t\t}\n\t\twhile ( _bots.Count < target )\n\t\t\t_bots.Add( SpawnPawn( true, \"Bot\", null ) );\n\t}\n\n\tGameObject FindConnectionPawn( Guid id )\n\t{\n\t\tforeach ( var a in Scene.GetAllComponents<ILobbyAgent>() )\n\t\t{\n\t\t\tif ( !a.IsValid() || a.IsBot ) continue;\n\t\t\tif ( a is Component c && c.Network.OwnerId == id ) return c.GameObject;\n\t\t}\n\t\treturn null;\n\t}\n\n\tvoid Sweep()\n\t{\n\t\tif ( _nextSweep > 0f ) return;\n\t\t_nextSweep = 1f;\n\t\tforeach ( var key in _pawns.Where( kv => !kv.Value.IsValid() ).Select( kv => kv.Key ).ToList() )\n\t\t\t_pawns.Remove( key );\n\t}\n\n\tGameObject SpawnPawn( bool isBot, string displayName, Connection owner )\n\t{\n\t\tvar go = PlayerPrefab.Clone( NextSpawn() );\n\t\tgo.Name = isBot ? \"Bot\" : $\"Player - {displayName}\";\n\t\tgo.Enabled = true;\n\n\t\tvar agent = go.Components.Get<ILobbyAgent>() ?? go.Components.GetInChildren<ILobbyAgent>();\n\t\tagent?.InitAgent( isBot, displayName );\n\n\t\tif ( isBot )\n\t\t{\n\t\t\tvar rend = go.Components.GetInChildren<SkinnedModelRenderer>();\n\t\t\tif ( rend is not null ) rend.Tint = BotTint;\n\t\t}\n\n\t\tif ( owner is not null ) go.NetworkSpawn( owner );\n\t\telse go.NetworkSpawn();\n\t\treturn go;\n\t}\n\n\tVector3 NextSpawn()\n\t{\n\t\tint idx = _spawnIndex++;\n\t\tvar dir = LobbyDirector.Current;\n\t\tif ( dir is not null && dir.UseRoundMap && dir.MapReady )\n\t\t\treturn dir.RoundSpawnPoint( idx );\n\t\treturn _spawns[idx % _spawns.Length];\n\t}\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "Code/LobbyState.cs",
"FileName": "LobbyState.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "namespace LobbySystem;\n\n/// <summary>Lifecycle of the lobby: Lobby, then Active, then Ended before looping back.</summary>\npublic enum LobbyState\n{\n\t/// <summary>Free roam before and after a round. The mode button works here.</summary>\n\tLobby,\n\t/// <summary>A round is running.</summary>\n\tActive,\n\t/// <summary>Round finished; results show before returning to the lobby.</summary>\n\tEnded\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "LobbyModeButton.cs",
"FileName": "LobbyModeButton.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "namespace LobbySystem;\n\n/// <summary>\n/// In-world button that opens the mode menu for the host, or a local suggestion menu for a client. It needs\n/// a ModelRenderer to be visible and is hidden while a round is live. When the local player is within\n/// <see cref=\"UseRange\"/> and presses Use, the menu opens.\n/// </summary>\npublic sealed class LobbyModeButton : Component\n{\n\t[Property] public float UseRange { get; set; } = 130f;\n\t[Property] public bool GlowWhenInRange { get; set; } = true;\n\t[Property] public Color IdleTint { get; set; } = new Color( 0.85f, 0.4f, 0.15f );\n\t[Property] public Color ActiveTint { get; set; } = new Color( 1f, 0.85f, 0.3f );\n\n\tModelRenderer _renderer;\n\tILobbyAgent _me;\n\n\tprotected override void OnStart()\n\t{\n\t\t_renderer = Components.Get<ModelRenderer>() ?? Components.GetInChildren<ModelRenderer>();\n\t\tif ( _renderer is not null ) _renderer.Tint = IdleTint;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tvar dir = LobbyDirector.Current;\n\t\tbool inLobby = dir is null || !dir.RoundLive;\n\t\tif ( _renderer is not null && _renderer.Enabled != inLobby )\n\t\t\t_renderer.Enabled = inLobby;\n\t\tif ( !inLobby ) return;\n\n\t\tvar me = LocalPlayer();\n\t\tbool inRange = me is not null && WorldPosition.Distance( me.WorldPosition ) <= UseRange;\n\n\t\tif ( GlowWhenInRange && _renderer is not null )\n\t\t\t_renderer.Tint = inRange ? ActiveTint : IdleTint;\n\n\t\tif ( inRange && Input.Pressed( \"Use\" ) )\n\t\t\tdir?.RequestModeMenu();\n\t}\n\n\tILobbyAgent LocalPlayer()\n\t{\n\t\tif ( _me is not null && _me.IsValid() && !_me.IsBot && !_me.IsProxy ) return _me;\n\t\ttry { _me = Scene.GetAllComponents<ILobbyAgent>().FirstOrDefault( c => c.IsValid() && !c.IsBot && !c.IsProxy ); }\n\t\tcatch { _me = null; }\n\t\treturn _me;\n\t}\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "Assembly.cs",
"FileName": "Assembly.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "global using Sandbox;\nglobal using System;\nglobal using System.Collections.Generic;\nglobal using System.Linq;\nglobal using System.Threading.Tasks;\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "Code/Examples/UI/LobbyHud.razor",
"FileName": "LobbyHud.razor",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "@using Sandbox\n@using Sandbox.UI\n@using LobbySystem\n@inherits PanelComponent\n@namespace LobbySystem.Examples\n\n<root>\n\t@if ( Dir is null )\n\t{\n\t\t<text></text>\n\t}\n\telse if ( Dir.MenuOpen || Dir.SuggestMenuOpen )\n\t{\n\t\t<div class=\"menu\">\n\t\t\t<div class=\"title\">MULTIPLAYER LOBBY</div>\n\t\t\t<div class=\"sub\">@(Dir.MenuOpen ? \"Choose a game mode\" : \"Suggest a mode to the host\")</div>\n\t\t\t<div class=\"row\">\n\t\t\t\t@for ( int i = 0; i < Dir.Modes.Count; i++ )\n\t\t\t\t{\n\t\t\t\t\tvar idx = i;\n\t\t\t\t\t<button class=\"mode\" onclick=@(() => Dir.PickMode( idx ))>\n\t\t\t\t\t\t<div class=\"k\">@(idx + 1)</div>\n\t\t\t\t\t\t<div class=\"n\">@Dir.Modes[idx].DisplayName</div>\n\t\t\t\t\t</button>\n\t\t\t\t}\n\t\t\t</div>\n\t\t\t<button class=\"back\" onclick=@(() => Dir.RequestCloseMenu())>Back to lobby [E]</button>\n\t\t</div>\n\t}\n\telse\n\t{\n\t\t<div class=\"top\">\n\t\t\t@if ( Dir.RoundLive )\n\t\t\t{\n\t\t\t\t<div class=\"mode\">@(Dir.ActiveMode?.DisplayName ?? \"\")</div>\n\t\t\t\t<div class=\"timer @(Dir.TimeLeftSeconds < 15 ? \"urgent\" : \"\")\">@TimerText</div>\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\t<div class=\"lobby\">LOBBY</div>\n\t\t\t}\n\t\t</div>\n\n\t\t@if ( !Dir.RoundLive && !string.IsNullOrEmpty( Dir.StatusMessage ) )\n\t\t{\n\t\t\t<div class=\"status\">@Dir.StatusMessage</div>\n\t\t}\n\t\t@if ( Dir.ChatVisible )\n\t\t{\n\t\t\t<div class=\"chat\">@Dir.ChatLine</div>\n\t\t}\n\n\t\t<div class=\"hints\">\n\t\t\t<span>[WASD] Move</span>\n\t\t\t<span>[Space] Jump</span>\n\t\t\t@if ( !Dir.RoundLive )\n\t\t\t{\n\t\t\t\t<span>Walk to the pad and press [E] to start a round</span>\n\t\t\t}\n\t\t</div>\n\t}\n</root>\n\n@code\n{\n\tLobbyDirector Dir => LobbyDirector.Current;\n\n\tstring TimerText\n\t{\n\t\tget\n\t\t{\n\t\t\tint t = Dir?.TimeLeftSeconds ?? 0;\n\t\t\tif ( t < 0 ) t = 0;\n\t\t\treturn $\"{t / 60:D2}:{t % 60:D2}\";\n\t\t}\n\t}\n\n\tprotected override int BuildHash() => System.HashCode.Combine(\n\t\tDir?.State, Dir?.MenuOpen, Dir?.SuggestMenuOpen, Dir?.RoundLive,\n\t\tDir?.TimeLeftSeconds, Dir?.StatusMessage, Dir?.ChatLine, Dir?.ChatVisible );\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "Code/Examples/LobbyPlayer.cs",
"FileName": "LobbyPlayer.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "using Sandbox;\nusing Sandbox.Citizen;\n\nnamespace LobbySystem.Examples;\n\n/// <summary>\n/// Self-contained networked third-person player: WASD with run and jump, a third-person camera created on\n/// demand, Citizen animation and owner-synced facing, plus <see cref=\"ILobbyAgent\"/> so the lobby can name,\n/// spawn, place and reset it. Built on the standard s&box Citizen; copy or extend it for your own game.\n/// </summary>\npublic sealed class LobbyPlayer : Component, ILobbyAgent\n{\n\t[Property] public float WalkSpeed { get; set; } = 130f;\n\t[Property] public float RunSpeed { get; set; } = 260f;\n\t[Property] public float JumpForce { get; set; } = 320f;\n\t[Property] public GameObject Head { get; set; }\n\t[Property] public GameObject Body { get; set; }\n\t[Property] public SkinnedModelRenderer Renderer { get; set; }\n\n\t[Sync( SyncFlags.FromHost )] public bool IsBot { get; set; }\n\t[Sync( SyncFlags.FromHost )] public string DisplayName { get; set; } = \"Player\";\n\t[Sync] public float LookYaw { get; set; }\n\n\tCharacterController _cc;\n\tCitizenAnimationHelper _anim;\n\tCameraComponent _cam;\n\tVector3 _wish;\n\tVector3 _lastPos;\n\tVector3 _proxyVel;\n\tfloat _jumpUntil;\n\n\tbool _dressed;\n\tTimeUntil _nextDressTry;\n\n\tVector3 _wanderDir = Vector3.Forward;\n\tfloat _wanderTime;\n\n\t// True on the peer that simulates this pawn: the owner, or anyone in single-player.\n\tbool Controlled => !Networking.IsActive || !IsProxy;\n\n\tpublic void InitAgent( bool isBot, string displayName ) { IsBot = isBot; DisplayName = displayName; }\n\tpublic void ResetForRound() { if ( _cc is not null ) _cc.Velocity = Vector3.Zero; }\n\n\t[Rpc.Broadcast]\n\tpublic void TeleportTo( Vector3 position )\n\t{\n\t\tWorldPosition = position;\n\t\tif ( _cc is not null ) _cc.Velocity = Vector3.Zero;\n\t}\n\n\tprotected override void OnStart()\n\t{\n\t\t_cc = Components.Get<CharacterController>();\n\t\tBody ??= GameObject.Children.FirstOrDefault( c => c.Name == \"Body\" );\n\t\tHead ??= GameObject.Children.FirstOrDefault( c => c.Name == \"Head\" );\n\t\tRenderer ??= Body?.Components.Get<SkinnedModelRenderer>();\n\t\tif ( Renderer is not null ) _anim = Renderer.Components.GetOrCreate<CitizenAnimationHelper>();\n\t\t_lastPos = WorldPosition;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tTryDressFromAvatar();\n\n\t\tif ( !Controlled )\n\t\t{\n\t\t\t_proxyVel = Time.Delta > 0f ? (WorldPosition - _lastPos) / Time.Delta : Vector3.Zero;\n\t\t\t_lastPos = WorldPosition;\n\t\t\tif ( Head is not null ) Head.WorldRotation = Rotation.FromYaw( LookYaw );\n\t\t\tAnimate();\n\t\t\treturn;\n\t\t}\n\n\t\tif ( !IsBot )\n\t\t{\n\t\t\tUpdateLookAndCamera();\n\t\t\tif ( Input.Pressed( \"Jump\" ) ) _jumpUntil = Time.Now + 0.2f;\n\t\t}\n\t\tAnimate();\n\t}\n\n\tprotected override void OnFixedUpdate()\n\t{\n\t\tif ( _cc is null || !Controlled ) return;\n\t\t_wish = IsBot ? WanderWish() : InputWish();\n\t\tMove();\n\t}\n\n\tVector3 InputWish()\n\t{\n\t\tif ( Head is null ) return Vector3.Zero;\n\t\tvar yaw = Head.WorldRotation.Angles().WithPitch( 0 ).ToRotation();\n\t\tvar move = Input.AnalogMove;\n\t\tfloat speed = Input.Down( \"Run\" ) ? RunSpeed : WalkSpeed;\n\t\treturn yaw * new Vector3( move.x, move.y, 0f ) * speed;\n\t}\n\n\t// Bots stroll around and turn back when they drift too far from the centre.\n\tVector3 WanderWish()\n\t{\n\t\t_wanderTime -= Time.Delta;\n\t\tif ( _wanderTime <= 0f )\n\t\t{\n\t\t\t_wanderDir = Rotation.FromYaw( Game.Random.Float( 0f, 360f ) ).Forward;\n\t\t\t_wanderTime = Game.Random.Float( 2f, 4f );\n\t\t}\n\n\t\tvar flat = WorldPosition.WithZ( 0 );\n\t\tif ( flat.Length > 700f ) _wanderDir = (-flat).Normal;\n\t\treturn _wanderDir.WithZ( 0 ).Normal * WalkSpeed;\n\t}\n\n\tvoid Move()\n\t{\n\t\tvar g = Scene.PhysicsWorld.Gravity;\n\t\tif ( _cc.IsOnGround )\n\t\t{\n\t\t\t_cc.Velocity = _cc.Velocity.WithZ( 0 );\n\t\t\t_cc.Accelerate( _wish );\n\t\t\t_cc.ApplyFriction( 5f );\n\t\t\tif ( Time.Now <= _jumpUntil ) { _cc.Punch( Vector3.Up * JumpForce ); _jumpUntil = 0f; }\n\t\t}\n\t\telse\n\t\t{\n\t\t\t_cc.Velocity += g * Time.Delta * 0.5f;\n\t\t\t_cc.Accelerate( _wish.ClampLength( 60f ) );\n\t\t\t_cc.ApplyFriction( 0.1f );\n\t\t}\n\n\t\t_cc.Move();\n\n\t\tif ( _cc.IsOnGround ) _cc.Velocity = _cc.Velocity.WithZ( 0 );\n\t\telse _cc.Velocity += g * Time.Delta * 0.5f;\n\t}\n\n\tvoid UpdateLookAndCamera()\n\t{\n\t\tif ( Head is null ) return;\n\t\tvar look = Input.AnalogLook;\n\t\tvar a = Head.WorldRotation.Angles();\n\t\ta.pitch = MathX.Clamp( a.pitch + look.pitch, -89f, 89f );\n\t\ta.yaw += look.yaw;\n\t\ta.roll = 0f;\n\t\tHead.WorldRotation = a.ToRotation();\n\t\tLookYaw = a.yaw;\n\n\t\tif ( !_cam.IsValid() )\n\t\t{\n\t\t\t_cam = Scene.GetAllComponents<CameraComponent>().FirstOrDefault();\n\t\t\tif ( _cam is null )\n\t\t\t{\n\t\t\t\tvar go = Scene.CreateObject();\n\t\t\t\tgo.Name = \"Player Camera\";\n\t\t\t\t_cam = go.Components.Create<CameraComponent>();\n\t\t\t}\n\t\t}\n\n\t\tvar rot = Head.WorldRotation;\n\t\t_cam.WorldPosition = WorldPosition + rot.Backward * 150f + rot.Right * 28f + Vector3.Up * 50f;\n\t\t_cam.WorldRotation = rot;\n\t}\n\n\tvoid Animate()\n\t{\n\t\tif ( _anim is null || _cc is null ) return;\n\t\tvar vel = Controlled ? _cc.Velocity : _proxyVel;\n\t\t_anim.WithVelocity( vel );\n\t\t_anim.WithWishVelocity( Controlled ? _wish : _proxyVel );\n\t\t_anim.IsGrounded = Controlled ? _cc.IsOnGround : MathF.Abs( _proxyVel.z ) < 50f;\n\t\tif ( !IsBot && Head is not null )\n\t\t\t_anim.WithLook( Head.WorldRotation.Forward, 1f, 0.75f, 0.5f );\n\t\tFaceMovement( vel );\n\t}\n\n\tvoid FaceMovement( Vector3 velocity )\n\t{\n\t\tif ( Body is null ) return;\n\n\t\tRotation target;\n\t\tif ( !IsBot && Head is not null )\n\t\t{\n\t\t\ttarget = Rotation.FromYaw( Head.WorldRotation.Yaw() );\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar flat = velocity.WithZ( 0 );\n\t\t\ttarget = flat.Length > 20f ? Rotation.LookAt( flat, Vector3.Up ) : Body.WorldRotation;\n\t\t}\n\n\t\tBody.WorldRotation = Rotation.Lerp( Body.WorldRotation, target, Time.Delta * 10f );\n\t}\n\n\t// Dress the Citizen in the player's own s&box avatar. Runs on every peer and retries until ownership has\n\t// replicated. Bots keep the plain model.\n\tvoid TryDressFromAvatar()\n\t{\n\t\tif ( _dressed || Renderer is null || _nextDressTry > 0f ) return;\n\t\t_nextDressTry = 0.25f;\n\t\tif ( IsBot ) { _dressed = true; return; }\n\n\t\tClothingContainer clothing;\n\t\ttry\n\t\t{\n\t\t\tvar owner = Network.Owner;\n\t\t\tif ( owner is not null )\n\t\t\t{\n\t\t\t\tvar json = owner.GetUserData( \"avatar\" );\n\t\t\t\tif ( string.IsNullOrEmpty( json ) ) return;\n\t\t\t\tclothing = new ClothingContainer();\n\t\t\t\tclothing.Deserialize( json );\n\t\t\t}\n\t\t\telse if ( !Networking.IsActive )\n\t\t\t{\n\t\t\t\tclothing = ClothingContainer.CreateFromLocalUser();\n\t\t\t}\n\t\t\telse return;\n\t\t}\n\t\tcatch { _dressed = true; return; }\n\n\t\t_dressed = true;\n\t\tApplyClothingAsync( clothing );\n\t}\n\n\tasync void ApplyClothingAsync( ClothingContainer clothing )\n\t{\n\t\ttry { await clothing.ApplyAsync( Renderer, System.Threading.CancellationToken.None ); }\n\t\tcatch { }\n\t}\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "Code/Examples/UI/LobbyHud.razor.scss",
"FileName": "LobbyHud.razor.scss",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "$accent: #ffb13b;\n\nroot {\n\tposition: absolute;\n\twidth: 100%;\n\theight: 100%;\n\tfont-family: \"Poppins\", sans-serif;\n\tcolor: white;\n\tpointer-events: none;\n\tflex-direction: column;\n}\n\n.top {\n\tflex-direction: column;\n\talign-items: center;\n\twidth: 100%;\n\tmargin-top: 36px;\n\n\t.mode { font-size: 20px; font-weight: 600; letter-spacing: 4px; color: $accent; text-shadow: 0 2px 6px black; }\n\t.lobby { font-size: 40px; font-weight: 800; letter-spacing: 3px; text-shadow: 0 3px 10px black; }\n\t.timer { font-size: 30px; font-weight: 700; text-shadow: 0 2px 6px black; &.urgent { color: #ff5a4d; } }\n}\n\n.status {\n\tposition: absolute; top: 48%; width: 100%; justify-content: center;\n\tfont-size: 24px; font-weight: 600; text-shadow: 0 2px 8px black;\n}\n\n.chat {\n\tposition: absolute; top: 70%; width: 100%; justify-content: center;\n\tfont-size: 18px; color: $accent; text-shadow: 0 2px 6px black;\n}\n\n.hints {\n\tposition: absolute; bottom: 24px; width: 100%; justify-content: center; gap: 18px;\n\tspan {\n\t\tfont-size: 15px; color: rgba(255,255,255,0.85);\n\t\tbackground-color: rgba(0,0,0,0.35); padding: 5px 12px; border-radius: 6px;\n\t}\n}\n\n.menu {\n\tpointer-events: all;\n\tposition: absolute; width: 100%; height: 100%;\n\tflex-direction: column; align-items: center; justify-content: center;\n\tbackground-color: rgba(8, 10, 16, 0.72);\n\n\t.title { font-size: 40px; font-weight: 800; letter-spacing: 6px; }\n\t.sub { font-size: 18px; color: rgba(255,255,255,0.7); margin-bottom: 28px; }\n\t.row { flex-direction: row; gap: 18px; }\n\n\t.mode {\n\t\tflex-direction: column; align-items: center; justify-content: center;\n\t\twidth: 220px; height: 150px; border-radius: 14px;\n\t\tbackground: linear-gradient(160deg, rgba(40,46,66,0.95), rgba(22,26,40,0.95));\n\t\tborder: 2px solid rgba(255,255,255,0.08);\n\t\ttransition: all 0.12s ease-out; cursor: pointer;\n\t\t&:hover { border-color: $accent; transform: scale(1.04); }\n\t\t.k { font-size: 22px; color: $accent; font-weight: 700; }\n\t\t.n { font-size: 24px; font-weight: 700; margin-top: 8px; }\n\t}\n\n\t.back {\n\t\tmargin-top: 30px; font-size: 16px; color: rgba(255,255,255,0.8);\n\t\tbackground-color: rgba(255,255,255,0.08); padding: 10px 20px; border-radius: 8px; cursor: pointer;\n\t\t&:hover { background-color: rgba(255,255,255,0.16); }\n\t}\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "Code/Examples/ExampleMode.cs",
"FileName": "ExampleMode.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "namespace LobbySystem.Examples;\n\n/// <summary>\n/// The simplest reference mode: it runs the round timer and nothing else, so you can see the full lobby,\n/// round and back-to-lobby loop. Replace the bodies with your own rules.\n/// </summary>\npublic sealed class ExampleMode : IGameMode\n{\n\tpublic string DisplayName => \"FREE PLAY\";\n\n\tpublic void OnRoundStart( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents )\n\t{\n\t}\n\n\tpublic bool IsRoundOver( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents ) => false;\n\n\tpublic string ResultText( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents ) => \"Round over!\";\n}\n\n/// <summary>\n/// Declares which modes the menu offers and in what order. Put one on the Game Manager.\n/// </summary>\npublic sealed class ExampleModeCatalog : Component, IGameModeCatalog\n{\n\tstatic readonly IGameMode[] _modes = { new ExampleMode() };\n\tpublic IReadOnlyList<IGameMode> Modes => _modes;\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "Code/ILobbyAgent.cs",
"FileName": "ILobbyAgent.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "namespace LobbySystem;\n\n/// <summary>\n/// Implemented by a player or bot pawn so the lobby can count, place, reset and teleport it without knowing\n/// anything about the gameplay. Keep roles (who is \"it\", score, team) on your own type and cast back to it\n/// inside an <see cref=\"IGameMode\"/>.\n/// </summary>\npublic interface ILobbyAgent : IValid\n{\n\t/// <summary>True for AI pawns.</summary>\n\tbool IsBot { get; }\n\n\t/// <summary>True on peers that don't own this pawn.</summary>\n\tbool IsProxy { get; }\n\n\t/// <summary>Steam name for players, a label for bots.</summary>\n\tstring DisplayName { get; }\n\n\t/// <summary>Used for spawn placement and proximity checks.</summary>\n\tVector3 WorldPosition { get; }\n\n\t/// <summary>Host brands the pawn just after spawn. Network these fields.</summary>\n\tvoid InitAgent( bool isBot, string displayName );\n\n\t/// <summary>Return all gameplay state to a neutral pre-round condition.</summary>\n\tvoid ResetForRound();\n\n\t/// <summary>Move the pawn on every peer. Implement with <c>[Rpc.Broadcast]</c>.</summary>\n\tvoid TeleportTo( Vector3 position );\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "IGameMode.cs",
"FileName": "IGameMode.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "namespace LobbySystem;\n\n/// <summary>\n/// The rules of one gamemode. The <see cref=\"LobbyDirector\"/> owns the lifecycle and calls into the active\n/// mode for gameplay decisions. Keep implementations stateless so they can be shared as singletons. All\n/// three methods run on the host.\n/// </summary>\npublic interface IGameMode\n{\n\t/// <summary>Name shown on the HUD and in the menu.</summary>\n\tstring DisplayName { get; }\n\n\t/// <summary>Called once when a round begins. Assign starting roles here.</summary>\n\tvoid OnRoundStart( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents );\n\n\t/// <summary>Return true to end the round before the timer expires.</summary>\n\tbool IsRoundOver( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents );\n\n\t/// <summary>Result line shown when the round ends.</summary>\n\tstring ResultText( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents );\n}\n\n/// <summary>\n/// One component in the scene implements this to declare the available modes and their order. The menu\n/// hotkeys 1, 2 and 3 map to the list indices.\n/// </summary>\npublic interface IGameModeCatalog\n{\n\tIReadOnlyList<IGameMode> Modes { get; }\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "Examples/ExampleMode.cs",
"FileName": "ExampleMode.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "namespace LobbySystem.Examples;\n\n/// <summary>\n/// The simplest reference mode: it runs the round timer and nothing else, so you can see the full lobby,\n/// round and back-to-lobby loop. Replace the bodies with your own rules.\n/// </summary>\npublic sealed class ExampleMode : IGameMode\n{\n\tpublic string DisplayName => \"FREE PLAY\";\n\n\tpublic void OnRoundStart( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents )\n\t{\n\t}\n\n\tpublic bool IsRoundOver( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents ) => false;\n\n\tpublic string ResultText( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents ) => \"Round over!\";\n}\n\n/// <summary>\n/// Declares which modes the menu offers and in what order. Put one on the Game Manager.\n/// </summary>\npublic sealed class ExampleModeCatalog : Component, IGameModeCatalog\n{\n\tstatic readonly IGameMode[] _modes = { new ExampleMode() };\n\tpublic IReadOnlyList<IGameMode> Modes => _modes;\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "Code/IGameMode.cs",
"FileName": "IGameMode.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "namespace LobbySystem;\n\n/// <summary>\n/// The rules of one gamemode. The <see cref=\"LobbyDirector\"/> owns the lifecycle and calls into the active\n/// mode for gameplay decisions. Keep implementations stateless so they can be shared as singletons. All\n/// three methods run on the host.\n/// </summary>\npublic interface IGameMode\n{\n\t/// <summary>Name shown on the HUD and in the menu.</summary>\n\tstring DisplayName { get; }\n\n\t/// <summary>Called once when a round begins. Assign starting roles here.</summary>\n\tvoid OnRoundStart( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents );\n\n\t/// <summary>Return true to end the round before the timer expires.</summary>\n\tbool IsRoundOver( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents );\n\n\t/// <summary>Result line shown when the round ends.</summary>\n\tstring ResultText( LobbyDirector director, IReadOnlyList<ILobbyAgent> agents );\n}\n\n/// <summary>\n/// One component in the scene implements this to declare the available modes and their order. The menu\n/// hotkeys 1, 2 and 3 map to the list indices.\n/// </summary>\npublic interface IGameModeCatalog\n{\n\tIReadOnlyList<IGameMode> Modes { get; }\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "Examples/LobbyPlayer.cs",
"FileName": "LobbyPlayer.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "using Sandbox;\nusing Sandbox.Citizen;\n\nnamespace LobbySystem.Examples;\n\n/// <summary>\n/// Self-contained networked third-person player: WASD with run and jump, a third-person camera created on\n/// demand, Citizen animation and owner-synced facing, plus <see cref=\"ILobbyAgent\"/> so the lobby can name,\n/// spawn, place and reset it. Built on the standard s&box Citizen; copy or extend it for your own game.\n/// </summary>\npublic sealed class LobbyPlayer : Component, ILobbyAgent\n{\n\t[Property] public float WalkSpeed { get; set; } = 130f;\n\t[Property] public float RunSpeed { get; set; } = 260f;\n\t[Property] public float JumpForce { get; set; } = 320f;\n\t[Property] public GameObject Head { get; set; }\n\t[Property] public GameObject Body { get; set; }\n\t[Property] public SkinnedModelRenderer Renderer { get; set; }\n\n\t[Sync( SyncFlags.FromHost )] public bool IsBot { get; set; }\n\t[Sync( SyncFlags.FromHost )] public string DisplayName { get; set; } = \"Player\";\n\t[Sync] public float LookYaw { get; set; }\n\n\tCharacterController _cc;\n\tCitizenAnimationHelper _anim;\n\tCameraComponent _cam;\n\tVector3 _wish;\n\tVector3 _lastPos;\n\tVector3 _proxyVel;\n\tfloat _jumpUntil;\n\n\tbool _dressed;\n\tTimeUntil _nextDressTry;\n\n\tVector3 _wanderDir = Vector3.Forward;\n\tfloat _wanderTime;\n\n\t// True on the peer that simulates this pawn: the owner, or anyone in single-player.\n\tbool Controlled => !Networking.IsActive || !IsProxy;\n\n\tpublic void InitAgent( bool isBot, string displayName ) { IsBot = isBot; DisplayName = displayName; }\n\tpublic void ResetForRound() { if ( _cc is not null ) _cc.Velocity = Vector3.Zero; }\n\n\t[Rpc.Broadcast]\n\tpublic void TeleportTo( Vector3 position )\n\t{\n\t\tWorldPosition = position;\n\t\tif ( _cc is not null ) _cc.Velocity = Vector3.Zero;\n\t}\n\n\tprotected override void OnStart()\n\t{\n\t\t_cc = Components.Get<CharacterController>();\n\t\tBody ??= GameObject.Children.FirstOrDefault( c => c.Name == \"Body\" );\n\t\tHead ??= GameObject.Children.FirstOrDefault( c => c.Name == \"Head\" );\n\t\tRenderer ??= Body?.Components.Get<SkinnedModelRenderer>();\n\t\tif ( Renderer is not null ) _anim = Renderer.Components.GetOrCreate<CitizenAnimationHelper>();\n\t\t_lastPos = WorldPosition;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tTryDressFromAvatar();\n\n\t\tif ( !Controlled )\n\t\t{\n\t\t\t_proxyVel = Time.Delta > 0f ? (WorldPosition - _lastPos) / Time.Delta : Vector3.Zero;\n\t\t\t_lastPos = WorldPosition;\n\t\t\tif ( Head is not null ) Head.WorldRotation = Rotation.FromYaw( LookYaw );\n\t\t\tAnimate();\n\t\t\treturn;\n\t\t}\n\n\t\tif ( !IsBot )\n\t\t{\n\t\t\tUpdateLookAndCamera();\n\t\t\tif ( Input.Pressed( \"Jump\" ) ) _jumpUntil = Time.Now + 0.2f;\n\t\t}\n\t\tAnimate();\n\t}\n\n\tprotected override void OnFixedUpdate()\n\t{\n\t\tif ( _cc is null || !Controlled ) return;\n\t\t_wish = IsBot ? WanderWish() : InputWish();\n\t\tMove();\n\t}\n\n\tVector3 InputWish()\n\t{\n\t\tif ( Head is null ) return Vector3.Zero;\n\t\tvar yaw = Head.WorldRotation.Angles().WithPitch( 0 ).ToRotation();\n\t\tvar move = Input.AnalogMove;\n\t\tfloat speed = Input.Down( \"Run\" ) ? RunSpeed : WalkSpeed;\n\t\treturn yaw * new Vector3( move.x, move.y, 0f ) * speed;\n\t}\n\n\t// Bots stroll around and turn back when they drift too far from the centre.\n\tVector3 WanderWish()\n\t{\n\t\t_wanderTime -= Time.Delta;\n\t\tif ( _wanderTime <= 0f )\n\t\t{\n\t\t\t_wanderDir = Rotation.FromYaw( Game.Random.Float( 0f, 360f ) ).Forward;\n\t\t\t_wanderTime = Game.Random.Float( 2f, 4f );\n\t\t}\n\n\t\tvar flat = WorldPosition.WithZ( 0 );\n\t\tif ( flat.Length > 700f ) _wanderDir = (-flat).Normal;\n\t\treturn _wanderDir.WithZ( 0 ).Normal * WalkSpeed;\n\t}\n\n\tvoid Move()\n\t{\n\t\tvar g = Scene.PhysicsWorld.Gravity;\n\t\tif ( _cc.IsOnGround )\n\t\t{\n\t\t\t_cc.Velocity = _cc.Velocity.WithZ( 0 );\n\t\t\t_cc.Accelerate( _wish );\n\t\t\t_cc.ApplyFriction( 5f );\n\t\t\tif ( Time.Now <= _jumpUntil ) { _cc.Punch( Vector3.Up * JumpForce ); _jumpUntil = 0f; }\n\t\t}\n\t\telse\n\t\t{\n\t\t\t_cc.Velocity += g * Time.Delta * 0.5f;\n\t\t\t_cc.Accelerate( _wish.ClampLength( 60f ) );\n\t\t\t_cc.ApplyFriction( 0.1f );\n\t\t}\n\n\t\t_cc.Move();\n\n\t\tif ( _cc.IsOnGround ) _cc.Velocity = _cc.Velocity.WithZ( 0 );\n\t\telse _cc.Velocity += g * Time.Delta * 0.5f;\n\t}\n\n\tvoid UpdateLookAndCamera()\n\t{\n\t\tif ( Head is null ) return;\n\t\tvar look = Input.AnalogLook;\n\t\tvar a = Head.WorldRotation.Angles();\n\t\ta.pitch = MathX.Clamp( a.pitch + look.pitch, -89f, 89f );\n\t\ta.yaw += look.yaw;\n\t\ta.roll = 0f;\n\t\tHead.WorldRotation = a.ToRotation();\n\t\tLookYaw = a.yaw;\n\n\t\tif ( !_cam.IsValid() )\n\t\t{\n\t\t\t_cam = Scene.GetAllComponents<CameraComponent>().FirstOrDefault();\n\t\t\tif ( _cam is null )\n\t\t\t{\n\t\t\t\tvar go = Scene.CreateObject();\n\t\t\t\tgo.Name = \"Player Camera\";\n\t\t\t\t_cam = go.Components.Create<CameraComponent>();\n\t\t\t}\n\t\t}\n\n\t\tvar rot = Head.WorldRotation;\n\t\t_cam.WorldPosition = WorldPosition + rot.Backward * 150f + rot.Right * 28f + Vector3.Up * 50f;\n\t\t_cam.WorldRotation = rot;\n\t}\n\n\tvoid Animate()\n\t{\n\t\tif ( _anim is null || _cc is null ) return;\n\t\tvar vel = Controlled ? _cc.Velocity : _proxyVel;\n\t\t_anim.WithVelocity( vel );\n\t\t_anim.WithWishVelocity( Controlled ? _wish : _proxyVel );\n\t\t_anim.IsGrounded = Controlled ? _cc.IsOnGround : MathF.Abs( _proxyVel.z ) < 50f;\n\t\tif ( !IsBot && Head is not null )\n\t\t\t_anim.WithLook( Head.WorldRotation.Forward, 1f, 0.75f, 0.5f );\n\t\tFaceMovement( vel );\n\t}\n\n\tvoid FaceMovement( Vector3 velocity )\n\t{\n\t\tif ( Body is null ) return;\n\n\t\tRotation target;\n\t\tif ( !IsBot && Head is not null )\n\t\t{\n\t\t\ttarget = Rotation.FromYaw( Head.WorldRotation.Yaw() );\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar flat = velocity.WithZ( 0 );\n\t\t\ttarget = flat.Length > 20f ? Rotation.LookAt( flat, Vector3.Up ) : Body.WorldRotation;\n\t\t}\n\n\t\tBody.WorldRotation = Rotation.Lerp( Body.WorldRotation, target, Time.Delta * 10f );\n\t}\n\n\t// Dress the Citizen in the player's own s&box avatar. Runs on every peer and retries until ownership has\n\t// replicated. Bots keep the plain model.\n\tvoid TryDressFromAvatar()\n\t{\n\t\tif ( _dressed || Renderer is null || _nextDressTry > 0f ) return;\n\t\t_nextDressTry = 0.25f;\n\t\tif ( IsBot ) { _dressed = true; return; }\n\n\t\tClothingContainer clothing;\n\t\ttry\n\t\t{\n\t\t\tvar owner = Network.Owner;\n\t\t\tif ( owner is not null )\n\t\t\t{\n\t\t\t\tvar json = owner.GetUserData( \"avatar\" );\n\t\t\t\tif ( string.IsNullOrEmpty( json ) ) return;\n\t\t\t\tclothing = new ClothingContainer();\n\t\t\t\tclothing.Deserialize( json );\n\t\t\t}\n\t\t\telse if ( !Networking.IsActive )\n\t\t\t{\n\t\t\t\tclothing = ClothingContainer.CreateFromLocalUser();\n\t\t\t}\n\t\t\telse return;\n\t\t}\n\t\tcatch { _dressed = true; return; }\n\n\t\t_dressed = true;\n\t\tApplyClothingAsync( clothing );\n\t}\n\n\tasync void ApplyClothingAsync( ClothingContainer clothing )\n\t{\n\t\ttry { await clothing.ApplyAsync( Renderer, System.Threading.CancellationToken.None ); }\n\t\tcatch { }\n\t}\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "Code/LobbyDirector.cs",
"FileName": "LobbyDirector.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "namespace LobbySystem;\n\n/// <summary>\n/// Gamemode-agnostic round and lobby lifecycle. Drives the Lobby, Active and Ended states, the mode menu,\n/// the synced countdown, optional map loading and agent tracking, then hands the actual rules to the active\n/// <see cref=\"IGameMode\"/>. Host-authoritative; clients read the synced values.\n/// </summary>\npublic sealed class LobbyDirector : Component\n{\n\tpublic static LobbyDirector Current { get; private set; }\n\n\t[Property] public float RoundDuration { get; set; } = 180f;\n\t[Property] public float RestartDelay { get; set; } = 4f;\n\t[Property] public int MinPlayers { get; set; } = 2;\n\n\t/// <summary>Optional map cloned in when a round starts. Leave null to play in the lobby scene.</summary>\n\t[Property] public GameObject MapPrefab { get; set; }\n\n\t/// <summary>Lobby floor; its renderer hides once a round map has loaded.</summary>\n\t[Property] public GameObject LobbyFloor { get; set; }\n\n\t/// <summary>Turn on when you use <see cref=\"MapPrefab\"/> so clients load it too.</summary>\n\t[Property, Sync( SyncFlags.FromHost )] public bool UseRoundMap { get; set; }\n\n\t[Sync( SyncFlags.FromHost )] public LobbyState State { get; set; } = LobbyState.Lobby;\n\t[Sync( SyncFlags.FromHost )] public int ActiveModeIndex { get; set; }\n\t[Sync( SyncFlags.FromHost )] public bool MenuOpen { get; set; }\n\t[Sync( SyncFlags.FromHost )] public int TimeLeftSeconds { get; set; }\n\t[Sync( SyncFlags.FromHost )] public string StatusMessage { get; set; } = \"\";\n\t[Sync( SyncFlags.FromHost )] public string Banner { get; set; } = \"\";\n\t[Sync( SyncFlags.FromHost )] public int EventPulse { get; set; }\n\n\t/// <summary>Local flag: a client opened the menu to suggest a mode to the host.</summary>\n\tpublic bool SuggestMenuOpen { get; set; }\n\n\tfloat _timeRemaining;\n\tfloat _restartAt;\n\tTimeUntil _startGrace;\n\tGameObject _mapInstance;\n\tModelRenderer _floorRenderer;\n\tbool _floorResolved;\n\n\tList<ILobbyAgent> _agents = new();\n\tpublic IReadOnlyList<ILobbyAgent> Agents => _agents;\n\tint LiveCount => _agents.Count;\n\n\tIReadOnlyList<IGameMode> _modes;\n\tpublic IReadOnlyList<IGameMode> Modes => _modes ?? ResolveModes();\n\tpublic IGameMode ActiveMode => Modes.Count > 0 ? Modes[ Math.Clamp( ActiveModeIndex, 0, Modes.Count - 1 ) ] : null;\n\n\tIReadOnlyList<IGameMode> ResolveModes()\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar catalog = Scene.GetAllComponents<IGameModeCatalog>().FirstOrDefault();\n\t\t\t_modes = catalog?.Modes ?? (IReadOnlyList<IGameMode>)Array.Empty<IGameMode>();\n\t\t}\n\t\tcatch\n\t\t{\n\t\t\t_modes = Array.Empty<IGameMode>();\n\t\t}\n\t\treturn _modes;\n\t}\n\n\tprotected override void OnAwake() => Current = this;\n\tprotected override void OnDestroy() { if ( Current == this ) Current = null; }\n\n\tbool _roundLive;\n\n\t/// <summary>\n\t/// True while a round is running. The RPC flag flips instantly for connected peers; OR-ing the synced\n\t/// State also catches players who join mid-round. Drive lobby-vs-round UI off this, not State.\n\t/// </summary>\n\tpublic bool RoundLive => _roundLive || State == LobbyState.Active;\n\t[Rpc.Broadcast] public void RpcSetRoundLive( bool live ) => _roundLive = live;\n\tvoid SetRoundLive( bool live ) { if ( Networking.IsActive ) RpcSetRoundLive( live ); else _roundLive = live; }\n\n\tpublic bool MapReady => _mapInstance.IsValid();\n\n\tTimeUntil _nextScan;\n\tvoid RefreshAgents()\n\t{\n\t\tif ( _nextScan > 0f && _agents.Count > 0 ) return;\n\t\t_nextScan = 0.2f;\n\t\ttry\n\t\t{\n\t\t\tvar fresh = new List<ILobbyAgent>();\n\t\t\tforeach ( var a in Scene.GetAllComponents<ILobbyAgent>() )\n\t\t\t\tif ( a.IsValid() ) fresh.Add( a );\n\t\t\t_agents = fresh;\n\t\t}\n\t\tcatch\n\t\t{\n\t\t\t// Scene changed during the scan; keep the previous list and retry next frame.\n\t\t}\n\t}\n\n\t/// <summary>A spawn-point position spread by index. False until the map collision has loaded, so retry.</summary>\n\tpublic bool TryRoundSpawn( int i, out Vector3 pos )\n\t{\n\t\tpos = default;\n\t\tList<SpawnPoint> spawns;\n\t\ttry { spawns = Scene.GetAllComponents<SpawnPoint>().Where( s => s.IsValid() ).ToList(); }\n\t\tcatch { return false; }\n\t\tif ( spawns.Count == 0 ) return false;\n\t\tvar sp = spawns[ ((i % spawns.Count) + spawns.Count) % spawns.Count ];\n\t\tpos = sp.WorldPosition + Vector3.Up * 20f;\n\t\treturn true;\n\t}\n\tpublic Vector3 RoundSpawnPoint( int i ) => TryRoundSpawn( i, out var p ) ? p : Vector3.Up * 300f;\n\n\tvoid SyncRoundMap()\n\t{\n\t\tif ( !UseRoundMap || MapPrefab is null ) return;\n\t\t// Clone once. Toggling or destroying a MapInstance cancels its async load.\n\t\tif ( RoundLive && !_mapInstance.IsValid() )\n\t\t\t_mapInstance = MapPrefab.Clone( Vector3.Zero );\n\t}\n\n\tvoid SyncLobbyFloor()\n\t{\n\t\tif ( !_floorResolved )\n\t\t{\n\t\t\t_floorRenderer = LobbyFloor?.Components.Get<ModelRenderer>();\n\t\t\t_floorResolved = true;\n\t\t}\n\t\tif ( _floorRenderer is not null )\n\t\t{\n\t\t\tbool show = !MapReady;\n\t\t\tif ( _floorRenderer.Enabled != show ) _floorRenderer.Enabled = show;\n\t\t}\n\t}\n\n\tpublic void RequestModeMenu()\n\t{\n\t\tbool host = !Networking.IsActive || Networking.IsHost;\n\t\tif ( host ) { if ( MenuOpen ) RequestCloseMenu(); else RequestOpenMenu(); }\n\t\telse SuggestMenuOpen = !SuggestMenuOpen;\n\t}\n\tpublic void RequestOpenMenu() { if ( !Networking.IsActive ) OpenMenu(); else RpcOpenMenu(); }\n\t[Rpc.Broadcast] public void RpcOpenMenu() { if ( Networking.IsActive && !Networking.IsHost ) return; OpenMenu(); }\n\tvoid OpenMenu() { MenuOpen = true; State = LobbyState.Lobby; }\n\n\tpublic void RequestCloseMenu() { SuggestMenuOpen = false; if ( !Networking.IsActive ) CloseMenu(); else RpcCloseMenu(); }\n\t[Rpc.Broadcast] public void RpcCloseMenu() { if ( Networking.IsActive && !Networking.IsHost ) return; CloseMenu(); }\n\tvoid CloseMenu() { MenuOpen = false; State = LobbyState.Lobby; Banner = \"\"; }\n\n\t/// <summary>Called by the HUD when a mode is clicked. The host starts it; a client suggests it.</summary>\n\tpublic void PickMode( int index )\n\t{\n\t\tbool host = !Networking.IsActive || Networking.IsHost;\n\t\tif ( host ) ChooseMode( index );\n\t\telse { SuggestMenuOpen = false; SuggestMode( index ); }\n\t}\n\tpublic void ChooseMode( int index ) { MenuOpen = false; Banner = \"\"; StartRound( index ); }\n\n\tpublic string ChatLine { get; private set; } = \"\";\n\tTimeUntil _chatUntil;\n\tpublic bool ChatVisible => !string.IsNullOrEmpty( ChatLine ) && _chatUntil > 0f;\n\tpublic void SuggestMode( int index )\n\t{\n\t\tstring who = Connection.Local?.DisplayName ?? \"Someone\";\n\t\tstring mode = (index >= 0 && index < Modes.Count) ? Modes[index].DisplayName : \"a mode\";\n\t\tRpcSuggest( who, mode );\n\t}\n\t[Rpc.Broadcast] public void RpcSuggest( string who, string mode ) { ChatLine = $\"{who} suggested starting {mode}\"; _chatUntil = 6f; }\n\n\tprotected override void OnUpdate()\n\t{\n\t\tRefreshAgents();\n\t\tSyncRoundMap();\n\t\tSyncLobbyFloor();\n\n\t\t// Only the host drives round state; clients render the synced values.\n\t\tif ( Networking.IsActive && !Networking.IsHost ) return;\n\n\t\tif ( MenuOpen ) { StatusMessage = \"Select a game mode\"; return; }\n\n\t\tswitch ( State )\n\t\t{\n\t\t\tcase LobbyState.Lobby:\n\t\t\t\tStatusMessage = \"Waiting in the lobby\";\n\t\t\t\tbreak;\n\n\t\t\tcase LobbyState.Active:\n\t\t\t\t_timeRemaining -= Time.Delta;\n\t\t\t\tint secs = (int)MathF.Ceiling( _timeRemaining );\n\t\t\t\tif ( secs != TimeLeftSeconds ) TimeLeftSeconds = secs;\n\t\t\t\tbool graceOver = _startGrace <= 0f;\n\t\t\t\tbool earlyOver = graceOver && (LiveCount < MinPlayers || (ActiveMode?.IsRoundOver( this, _agents ) ?? false));\n\t\t\t\tif ( _timeRemaining <= 0f || earlyOver ) EndRound();\n\t\t\t\tbreak;\n\n\t\t\tcase LobbyState.Ended:\n\t\t\t\tif ( Time.Now >= _restartAt )\n\t\t\t\t{\n\t\t\t\t\tBanner = \"\";\n\t\t\t\t\tState = LobbyState.Lobby;\n\t\t\t\t\tSetRoundLive( false );\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\t/// <summary>Begin a round with the given mode index. Host only.</summary>\n\tpublic void StartRound( int modeIndex )\n\t{\n\t\tif ( Modes.Count == 0 ) return;\n\t\tActiveModeIndex = Math.Clamp( modeIndex, 0, Modes.Count - 1 );\n\n\t\tvar live = _agents.Where( a => a.IsValid() ).ToList();\n\t\tif ( live.Count < 1 ) { State = LobbyState.Lobby; return; }\n\n\t\tState = LobbyState.Active;\n\t\tSetRoundLive( true );\n\t\t_startGrace = 1.5f;\n\t\t_timeRemaining = RoundDuration;\n\t\tTimeLeftSeconds = (int)RoundDuration;\n\n\t\tforeach ( var a in live ) a.ResetForRound();\n\t\tActiveMode?.OnRoundStart( this, live );\n\n\t\tStatusMessage = \"\";\n\t\tBanner = ActiveMode?.DisplayName ?? \"\";\n\t}\n\n\tvoid EndRound()\n\t{\n\t\tState = LobbyState.Ended;\n\t\t_timeRemaining = 0f;\n\t\tTimeLeftSeconds = 0;\n\t\t_restartAt = Time.Now + RestartDelay;\n\t\tStatusMessage = ActiveMode?.ResultText( this, _agents ) ?? \"Round over!\";\n\t\tBanner = \"ROUND OVER\";\n\t}\n\n\t/// <summary>Bump <see cref=\"EventPulse\"/> to flash every HUD once.</summary>\n\tpublic void PulseEvent() => EventPulse++;\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "Examples/UI/LobbyHud.razor",
"FileName": "LobbyHud.razor",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "@using Sandbox\n@using Sandbox.UI\n@using LobbySystem\n@inherits PanelComponent\n@namespace LobbySystem.Examples\n\n<root>\n\t@if ( Dir is null )\n\t{\n\t\t<text></text>\n\t}\n\telse if ( Dir.MenuOpen || Dir.SuggestMenuOpen )\n\t{\n\t\t<div class=\"menu\">\n\t\t\t<div class=\"title\">MULTIPLAYER LOBBY</div>\n\t\t\t<div class=\"sub\">@(Dir.MenuOpen ? \"Choose a game mode\" : \"Suggest a mode to the host\")</div>\n\t\t\t<div class=\"row\">\n\t\t\t\t@for ( int i = 0; i < Dir.Modes.Count; i++ )\n\t\t\t\t{\n\t\t\t\t\tvar idx = i;\n\t\t\t\t\t<button class=\"mode\" onclick=@(() => Dir.PickMode( idx ))>\n\t\t\t\t\t\t<div class=\"k\">@(idx + 1)</div>\n\t\t\t\t\t\t<div class=\"n\">@Dir.Modes[idx].DisplayName</div>\n\t\t\t\t\t</button>\n\t\t\t\t}\n\t\t\t</div>\n\t\t\t<button class=\"back\" onclick=@(() => Dir.RequestCloseMenu())>Back to lobby [E]</button>\n\t\t</div>\n\t}\n\telse\n\t{\n\t\t<div class=\"top\">\n\t\t\t@if ( Dir.RoundLive )\n\t\t\t{\n\t\t\t\t<div class=\"mode\">@(Dir.ActiveMode?.DisplayName ?? \"\")</div>\n\t\t\t\t<div class=\"timer @(Dir.TimeLeftSeconds < 15 ? \"urgent\" : \"\")\">@TimerText</div>\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\t<div class=\"lobby\">LOBBY</div>\n\t\t\t}\n\t\t</div>\n\n\t\t@if ( !Dir.RoundLive && !string.IsNullOrEmpty( Dir.StatusMessage ) )\n\t\t{\n\t\t\t<div class=\"status\">@Dir.StatusMessage</div>\n\t\t}\n\t\t@if ( Dir.ChatVisible )\n\t\t{\n\t\t\t<div class=\"chat\">@Dir.ChatLine</div>\n\t\t}\n\n\t\t<div class=\"hints\">\n\t\t\t<span>[WASD] Move</span>\n\t\t\t<span>[Space] Jump</span>\n\t\t\t@if ( !Dir.RoundLive )\n\t\t\t{\n\t\t\t\t<span>Walk to the pad and press [E] to start a round</span>\n\t\t\t}\n\t\t</div>\n\t}\n</root>\n\n@code\n{\n\tLobbyDirector Dir => LobbyDirector.Current;\n\n\tstring TimerText\n\t{\n\t\tget\n\t\t{\n\t\t\tint t = Dir?.TimeLeftSeconds ?? 0;\n\t\t\tif ( t < 0 ) t = 0;\n\t\t\treturn $\"{t / 60:D2}:{t % 60:D2}\";\n\t\t}\n\t}\n\n\tprotected override int BuildHash() => System.HashCode.Combine(\n\t\tDir?.State, Dir?.MenuOpen, Dir?.SuggestMenuOpen, Dir?.RoundLive,\n\t\tDir?.TimeLeftSeconds, Dir?.StatusMessage, Dir?.ChatLine, Dir?.ChatVisible );\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "ILobbyAgent.cs",
"FileName": "ILobbyAgent.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "namespace LobbySystem;\n\n/// <summary>\n/// Implemented by a player or bot pawn so the lobby can count, place, reset and teleport it without knowing\n/// anything about the gameplay. Keep roles (who is \"it\", score, team) on your own type and cast back to it\n/// inside an <see cref=\"IGameMode\"/>.\n/// </summary>\npublic interface ILobbyAgent : IValid\n{\n\t/// <summary>True for AI pawns.</summary>\n\tbool IsBot { get; }\n\n\t/// <summary>True on peers that don't own this pawn.</summary>\n\tbool IsProxy { get; }\n\n\t/// <summary>Steam name for players, a label for bots.</summary>\n\tstring DisplayName { get; }\n\n\t/// <summary>Used for spawn placement and proximity checks.</summary>\n\tVector3 WorldPosition { get; }\n\n\t/// <summary>Host brands the pawn just after spawn. Network these fields.</summary>\n\tvoid InitAgent( bool isBot, string displayName );\n\n\t/// <summary>Return all gameplay state to a neutral pre-round condition.</summary>\n\tvoid ResetForRound();\n\n\t/// <summary>Move the pawn on every peer. Implement with <c>[Rpc.Broadcast]</c>.</summary>\n\tvoid TeleportTo( Vector3 position );\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "LobbyState.cs",
"FileName": "LobbyState.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "namespace LobbySystem;\n\n/// <summary>Lifecycle of the lobby: Lobby, then Active, then Ended before looping back.</summary>\npublic enum LobbyState\n{\n\t/// <summary>Free roam before and after a round. The mode button works here.</summary>\n\tLobby,\n\t/// <summary>A round is running.</summary>\n\tActive,\n\t/// <summary>Round finished; results show before returning to the lobby.</summary>\n\tEnded\n}\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": ".obj/__compiler_extra.cs",
"FileName": "__compiler_extra.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"Lobby System\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"lobbysystem\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"rwtrcsc\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"rwtrcsc.lobbysystem\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"6/15/2026 4:29:44 PM\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"25\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.112.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.112.0\")]"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "Code/Assembly.cs",
"FileName": "Assembly.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "global using Sandbox;\nglobal using System;\nglobal using System.Collections.Generic;\nglobal using System.Linq;\nglobal using System.Threading.Tasks;\n"
},
{
"Ident": "rwtrcsc.lobbysystem",
"Path": "Code/LobbyModeButton.cs",
"FileName": "LobbyModeButton.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 281752,
"IsPrivate": false,
"Code": "namespace LobbySystem;\n\n/// <summary>\n/// In-world button that opens the mode menu for the host, or a local suggestion menu for a client. It needs\n/// a ModelRenderer to be visible and is hidden while a round is live. When the local player is within\n/// <see cref=\"UseRange\"/> and presses Use, the menu opens.\n/// </summary>\npublic sealed class LobbyModeButton : Component\n{\n\t[Property] public float UseRange { get; set; } = 130f;\n\t[Property] public bool GlowWhenInRange { get; set; } = true;\n\t[Property] public Color IdleTint { get; set; } = new Color( 0.85f, 0.4f, 0.15f );\n\t[Property] public Color ActiveTint { get; set; } = new Color( 1f, 0.85f, 0.3f );\n\n\tModelRenderer _renderer;\n\tILobbyAgent _me;\n\n\tprotected override void OnStart()\n\t{\n\t\t_renderer = Components.Get<ModelRenderer>() ?? Components.GetInChildren<ModelRenderer>();\n\t\tif ( _renderer is not null ) _renderer.Tint = IdleTint;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tvar dir = LobbyDirector.Current;\n\t\tbool inLobby = dir is null || !dir.RoundLive;\n\t\tif ( _renderer is not null && _renderer.Enabled != inLobby )\n\t\t\t_renderer.Enabled = inLobby;\n\t\tif ( !inLobby ) return;\n\n\t\tvar me = LocalPlayer();\n\t\tbool inRange = me is not null && WorldPosition.Distance( me.WorldPosition ) <= UseRange;\n\n\t\tif ( GlowWhenInRange && _renderer is not null )\n\t\t\t_renderer.Tint = inRange ? ActiveTint : IdleTint;\n\n\t\tif ( inRange && Input.Pressed( \"Use\" ) )\n\t\t\tdir?.RequestModeMenu();\n\t}\n\n\tILobbyAgent LocalPlayer()\n\t{\n\t\tif ( _me is not null && _me.IsValid() && !_me.IsBot && !_me.IsProxy ) return _me;\n\t\ttry { _me = Scene.GetAllComponents<ILobbyAgent>().FirstOrDefault( c => c.IsValid() && !c.IsBot && !c.IsProxy ); }\n\t\tcatch { _me = null; }\n\t\treturn _me;\n\t}\n}\n"
}
]
}