Concepts
How Intent Forge is shaped, and the one rule that makes the whole architecture work.
[ Concepts ]
The mental model
Why Intent Forge is shaped the way it is, and the one rule that makes the whole architecture work. If something in the codebase looks surprising, the answer is probably here.
The one rule
This is the entire product in one sentence. The planner emits an
immutable FPlan. It never ticks. It never holds timers. It never
queries the world during execution.
The runtime component (UIntentForgeComponent) owns the plan and pops
one action at a time. A dispatcher spawns an executor for the
current action and ticks it until terminal. When the executor
terminates, the component advances or replans.
If you ever find yourself adding Tick() to the planner or having an
executor write the world state directly, stop. The boundary is the
entire value of the architecture.
The dispatcher abstraction
Execution is who calls EnterAction / TickAction / ExitAction. Intent
Forge ships four dispatcher implementations and you pick one per
component via UIntentForgeComponent::DispatchMode:
Mode (EIntentDispatchMode) | Who drives the executor |
|---|---|
Auto (default) | UIntentForgeDispatcherSubsystem — ticks every Auto-mode component once per frame. Works with no AI runtime at all. |
StateTree | FIntentForgeRunActionTask in any StateTree assigned to the agent. |
BehaviorTree | UBTTask_RunIntentForgeAction placed in any Behavior Tree. |
External | Your own code — custom Blueprint AI, third-party state machine, etc. |
The StateTree task auto-flips DispatchMode to StateTree on enter and
restores the previous DispatchMode value on exit (snapshotted into
instance data at enter time), so the dispatcher subsystem never fights
the task and deliberate External settings aren't clobbered. Same
pattern for BT.
The component contract is identical regardless of dispatcher:
- Read
GetCurrentAction()(FActionHandle). - Resolve via
GetActionByHandle(Handle)->ExecutorClass. NewObject<>(&Component, ExecutorClass); callEnterAction(Owner, Params).- Tick until
TickActionreturns terminal. ExitAction(Status), thenComponent->NotifyActionCompleted(Status).- Recommended:
Component->SetActiveExecutor(...)so the Live Inspector can render progress bars.
If you write an External-mode dispatcher, implement that contract
and you're indistinguishable from the built-in ones.
The boundary contract
Whichever dispatcher you choose executes one action at a time on the component's behalf. It is not allowed to decide which action to run, which goal to pursue, or whether to replan. Those decisions belong to the planner + component.
| Allowed in your AI graph | Forbidden |
|---|---|
| Outer-loop transitions: enter combat mode, exit on death, etc. | Conditions that check fact values (those belong in preconditions) |
| Branching on player input, gameplay events, animation notifies | Tasks that pick an Intent Forge action and run it directly |
| Linking the Intent Forge task to external state for the component | Calling UIntentForgePlannerSubsystem::Plan from a task |
| Wrapping the Intent Forge task in parent logic | Mutating FWorldState from inside a dispatcher task |
If your graph has 30 nodes deciding behaviour, you're back at a
Behavior Tree that decides — defeating the point. The Intent Forge
model is one task per agent (or simply DispatchMode=Auto to skip the
AI runtime entirely); the planner does the decision-making.
Every dispatcher enforces this by exposing only the minimal component handshake:
GetCurrentAction()(read)GetActionByHandle()(read)GetPlanGeneration()(poll; cache the value, compare next tick)NotifyActionCompleted()(notify, do not influence the next pick)
The component is the only place that calls the planner, and the
planner is const. There is no back-channel from execution to
planning except the post-completion NotifyActionCompleted callback,
which causes a replan but does not influence which action gets chosen.
Layers
The conceptual view above shows the closed loop. The diagram below calls out the actual class boundaries:
Layers depend downward only. You should be able to use the planner without StateTree or BT (via the dispatcher subsystem, or from a custom AI controller) and ship the core without the debug, BT, or executor modules.
The dispatcher slot is the swap point: UIntentForgeDispatcherSubsystem
(Auto, default — works with no AI runtime), FIntentForgeRunActionTask
(StateTree), UBTTask_RunIntentForgeAction (Behavior Tree), or your
own code (External). The component contract is identical regardless.
Facts
A fact is one named slot in the world state. There are three kinds:
The derived-facts layer is where Families 2 (latched bool) and 4 (EMA smoothing) of the anti-flap toolkit live — see Anti-Flap Toolkit.
| Kind | Storage | Use for |
|---|---|---|
| Bool | Position in TBitArray indexed by schema | Has-X questions: HasTarget, IsArmed, HasFood |
| Scalar | TMap<FName, float> keyed by id | Continuous values: Health %, DistanceToTarget |
| Object | TMap<FName, FInstancedStruct> keyed by id | Rare: structured data not naturally bool/scalar |
Fact identifiers are FNames; the UFactSchemaAsset is the source of
truth and catches typos at load time. Gameplay Tags are NOT used as
facts — they're used as fact identifiers and categories (the
naming convention) but the storage is always typed. Mixing the two
would muddy ownership.
Rule of thumb: a fact exists only if at least one precondition or effect uses it. Otherwise it's just data the executor can read directly. Six months into a project, a fact catalog with 200 entries is a sign the rule was broken.
Goals, Actions, and Archetypes
- A Goal declares a target world state (the
DesiredStatearray of preconditions) and a scoring policy (aUIntentGoalConsiderationSet). - An Action declares preconditions, effects, an
ExecutorClass, parameter defaults, and an optionalUIntentCostStrategy. - An Archetype bundles a schema + goals + actions. Assign one to an IntentForge component to give an agent its complete behaviour space.
Designers can swap archetypes at runtime to model role changes (peasant → guard). The component resets world state and forces a replan on archetype change.
The planner
A* over symbolic world state.
- State key: hash of the bool bitset + lexically-sorted scalar facts quantized to 0.01. Object facts don't participate.
- Heuristic: count of unsatisfied predicates in the goal's
DesiredState. Admissible if minimum action cost ≥ 1. - Budgets:
MaxPlanDepth(12),MaxNodesExpanded(1024),MaxScalarFactsForHash(16). All configurable onUIntentForgePlannerSubsystem.
Goal selection: score every goal, sort by score then priority, skip
already-satisfied goals, try PlanForGoal on each in order, return
the first non-empty plan. The planner accepts an optional
FPlannerHints struct (carrying the previous goal + momentum bonus) —
when supplied, the previous goal gets a multiplicative score bonus
and wins exact-score ties. Stateless callers (e.g. test code) pass
the no-hints Plan(WorldState, Archetype) overload and see vanilla
selection.
Replanning policy
The component decides when to replan. Triggers:
- State changed —
WorldState.StateVersion != LastPlannedStateVersion. - Forced —
RequestReplan()orNotifyActionCompleted(Failed). - Safety net — at least
ReplanSafetyNetIntervalseconds since last replan.
Throttles:
- Coalescing: N
ScheduleNextTickReplancalls inside one frame produce exactly oneEvaluateReplanon the next tick, viaFTimerManager::SetTimerForNextTick. Spammy state writes are free — they only pay for one replan. - Min-commitment gate: once an action has started, it can't be
preempted for
MinActionCommitTimeseconds unlessRequestReplanhas set the internalbBypassCommitGateflag. - Goal momentum: the currently-active goal gets a
+GoalMomentumBonusmultiplier when scored (one of five anti-flap families. See the Anti-Flap Toolkit). Switching goals must clear the bonus margin. - Plan reuse: if the new plan's first action matches the current action, the in-flight executor keeps running.
Dispatch chain
The flowchart below maps the same contract to the actual method calls and life-cycle transitions:
Plan invalidation
When the component replans and the new plan's first action differs
from the current step, it bumps PlanGeneration (a monotonic int32
— UHT rejects uint32 in BlueprintCallable signatures, so the API
uses the signed variant). Each dispatcher task caches the value it
last synced against in its per-instance memory and detects
invalidation by comparison:
const int32 ComponentGen = Component.GetPlanGeneration();
if (ComponentGen != Memory.LastObservedPlanGeneration)
{
// Cancel the in-flight executor, spawn the new one
Memory.LastObservedPlanGeneration = ComponentGen;
}The counter is idempotent — multiple observers (the StateTree task,
BT task, dispatcher subsystem) can each cache independently without a
shared "clear" handshake. The pattern mirrors
FWorldState::StateVersion.
Performance contract
Targets that the design holds itself to. There's no CI-enforced benchmark gate yet, but these are the budgets that won't accept regressions:
| Operation | Budget |
|---|---|
| Plan generation (30 actions / 64 facts / depth ≤ 8) | < 0.5 ms |
FWorldState::SetBool / SetScalar | < 1 µs |
| 100 idle agents (no state change, no sensors tripping) | 0 ms / tick |
| 100 agents all dirty every tick (worst case to avoid) | < 5 ms / tick |
| Memory per agent (state + plan + component) | < 4 KB |
If you exceed any of these in practice, file an issue with a repro.
Event-driven by construction
UIntentForgeComponent does not tick. There is no
TickComponent override; bCanEverTick is false. All work is driven
by:
- FTimerManager per sensor — each archetype sensor runs on its
own loop timer at its declared
SampleInterval. Idle sensors do nothing between samples. ScheduleNextTickReplan()on state change — every component-level fact setter (SetFactBool,SetFactScalar,SetFactObject,ClearFactObject), everyNotifyActionCompleted, and everyRequestReplancall routes through the internalScheduleNextTickReplan(). Requests coalesce viaSetTimerForNextTick— N changes in one frame produce one EvaluateReplan next frame.- Safety-net timer — a single recurring
FTimerHandlecallsScheduleNextTickReplaneveryReplanSafetyNetIntervalseconds. Catches drift that didn't trigger an explicit event (e.g. an external system mutatingFWorldStatedirectly without going through the component).
The result: 100 agents standing idle contribute literally zero per-frame work. The only floor cost is the per-sensor timer overhead, which UE batches efficiently at the timer-manager level.
If you ever need to add a tick, the rule is: it must do bounded work in the no-work case, ideally an early-out under a few cycles. Otherwise use a timer or a state-change event.
Debug & diagnostics toolkit
Intent Forge ships an extensive set of debug primitives, layered from quickest-to-use to deepest. Pick the right tier for the problem.
Live inspection (PIE)
| Tool | When to use |
|---|---|
| IntentForge Live Inspector tab (Window → Developer Tools) | At-a-glance view of every live agent. Click a row for full state (plan, facts, replan history). The Slate companion to the Gameplay Debugger. |
Gameplay Debugger (apostrophe key, then IntentForge category) | "What is this agent doing right now?" In-world overlay on the selected debug actor. |
IntentForge.AgentCount | "Are my agents even spawning?" One line. |
IntentForge.DumpAllAgents | One-line summary per live agent. |
IntentForge.DumpAgent <PartialName> | Deep dump for a specific agent (or any matching the substring). |
Retrospection (after-the-fact)
| Tool | When to use |
|---|---|
| Visual Logger (Window → Visual Logger) | "Why did the planner choose X two seconds ago?" Every replan emits an entry per considered goal with level/score/priority, plus plan-adoption and action-completion events. Scrub the timeline. |
IntentForge.HistoryFor <PartialName> | Print recent replan history (newest first) for matching agents. Quick "what plan did this run a moment ago?" |
UIntentForgeComponent::GetReplanHistory() | Same data, Blueprint/C++-callable. Drive custom inspectors. |
Profiling
| Tool | When to use |
|---|---|
IntentForge.ProfileSensors <PartialName> | "Which sensor is slow / sampling too often?" Prints sample count, total time, avg ms per sample. |
UIntentForgeComponent::GetSensorProfile() | Same data, Blueprint/C++-callable. |
IntentForge.Performance.* automation tests | Catch regressions against the perf contract. Run from Window → Test Automation. |
Editor authoring
| Tool | When to use |
|---|---|
| IntentForge Archetype Browser tab (Window → Developer Tools) | Pick any archetype, see its full structure as a tree: schema facts, actions with preconditions/effects in human-readable form, goals, and producer/consumer cross-reference. Double-click any row to jump to the underlying asset. |
| Asset Validator (auto-runs on save) | Catches typo'd fact ids, missing schema bindings, actions without executors, goals without desired-state. |
| Test Plan button (in any archetype asset's details panel) | Dry-run the planner against a default world state without entering PIE. Output lands in the IntentForge MessageLog. |
| Analyze Archetype button (in any archetype asset's details panel) | Walk the action graph, report orphan facts (never produced) and unused actions. Surfaces "why no plan?" before runtime. |
| IntentForge MessageLog (Window → Developer Tools → Message Log → IntentForge) | Persistent record of validation runs + Test Plan + Analyze results. |
Programmatic access
If none of the above fits, the registry exposes the raw agent set:
UIntentForgeWorldSubsystem* Registry = UIntentForgeWorldSubsystem::Get(this);
for (UIntentForgeComponent* Agent : Registry->GetAllAgents())
{
// ... do whatever inspector / overlay / coordination you need
}The subsystem is the canonical entry point — never iterate actors directly looking for the component.
What Intent Forge isn't
- Not a replacement for StateTree, Behavior Trees, or hand-written controllers. It sits above them.
- Not a multi-agent coordinator. Per-agent planning only.
- Not networked. Server-authoritative is enforceable but no replication patterns ship today.
- Not a utility AI framework. The goal-scoring layer borrows from utility AI, but the planner is the centerpiece.
- Not genre-coupled. Combat, survival, patrol — none of these are baked into the core.
Try this next
Build a forager agent end-to-end in 10–15 minutes.
Signature /Anti-Flap ToolkitThe five-family stability layer — the actual differentiator.
See it run /ExamplesNine worked examples — hello-world through replicated multiplayer.
Patterns /Cookbook25 copy-pasteable recipes for the patterns you'll actually use.