IntentForge — Concepts¶
This document explains the core design decisions and why the plugin is shaped the way it is. If something in the codebase looks surprising, the answer is probably here.
The one rule: the planner does not execute¶
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:
1. Read GetCurrentAction() (FActionHandle).
2. Resolve via GetActionByHandle(Handle)->ExecutorClass.
3. NewObject<>(&Component, ExecutorClass); call EnterAction(Owner, Params).
4. Tick until TickAction returns terminal.
5. ExitAction(Status), then Component->NotifyActionCompleted(Status).
6. 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 (the dispatcher is not the brain)¶
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.
Concretely, whether you author a StateTree, a Behavior Tree, or custom
Blueprint AI on top of the Run Intent Forge Action task / dispatcher,
observe these rules:
| 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)
- IsCurrentActionStale() / ClearCurrentActionStale() (poll)
- 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¶
Sensors / Perception adapters
│ write into
▼
FWorldState ◄── owned by ──┐
│ │
▼ │
UIntentForgePlannerSubsystem ◄───┤
pure function: returns FPlan │
│ │
▼ │
UIntentForgeComponent ────────────┤
- keeps current plan + step
- decides when to replan
│ exposes current action handle
▼
Dispatcher (selected by DispatchMode):
- UIntentForgeDispatcherSubsystem (Auto, default)
- FIntentForgeRunActionTask (StateTree)
- UBTTask_RunIntentForgeAction (BehaviorTree)
- your code (External)
spawns the right UIntentActionExecutor, ticks, reports terminal
│
▼
Game world
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.
Facts¶
A fact is one named slot in the world state. There are three kinds:
| 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()(renamed fromForceReplanin v0.34; the old name is a deprecated forwarder) 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 internalbForceReplanbypass flag. - 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¶
Component sets CurrentAction = Plan.Steps[CurrentStepIndex].Action
│
▼
StateTree task notices via GetCurrentAction()
│
▼
Task creates NewObject<UIntentActionExecutor>(Component, Action->ExecutorClass)
│
▼
Executor.EnterAction(OwnerActor, Action->DefaultParams)
│
▼ tick loop
Executor.TickAction(DeltaTime) → Running / Succeeded / Failed
│
▼ terminal
Executor.ExitAction(status)
│
▼
Component.NotifyActionCompleted(status)
│
▼ on Succeeded: apply effects, increment index, fire next step
▼ on Failed: clear plan, force replan next tick
Plan invalidation while a step is running¶
When the component replans and the new plan's first action differs from the
current step, it sets an internal stale flag. The StateTree task polls
IsCurrentActionStale() once per tick. On a stale signal it calls
CancelAction on the live executor, then spawns the new executor inline.
This polling handshake avoids a StateTree-level transition for what should be a local concern.
Performance contract¶
Targets that the design holds itself to. We don't have CI-enforced benchmarks yet, but these are the budgets we won't accept regressions against:
| 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 throughScheduleNextTickReplan()(renamed from the privateRequestReplanin v0.34 when the public method took that name). 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¶
IntentForge 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 IntentForge 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 in 0.x.
- 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.