In this post I’m going to show you how to create your own custom AI senses. For demonstration purposes I’m going to create a new AI sense named “Aquaphobia” which will be responsible for identifying nearby water resources and register stimulus for each wate resouce. Then, the controller of the AI pawn will move the pawn away from these resources. Here’s the end result of the blog post:

This post was created using the 4.21 version of the engine. Depending on the version you’re using you may need to adjust the demonstrated code to match the corresponding API.

In order to add a new, custom AI sense in the engine we really need two classes:

The AI sense class, which will contain the core functionality regarding the logic of the sense

The config class, which will contain the properties that we’re going to use for the AI sense

With that said, add two new class, named AISense_Aquaphobia and AISenseConfig_Aquaphobia which will inherit the AISense and AISenseConfig classes respectively.

Creating the sense config

Now that we have inherited the correct classes, provide the following logic inside your config class:

Sense config header #pragma once #include "CoreMinimal.h" #include "Perception/AISenseConfig.h" #include "AISense_Aquaphobia.h" #include "AISenseConfig_Aquaphobia.generated.h" /** * */ UCLASS(meta = (DisplayName = "AI Aquaphobia Config")) class CUSTOMAISENSE_API UAISenseConfig_Aquaphobia : public UAISenseConfig { GENERATED_BODY() public: /*The class reference which contains the logic for this sense config */ UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Sense", NoClear, config) TSubclassOf<UAISense_Aquaphobia> Implementation; /* The radius around the pawn that we're checking for "water resources" */ UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Sense", config, meta = (UIMin = 0.0, ClampMin = 0.0)) float PhobiaRadius; /* True if you want to display the debug sphere around the pawn */ UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Sense", config) bool bDisplayDebugSphere; UAISenseConfig_Aquaphobia(const FObjectInitializer& ObjectInitializer); /* The editor will call this when we're about to assign an implementation for this config */ virtual TSubclassOf<UAISense> GetSenseImplementation() const override; }; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 #pragma once #include "CoreMinimal.h" #include "Perception/AISenseConfig.h" #include "AISense_Aquaphobia.h" #include "AISenseConfig_Aquaphobia.generated.h" /** * */ UCLASS ( meta = ( DisplayName = "AI Aquaphobia Config" ) ) class CUSTOMAISENSE_API UAISenseConfig_Aquaphobia : public UAISenseConfig { GENERATED_BODY ( ) public : /*The class reference which contains the logic for this sense config */ UPROPERTY ( EditDefaultsOnly , BlueprintReadOnly , Category = "Sense" , NoClear , config ) TSubclassOf < UAISense_Aquaphobia > Implementation ; /* The radius around the pawn that we're checking for "water resources" */ UPROPERTY ( EditDefaultsOnly , BlueprintReadOnly , Category = "Sense" , config , meta = ( UIMin = 0.0 , ClampMin = 0.0 ) ) float PhobiaRadius ; /* True if you want to display the debug sphere around the pawn */ UPROPERTY ( EditDefaultsOnly , BlueprintReadOnly , Category = "Sense" , config ) bool bDisplayDebugSphere ; UAISenseConfig_Aquaphobia ( const FObjectInitializer & ObjectInitializer ) ; /* The editor will call this when we're about to assign an implementation for this config */ virtual TSubclassOf < UAISense > GetSenseImplementation ( ) const override ; } ;

Then, add the following logic for the source file:

Sense config source UAISenseConfig_Aquaphobia::UAISenseConfig_Aquaphobia(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer) { DebugColor = FColor::Blue; Implementation = GetSenseImplementation(); } TSubclassOf<UAISense> UAISenseConfig_Aquaphobia::GetSenseImplementation() const { return *Implementation; } 1 2 3 4 5 6 7 8 9 10 UAISenseConfig_Aquaphobia :: UAISenseConfig_Aquaphobia ( const FObjectInitializer & ObjectInitializer ) : Super ( ObjectInitializer ) { DebugColor = FColor :: Blue ; Implementation = GetSenseImplementation ( ) ; } TSubclassOf < UAISense > UAISenseConfig_Aquaphobia :: GetSenseImplementation ( ) const { return * Implementation ; }

We’re done with the config class for our case so let’s move on to the core logic for our sense

Creating the actual sense

Here are the whole header and source files needed for our simple AI sense for this post:

Sense header #include "CoreMinimal.h" #include "Perception/AISense.h" #include "AISense_Aquaphobia.generated.h" /** * */ class UAISenseConfig_Aquaphobia; //forward declaration to avoid circular dependencies UCLASS(meta=(DisplayName="AI Aquaphobia config")) class CUSTOMAISENSE_API UAISense_Aquaphobia : public UAISense { GENERATED_BODY() public: /* * After inspecting the engine's code it seems like there's a pattern to use a struct * to consume the properties from the config class so it's better to follow the engine's * standards and workflows. */ struct FDigestedAquaProperties { float PhobiaRadius; bool bDisplayDebugSphere; FDigestedAquaProperties(); FDigestedAquaProperties(const UAISenseConfig_Aquaphobia& SenseConfig); }; /* Consumed properties from config */ TArray<FDigestedAquaProperties> DigestedProperties; UAISense_Aquaphobia(); protected: /* Core logic for the sense */ virtual float Update() override; /* A listener is someone who has a Perception component with various senses * This function will be called when a new listener gained this sense */ void OnNewListenerImpl(const FPerceptionListener& NewListener); /* * Called whenever the listener is removed (eg destroyed or game has stopped) */ void OnListenerRemovedImpl(const FPerceptionListener& UpdatedListener); }; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 #include "CoreMinimal.h" #include "Perception/AISense.h" #include "AISense_Aquaphobia.generated.h" /** * */ class UAISenseConfig_Aquaphobia ; //forward declaration to avoid circular dependencies UCLASS ( meta = ( DisplayName = "AI Aquaphobia config" ) ) class CUSTOMAISENSE_API UAISense_Aquaphobia : public UAISense { GENERATED_BODY ( ) public : /* * After inspecting the engine's code it seems like there's a pattern to use a struct * to consume the properties from the config class so it's better to follow the engine's * standards and workflows. */ struct FDigestedAquaProperties { float PhobiaRadius ; bool bDisplayDebugSphere ; FDigestedAquaProperties ( ) ; FDigestedAquaProperties ( const UAISenseConfig_Aquaphobia & SenseConfig ) ; } ; /* Consumed properties from config */ TArray < FDigestedAquaProperties > DigestedProperties ; UAISense_Aquaphobia ( ) ; protected : /* Core logic for the sense */ virtual float Update ( ) override ; /* A listener is someone who has a Perception component with various senses * This function will be called when a new listener gained this sense */ void OnNewListenerImpl ( const FPerceptionListener & NewListener ) ; /* * Called whenever the listener is removed (eg destroyed or game has stopped) */ void OnListenerRemovedImpl ( const FPerceptionListener & UpdatedListener ) ; } ;

Sense source #include "AISense_Aquaphobia.h" #include "Kismet/GameplayStatics.h" #include "DrawDebugHelpers.h" #include "AISenseConfig_Aquaphobia.h" #include "Perception/AIPerceptionComponent.h" UAISense_Aquaphobia::UAISense_Aquaphobia() { //Bind the required functions OnNewListenerDelegate.BindUObject(this, &UAISense_Aquaphobia::OnNewListenerImpl); OnListenerRemovedDelegate.BindUObject(this, &UAISense_Aquaphobia::OnListenerRemovedImpl); } float UAISense_Aquaphobia::Update() { AIPerception::FListenerMap& ListenersMap = *GetListeners(); //For each listener who has this sense we're going to perform a sweep to determine nearby aqua actors for (auto& Elem : ListenersMap) { //Get the listener FPerceptionListener Listener = Elem.Value; const AActor* ListenerBodyActor = Listener.GetBodyActor(); for (int32 DigestedPropertyIndex = 0; DigestedPropertyIndex < DigestedProperties.Num(); DigestedPropertyIndex++) { //Create the sphere for this sense and perform the sweep to determine nearby actors FCollisionShape CollisionSphere = FCollisionShape::MakeSphere(DigestedProperties[DigestedPropertyIndex].PhobiaRadius); TArray<FHitResult> HitResults; GetWorld()->SweepMultiByChannel(HitResults, ListenerBodyActor->GetActorLocation(), ListenerBodyActor->GetActorLocation() + FVector::UpVector*CollisionSphere.GetSphereRadius(), FQuat(), ECollisionChannel::ECC_WorldDynamic, CollisionSphere); //Draw debug sphere if we have activated it via the config if (DigestedProperties[DigestedPropertyIndex].bDisplayDebugSphere) { DrawDebugSphere(GetWorld(), ListenerBodyActor->GetActorLocation(), DigestedProperties[DigestedPropertyIndex].PhobiaRadius, 8, FColor::Blue, false, 30.f, 1, 2.f); } //Check hit results for aqua actors for (int32 i = 0; i < HitResults.Num(); i++) { FHitResult hit = HitResults[i]; //To simplify things, we're going to assume that "water resources" for this post are actors that have the following game tag if (hit.GetActor()->ActorHasTag(FName("AquaActor"))) { if ((hit.GetActor()->GetActorLocation() - ListenerBodyActor->GetActorLocation()).Size() <= DigestedProperties[DigestedPropertyIndex].PhobiaRadius) { Elem.Value.RegisterStimulus(hit.GetActor(), FAIStimulus(*this, 5.f, hit.GetActor()->GetActorLocation(), ListenerBodyActor->GetActorLocation())); GLog->Log("registered stimulus!"); } } } } } //Time until next update; in this case we're forcing the update to happen in each frame return 0.f; } void UAISense_Aquaphobia::OnNewListenerImpl(const FPerceptionListener& NewListener) { //Since we have at least one AI actor with this sense this function will fire when the game starts GLog->Log("hello new listener!"); check(NewListener.Listener.IsValid()); //Get the config UAISenseConfig* Config = NewListener.Listener->GetSenseConfig(GetSenseID()); const UAISenseConfig_Aquaphobia* SenseConfig = Cast<const UAISenseConfig_Aquaphobia>(Config); check(SenseConfig); //Consume properties from the sense config FDigestedAquaProperties PropertyDigest(*SenseConfig); DigestedProperties.Add(PropertyDigest); RequestImmediateUpdate(); } void UAISense_Aquaphobia::OnListenerRemovedImpl(const FPerceptionListener& UpdatedListener) { //In our case, executes when we stop playing GLog->Log("on listener removed!"); } UAISense_Aquaphobia::FDigestedAquaProperties::FDigestedAquaProperties() { //Init. PhobiaRadius = 15.f; bDisplayDebugSphere = false; } UAISense_Aquaphobia::FDigestedAquaProperties::FDigestedAquaProperties(const UAISenseConfig_Aquaphobia& SenseConfig) { //Copy constructor PhobiaRadius = SenseConfig.PhobiaRadius; bDisplayDebugSphere = SenseConfig.bDisplayDebugSphere; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 #include "AISense_Aquaphobia.h" #include "Kismet/GameplayStatics.h" #include "DrawDebugHelpers.h" #include "AISenseConfig_Aquaphobia.h" #include "Perception/AIPerceptionComponent.h" UAISense_Aquaphobia :: UAISense_Aquaphobia ( ) { //Bind the required functions OnNewListenerDelegate . BindUObject ( this , &UAISense_Aquaphobia :: OnNewListenerImpl ) ; OnListenerRemovedDelegate . BindUObject ( this , &UAISense_Aquaphobia :: OnListenerRemovedImpl ) ; } float UAISense_Aquaphobia :: Update ( ) { AIPerception :: FListenerMap & ListenersMap = * GetListeners ( ) ; //For each listener who has this sense we're going to perform a sweep to determine nearby aqua actors for ( auto & Elem : ListenersMap ) { //Get the listener FPerceptionListener Listener = Elem . Value ; const AActor * ListenerBodyActor = Listener . GetBodyActor ( ) ; for ( int32 DigestedPropertyIndex = 0 ; DigestedPropertyIndex < DigestedProperties . Num ( ) ; DigestedPropertyIndex ++ ) { //Create the sphere for this sense and perform the sweep to determine nearby actors FCollisionShape CollisionSphere = FCollisionShape :: MakeSphere ( DigestedProperties [ DigestedPropertyIndex ] . PhobiaRadius ) ; TArray < FHitResult > HitResults ; GetWorld ( ) -> SweepMultiByChannel ( HitResults , ListenerBodyActor -> GetActorLocation ( ) , ListenerBodyActor -> GetActorLocation ( ) + FVector :: UpVector * CollisionSphere . GetSphereRadius ( ) , FQuat ( ) , ECollisionChannel :: ECC_WorldDynamic , CollisionSphere ) ; //Draw debug sphere if we have activated it via the config if ( DigestedProperties [ DigestedPropertyIndex ] . bDisplayDebugSphere ) { DrawDebugSphere ( GetWorld ( ) , ListenerBodyActor -> GetActorLocation ( ) , DigestedProperties [ DigestedPropertyIndex ] . PhobiaRadius , 8 , FColor :: Blue , false , 30.f , 1 , 2.f ) ; } //Check hit results for aqua actors for ( int32 i = 0 ; i < HitResults . Num ( ) ; i ++ ) { FHitResult hit = HitResults [ i ] ; //To simplify things, we're going to assume that "water resources" for this post are actors that have the following game tag if ( hit . GetActor ( ) -> ActorHasTag ( FName ( "AquaActor" ) ) ) { if ( ( hit . GetActor ( ) -> GetActorLocation ( ) - ListenerBodyActor -> GetActorLocation ( ) ) . Size ( ) <= DigestedProperties [ DigestedPropertyIndex ] . PhobiaRadius ) { Elem . Value . RegisterStimulus ( hit . GetActor ( ) , FAIStimulus ( * this , 5.f , hit . GetActor ( ) -> GetActorLocation ( ) , ListenerBodyActor -> GetActorLocation ( ) ) ) ; GLog -> Log ( "registered stimulus!" ) ; } } } } } //Time until next update; in this case we're forcing the update to happen in each frame return 0.f ; } void UAISense_Aquaphobia :: OnNewListenerImpl ( const FPerceptionListener & NewListener ) { //Since we have at least one AI actor with this sense this function will fire when the game starts GLog -> Log ( "hello new listener!" ) ; check ( NewListener . Listener . IsValid ( ) ) ; //Get the config UAISenseConfig * Config = NewListener . Listener -> GetSenseConfig ( GetSenseID ( ) ) ; const UAISenseConfig_Aquaphobia * SenseConfig = Cast < const UAISenseConfig_Aquaphobia > ( Config ) ; check ( SenseConfig ) ; //Consume properties from the sense config FDigestedAquaProperties PropertyDigest ( * SenseConfig ) ; DigestedProperties . Add ( PropertyDigest ) ; RequestImmediateUpdate ( ) ; } void UAISense_Aquaphobia :: OnListenerRemovedImpl ( const FPerceptionListener & UpdatedListener ) { //In our case, executes when we stop playing GLog -> Log ( "on listener removed!" ) ; } UAISense_Aquaphobia :: FDigestedAquaProperties :: FDigestedAquaProperties ( ) { //Init. PhobiaRadius = 15.f ; bDisplayDebugSphere = false ; } UAISense_Aquaphobia :: FDigestedAquaProperties :: FDigestedAquaProperties ( const UAISenseConfig_Aquaphobia & SenseConfig ) { //Copy constructor PhobiaRadius = SenseConfig . PhobiaRadius ; bDisplayDebugSphere = SenseConfig . bDisplayDebugSphere ; }

At this point we’re almost. Once you compile your code you need to create an AI pawn and AI controller who was the “AIPerception” component. Then, you can assign the sense config and the sense itself like it was displayed in the video at the start of the post.

The sense we have created is only responsible for registering stimulus to each listener. In order to determine where the pawn needs to move every time we sense a water resource I created the following blueprint code inside the AI controller to reduce the overall code need for this post:

At this point, don’t forget to add a nav mesh to your level and tag some actors as “AquaActor” so you can test the functionality of the sense. Moreover, please note that you may have to restart the engine so your AI sense can be configured correctly.

Thanks for reading!