A Razor UI panel that lists spectator clients. It collects all connections that do not own an in-scene Player component, computes dynamic sizing and opacity based on count and game state, and renders avatar, a videocam icon and a truncated display name for each spectator.
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@{
var clients = Connection.All.ToList();
var players = Game.ActiveScene.GetAllComponents<Player>().ToList();
var spectatorClients = new List<Connection>();
foreach ( var client in clients )
{
var player = players.FirstOrDefault( p => p.Network?.Owner == client );
if ( player is null )
spectatorClients.Add( client );
}
int count = spectatorClients.Count;
bool isPlaying = Manager.Instance?.GameState == GameState.Playing;
// Dynamic sizing: shrink names as more spectators join
float baseFontSize = isPlaying ? 16f : 22f;
float fontSize = Math.Max( 10f, baseFontSize - count * 0.5f );
float baseImgSize = isPlaying ? 20f : 44f;
float imgSize = Math.Max( 12f, baseImgSize - count * 1.5f );
float opacity = Math.Max( 0.2f, 0.5f - count * 0.03f );
}
<root class="@(isPlaying ? "playing" : "lobby")" style="opacity: @opacity;">
@foreach ( var client in spectatorClients )
{
int maxNameLen = isPlaying ? 14 : 20;
string truncatedName = client.DisplayName.Length > maxNameLen
? client.DisplayName[..maxNameLen] + "…"
: client.DisplayName;
<div class="spectator" style="font-size: @(fontSize)px;">
<i>videocam</i>
<img class="avatar" src="avatar:@(client.SteamId)" style="width: @(imgSize)px; height: @(imgSize)px;" />
<label class="name">@truncatedName</label>
</div>
}
</root>
@code {
protected override int BuildHash()
{
return System.HashCode.Combine( Connection.All.Count, Manager.Instance?.Players?.Count, Manager.Instance?.GameState );
}
}