managers/GameManager.cs

Main game manager component. Builds the tile grid, spawns players and a countdown drone on host start, runs the pre-game countdown, activates grid physics and player input when countdown finishes or StartGame is called, handles player eliminations and switches eliminated/joining clients into spectator mode, and forwards end-of-game to VictoryManager.

NetworkingFile Access
using System;
using System.Linq;
using Sandbox;

/// <summary>
/// Owns the core game loop: building the arena, spawning players, running the
/// pre-game countdown, and detecting eliminations. Once only one player remains it
/// hands off to <see cref="VictoryManager"/> for the post-game victory sequence.
/// </summary>
public sealed class GameManager : Component, Component.INetworkListener
{
	[Property] TileManager TileManager { get; set; }
	[Property] PlayerManager PlayerManager { get; set; }
	[Property] VictoryManager VictoryManager { get; set; }
	[Property] public SoundEvent EliminatedSound { get; set; }

	[Property] public GameObject CountdownDronePrefab { get; set; }
	[Property] public Vector3 CountdownDroneSpawnOffset { get; set; } = new Vector3( 0f, 0f, 220f );

	[Property, Group( "Debug" )] public bool Debug_DisableGridPhysics { get; set; } = false;

	public TimeUntil CountdownTimer = 5f;
	public bool CountdownActive { get; private set; } = false;
	public bool GameInProgress { get; private set; } = false;

	public static GameManager Current { get; private set; }

	protected override void OnEnabled()
	{
		Current = this;
	}

	protected override void OnDisabled()
	{
		if ( Current == this )
			Current = null;
	}

	protected override void OnStart()
	{
		if ( !Networking.IsHost ) return;

		TileManager.BuildGrid();
		SpawnCountdownDrone();
		PlayerManager.SpawnPlayers();
		CountdownActive = true;
	}

	private void SpawnCountdownDrone()
	{
		if ( CountdownDronePrefab == null ) return;

		Vector3 spawnPos = TileManager.IsValid()
			? TileManager.WorldPosition + CountdownDroneSpawnOffset
			: WorldPosition + CountdownDroneSpawnOffset;

		GameObject drone = CountdownDronePrefab.Clone( new CloneConfig
		{
			Transform = new Transform( spawnPos ),
			Name = "CountdownDrone"
		} );
		drone.NetworkSpawn();
	}

	protected override void OnFixedUpdate()
	{
		if ( !Networking.IsHost ) return;

		if ( CountdownActive && CountdownTimer <= 0f )
		{
			GameInProgress = true;
			CountdownActive = false;
			if ( !Debug_DisableGridPhysics ) TileManager.ActivateGrid();
			PlayerManager.EnablePlayersInput();
		}
	}

	public void StartGame()
	{
		if ( !Networking.IsHost ) return;

		GameInProgress = true;
		CountdownActive = false;
		if ( !Debug_DisableGridPhysics ) TileManager.ActivateGrid();
		PlayerManager.EnablePlayersInput();
	}

	public void PlayerEliminated( PlayerController player )
	{
		if ( !Networking.IsHost ) return;
		if ( player == null || !player.IsValid() ) return;

		string name = player.Network?.Owner?.DisplayName ?? player.GameObject.Name;

		// Destroy hasn't propagated yet, so filter out the eliminated player explicitly.
		List<PlayerController> remaining = Scene.GetAllComponents<PlayerController>()
			.Where( p => p.IsValid() && p != player )
			.ToList();

		Log.Info( $"Player '{name}' eliminated. Players remaining: {remaining.Count}" );

		// Capture the owning connection before we destroy the player — Network.Owner
		// becomes unreachable once the GameObject is gone.
		Connection ownerConnection = player.Network?.Owner;
		if ( ownerConnection != null )
		{
			using ( Rpc.FilterInclude( ownerConnection ) )
			{
				EnterSpectatorAfterElimination();
			}
		}

		PlayerManager.DestroyPlayer( player );

		if ( remaining.Count <= 1 )
		{
			GameInProgress = false;
			VictoryManager?.BeginVictory( remaining.FirstOrDefault() );
		}
	}

	// Caller is expected to wrap this in Rpc.FilterInclude( ownerConnection ) so it
	// only runs on the eliminated player's client. Without that filter every client
	// would play the sound and enter spectator mode.
	[Rpc.Broadcast]
	private void EnterSpectatorAfterElimination()
	{
		if ( EliminatedSound != null )
		{
			// ListenLocal makes the sound play from the listener regardless of world
			// position — needed because the SoundEvent is 3D and Sound.Play with no
			// position plays at world origin, which is far from the player when they
			// fall into the killbox.
			SoundHandle handle = Sound.Play( EliminatedSound );
			handle.Volume = 0.2f;
			handle.ListenLocal = true;
		}

		SpectatorMode.Current?.Activate();
	}

	/// <summary>
	/// Host-only. Called when a client connects to the server. Players who join
	/// after the game has started don't get a player controller — instead we tell
	/// their client to drop straight into spectator mode.
	/// </summary>
	public void OnActive( Connection newConnection )
	{
		if ( !Networking.IsHost ) return;

		if ( GameInProgress )
		{
			Log.Info( $"Game already in progress; sending '{newConnection.Name}' into spectator mode." );

			// Scoped filter so the [Rpc.Broadcast] call below is delivered only to the
			// joining connection instead of fanning out to every client.
			using ( Rpc.FilterInclude( newConnection ) )
			{
				JoinAsSpectator();
			}
		}
	}

	// Caller is expected to wrap this in Rpc.FilterInclude( newConnection ) so it only runs
	// on the joining client. Without that filter everyone would flip into spectator mode.
	[Rpc.Broadcast]
	private void JoinAsSpectator()
	{
		Log.Info( "Joining as spectator." );
		SpectatorMode.Current?.Activate();
	}
}