Cookbook
25 recipes for common AI design problems on top of Intent Forge — sensors, executors, anti-flap, debugging, dispatcher integration.
[ Cookbook ]
25 recipes for common AI problems
Tight, copy-pasteable recipes for the patterns you'll actually reach for in shipping code. All assume you've finished the Quickstart. Each recipe is a self-contained snippet. Skim the category cards below, or use the sidebar TOC to jump straight to a numbered recipe.
Browse by category
Goal authoring & selection
Critical interruption, planning gates, anti-flap latches.
Action authoring & cost design
Range-based picks, cost curves, custom precondition types.
Sensors & perception
Wire sight/hearing/damage, author custom sensors, profile the slow ones.
Executors
One-shot actions, custom executors, Smart Object claim-and-hold.
Anti-flap & stability
Schmitt latches, EMA smoothing, event-only replan gates.
Lifecycle & coordination
Hot-swap archetypes, share state across a squad.
Debugging & inspection
Why-not-chosen, time-travel replay, Live Inspector, slow-sensor finder, playbook.
Dispatcher integration
Dispatch from BT, from StateTree, or drive the lifecycle from your own code.
1. Flee at low health (emergencies that preempt mid-action)
Problem: when health drops below 20%, override whatever the agent is doing — even if it's mid-MoveTo, and run for cover.
Recipe: use a Critical interruption-level goal.
- Add
Health(scalar,bTriggersReplan = true) to your fact schema. - Author
DA_Goal_Survive:- Desired State: Bool Fact Equals →
IsSafe = true. - Considerations: Goal Consideration: Fact Score with a
curve that returns >0 only when
Health < 0.2. - InterruptionLevel: Critical.
- Desired State: Bool Fact Equals →
- Author
DA_Action_Fleewith effectIsSafe = true. - Because Critical outranks Normal regardless of score, the planner
picks Flee as soon as the consideration starts returning a positive
score. The MinActionCommitTime gate is automatically bypassed —
the in-flight action gets
CancelAction()immediately.
Why not just a high score? Score-based preemption is blocked by
the min-commit gate for MinActionCommitTime seconds after every
action starts. That's correct for normal goal-switching (prevents
flapping), but wrong for emergencies. Interruption levels are the
escape hatch.
Watch out for: every Critical goal raises the noise floor for every other goal. Reserve Critical for true emergencies (low HP, taking damage, ally calling for help). Use Urgent for important-but-not-critical priorities (low ammo, lost target). Normal covers everything else.
2. Prefer ranged attacks at distance, melee up close
Problem: same goal (TargetDestroyed), two ways to satisfy it.
Recipe:
- Schema: add
DistanceToTarget(scalar, replan-triggering). - Create two actions:
DA_Action_RangedAttackandDA_Action_MeleeAttack. Both have effectTargetDestroyed = true. - Give
DA_Action_RangedAttackaUCostStrategy_FactWeighted:BaseCost = 1.0FactWeights = { DistanceToTarget: -0.01 }— cost goes down as distance goes up.
- Give
DA_Action_MeleeAttacka fact-weighted cost that goes up with distance:FactWeights = { DistanceToTarget: 0.02 }. - The planner picks whichever has lower projected cost given the current distance.
Why this works: UCostStrategy_FactWeighted evaluates against
the current state during planning. Same goal, different costs by
context.
3. Replan only on important events
Problem: the safety net replan every 0.75 s is wasteful — most ticks nothing has changed.
Recipe: this is already on by default. Replanning is gated on
WorldState.StateVersion. The version only bumps when a fact
actually changes. To take it further:
- Mark only meaningful facts with
bTriggersReplan = true(the schema honors this implicitly via version-bumping). - Raise
ReplanSafetyNetIntervalon the component to 2.0 or more. - Lower
Replan Check Tick Rateindirectly by settingPrimaryComponentTick.TickIntervalto ~0.1 s in C++ if you need very long-running idle agents.
4. One-shot action that releases external resources
Problem: you wrote a custom executor that claims a Smart Object, spawns a particle system, or starts a timeline. If the plan is invalidated mid-step, those resources leak.
Recipe: implement CancelAction(). Override it on your executor:
void UMyExecutor::CancelAction()
{
if (ClaimedSmartObject.IsValid())
{
SmartObjectSubsystem->Release(ClaimedSmartObject);
ClaimedSmartObject.Invalidate();
}
if (ActiveTimelineHandle.IsValid())
{
ActiveTimelineHandle.Stop();
}
}The dispatcher task calls CancelAction() exactly once when the plan
goes stale or the state is exited. There is no ExitAction
follow-up on cancellation.
5. Squad assault: one agent provides cover while another flanks
Problem: needs coordination, but Intent Forge is per-agent.
Recipe: model the squad as shared world state.
- Create a custom
USquadCoordinatoractor that exposesIsLayingCoverFire(),IsFlankingNorth(), etc. - In each agent's IntentForge sensor, copy the coordinator's flags
into that agent's
FWorldState(e.g.,AllyCovering = true). - The "flanking" agent's
Action_Flankhas preconditionAllyCovering = true. - The "cover" agent's
Action_LayCoverFirehas effectAllyCovering = true.
This is cooperative-by-convention rather than enforced. Full multi-agent planning is on the roadmap.
6. Designer-authored cost curve
Problem: cost should scale non-linearly. Linear FactWeighted
isn't enough.
Recipe: subclass UIntentCostStrategy in Blueprint.
- Create
BP_CostStrategy_Curvedderiving fromUIntentCostStrategy. - Override
Evaluate. In the graph, use aCurve Floatasset, sample it withGet Scalarfrom the world state, and return the result. - Set this strategy on whichever actions need curved costs.
Blueprint-authored cost strategies are ~5–20× slower than C++. For actions called many times per plan that's measurable; for archetypes with 20-ish actions it's still well within the per-plan budget.
7. Sense the world via sensors on the archetype
Problem: facts must reflect the live game world (health, target
distance, ability cooldowns). Without sensors, every game system
would need to call SetFactBool directly.
Recipe: declare sensors on the archetype. The component
duplicates them per-agent on InitializeComponent, ticks them at
their declared SampleInterval, and tears them down on archetype
change or end-play.
- In your archetype asset's Sensor Defaults, add an instance of
Sensor: Gameplay Tag On Actor. Set itsTagToObserve(e.g.Combat.IsArmed) andResultFactId(IsArmed). - Add an instance of
Sensor: Distance To Referenced Actor. SetTargetActorObjectFactId = "PrimaryEnemy"andResultScalarFactId = "DistanceToEnemy". - Somewhere in gameplay code (e.g. a perception adapter), populate
the
PrimaryEnemyobject fact with anFIntentActorFactwhoseActorpoints at the perceived target. The distance sensor reads it on its next tick and writes the scalar fact.
Custom sensors: subclass UIntentSensorBase (C++) or
UIntentSensor_BP (Blueprint). Override Initialize to bind
delegates (e.g. AIPerception), Sample to write facts, Deinitialize
to release. Set SampleInterval for polling sensors; for
event-driven sensors override Sample to a no-op and do the work in
the bound delegate.
Event-driven without a sensor: for one-off events (e.g. taking
damage), call IntentForgeComponent->SetFactBool directly from your
gameplay code. The sensor framework exists for continuous
observation, not every world-state mutation.
8. Stop planning when no target is visible
Problem: combat archetype shouldn't even consider attack goals if there's no target.
Recipe: gate the goal with a precondition.
- Add fact
HasTarget(bool, replan-triggering). - In each combat goal's
DesiredState, the first row is Bool Fact Equals →HasTarget = true. (This makes the goal trivially unsatisfied when no target exists.) - Since the goal can't be satisfied without a target, the planner returns no plan, and the component idles.
Alternative: write a UIntentGoalConsiderationSet Blueprint that
returns 0 when HasTarget is false. The goal scores at 0 and gets
skipped.
9. Inspect why an action wasn't chosen
Problem: the agent picked the wrong action; you want to understand why.
Recipe:
- Enable verbose planner logs:
Log LogIntentForgePlanner VeryVerbose - In PIE, with Gameplay Debugger open, find the IntentForge category. It shows the active plan and goal.
- For a single-frame snapshot, run the
IntentForge.DumpAllAgentsconsole command — it prints one line per agent with the goal, plan length, cost, and world version.
A richer "why didn't this action get chosen?" inspector is planned.
10. Hot-swap the archetype at runtime
Problem: a peasant becomes a soldier when their barracks is built.
Recipe:
- Get the IntentForge component.
- Call
Set Archetypewith the new archetype asset.
This will:
- Cancel any in-flight action.
- Reset the world state.
- Rebind the schema.
- Force a replan on the next tick.
Carry-over facts (e.g., the agent's name, current location) belong
on the actor, not in FWorldState — the world state is
intentionally wiped on archetype change.
11. Wire sight/hearing/damage perception in two minutes
Problem: an enemy needs to plan based on what it can see. Writing the AIPerception → fact glue from scratch is tedious.
Recipe: enable the IntentForgePerception module, then drop the
sensor on your archetype.
- Your pawn (or its AIController) must already have a
UAIPerceptionComponentconfigured with whichever senses you want (Sight, Hearing, etc.). - In your archetype's Sensor Defaults, add an instance of
Sensor: AI Perception. - Set
HasTargetFactIdto a bool fact in your schema (e.g.HasTarget). SetTargetActorFactIdto an object fact (e.g.Target). LeaveSenseFilternull to react to any sense, or set it toUAISense_Sightto listen only to vision. - Now your archetype's goals can use
HasTarget = trueas a precondition, and actions can pair with the distance sensor to compute range-to-target.
When a target is perceived, HasTarget flips to true and the object
fact is populated with an FIntentActorFact. When the same target
is lost (sense-loss event), both clear.
For multi-target priority schemes (e.g. "focus the closest visible
enemy"), subclass UIntentSensor_AIPerception and override
HandlePerceptionUpdated — the rest of the lifecycle is reused.
12. Time-travel debug a misbehaving agent
Problem: an agent picks the wrong action and you want to see the exact sequence of events leading up to it.
Recipe: use the Visual Logger.
- In PIE, press Ctrl+L (default binding) to open the Visual Logger.
- Select your pawn.
- Scrub the timeline. You'll see entries each time the planner adopts a new plan (goal, step count, cost) and each time an action completes (handle + terminal status).
- Pair with the Gameplay Debugger overlay (apostrophe key) for live world-state inspection at the same timestamp.
VL entries are stripped in Shipping builds — zero runtime cost in release.
13. Reserve a Smart Object slot with claim-and-hold
Problem: multiple agents want to use the same bench / workstation / cover point. Without coordination they'll fight over it.
Recipe: enable the IntentForgeSmartObjects module, then use
SO: Claim And Hold as the executor for the "use this kind of
thing" action.
- Make sure your SO actors have
USmartObjectComponentconfigured with aUSmartObjectDefinition(engine's standard SO authoring). - Author an IntentForge action
DA_Action_UseBenchwith executorSO: Claim And Hold. Set:- Activity Requirements: gameplay-tag query the candidate SO
must satisfy (e.g.
SmartObject.Activity.Rest). - Search Radius: e.g. 1000 cm.
- Hold Seconds: how long the agent occupies the slot.
- Activity Requirements: gameplay-tag query the candidate SO
must satisfy (e.g.
- Prepend a
MoveToaction whose target is the slot's location. The claim executor does not walk the agent there — keeping the responsibilities separate means an agent can claim a slot before moving (a common reservation pattern).
The executor releases the slot on success and on cancellation, so plan-invalidation mid-hold cleanly returns the slot to the pool.
14. Live-inspect agents at-a-glance during PIE
Problem: Gameplay Debugger overlays a single selected agent, but you want to see every agent in the world at once and click between them.
Recipe: use the IntentForge Live Inspector tab.
- Window → Developer Tools → IntentForge Live Inspector. Dock it wherever you like (a vertical panel on the right works well).
- Start PIE. The left pane fills with every live agent (actor name | current goal | step count). Refresh is 0.5 s.
- Click an agent. The right pane shows:
- Header: actor, archetype, world-state version
- Current plan with an arrow on the current step
- Every schema fact and its live value
- Recent replan history (newest 8)
- Pair with the Visual Logger for after-the-fact "why did it do that?" replay.
The inspector uses UIntentForgeWorldSubsystem::GetAllAgents() —
same registry exposed for your own debug tooling if you want a
custom panel.
15. Find the slow sensor
Problem: your AI feels sluggish. You suspect a sensor is doing too much work per Sample. Profile it.
Recipe:
- In PIE, open the editor console.
- Run
IntentForge.ProfileSensors <PartialActorName>(omit the arg to see every agent). - The output lists each sensor's class, total sample count, accumulated time, and average ms per sample. The hot sensor is the one with high avg-ms or runaway sample count.
Common causes of slow sensors:
- Sample interval too low (samples 60×/sec when 10× would do).
- Expensive per-sample work (line traces, EQS queries) without caching.
- Calling
FindComponentByClassevery sample (useUIntentForgeStatics::GetIntentForgeComponentand cache).
For Blueprint-authored sensors, expect 5–20× slower than C++ per sample. Rewrite to C++ if profiling shows it matters for your scale.
16. Surface authoring bugs with Analyze Archetype
Problem: your agent does nothing at runtime. Validator passed. Test Plan returns empty. What's wrong?
Recipe:
- Open your archetype asset.
- Under IntentForge Authoring in the details panel, click Analyze Archetype.
- The IntentForge MessageLog shows:
- Orphan facts: facts referenced by preconditions / goal desired-state that NO action produces. These guarantee unreachable goals.
- Unused actions: actions whose effects no consumer reads — usually a sign of a missing precondition or a typo'd fact id.
A clean run prints "OK: every consumed fact has at least one producer" and "OK: every action's effects are referenced." If you see orphans, trace the fact name and either spell it correctly or add a producing action.
17. Author a custom precondition type
Problem: the bundled BoolFactEquals and ScalarComparison
preconditions don't cover your need (e.g. "fact within a numeric
range", "tag query against actor's GAS"). Add a custom one.
Recipe (C++):
-
Subclass
FIntentPrecondition:USTRUCT(BlueprintType, DisplayName = "Scalar In Range") struct FPrecondition_ScalarInRange : public FIntentPrecondition { GENERATED_BODY() UPROPERTY(EditAnywhere) FName FactId; UPROPERTY(EditAnywhere) float Min = 0.f; UPROPERTY(EditAnywhere) float Max = 1.f; bool EvaluateAgainst(const FWorldState& State) const { const float V = State.GetScalar(FactId); return V >= Min && V <= Max; } }; -
Register a dispatch handler at module startup so
IntentForge::CheckPreconditionroutes calls to your type:void FMyModule::StartupModule() { IntentForge::FPreconditionRegistry::Register( FPrecondition_ScalarInRange::StaticStruct(), [](const FInstancedStruct& S, const FWorldState& State) { return S.Get<FPrecondition_ScalarInRange>().EvaluateAgainst(State); }); } -
(Optional) Teach the validator about your type by extending its
GetReferencedFactIdswitch — otherwise typo'd fact ids won't be caught at save time for your custom shape.
Effect-shaped types follow the same pattern via FIntentEffect and
the effect registry.
18. Stop a goal flapping near a threshold (Schmitt latch on a scalar fact)
Problem: you want a goal to fire when Health < 0.30, but the
player takes a series of small hits and Health oscillates around
the threshold (0.29, 0.31, 0.29, 0.32...). Without protection the
goal flaps in/out every replan and the agent visibly stutters
between Flee and Patrol.
Recipe (no C++):
- Declare
Healthas a Scalar fact andIsLowHealthas a Bool fact in yourUFactSchemaAsset. Both withbTriggersReplan = true. - On the same schema asset, populate the
LatchedBoolFactsarray with one row:- Source Scalar Fact Id:
Health - Output Bool Fact Id:
IsLowHealth - Enter Threshold:
0.30 - Exit Threshold:
0.40 - Initial Value:
false
- Source Scalar Fact Id:
- Author your Flee goal's
DesiredStateto readIsLowHealth == false(so the agent flees while the latch is true). - Sensors / damage events write
HealthviaSetFactScalar; the schema derivesIsLowHealthreactively.Healthjittering between 0.29 and 0.31 no longer flipsIsLowHealth— only a sustained drop below 0.30 trips the latch true, and only a sustained recovery above 0.40 trips it back.
Why this works: IsLowHealth carries memory (the latched
state) that the raw Health scalar can't. Preconditions stay pure
functions of world state; the hysteresis lives at the
fact-derivation layer. See the
Anti-Flap Toolkit for the full layered
toolkit.
19. Smooth a noisy scalar fact (EMA filter)
Problem: a sensor writes DistanceToTarget every frame, but
the target's position is jittery (e.g. animated rig) or the sensor
itself has fast-vs-slow sampling artifacts. Goal scores tied to
DistanceToTarget flap.
Recipe (no C++):
- On the relevant
FFactSchemaEntryforDistanceToTarget(Scalar type), expand theFiltersubstruct in the details panel. - Set
bEnabled = true,Alpha = 0.25(heavier smoothing for noisier inputs — try0.10for very noisy signals,0.50for mostly-clean ones). - That's it. Every write to
DistanceToTargetviaSetFactScalar(or any sensor'sOutState.SetScalar) is nowAlpha*raw + (1-Alpha)*priorbefore storage. The Live Inspector's "Derived Facts" panel shows the raw input alongside the smoothed value so you can verify the curve.
Note on first-sample passthrough: the very first write of a filtered fact stores raw (no smoothing bias toward zero). The EMA only kicks in on the second and subsequent samples.
Note on planner search: the planner suppresses filtering during A* expansion (effects in simulated futures are deterministic, not noisy). Latching is not suppressed — derived bools are part of the symbolic state the planner reasons about. See the Anti-Flap Toolkit for why this asymmetry is the load-bearing rule.
20. Author a custom sensor from scratch
Problem: the bundled sensors (distance, line-of-sight, gameplay tag, AI perception, blackboard bridges) don't cover what you need — e.g. "ammo in the equipped weapon", "team-relative danger", "footstep noise recently heard". Write a custom one.
Recipe (C++):
-
Subclass
UIntentSensorBase:UCLASS(Blueprintable, meta = (DisplayName = "Sensor: Equipped Ammo")) class MYGAME_API UIntentSensor_EquippedAmmo : public UIntentSensorBase { GENERATED_BODY() public: /** Scalar fact (0..1) — fraction of magazine remaining. */ UPROPERTY(EditAnywhere, Category = "Sensor", meta = (ToolTip = "Scalar fact that receives the ammo fraction.")) FName ResultScalarFactId; virtual void Initialize(AActor* Owner) override; virtual void Sample(AActor* Owner, FWorldState& OutState) override; virtual void Deinitialize(AActor* Owner) override; }; -
Implement the lifecycle:
void UIntentSensor_EquippedAmmo::Initialize(AActor* Owner) { // Cache anything stable for the actor's lifetime. CachedWeaponComp = Owner->FindComponentByClass<UWeaponComponent>(); SampleInterval = 0.2f; // 5 Hz — ammo changes are slow } void UIntentSensor_EquippedAmmo::Sample(AActor* Owner, FWorldState& OutState) { if (!CachedWeaponComp.IsValid() || ResultScalarFactId.IsNone()) return; const float Fraction = CachedWeaponComp->GetAmmoFraction(); OutState.SetScalar(ResultScalarFactId, Fraction); } void UIntentSensor_EquippedAmmo::Deinitialize(AActor* Owner) { CachedWeaponComp = nullptr; } -
In your archetype's Sensor Defaults, add an instance of
Sensor: Equipped Ammo. SetResultScalarFactId = "AmmoFraction". The schema'sAmmoFractionfact must exist and beScalar.
Why this pattern: SampleInterval is honored by the
component's timer scheduler — your Sample runs at exactly the
rate you declare, not every tick. The component duplicates the
sensor template per-agent on InitializeComponent, so
CachedWeaponComp is per-agent state.
Event-driven instead of polling: for "fact-on-event" patterns
(ammo fired, hit landed), don't poll — bind a delegate in
Initialize, write the fact from the delegate handler, and set
SampleInterval to a large number (the framework still calls
Sample as a fallback). Or override Sample to a no-op. The fact
write triggers replan-coalesce regardless of which code path wrote
it.
Blueprint variant: subclass UIntentSensor_BP instead. The
lifecycle hooks are exposed as Blueprint events with the same
signatures. Expect 5–20× slower than C++ per Sample; profile with
IntentForge.ProfileSensors if it matters at your scale (see
recipe 15).
21. Author a custom action executor
Problem: the bundled executors (MoveTo, Wait, PlayAnim,
RunBlueprintFunction, RotateToFaceLocation, SO:ClaimAndHold,
etc.) don't cover your action. The action's job is "do this
specific thing in the world" — write an executor for it.
Recipe (C++):
-
Subclass
UIntentActionExecutor:UCLASS(Blueprintable, meta = (DisplayName = "Executor: Throw Grenade")) class MYGAME_API UExecutor_ThrowGrenade : public UIntentActionExecutor { GENERATED_BODY() public: UPROPERTY(EditAnywhere, Category = "Throw") TSubclassOf<AActor> GrenadeClass; virtual void EnterAction(AActor* OwnerActor, const FInstancedStruct& Params) override; virtual EActionStatus TickAction(float DeltaTime) override; virtual void ExitAction(EActionStatus FinalStatus) override; virtual void CancelAction() override; private: float ChargeTime = 0.f; bool bThrown = false; TWeakObjectPtr<AActor> SpawnedGrenade; }; -
Implement the lifecycle. The contract is exactly four methods:
void UExecutor_ThrowGrenade::EnterAction(AActor* OwnerActor, const FInstancedStruct& Params) { ChargeTime = 0.f; bThrown = false; // Play wind-up anim, lock locomotion, etc. } EActionStatus UExecutor_ThrowGrenade::TickAction(float DeltaTime) { ChargeTime += DeltaTime; if (!bThrown && ChargeTime >= 0.6f) { SpawnedGrenade = GetWorld()->SpawnActor<AActor>(GrenadeClass, GetOwner()->GetActorLocation(), GetOwner()->GetActorRotation()); bThrown = true; } if (ChargeTime >= 1.2f) // total: 0.6s wind-up + 0.6s recovery { return EActionStatus::Succeeded; } return EActionStatus::Running; } void UExecutor_ThrowGrenade::ExitAction(EActionStatus FinalStatus) { // Restore locomotion lock, stop anim, etc. } void UExecutor_ThrowGrenade::CancelAction() { // The plan went stale mid-throw. Decide: keep the spawned grenade // (it's already a live actor) but unwind the executor's own state. // CancelAction is called instead of ExitAction — there is no // follow-up ExitAction after a cancel. } -
Set
ExecutorClass = UExecutor_ThrowGrenade::StaticClass()on theDA_Action_ThrowGrenadeaction. (Default Paramson the action is anFInstancedStruct— populate with whatever your executor reads from theParamsarg toEnterAction.)
The four methods, in plain language:
| Method | When | Job |
|---|---|---|
EnterAction | Once, on dispatch | Read params, allocate resources, kick off any background work |
TickAction | Every dispatcher tick | Drive state forward; return Running / Succeeded / Failed |
ExitAction | Once, when TickAction returned terminal | Release resources; cleanup is the only allowed work |
CancelAction | Once, when the plan went stale mid-step | Release resources; no ExitAction follow-up |
Blueprint variant: subclass UIntentActionExecutor_BP. The
four methods become Blueprint events. The dispatcher tick interval
is whatever the dispatcher uses — UIntentForgeDispatcherSubsystem
ticks once per frame; the StateTree task ticks at the StateTree's
rate.
See also: recipe 4 (releasing external resources via
CancelAction).
22. Debug a stuck or misbehaving agent
Problem: the agent isn't doing what you expect. It might be standing still, looping the same action, picking the wrong goal, or just not spawning a plan. Walk through the diagnostic ladder.
Recipe: in this order, stop when one of the steps reveals the cause.
-
Is the agent registered?
IntentForge.AgentCountZero means the component isn't on the actor, or the component's
Archetypeis null. Fix the spawn / Details panel and retry. -
What does the planner think the agent should be doing?
IntentForge.DumpAgent <PartialActorName>Output includes the active goal, plan steps, current step index, and the world version. If the goal is empty: no goal scored above its considerations cutoff. If the plan is empty: no satisfiable goal. If the step index is stuck: something is preventing
TickActionfrom returning terminal. -
Why no plan?
- Open the archetype asset.
- Click Analyze Archetype (see recipe 16). Orphan facts
surfaced here are the single most common cause of "no plan" —
a goal needs
HasTarget = truebut no action produces it, and no sensor writes it.
-
Live inspect the world state.
- Window → Developer Tools → IntentForge Live Inspector (recipe 14).
- Click the agent. Cross-check every fact value against what a
working plan would need. If
HasTargetisfalsewhen you expectedtrue, trace back to the sensor (recipe 7) or the gameplay code that should have written it.
-
What did the planner consider?
- Open Visual Logger (recipe 12, default Ctrl+L).
- Select the agent, scrub to a recent replan. Each entry lists
every scored goal with its score and priority. The goal you
expected is either: not present (precondition failed — check
DesiredStateof the goal vs current facts), scoring 0 (consideration set returned 0 — check the consideration curve), or scoring below another goal (a different goal is winning — check why).
-
Is the action stuck?
- If
DumpAgentshows the same plan step for many seconds, the executor'sTickActionis returningRunningforever. Check the executor — likely missing a terminal condition or waiting on something that never happens.MinActionCommitTimewill also keep the agent on an action for at least that long after starting it; check the component'sMinActionCommitTimesetting if you expected faster preemption.
- If
-
Is a slow sensor starving the planner?
IntentForge.ProfileSensors <PartialActorName>Sensors that take ms-per-Sample at high frequencies will starve the game thread before the planner does. Recipe 15 covers the fix paths.
-
Last resort: verbose planner logs.
Log LogIntentForgePlanner VeryVerboseLogs every goal scoring decision and every A* node expansion. Drastically slows planning — only use when nothing else points at the problem.
Common patterns and their fix:
| Symptom | Likely cause | Fix |
|---|---|---|
AgentCount = 0 | Archetype not assigned, or actor doesn't have the component | Drop component in BP / set archetype in Details |
| Plan adopted, then immediately replaced every tick | Flapping between two equal-score goals | Set GoalMomentumBonus > 0; add a Schmitt latch on the driving scalar fact (recipe 18) |
| Plan empty, "no satisfiable goal" | Goal needs a fact no action produces | Analyze Archetype → orphan fact → add producer |
| Plan starts but never advances | Executor returns Running forever | Inspect the executor's terminal logic; missing condition |
| Plan adopts a wrong goal | Consideration curves return wrong scores | Visual Logger entry shows scored goals; check the curve |
| Agent picks correctly in editor but not at runtime | bTriggersReplan missing on a key fact, or sensor not wired | Schema: enable replan-triggering on the fact; verify sensor in archetype |
If you've worked through this list and still can't find it, file
an issue on the plugin repo with the IntentForge.DumpAgent
output and the relevant Visual Logger screenshot — that's enough
information to reproduce most issues.
23. Dispatch from a Behavior Tree
Problem: your project already drives AI from Behavior Trees and you want Intent Forge to be a task in the tree rather than replace the tree. The planner picks the action; the BT decides when to run the planner.
Recipe (no C++):
-
Make sure the IntentForgeBT module is enabled. (It's an optional module that ships with the plugin; enable it under Edit → Plugins → search "Intent Forge BT" if it isn't on already.)
-
On your pawn, add a
UIntentForgeComponent(same as the Quickstart). Set its Archetype to the archetype you want the planner to drive. Leave Dispatch Mode atAuto— the BT task will switch it toBehaviorTreewhen active and restore it when finished. -
Open the AIController's Behavior Tree asset. Right-click in the graph → Add Task → search for Run Intent Forge Action (the task's
UCLASSDisplayName). -
Wire the task into the tree wherever you want planner-driven behaviour. The task drives one action at a time: it spawns the executor, ticks it, calls
NotifyActionCompletedon terminal status, then spawns the next step. It only returns from the task when the plan is empty or the BT aborts it. -
Compile and Save the BT.
Why this works: UBTTask_RunIntentForgeAction resolves the
component on the controlled pawn (or the controller's actor),
claims ownership by setting DispatchMode = BehaviorTree for the
duration, and runs the same executor lifecycle (EnterAction / TickAction / ExitAction / CancelAction) the Auto dispatcher
runs. The component side of the contract is identical regardless
of dispatcher — the planner decides; the BT task is just the
actor that calls the lifecycle methods.
When to use this dispatcher:
- You have legacy BTs you want to keep.
- Your BT mixes planner-driven action selection (the Intent Forge task) with hand-authored decision selectors (move-to-cover, wait for cue, etc.).
When NOT to use it: if your AI is entirely planner-driven,
skip the BT and use DispatchMode = Auto. The BT adds a layer
with no benefit.
24. Dispatch from a StateTree
Problem: same as recipe 23, but for StateTree instead of BT.
Recipe (no C++):
-
Enable the IntentForgeStateTree module under Edit → Plugins.
-
On the executing actor, add a
UIntentForgeComponentand set its Archetype. Leave Dispatch Mode atAuto. -
Open the StateTree asset. The task is
FIntentForgeRunActionTaskwithUCLASSDisplayName "Intent Forge: Run Action". Add it to a state in your tree:- Click the state.
- In the right-hand details panel, find the Tasks array.
- Click + → search for Intent Forge: Run Action and select it.
-
The task takes the
UIntentForgeComponentas aTStateTreeExternalDataHandle<UIntentForgeComponent>. Bind it in the state-tree schema (the actor schema usually auto-discovers it from the owning actor's component). -
Save the StateTree.
Why this works: same contract as the BT task. The StateTree
task claims DispatchMode = StateTree while it's running, drives
the executor lifecycle, and restores the previous DispatchMode on
exit. Plan invalidation is detected by comparing
LastObservedPlanGeneration against Component.GetPlanGeneration()
— if the component replans and the new first-step doesn't match
the running executor, the task tears down + respawns.
When to use it: same logic as recipe 23. Pick the runtime that matches your project's existing convention.
25. External dispatch — drive the lifecycle from your own code
Problem: you have a custom AI orchestrator (your own state
machine, a third-party plugin, custom Blueprint AI) and you want
Intent Forge to plan but your code to drive the action
lifecycle. Neither Auto nor a BT/StateTree task is the right
fit.
Recipe (C++):
-
On the component, set
DispatchMode = EIntentDispatchMode::External. This makesUIntentForgeDispatcherSubsystemskip the component entirely — you take full responsibility for spawning executors and reporting terminal status. -
In your tick loop (or whenever your orchestrator decides to step the action lifecycle), implement this loop per agent:
void FMyOrchestrator::TickAgent(UIntentForgeComponent& Component, FMyAgentState& State, float DeltaTime) { // 1. Detect plan invalidation. If the component replanned and // the active action changed, tear down + respawn. const int32 CurrentGen = Component.GetPlanGeneration(); if (CurrentGen != State.LastObservedPlanGeneration) { if (State.CurrentExecutor) { State.CurrentExecutor->CancelAction(); State.CurrentExecutor = nullptr; } State.LastObservedPlanGeneration = CurrentGen; } // 2. If no executor is running, try to spawn one for the // component's current step. if (!State.CurrentExecutor) { const FIntentAction* Action = Component.GetCurrentAction(); if (!Action || !Action->ExecutorClass) return; State.CurrentExecutor = NewObject<UIntentActionExecutor>( &Component, Action->ExecutorClass); State.CurrentExecutor->EnterAction( Component.GetOwner(), Action->DefaultParams); } // 3. Tick the executor. On terminal status, exit + notify the // component, then let the next iteration spawn the next step. const EIntentActionStatus Status = State.CurrentExecutor->TickAction(DeltaTime); if (Status != EIntentActionStatus::Running) { State.CurrentExecutor->ExitAction(Status); State.CurrentExecutor = nullptr; Component.NotifyActionCompleted(Status); } } -
The
FMyAgentStatestruct only needs two fields:struct FMyAgentState { TObjectPtr<UIntentActionExecutor> CurrentExecutor = nullptr; int32 LastObservedPlanGeneration = 0; };Mark
CurrentExecutorwithUPROPERTY()if you're storing the state on aUObject— otherwise the GC will reap your executor mid-action.
Why this works: the External dispatcher mode tells the
built-in dispatcher subsystem to leave the component alone. Your
code is now playing the same role the subsystem plays for Auto
components: spawn executors, tick them, detect plan invalidation
via PlanGeneration, report terminals. The component-side
contract is identical.
When to use it:
- You're integrating Intent Forge into an existing custom AI runtime.
- You need finer control over when the executor ticks (e.g. tier-based ticking — front-of-camera agents tick every frame, distant agents tick every fourth frame).
- You're prototyping a new dispatch strategy that doesn't fit Auto/BT/StateTree.
Gotchas:
- Forgetting to
NotifyActionCompletedleaves the component waiting forever — it has no other signal that the action terminated. - Calling
EnterActiontwice for the same handle is undefined; always null outCurrentExecutorafterExitAction. - The component owns the canonical strong reference to the
executor via its
ActiveExecutorUPROPERTY; treat your state struct's reference as observational only.