HomeGamesResourcesAssetsContact

Check us out
on social media

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.

Customise Project Name / API Macro
Replacing: YOUTUBETUTORIALS_V3_API→YOUTUBETUTORIALS_V3_API

Type 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();
}