PlayerReplicator.cs
using System.Numerics;
using Sandbox;

public interface IPlayerReplicatorEvents : ISceneEvent<IPlayerReplicatorEvents>
{
	void PlayersReplicated( int newNumberOfPlayers ) { }
	void PlayerSpawned( GameObject player ) { }
}

public sealed class PlayerReplicator : Component, IPlayerHealthEvent, IEnemySpawnerEvents
{
	[Property]
	public Material ReplicatorMaterial { get; set; }

	Stack<Vector4> AvailableViewports;

	public int NumberOfPlayers { get; private set; } = 1;

	protected override void OnEnabled()
	{
		base.OnEnabled();

		AvailableViewports = new();
	}

	IEnumerable<Vector4> Viewports( int numberOfPlayers )
	{
		if ( numberOfPlayers == 2 )
		{
			yield return new Vector4( 0, 0, 1, 0.5f );
			yield return new Vector4( 0, 0.5f, 1, 0.5f );
		}
		else if ( numberOfPlayers == 4 )
		{
			yield return new Vector4( 0, 0, 0.5f, 0.5f );
			yield return new Vector4( 0, 0.5f, 0.5f, 0.5f );
			yield return new Vector4( 0.5f, 0, 0.5f, 0.5f );
			yield return new Vector4( 0.5f, 0.5f, 0.5f, 0.5f );
		}
		else
		{
			throw new System.NotImplementedException();
		}
	}

	public void ReplicatePlayers()
	{
		while ( AvailableViewports.Any() ) RespawnPlayer();

		var playerObjects = Scene.GetComponentsInChildren<PlayerController>().Select( pc => pc.GameObject );

		var newPlayerCount = playerObjects.Count() * 2;
		var viewports = Viewports( newPlayerCount ).ToArray();
		var viewportIndex = 0;

		foreach ( var player in playerObjects )
		{
			// 180 -> 90 -> 45... but 8 players will require some other rotation
			var rot = Rotation.FromYaw( 360 / newPlayerCount );
			var clonePosition = player.WorldPosition.RotateAround( Vector3.Zero, rot );
			var oldPlayerController = player.GetComponent<PlayerController>();
			// the rotation for the new clone should be based on the eye angles, not the actual gameobject's rotation
			var eyeAngles = oldPlayerController.EyeAngles;
			var cloneRotation = eyeAngles * rot;
			oldPlayerController.EyeAngles = eyeAngles;

			// clone (replicate) the player
			var newPlayer = player.Clone( clonePosition, cloneRotation );

			foreach ( var sm in newPlayer.GetComponentsInChildren<SkinnedModelRenderer>() )
			{
				if ( !sm.GameObject.Name.Contains( "mp5" ) )
				{
					sm.MaterialOverride = ReplicatorMaterial;
				}
			}

			// copy old eyeangles
			var newPlayerController = newPlayer.GetComponent<PlayerController>();
			newPlayerController.EyeAngles = newPlayerController.EyeAngles.WithPitch( eyeAngles.pitch ).WithRoll( eyeAngles.roll );
			// move viewports (TODO: lerp?)
			var oldPlayerCamera = player.GetComponentInChildren<CameraComponent>();
			oldPlayerCamera.Viewport = viewports[viewportIndex++];
			var oldHud = player.GetComponent<HUD>();
			if (IsTopRight(oldPlayerCamera.Viewport))
			{
				oldHud.Viewport = new Vector4( 0.58f, 0, 0.5f, 0.5f );
			}
			else
			{
				oldHud.Viewport = oldPlayerCamera.Viewport;
			}
			// have to toggle it to make it refit itself properly
			oldHud.Enabled = false;
			oldHud.Enabled = true;
			var newPlayerCamera = newPlayer.GetComponentInChildren<CameraComponent>();
			newPlayerCamera.Viewport = viewports[viewportIndex++];
			newPlayer.GetComponent<HUD>().Viewport = newPlayerCamera.Viewport;
			// remove old viewer tag
			var oldPlayerCameraControl = player.GetComponent<LocalCameraControl>();
			newPlayer.Children[0].Tags.Remove( oldPlayerCameraControl.ExcludeRenderTag );
			newPlayerCamera.RenderExcludeTags.Remove( oldPlayerCameraControl.ExcludeRenderTag );

			IPlayerReplicatorEvents.Post( x => x.PlayerSpawned( newPlayer ) );
		}

		NumberOfPlayers = newPlayerCount;
		IPlayerReplicatorEvents.Post( x => x.PlayersReplicated( newPlayerCount ) );
	}

	bool IsTopRight(Vector4 viewport)
	{
		return viewport.x == 0.5f && viewport.y == 0f && viewport.w == 0.5f && viewport.z == 0.5f;
	}

	public void RespawnPlayer()
	{
		if ( !AvailableViewports.Any() ) return;

		var playerViewport = AvailableViewports.Pop();

		var alivePlayers = Scene.GetComponentsInChildren<PlayerController>().ToList();
		var playerToMirror = alivePlayers[0].GameObject;
		var rot = Rotation.Identity;

		if ( alivePlayers.Count == 1 )
		{
			rot = Rotation.FromYaw( 180 );
		}
		else
		{
			for ( var i = 1; i < 4; i++ )
			{
				rot = Rotation.FromYaw( 90 * i );
				var desiredClonePosition = playerToMirror.WorldPosition.RotateAround( Vector3.Zero, rot );
				var nearbyPlayer = alivePlayers
					.Select( player => player.WorldPosition )
					.Select( pos => pos.DistanceSquared( desiredClonePosition ) )
					.Where( dist => dist < 1000 );

				if ( !nearbyPlayer.Any() ) break;
			}
		}

		// 180 -> 90 -> 45... but 8 players will require some other rotation
		var clonePosition = playerToMirror.WorldPosition.RotateAround( Vector3.Zero, rot );
		var oldPlayerController = playerToMirror.GetComponent<PlayerController>();
		// the rotation for the new clone should be based on the eye angles, not the actual gameobject's rotation
		var eyeAngles = oldPlayerController.EyeAngles;
		var cloneRotation = eyeAngles * rot;
		// clone (replicate) the player
		var newPlayer = playerToMirror.Clone( clonePosition, cloneRotation );
		foreach ( var sm in newPlayer.GetComponentsInChildren<SkinnedModelRenderer>() )
		{
			if ( !sm.GameObject.Name.Contains( "mp5" ) )
			{
				sm.MaterialOverride = ReplicatorMaterial;
			}
		}
		// copy old eyeangles
		var newPlayerController = newPlayer.GetComponent<PlayerController>();
		newPlayerController.EyeAngles = newPlayerController.EyeAngles.WithPitch( eyeAngles.pitch ).WithRoll( eyeAngles.roll );
		// set up viewport
		var newPlayerCamera = newPlayer.GetComponentInChildren<CameraComponent>();
		newPlayerCamera.Viewport = playerViewport;
		if ( IsTopRight( newPlayerCamera.Viewport ) )
		{
			newPlayer.GetComponent<HUD>().Viewport = new Vector4( 0.58f, 0, 0.5f, 0.5f );
		}
		else
		{
			newPlayer.GetComponent<HUD>().Viewport = newPlayerCamera.Viewport;
		}
		// remove old viewer tag
		var oldPlayerCameraControl = playerToMirror.GetComponent<LocalCameraControl>();
		newPlayer.Children[0].Tags.Remove( oldPlayerCameraControl.ExcludeRenderTag );
		newPlayerCamera.RenderExcludeTags.Remove( oldPlayerCameraControl.ExcludeRenderTag );

		IPlayerReplicatorEvents.Post( x => x.PlayerSpawned( newPlayer ) );
	}

	/// <summary>
	/// Put everyone on the same position as the first player again, fix movement desync
	/// 
	/// EyeAngles should always sync, so we only need to sync positions
	/// </summary>
	public void RealignPlayers()
	{
		if ( NumberOfPlayers == 1 ) return;

		var targetPlayer = Scene.Get<PlayerController>();

		List<Vector3> desiredPositions = NumberOfPlayers switch {
			2 => [targetPlayer.WorldPosition.RotateAround( Vector3.Zero, Rotation.FromYaw( 180 ) )],
			4 => [targetPlayer.WorldPosition.RotateAround( Vector3.Zero, Rotation.FromYaw( 90 ) ),
				  targetPlayer.WorldPosition.RotateAround( Vector3.Zero, Rotation.FromYaw( 180 ) ),
				  targetPlayer.WorldPosition.RotateAround( Vector3.Zero, Rotation.FromYaw( 270 ) )],
			_ => throw new System.NotImplementedException(),
		};

		var otherAlivePlayers = Scene.GetAll<PlayerController>()
			.Where( pl => pl != targetPlayer )
			.Where( pl => pl.GetComponent<Health>().IsAlive )
			.ToList();

		foreach (var player in otherAlivePlayers)
		{
			player.WorldPosition = desiredPositions
				.Aggregate( ( best, next ) => best.DistanceSquared(player.WorldPosition) < next.DistanceSquared(player.WorldPosition) ? best : next );
		}
	}

	/// <summary>
	/// Called by LocalCameraControl to re-allow respawning after a player has timed out from death.
	/// </summary>
	/// <param name="viewport"></param>
	public void AddViewport( Vector4 viewport )
	{
		AvailableViewports.Push( viewport );
	}

	void IEnemySpawnerEvents.FinishedWave( int count )
	{
		RealignPlayers();

		while (AvailableViewports.Any())
		{
			RespawnPlayer();
		}
	}
}