Utility AI In UE5 : Part 3
Posted on August 2026
In this tutorial, we're going to continue working on the Utility AI system. In this third tutorial, we will add in the AI schedules.
STEP 1 - Create a C++ class for the NpcSchedulePeriod
Go to Tools -> New C++ Class -> All Classes. Select Structure and name it NpcSchedulePeriod. Click Create Class.
YOUTUBETUTORIALS_V3_API→YOUTUBETUTORIALS_V3_APIType your Unreal Engine project name or module API macro. All code blocks below will update in real time for effortless copying!
Inside the NpcSchedulePeriod.h add:
#pragma once
#include "CoreMinimal.h"
#include "NpcBrainComponent.h"
#include "NpcSchedulePeriod.generated.h"
class UNpcTaskDefinition;
// Enum for types of schedule to run
UENUM()
enum class ENpcScheduleMode: uint8 {
HighestScore,
Random,
Sequence
};
/**
* A period of time during which a set of tasks should be considered to run.
*/
USTRUCT(BlueprintType)
struct FNpcSchedulePeriod
{
GENERATED_BODY()
public:
// The time the task should start from (0 = midnight / day start, 24 = midnight / day end)
UPROPERTY(EditAnywhere, meta=(ClampMin="0.0", ClampMax="24.0", UIMin="0.0", UIMax="24.0"))
float StartTime = 0.f;
// The time the task should end (0 = midnight / day start, 24 = midnight / day end)
UPROPERTY(EditAnywhere, meta=(ClampMin="0.0", ClampMax="24.0", UIMin="0.0", UIMax="24.0"))
float EndTime = 24.f;
// Enum to decide what schedule mode the periods should run in
UPROPERTY(EditAnywhere)
ENpcScheduleMode ScheduleMode = ENpcScheduleMode::HighestScore;
// The tasks that should be considered to run during the schedule period.
UPROPERTY(EditAnywhere)
TArray<UNpcTaskDefinition*> Tasks;
// Is the current time within the schedule period.
bool IsTimeInRange(float CurrentTime) const;
};Inside the NpcSchedulePeriod.cpp add:
#include "NpcSchedulePeriod.h"
bool FNpcSchedulePeriod::IsTimeInRange(float CurrentTime) const
{
if (StartTime <= EndTime)
{
return CurrentTime >= StartTime && CurrentTime < EndTime;
}
return (CurrentTime >= StartTime || CurrentTime < EndTime);
}STEP 2 - Create a C++ class for the NpcScheduleDataAsset
Go to Tools -> New C++ Class -> All Classes. Select Primary Data Asset and name it NpcScheduleDataAsset. Click Create Class.
Note: If you named it something custom, replace the class name in the code below (or use the customizer at the top of this tutorial).
Inside the NpcScheduleDataAsset.h add:
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "NpcSchedulePeriod.h"
#include "NpcScheduleDataAsset.generated.h"
class UNpcTaskDefinition;
struct FNpcSchedulePeriod;
/**
*
*/
UCLASS(BlueprintType)
class YOUTUBETUTORIALS_V3_API UNpcScheduleDataAsset : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
// All time based schedule periods
UPROPERTY(EditAnywhere)
TArray<FNpcSchedulePeriod> Periods;
// Optional fallback tasks if no periods are active
UPROPERTY(EditAnywhere)
TArray<FNpcTaskInstance> FallbackTasks;
// Get periods that are within the current time
TArray<FNpcSchedulePeriod*> GetActivePeriods(float CurrentTime);
// Get all the tasks for the current time
TArray<FNpcTaskInstance> GetTasksForTime(float CurrentTime);
// When the properties change, run validation
virtual void PostEditChangeProperty(struct FPropertyChangedEvent& PropertyChangedEvent) override;
};Inside the NpcScheduleDataAsset.cpp add:
#include "NpcScheduleDataAsset.h"
#include "NpcSchedulePeriod.h"
TArray<FNpcSchedulePeriod*> UNpcScheduleDataAsset::GetActivePeriods(float CurrentTime)
{
TArray<FNpcSchedulePeriod*> PeriodsInRange;
for (FNpcSchedulePeriod& Period : Periods)
{
if (Period.IsTimeInRange(CurrentTime))
{
PeriodsInRange.Add(&Period);
}
}
return PeriodsInRange;
}
TArray<FNpcTaskInstance> UNpcScheduleDataAsset::GetTasksForTime(float CurrentTime, UNpcBrainComponent* BrainComponent)
{
TArray<FNpcTaskInstance> Tasks;
for (FNpcSchedulePeriod* Period : GetActivePeriods(CurrentTime))
{
Tasks.Append(Period->Tasks);
}
if (Tasks.Num() > 0)
return Tasks;
return FallbackTasks;
}
void UNpcScheduleDataAsset::PostEditChangeProperty(struct FPropertyChangedEvent& PropertyChangedEvent)
{
Super::PostEditChangeProperty(PropertyChangedEvent);
for (FNpcSchedulePeriod& Period : Periods)
{
Period.StartTime = FMath::RoundToFloat(Period.StartTime * 2.f) / 2.f;
Period.EndTime = FMath::RoundToFloat(Period.EndTime * 2.f) / 2.f;
// Optional
if (Period.EndTime < Period.StartTime)
{
Period.EndTime = Period.StartTime;
}
}
}STEP 3 - Modify the existing NpcBrainComponent
Inside the NpcBrainComponent.h modify it to have:
#pragma once
#include "CoreMinimal.h"
#include "NpcTaskDefinition.h"
#include "Components/ActorComponent.h"
#include "NpcBrainComponent.generated.h"
class UNpcTaskDefinition;
class UNpcScheduleDataAsset;
USTRUCT(BlueprintType)
struct FNpcDynamicTask
{
GENERATED_BODY()
// A task thats being added dynamically
UPROPERTY(EditAnywhere, Instanced)
UNpcTaskDefinition* Task = nullptr;
// Should we remove this task when its finished?
UPROPERTY(EditAnywhere)
bool bRemoveWhenFinished = true;
// Should this task be enabled
UPROPERTY(EditAnywhere)
bool bEnabled = true;
};
UENUM()
enum class ENpcTaskSource: uint8
{
Schedule,
AlwaysAvailableTasks,
DynamicTasks
};
USTRUCT()
struct FNpcTaskInstance
{
GENERATED_BODY()
UPROPERTY()
UNpcTaskDefinition* DefinitionTask = nullptr;
UPROPERTY()
UNpcTaskDefinition* RuntimeTask = nullptr;
UPROPERTY()
ENpcTaskSource Source = ENpcTaskSource::AlwaysAvailableTasks;
int32 DynamicIndex = INDEX_NONE;
UPROPERTY()
int32 SchedulePeriodIndex = INDEX_NONE;
};
UCLASS(Blueprintable, ClassGroup=(Custom), meta=(BlueprintSpawnableComponent), DisplayName="Npc Brain Component")
class YOUTUBETUTORIALS_V3_API UNpcBrainComponent : public UActorComponent
{
GENERATED_BODY()
public:
// Sets default values for this component's properties
UNpcBrainComponent();
// Called every frame
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
// What schedule the NPC is assigned to
UPROPERTY(EditAnywhere, Category="NpcBrainComponent|Tasks")
UNpcScheduleDataAsset* Schedule;
// Tasks that the NPC is always allowed to do
UPROPERTY(EditAnywhere, Instanced, Category="NpcBrainComponent|Tasks")
TArray<UNpcTaskDefinition*> AlwaysAvailableTasks;
// Tasks that are dynamically added from quests, events, etc...
UPROPERTY(EditAnywhere, Category="NpcBrainComponent|Tasks")
TArray<FNpcDynamicTask> DynamicTasks;
// Function to handle evaluating tasks
void EvaluateTasks();
// Build the brain context for each task
FNpcBrainContext BuildBrainContext();
// When the current active task has finished
UFUNCTION()
void OnActiveTaskFinished(UNpcTaskDefinition* Task, FNpcBrainContext Context);
// Get the current world time so the scheduler can run.
UFUNCTION(BlueprintNativeEvent, Category="NpcBrainComponent|Time")
float GetCurrentTime() const;
float GetCurrentTime_Implementation() const;
protected:
// Called when the game starts
virtual void BeginPlay() override;
private:
// The active task in the current execution
UPROPERTY(EditAnywhere, Category="NpcBrainComponent|Tasks|Cache")
FNpcTaskInstance ActiveTask;
// The pending task in the next execution
UPROPERTY(EditAnywhere, Category="NpcBrainComponent|Tasks|Cache")
FNpcTaskInstance PendingTask;
// How often will the brain component re-evaluate
UPROPERTY(EditAnywhere, Category="NpcBrainComponent|Timers")
float EvaluationInterval = 5.0f;
// How much higher does a score have to be to switch to a new task
UPROPERTY(EditAnywhere, Category="NpcBrainComponent|Timers")
float SwitchInterval = 0.15f;
// When the last evaluation was performed to prevent spam
float TimeSinceLastEvaluation = 0.f;
// The timer handle for the evaluation timer
FTimerHandle EvaluationTimerHandle;
// Start the task helper function
UFUNCTION()
void StartTask(FNpcTaskInstance& Task, FNpcBrainContext& Context);
// Stop the task helper function
UFUNCTION()
void StopTask();
};Inside the NpcBrainComponent.cpp modify it to:
#include "NpcBrainComponent.h"
#include "AbilitySystemComponent.h"
#include "NpcScheduleDataAsset.h"
// Sets default values for this component's properties
UNpcBrainComponent::UNpcBrainComponent()
{
// Set this component to be initialized when the game starts, and to be ticked every frame. You can turn these features
// off to improve performance if you don't need them.
PrimaryComponentTick.bCanEverTick = true;
// ...
}
float UNpcBrainComponent::GetCurrentTime_Implementation() const
{
const float WorldTime = GetWorld()->GetTimeSeconds();
return WorldTime;
}
// Called when the game starts
void UNpcBrainComponent::BeginPlay()
{
Super::BeginPlay();
GetWorld()->GetTimerManager().SetTimer(
EvaluationTimerHandle,
this,
&UNpcBrainComponent::EvaluateTasks,
EvaluationInterval,
true
);
// Call straight away
EvaluateTasks();
}
void UNpcBrainComponent::StartTask(FNpcTaskInstance& Task, FNpcBrainContext& Context)
{
ActiveTask = Task;
ActiveTask.RuntimeTask = DuplicateObject(Task.DefinitionTask, this);
ActiveTask.RuntimeTask->OnTaskFinished.AddDynamic(this, &UNpcBrainComponent::OnActiveTaskFinished);
ActiveTask.RuntimeTask->Execute(Context);
}
void UNpcBrainComponent::StopTask()
{
if (ActiveTask.RuntimeTask && ActiveTask.RuntimeTask->IsValidLowLevel())
{
ActiveTask.RuntimeTask->OnTaskFinished.RemoveDynamic(this, &UNpcBrainComponent::OnActiveTaskFinished);
ActiveTask.RuntimeTask->FinishTask(BuildBrainContext());
ActiveTask.RuntimeTask = nullptr;
}
}
// Called every frame
void UNpcBrainComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (ActiveTask.RuntimeTask)
{
ActiveTask.RuntimeTask->TickTask(BuildBrainContext());
}
}
void UNpcBrainComponent::EvaluateTasks()
{
if (!GetWorld())
return;
TArray<FNpcTaskInstance> Candidates;
// Get the current scheduled tasks
const float CurrentTime = GetCurrentTime();
if (Schedule)
{
for (UNpcTaskDefinition* Task : Schedule->GetTasksForTime(CurrentTime))
{
FNpcTaskInstance Candidate;
Candidate.DefinitionTask = Task;
Candidate.Source = ENpcTaskSource::Schedule;
Candidates.Add(Candidate);
}
}
// Always available tasks
for (UNpcTaskDefinition* Task : AlwaysAvailableTasks)
{
FNpcTaskInstance Candidate;
Candidate.DefinitionTask = Task;
Candidate.Source = ENpcTaskSource::AlwaysAvailableTasks;
Candidates.Add(Candidate);
}
// Dynamic Tasks
for (int32 i = 0; i < DynamicTasks.Num(); i++)
{
FNpcTaskInstance Candidate;
Candidate.DefinitionTask = DynamicTasks[i].Task;
Candidate.Source = ENpcTaskSource::DynamicTasks;
Candidate.DynamicIndex = i;
Candidates.Add(Candidate);
}
// Store the current brain context
FNpcBrainContext context = BuildBrainContext();
FNpcTaskInstance BestTask;
float BestScore = -1.f;
for (FNpcTaskInstance& Candidate : Candidates)
{
if (!Candidate.DefinitionTask)
continue;
if (!Candidate.DefinitionTask->CanRun(context))
continue;
if (Candidate.DefinitionTask->bForceSelection)
{
BestTask = Candidate;
break;
}
float Score = Candidate.DefinitionTask->Score(context)
+ Candidate.DefinitionTask->BaseScore
+ Candidate.DefinitionTask->Priority * 0.01f;
if (Score > BestScore)
{
BestScore = Score;
BestTask = Candidate;
}
}
// If no best task, return
if (!BestTask.DefinitionTask)
return;
// If no active task, start the best task
if (!ActiveTask.DefinitionTask)
{
StartTask(BestTask, context);
return;
}
// Check if the active task can be interrupted
if (!ActiveTask.DefinitionTask->bCanBeInterrupted)
{
PendingTask = BestTask;
return;
}
// Compare scores if its over our threshold base score
float CurrentScore = ActiveTask.DefinitionTask->Score(context)
+ ActiveTask.DefinitionTask->BaseScore
+ ActiveTask.DefinitionTask->Priority * 0.01f;
if (BestScore > CurrentScore + SwitchInterval)
{
StopTask();
StartTask(BestTask, context);
}
}
FNpcBrainContext UNpcBrainComponent::BuildBrainContext()
{
FNpcBrainContext Context;
Context.Pawn = Cast<APawn>(GetOwner());
if (Context.Pawn)
{
Context.ASC = Context.Pawn->FindComponentByClass<UAbilitySystemComponent>();
}
Context.WorldTime = GetWorld() ? GetWorld()->GetTimeSeconds() : 0.0f;
return Context;
}
void UNpcBrainComponent::OnActiveTaskFinished(UNpcTaskDefinition* Task, FNpcBrainContext Context)
{
if (ActiveTask.DynamicIndex != INDEX_NONE)
{
if (DynamicTasks[ActiveTask.DynamicIndex].bRemoveWhenFinished)
{
DynamicTasks.RemoveAtSwap(ActiveTask.DynamicIndex);
}
}
ActiveTask = FNpcTaskInstance(); // reset the active task
if (PendingTask.DefinitionTask)
{
StartTask(PendingTask, Context);
PendingTask = FNpcTaskInstance();
return;
}
EvaluateTasks();
}