terminalCode Example
Sandbox: NPC system — Schedule, Task, and Layer architecture
NPC System — Schedule, Task, and Layer Architecture
The Sandbox NPC system uses a layered architecture: Layers handle continuous behaviours (navigation, senses, animation, speech), while Schedules sequence discrete Tasks.
Base NPC component
CSHARP
namespace Sandbox.Npcs;
[Hide]
public partial class Npc : Component, IKillSource
{
[Property] public SkinnedModelRenderer Renderer { get; set; }
[Property] public string DisplayName { get; set; } = "NPC";
protected override void OnStart()
{
GameObject.Tags.Add( "npc" );
_rigidbody = GetComponent<Rigidbody>();
_navAgent = GetComponent<NavMeshAgent>();
}
protected override void OnFixedUpdate()
{
if ( IsProxy || !_rigidbody.IsValid() || !_navAgent.IsValid() ) return;
if ( _rigidbody.MotionEnabled )
{
// Physics is active (e.g. physgun grabbed) — disable NavMesh
_navAgent.UpdatePosition = false;
}
else
{
// Physics settled — re-enable NavMesh
_navAgent.UpdatePosition = true;
}
}
protected override void OnUpdate()
{
if ( IsProxy ) return;
TickSchedule();
}
protected virtual void Die( in DamageInfo damage )
{
GameManager.Current?.OnNpcDeath( DisplayName, damage );
CreateRagdoll( GetDeathLaunchVelocity( damage ), damage.Origin );
GameObject.Destroy();
}
}Schedule and Task pattern
CSHARP
// A schedule sequences tasks
public class ScientistIdleSchedule : ScheduleBase
{
protected override IEnumerable<TaskBase> GetTasks()
{
yield return new Wait { Duration = 2f };
yield return new LookAt { Target = () => FindNearestPlayer() };
yield return new Say { Line = "Hello there!" };
}
}
// Tasks return TaskStatus
public class MoveTo : TaskBase
{
public Func<Vector3?> Target { get; set; }
public override TaskStatus Tick( Npc npc )
{
var target = Target?.Invoke();
if ( !target.HasValue ) return TaskStatus.Failed;
npc.Navigation.MoveTo( target.Value );
return npc.Navigation.GetStatus();
}
}Layers
CSHARP
// NavigationLayer — wraps NavMeshAgent
public class NavigationLayer : BaseNpcLayer
{
public NavMeshAgent Agent { get; private set; }
public Vector3? MoveTarget { get; private set; }
public float WishSpeed { get; set; } = 100f; // Schedules can raise this to run
public void MoveTo( Vector3 target, float stopDistance = 10f )
{
MoveTarget = target;
Agent.MoveTo( target );
}
public TaskStatus GetStatus()
{
if ( !MoveTarget.HasValue ) return TaskStatus.Success;
var distance = Npc.WorldPosition.Distance( MoveTarget.Value );
if ( distance <= StopDistance ) return TaskStatus.Success;
if ( Agent.IsValid() && !Agent.IsNavigating ) return TaskStatus.Failed;
return TaskStatus.Running;
}
}
// SensesLayer — scans for nearby objects by tag
public class SensesLayer : BaseNpcLayer
{
[Property] public float SightRange { get; set; } = 500f;
[Property] public float HearingRange { get; set; } = 300f;
[Property] public TagSet ScanTags { get; set; } = ["player"];
[Property] public TagSet TargetTags { get; set; } = ["player"];
// Results cached every ScanInterval (default 100ms)
public IEnumerable<GameObject> GetObjectsWithTag( string tag ) { ... }
public GameObject GetNearestTarget() { ... }
}Ragdoll on death
CSHARP
[Rpc.Broadcast( NetFlags.HostOnly )]
protected void CreateRagdoll( Vector3 velocity, Vector3 origin, float duration = 30 )
{
var go = new GameObject( true, "Ragdoll" );
go.Tags.Add( "ragdoll" );
go.WorldTransform = WorldTransform;
var mainBody = go.Components.Create<SkinnedModelRenderer>();
mainBody.CopyFrom( Renderer );
mainBody.UseAnimGraph = false;
var physics = go.Components.Create<ModelPhysics>();
physics.Model = mainBody.Model;
physics.Renderer = mainBody;
physics.CopyBonesFrom( Renderer, true );
// Apply launch force after a frame delay
ApplyRagdollForce( physics, velocity, origin );
}Key points
- NPCs detect physgun grabs via _rigidbody.MotionEnabled and disable NavMesh accordingly
- TaskStatus has three values: Running, Success, Failed
- Schedules are swapped by assigning a new ScheduleBase to the NPC
- SensesLayer scans on a configurable interval (default 100ms) to avoid per-frame overhead
- [Rpc.Broadcast( NetFlags.HostOnly )] on CreateRagdoll ensures all clients see the ragdoll
Was this helpful?