Utility AI In UE5 : Part 4
Posted on September 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 - Modify the existing NpcSchedulePeriod
Inside the NpcSchedulePeriod.h modify it to add the new enum:
class UNpcTaskDefinition;
// Enum for types of schedule to run
UENUM()
enum class ENpcScheduleMode: uint8 {
HighestScore,
Random,
Sequence
};Also modify the struct to have the two new variables:
// Enum to decide what schedule mode the periods should run in
UPROPERTY(EditAnywhere)
ENpcScheduleMode ScheduleMode = ENpcScheduleMode::HighestScore;
// Get all the tasks that can be run
TArray<FNpcTaskInstance> GetCandiates(UNpcBrainComponent* BrainComponent, int32 PeriodIndex);Inside the NpcSchedulePeriod.cpp add:
TArray<FNpcTaskInstance> FNpcSchedulePeriod::GetCandiates(UNpcBrainComponent* BrainComponent, int32 PeriodIndex)
{
TArray<FNpcTaskInstance> Candidates;
if (!BrainComponent)
{
return Candidates;
}
switch (ScheduleMode)
{
case ENpcScheduleMode::HighestScore:
{
for (UNpcTaskDefinition* TaskDefinition : Tasks)
{
if (!TaskDefinition)
{
continue;
}
FNpcTaskInstance Candidate;
Candidate.DefinitionTask = TaskDefinition;
Candidate.Source = ENpcTaskSource::Schedule;
Candidate.SchedulePeriodIndex = PeriodIndex;
Candidates.Add(Candidate);
}
break;
}
case ENpcScheduleMode::Random:
{
if (Tasks.Num() == 0)
{
break;
}
const int32 RandomIndex = FMath::RandRange(0, Tasks.Num() - 1);
if (Tasks[RandomIndex])
{
FNpcTaskInstance Candidate;
Candidate.DefinitionTask = Tasks[RandomIndex];
Candidate.Source = ENpcTaskSource::Schedule;
Candidate.SchedulePeriodIndex = PeriodIndex;
Candidates.Add(Candidate);
}
break;
}
case ENpcScheduleMode::Sequence:
{
if (Tasks.Num() == 0)
{
break;
}
FNpcScheduleSequenceState& SequenceState = BrainComponent->GetScheduleSequenceState(PeriodIndex);
const int32 NextIndex = (SequenceState.LastTaskIndex + 1) % Tasks.Num();
if (Tasks[NextIndex])
{
FNpcTaskInstance Candidate;
Candidate.DefinitionTask = Tasks[NextIndex];
Candidate.Source = ENpcTaskSource::Schedule;
Candidate.SchedulePeriodIndex = PeriodIndex;
Candidates.Add(Candidate);
}
break;
}
}
return Candidates;
}STEP 2 - Modify the existing NpcScheduleDataAsset
Inside the NpcScheduleDataAsset.h modify it to add the new varaibles:
// Get all the tasks for the current time
TArray<FNpcTaskInstance> GetTasksForTime(float CurrentTime, UNpcBrainComponent* BrainComponent);
// Get the current active period indices
TArray<int32> GetActivePeriodIndices(float CurrentTime);Inside the NpcScheduleDataAsset.cpp add:
TArray<FNpcTaskInstance> UNpcScheduleDataAsset::GetTasksForTime(float CurrentTime, UNpcBrainComponent* BrainComponent)
{
TArray<FNpcTaskInstance> Tasks;
for (int32 PeriodIndex : GetActivePeriodIndices(CurrentTime))
{
FNpcSchedulePeriod& Period = Periods[PeriodIndex];
Tasks.Append(Period.GetCandiates(BrainComponent, PeriodIndex));
}
if (Tasks.Num() > 0)
return Tasks;
return FallbackTasks;
}
TArray<int32> UNpcScheduleDataAsset::GetActivePeriodIndices(float CurrentTime)
{
TArray<int32> ActivePeriods;
for (int32 i = 0; i < Periods.Num(); i++)
{
if (Periods[i].IsTimeInRange(CurrentTime))
{
ActivePeriods.Add(i);
}
}
return ActivePeriods;
}STEP 3 - Modify the existing NpcBrainComponent
Inside the NpcBrainComponent.h modify the FNpcTaskInstance struct to be:
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;
};Inside the NpcBrainComponent.h add a new struct for storing the schedules state:
USTRUCT(BlueprintType)
struct FNpcScheduleSequenceState
{
GENERATED_BODY()
UPROPERTY()
int32 LastTaskIndex = INDEX_NONE;
UPROPERTY()
bool bWasInPeriod = false;
};Inside the NpcBrainComponent.h add a new variable for storing the schedules state:
// Store the schedules sequence states
UPROPERTY()
TMap<int32, FNpcScheduleSequenceState> ScheduleSequenceStates;Inside the NpcBrainComponent.h add two new functions for getting and updating the state:
// Get the schedule sequence state
FNpcScheduleSequenceState& GetScheduleSequenceState(int32 PeriodIndex);
// Update the schedule period states
void UpdateSchedulePeriodStates(const TArray<int32>& ActivePeriodIndices);Inside the NpcBrainComponent.cpp add the two new functions:
FNpcScheduleSequenceState& UNpcBrainComponent::GetScheduleSequenceState(int32 PeriodIndex)
{
return ScheduleSequenceStates.FindOrAdd(PeriodIndex);
}
void UNpcBrainComponent::UpdateSchedulePeriodStates(const TArray<int32>& ActivePeriodIndices)
{
TSet<int32> ActiveSets;
for (int32 PeriodIndex : ActivePeriodIndices)
{
ActiveSets.Add(PeriodIndex);
FNpcScheduleSequenceState& State = GetScheduleSequenceState(PeriodIndex);
State.bWasInPeriod = true;
}
for (auto& Pair : ScheduleSequenceStates)
{
const int32 PeriodIndex = Pair.Key;
FNpcScheduleSequenceState& State = Pair.Value;
if (!ActiveSets.Contains(PeriodIndex) && State.bWasInPeriod)
{
State.bWasInPeriod = false;
State.LastTaskIndex = INDEX_NONE;
}
}
}Inside the NpcBrainComponent.cpp modify the EvaluateTasks function to be:
void UNpcBrainComponent::EvaluateTasks()
{
if (!GetWorld())
return;
TArray<FNpcTaskInstance> Candidates;
// Get the current scheduled tasks
const float CurrentTime = GetCurrentTime();
if (Schedule)
{
const TArray<int32> ActivePeriodIndices = Schedule->GetActivePeriodIndices(CurrentTime);
UpdateSchedulePeriodStates(ActivePeriodIndices);
Candidates.Append(Schedule->GetTasksForTime(CurrentTime, this));
}
// 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);
}
}Inside the NpcBrainComponent.cpp modify the StartTask function to set the sequence if needed:
void UNpcBrainComponent::StartTask(FNpcTaskInstance& Task, FNpcBrainContext& Context)
{
if (Task.Source == ENpcTaskSource::Schedule && Task.SchedulePeriodIndex != INDEX_NONE && Schedule)
{
if (Schedule->Periods.IsValidIndex(Task.SchedulePeriodIndex))
{
const FNpcSchedulePeriod& Period = Schedule->Periods[Task.SchedulePeriodIndex];
if (Period.ScheduleMode == ENpcScheduleMode::Sequence)
{
FNpcScheduleSequenceState& State = GetScheduleSequenceState(Task.SchedulePeriodIndex);
State.LastTaskIndex = Period.Tasks.IndexOfByKey(Task.DefinitionTask);
}
}
}
ActiveTask = Task;
ActiveTask.RuntimeTask = DuplicateObject(Task.DefinitionTask, this);
ActiveTask.RuntimeTask->OnTaskFinished.AddDynamic(this, &UNpcBrainComponent::OnActiveTaskFinished);
ActiveTask.RuntimeTask->Execute(Context);
}