terminalCode Example

Sandbox: NPC system — Schedule, Task, and Layer architecture

calendar_today May 4, 2026 schedule ~1 min read person patrickjr verified 50

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

Was this helpful?