A Nextbot AI component for NPCs. It searches for the closest player or push-ability object, moves toward them using either simple kinematic movement or a NavMeshAgent path, plays proximity/chase and attack sounds, jumps over obstacles, and performs a melee-style attack that downs the target and applies knockback.
using Sandbox;
using System;
public sealed class NextbotAI : Component
{
[Property] public float MoveSpeed { get; set; } = 420f;
[Property] public float Acceleration { get; set; } = 8f;
[Property] public float AttackDistance { get; set; } = 70f;
[Property] public float Damage { get; set; } = 100f;
[Property] public float KnockbackForce { get; set; } = 900f;
[Property] public string AttackSound { get; set; } = "music/buttonhover.sound";
[Property] public string ChaseSound { get; set; } = "music/ssstik.io_1790169556772 (mp3cut.net).sound";
[Property] public float ProximityMaxDistance { get; set; } = 2200f;
[Property] public bool BillboardFacing { get; set; } = true;
[Property] public float JumpForce { get; set; } = 280f;
[Property] public float TargetRetargetInterval { get; set; } = 0.2f;
[Property] public float InitialGracePeriod { get; set; } = 6f;
private GameObject _targetPlayer;
private PlayerHealth _targetHealth;
private CharacterController _targetController;
private Rigidbody _targetRigidbody;
private NavMeshAgent _navMeshAgent;
private bool _hasNavMeshPath;
private Vector3 _currentVelocity;
private float _verticalVelocity = 0f;
private SoundHandle _proximitySound;
[Sync] private TimeSince _timeSinceSpawned { get; set; } = 0f;
[Sync] private TimeSince _lastAttack { get; set; } = 2f;
[Sync] private TimeSince _lastTargetSearch { get; set; } = 1f;
public static float GetClosestBotDistance( Vector3 fromPosition )
{
float minDistance = float.MaxValue;
var bots = Game.ActiveScene?.GetAllComponents<NextbotAI>();
if ( bots == null ) return minDistance;
foreach ( var bot in bots )
{
if ( !bot.IsValid() || !bot.GameObject.IsValid() || !bot.GameObject.Enabled ) continue;
float d = Vector3.DistanceBetween( fromPosition, bot.WorldPosition );
if ( d < minDistance ) minDistance = d;
}
return minDistance;
}
protected override void OnStart()
{
_timeSinceSpawned = 0;
_navMeshAgent = Components.Get<NavMeshAgent>();
if ( !_navMeshAgent.IsValid() )
{
_navMeshAgent = Components.Create<NavMeshAgent>();
}
if ( _navMeshAgent.IsValid() )
{
_navMeshAgent.UpdatePosition = true;
_navMeshAgent.UpdateRotation = false;
_navMeshAgent.Acceleration = MoveSpeed * Acceleration;
}
if ( !string.IsNullOrEmpty( ChaseSound ) )
{
_proximitySound = Sound.Play( ChaseSound, WorldPosition );
}
}
protected override void OnUpdate()
{
if ( IsProxy ) return;
if ( _proximitySound != null )
{
_proximitySound.Position = WorldPosition;
var listenerPos = Scene.Camera.IsValid() ? Scene.Camera.WorldPosition :
(PlayerHealth.LocalInstance.IsValid() ? PlayerHealth.LocalInstance.WorldPosition : WorldPosition);
float dist = Vector3.DistanceBetween( WorldPosition, listenerPos );
float vol = MathX.Clamp( 1.0f - (dist / ProximityMaxDistance), 0f, 1f );
_proximitySound.Volume = vol;
}
if ( NextbotSpawner.IsGracePeriodActive || _timeSinceSpawned < InitialGracePeriod )
{
_hasNavMeshPath = false;
if ( _navMeshAgent.IsValid() ) _navMeshAgent.UpdatePosition = false;
return;
}
if ( _lastTargetSearch > TargetRetargetInterval )
{
_lastTargetSearch = 0;
FindClosestTarget();
}
if ( !_targetPlayer.IsValid() || ( _targetHealth.IsValid() && ( _targetHealth.IsDead || _targetHealth.IsDowned ) ) )
{
_hasNavMeshPath = false;
if ( _navMeshAgent.IsValid() ) _navMeshAgent.UpdatePosition = false;
return;
}
Vector3 targetPos = _targetPlayer.WorldPosition;
Vector3 myPos = WorldPosition;
Vector3 dirHorizontal = (targetPos - myPos).WithZ( 0 );
float distance = dirHorizontal.Length;
if ( dirHorizontal.LengthSquared > 0.01f )
{
if ( BillboardFacing && Scene.Camera.IsValid() )
{
WorldRotation = Rotation.LookAt( (Scene.Camera.WorldPosition - WorldPosition).WithZ( 0 ) );
}
else
{
WorldRotation = Rotation.LookAt( dirHorizontal.Normal );
}
}
if ( distance > AttackDistance * 0.6f )
{
if ( !_hasNavMeshPath )
{
Vector3 desiredVelocity = dirHorizontal.Normal * MoveSpeed;
_currentVelocity = Vector3.Lerp( _currentVelocity, desiredVelocity, Time.Delta * Acceleration );
var forwardTr = Scene.Trace.Ray( myPos + Vector3.Up * 15f, myPos + Vector3.Up * 15f + dirHorizontal.Normal * 45f )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
if ( forwardTr.Hit && _verticalVelocity <= 0f )
{
_verticalVelocity = JumpForce;
}
_verticalVelocity -= 800f * Time.Delta;
var groundTr = Scene.Trace.Ray( myPos + Vector3.Up * 25f, myPos + Vector3.Down * 35f )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
float nextZ = myPos.z + _verticalVelocity * Time.Delta;
if ( groundTr.Hit && nextZ <= groundTr.EndPosition.z )
{
nextZ = groundTr.EndPosition.z;
_verticalVelocity = 0f;
}
Vector3 nextPos = myPos + _currentVelocity * Time.Delta;
nextPos.z = nextZ;
WorldPosition = nextPos;
}
}
if ( distance <= AttackDistance && _lastAttack > 0.8f )
{
ExecuteAttack();
}
}
private void FindClosestTarget()
{
float minDistance = float.MaxValue;
GameObject closest = null;
PlayerHealth closestHealth = null;
CharacterController closestController = null;
Rigidbody closestRigidbody = null;
Vector3 myPos = WorldPosition;
var players = Scene.GetAllComponents<PlayerController>();
foreach ( var p in players )
{
if ( !p.IsValid() || !p.GameObject.IsValid() || !p.GameObject.Enabled ) continue;
if ( p.GameObject == GameObject ) continue;
var health = p.GameObject.Components.Get<PlayerHealth>() ??
p.GameObject.Components.GetInChildren<PlayerHealth>();
if ( health.IsValid() && ( health.IsDead || health.IsDowned ) ) continue;
float dist = Vector3.DistanceBetween( myPos, p.WorldPosition );
if ( dist < minDistance )
{
minDistance = dist;
closest = p.GameObject;
closestHealth = health;
closestController = p.GameObject.Components.Get<CharacterController>() ??
p.GameObject.Components.GetInChildren<CharacterController>();
closestRigidbody = p.GameObject.Components.Get<Rigidbody>() ??
p.GameObject.Components.GetInChildren<Rigidbody>();
}
}
if ( closest == null )
{
var pushAbilities = Scene.GetAllComponents<PushAbility>();
foreach ( var push in pushAbilities )
{
if ( !push.IsValid() || !push.GameObject.IsValid() || !push.GameObject.Enabled || push.GameObject == GameObject ) continue;
var health = push.GameObject.Components.Get<PlayerHealth>() ??
push.GameObject.Components.GetInChildren<PlayerHealth>();
if ( health.IsValid() && ( health.IsDead || health.IsDowned ) ) continue;
float dist = Vector3.DistanceBetween( myPos, push.WorldPosition );
if ( dist < minDistance )
{
minDistance = dist;
closest = push.GameObject;
closestHealth = health;
closestController = push.GameObject.Components.Get<CharacterController>() ??
push.GameObject.Components.GetInChildren<CharacterController>();
closestRigidbody = push.GameObject.Components.Get<Rigidbody>() ??
push.GameObject.Components.GetInChildren<Rigidbody>();
}
}
}
_targetPlayer = closest;
_targetHealth = closestHealth;
_targetController = closestController;
_targetRigidbody = closestRigidbody;
_hasNavMeshPath = _targetPlayer.IsValid() && TrySetNavigationPath( _targetPlayer.WorldPosition );
}
private bool TrySetNavigationPath( Vector3 targetPosition )
{
if ( !_navMeshAgent.IsValid() || Scene.NavMesh == null || !Scene.NavMesh.IsEnabled || Scene.NavMesh.IsGenerating )
{
if ( _navMeshAgent.IsValid() ) _navMeshAgent.UpdatePosition = false;
return false;
}
var path = Scene.NavMesh.CalculatePath( new()
{
Start = WorldPosition,
Target = targetPosition,
Agent = _navMeshAgent
} );
if ( !path.IsValid || path.Points.Count == 0 )
{
_navMeshAgent.UpdatePosition = false;
return false;
}
_navMeshAgent.SetAgentPosition( WorldPosition );
_navMeshAgent.UpdatePosition = true;
_navMeshAgent.SetPath( path );
return true;
}
private void ExecuteAttack()
{
_lastAttack = 0;
if ( !string.IsNullOrEmpty( AttackSound ) )
{
Sound.Play( AttackSound, WorldPosition );
}
CameraJuice.Instance?.Shake( 26f, 7f );
if ( _targetHealth.IsValid() )
{
_targetHealth.EnterDowned();
}
Vector3 pushDir = (_targetPlayer.WorldPosition - WorldPosition).Normal + Vector3.Up * 0.45f;
Vector3 knockback = pushDir * KnockbackForce;
if ( _targetController.IsValid() )
{
_targetController.Velocity += knockback;
}
else if ( _targetRigidbody.IsValid() )
{
_targetRigidbody.ApplyImpulse( knockback );
}
if ( Killfeed.Instance != null )
{
Killfeed.Instance.AddEntry( "NEXTBOT", _targetPlayer.Name, "DOWNED" );
}
}
protected override void OnDestroy()
{
_proximitySound?.Stop();
_proximitySound = null;
base.OnDestroy();
}
}