NetworkManager.Bots.cs
using System;

namespace Sandbox;

public sealed partial class NetworkManager
{
	/// <summary>Number of bots in addition to human players. Set to zero to disable bots.</summary>
	[ConVar( "bot_count", ConVarFlags.GameSetting | ConVarFlags.Replicated ), Range( 0, 32 ), Step( 1 )]
	public static int BotCount { get; set; } = 4;

	private float _nextBotUpdate;
	private int _nextBotNumber = 1;

	private void UpdateBots()
	{
		if ( !Networking.IsHost || Time.Now < _nextBotUpdate )
			return;

		_nextBotUpdate = Time.Now + 0.5f;
		var sessions = PlayerSessions.Where( s => s.IsValid() && s.IsBot ).ToList();
		var desiredCount = Math.Clamp( BotCount, 0, 32 );

		while ( sessions.Count > desiredCount )
		{
			var session = sessions[^1];
			DestroyBotPawns( session.BotId );
			session.GameObject.Destroy();
			sessions.RemoveAt( sessions.Count - 1 );
		}

		// Map loading and navmesh generation finish independently. Wait until
		// navigation is ready for the bots to move after spawning.
		if ( !IsMapReady || !Scene.NavMesh.IsEnabled || Scene.NavMesh.IsGenerating )
			return;

		if ( !PlayerPrefab.IsValid() || DeathmatchGame.Instance is not { State: MatchState.IN_PROGRESS } )
			return;

		while ( sessions.Count < desiredCount )
		{
			var sessionObject = new GameObject( false, $"BotSession_{_nextBotNumber}" );
			var session = sessionObject.AddComponent<PlayerSession>();
			session.BotId = Guid.NewGuid();
			session.DisplayName = $"Bot {_nextBotNumber++}";
			session.IsReadyToSpawn = true;
			sessionObject.NetworkSpawn( new NetworkSpawnOptions
			{
				Owner = Connection.Host,
				OwnerTransfer = OwnerTransfer.Fixed,
				StartEnabled = true
			} );
			sessions.Add( session );
			TrySpawnPlayer( session );
		}
	}

	private void DestroyBotPawns( Guid botId = default )
	{
		if ( !Networking.IsHost )
			return;

		foreach ( var bot in Scene.GetAll<Player>().Where( p => p.IsBot && (botId == Guid.Empty || p.BotId == botId) ).ToArray() )
			bot.GameObject.Destroy();
	}
}