Skip to Content

Flayer Promises

Flayer promises are the backbone of all long running operations in the Nunu SDK. They provide a way to handle asynchronous operations that can’t be completed in a single frame, need to track progress or require continuous monitoring.

Why Use Flayer Promises?

All Flayer functions are executed on the main game thread, meaning they all need to complete within a single tick. But long operations like navigation, camera manipulation or other complex tasks can take multiple frames to complete.

So instead of blocking/freezing the main thread by trying to navigate to a objective in a single tick, we can return a flayer promise and yield the function execution. The network thread will then await for the promise to be resolved before sending the result back to the AI agent. This way we can delay the sending of the result until the operation is complete, without blocking the main thread, allowing operations to be performed over multiple frames.

How Do Flayer Promises Work?

When you create a Flayer promise, and return it you’re essentially telling the NunuSubsystem This Eldritchlink packet started a flayer function that will take some time to complete, and I’ll let you know when the result is ready. The PromiseManager will then track the promise and send the result back to the AI agent when it’s ready. All Promises have a timeout of 120 seconds, meaning if the promise is not resolved within that time, it will be automatically cancelled and the default result will be sent back to the AI agent with a timeout error. This way we don’t let the AI agent wait forever for a result.

Creating and Managing Promises

All UFlayerFunctionLibrary subclasses have access to several promise management methods:

// Create a new promise FFlayerPromise CreateFlayerPromise(FString DebugName = TEXT("")); // Add completion callback bool AddOnCompletedFlayerPromise(FFlayerPromise Promise, FOnPromiseCompleted::FDelegate Delegate); // Add cancellation callback bool AddOnCanceledFlayerPromise(FFlayerPromise Promise, FOnPromiseCanceled::FDelegate Delegate); // Complete a promise with result bool CompleteFlayerPromise(const FFlayerPromise& Promise, const FDynamicParams& Result);

Promise Event Delegates

Flayer Promises include two delegate mechanisms that allow you to respond to changes in a promise’s state:

OnCompleted

The OnCompleted delegate is invoked when a promise successfully completes with a result. Any function bound to this delegate will receive the final FDynamicParams result, allowing you to perform follow-up actions based on the operation’s outcome:

AddOnCompletedFlayerPromise(Promise, FOnPromiseCompleted::FDelegate::CreateLambda([](const FDynamicParams& Result) { // Handle completion with access to the result }));

OnCancelled

The OnCancelled delegate fires when a promise is cancelled before completion, which can happen due to timeout, manual cancellation, or another interruption:

AddOnCanceledFlayerPromise(Promise, FOnPromiseCanceled::FDelegate::CreateLambda([]() { // Handle cancellation - no result available }));

These delegates enable powerful patterns like operation chaining, progress monitoring, and resource cleanup without needing to constantly poll the promise’s state.

Promise Utils

The UFlayerPromiseUtils class provides several factory methods for common promise patterns:

// Immediate completion for errors or simple results FFlayerPromise Promise = UFlayerPromiseUtils::CreateCompleted(this, Result); // Simple promise that just waits for a delay before completing FFlayerPromise Promise = UFlayerPromiseUtils::DelayedPromise(this, 2.0f, Result); // Promise that ticks every frame until a condition is met or timeout occurs FStandardPromiseConditionDelegate ConditionDelegate; ConditionDelegate.BindLambda([](UPacketLogger* Logger) -> bool { // Return true when condition is met return SomeCondition(); }); FFlayerPromise Promise = UFlayerPromiseUtils::ConditionalPromise( this, ConditionDelegate, Logger, Result, 5.0f, 0.3f ); // Promise that performs incremental work each tick until complete or timeout FStandardPromiseTickDelegate TickDelegate; TickDelegate.BindLambda([](float DeltaTime, UPacketLogger* Logger) -> bool { // Perform incremental work each tick // Return true when complete return WorkComplete(); }); FFlayerPromise Promise = UFlayerPromiseUtils::TickingPromise( this, TickDelegate, Logger, Result, 5.0f, 0.1f );

Implementation Examples

Let’s look at an example of how to use Flayer promises in a camera manipulation function. This function will move the camera to a specific location over multiple frames, and return a promise that will be resolved when the camera has reached the target location.

UCLASS() class UCameraControl : public UFlayerFunctionLibrary { GENERATED_BODY() public: UCameraControl() { REGISTER_FLAYER_FUNCTION_SHORT( LookAtTarget, "look_at_target", "Smoothly rotates the camera to look at a target actor" ); } // Structure to hold our camera operation state struct FCameraContext { APlayerController* Controller = nullptr; AActor* TargetActor = nullptr; float TimeElapsed = 0.0f; float InterpSpeed = 5.0f; // How fast to rotate FString ActorName; }; UFUNCTION() FFlayerPromise LookAtTarget(FString ActorName, float Duration, UPacketLogger* Logger) { // First, validate and set up our operation APlayerController* PC = UGameplayStatics::GetPlayerController(GetWorld(), 0); if (!PC) { Logger->LogToAI(TEXT("Failed to get player controller"), ELogLevel::Error); return UFlayerPromiseUtils::CreateCompleted(this, UDynamicParamUtils::BoolToDynamicParam(false)); } // Find our target actor AActor* Target = nullptr; for (TActorIterator<AActor> It(GetWorld()); It; ++It) { if (It->GetName() == ActorName) { Target = *It; break; } } if (!Target) { Logger->LogToAI(FString::Printf(TEXT("Could not find target: %s"), *ActorName), ELogLevel::Error); return UFlayerPromiseUtils::CreateCompleted(this, UDynamicParamUtils::BoolToDynamicParam(false)); } // Create our operation context FCameraContext* Context = new FCameraContext(); Context->Controller = PC; Context->TargetActor = Target; Context->ActorName = ActorName; Logger->LogToAI(FString::Printf(TEXT("Starting camera look at: %s"), *ActorName), ELogLevel::Info); // Create a ticking promise to handle the rotation FStandardPromiseTickDelegate TickDelegate; TickDelegate.BindLambda([this, Context, Duration](float DeltaTime, UPacketLogger* Logger) -> bool { // Update our elapsed time Context->TimeElapsed += DeltaTime; // Check if our objects are still valid if (!Context->Controller || !Context->TargetActor || !Context->Controller->IsValidLowLevel() || !Context->TargetActor->IsValidLowLevel()) { Logger->LogToAI(TEXT("Lost required references"), ELogLevel::Error); delete Context; return true; // End the promise } // Calculate the desired rotation FVector TargetLocation = Context->TargetActor->GetActorLocation(); FVector CameraLocation = Context->Controller->PlayerCameraManager-> GetCameraLocation(); FRotator DesiredRotation = UKismetMathLibrary::FindLookAtRotation( CameraLocation, TargetLocation ); // Get our current rotation FRotator CurrentRotation = Context->Controller->GetControlRotation(); // Smoothly interpolate to the target rotation FRotator NewRotation = FMath::RInterpTo( CurrentRotation, DesiredRotation, DeltaTime, Context->InterpSpeed ); // Apply the rotation Context->Controller->SetControlRotation(NewRotation); // Log progress occasionally static float TimeSinceLastLog = 0.0f; TimeSinceLastLog += DeltaTime; if (TimeSinceLastLog >= 0.5f) { float RotationDiff = (DesiredRotation - NewRotation).GetNormalized() .Euler().Size(); Logger->LogToAI( FString::Printf(TEXT("Rotating camera. Difference: %.2f degrees"), RotationDiff), ELogLevel::Info ); TimeSinceLastLog = 0.0f; } // Check if we should end (either timeout or close enough to target) bool ShouldEnd = Context->TimeElapsed >= Duration; if (ShouldEnd) { Logger->LogToAI(TEXT("Camera rotation completed"), ELogLevel::Info); delete Context; return true; } return false; // Continue ticking }); FDynamicParams SuccessResult; SuccessResult.Add(UDynamicParamUtils::BoolToDynamicParam(true)); SuccessResult.Add(UDynamicParamUtils::StringToDynamicParam(ActorName)); return UFlayerPromiseUtils::TickingPromise( this, // WorldContextObject (this library has GetWorld()) TickDelegate, // Our tick function Logger, // Logger for the utility SuccessResult, // Result to return on completion Duration, // Max time to run 0.0f // Tick every frame ); } };

Let’s break down why this needs to be a promise and what’s happening.

The camera rotation needs to be updated every frame because:

  • The target might be moving
  • We want smooth interpolation between rotations
  • We need to check if we’ve achieved our goal

Because the flayer function actually completes withing a single tick, we need to somehow create a state context for the tick function to remember the target actor, time elapsed etc. This is done by creating a FCameraContext struct that holds all the necessary information. We then create a ticking promise that will call our lambda function every frame until the operation is complete or we reach the timeout.

The ticking promise binds the lambda function with the context and the logger, allowing us to make a little bit of progress every frame. With the output boolean we can control when to stop and complete the promise.

Here’s an example showing how to use the OnCompleted and OnCancelled delegates for proper resource management in a particle effect system:

UFUNCTION() FFlayerPromise PlayParticleEffect(FString EffectName, FVector Location, float Duration, UPacketLogger* Logger) { // Validate inputs UParticleSystem* ParticleTemplate = LoadObject<UParticleSystem>(nullptr, *EffectName); if (!ParticleTemplate) { Logger->LogToAI(FString::Printf(TEXT("Could not find particle effect: %s"), *EffectName), ELogLevel::Error); return FFlayerPromise::CreateCompleted(false); } // Create a shared context to track our emitter struct FEffectContext { UParticleSystemComponent* EmitterComponent = nullptr; float ElapsedTime = 0.0f; }; FEffectContext* Context = new FEffectContext(); // Spawn our particle system Context->EmitterComponent = UGameplayStatics::SpawnEmitterAtLocation( GetWorld(), ParticleTemplate, Location, FRotator::ZeroRotator, true // Auto activate ); if (!Context->EmitterComponent) { Logger->LogToAI(TEXT("Failed to spawn particle effect"), ELogLevel::Error); delete Context; return FFlayerPromise::CreateCompleted(false); } // Create our ticking promise FFlayerPromise Promise = UFlayerPromiseUtils::TickingPromise( GetWorld(), FStandardPromiseTickDelegate::CreateLambda( [Context, Duration](float DeltaTime, UPacketLogger* Logger) -> bool { // Update elapsed time Context->ElapsedTime += DeltaTime; // Check if our emitter is still valid if (!Context->EmitterComponent || !Context->EmitterComponent->IsValidLowLevel()) { Logger->LogToAI(TEXT("Particle emitter was destroyed"), ELogLevel::Warning); delete Context; return true; // End the promise } // Check if we've reached our duration if (Context->ElapsedTime >= Duration) { Logger->LogToAI(TEXT("Particle effect duration completed"), ELogLevel::Info); // Note: We don't delete Context here because we'll handle cleanup in delegates return true; // End the promise } return false; // Continue ticking } ), Logger, FDynamicParams(), // Default result Duration, // Max time to run 0.0f // Tick every frame ); // Set up our completion delegate to clean up resources Promise.OnCompleted().AddLambda([Context, Logger](const FDynamicParams& Result) { // The promise has completed normally - fade out the effect if (Context->EmitterComponent && Context->EmitterComponent->IsValidLowLevel()) { // Deactivate the emitter with a smooth fade Context->EmitterComponent->DeactivateSystem(); // Start a timer to fully destroy the component after fade FTimerHandle DestroyTimerHandle; Context->EmitterComponent->GetWorld()->GetTimerManager().SetTimer( DestroyTimerHandle, FTimerDelegate::CreateLambda([Context]() { if (Context->EmitterComponent && Context->EmitterComponent->IsValidLowLevel()) { Context->EmitterComponent->DestroyComponent(); } delete Context; // Clean up our context }), 1.0f, // Give it 1 second to fade out false ); Logger->LogToAI(TEXT("Particle effect fading out"), ELogLevel::Info); } else { // Component is already gone, just clean up the context delete Context; } }); // Set up our cancellation delegate Promise.OnCancelled().AddLambda([Context, Logger]() { // The promise was cancelled - immediately stop the effect if (Context->EmitterComponent && Context->EmitterComponent->IsValidLowLevel()) { // Kill the emitter immediately Context->EmitterComponent->DestroyComponent(); Logger->LogToAI(TEXT("Particle effect cancelled and destroyed"), ELogLevel::Warning); } // Clean up our context delete Context; }); return Promise; }

Check out the Flayer Function Utils section for additonal Flayer promise utility functions

Best Practices

When working with promises, keep these principles in mind:

  • Keep all your operation state in a context structure. This makes it easier to track what data your operation needs and clean up resources properly.
  • Always properly validate your inputs and log errors.
  • Use utilities when possible: UFlayerPromiseUtils provides tested implementations for common patterns
  • Pass this as WorldContextObject: Your UFlayerFunctionLibrary subclass has access to GetWorld()
  • Handle immediate failures: Use UFlayerPromiseUtils::CreateCompleted for validation failures
  • Leverage delegates: Use AddOnCompletedFlayerPromise and AddOnCanceledFlayerPromise for resource cleanup (stopping navigation, destroying components etc)
Last updated on