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.

Read after QuickstartMental model

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.
StateTreeFIntentForgeRunActionTask in any StateTree assigned to the agent.
BehaviorTreeUBTTask_RunIntentForgeAction placed in any Behavior Tree.
ExternalYour 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

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 graphForbidden
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 notifiesTasks that pick an Intent Forge action and run it directly
Linking the Intent Forge task to external state for the componentCalling UIntentForgePlannerSubsystem::Plan from a task
Wrapping the Intent Forge task in parent logicMutating 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

Intent Forge pipeline: Goal selection → Planner → Plan → Dispatcher → Executor → World State → loop

The conceptual view above shows the closed loop. The diagram below calls out the actual class boundaries:

write into returns FPlan exposes currentaction handle spawns, ticks,reports terminal Sensors · Perception adapters FWorldStateowned by the component UIntentForgePlannerSubsystempure function — returns FPlan UIntentForgeComponentkeeps current plan + stepdecides when to replan Dispatcherselected by DispatchMode UIntentActionExecutorspawned by the dispatcher 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.

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:

Fact flow: raw sensor reads pass through an optional EMA filter or Schmitt latch before reaching the world state and planner

The derived-facts layer is where Families 2 (latched bool) and 4 (EMA smoothing) of the anti-flap toolkit live — see Anti-Flap Toolkit.

KindStorageUse for
BoolPosition in TBitArray indexed by schemaHas-X questions: HasTarget, IsArmed, HasFood
ScalarTMap<FName, float> keyed by idContinuous values: Health %, DistanceToTarget
ObjectTMap<FName, FInstancedStruct> keyed by idRare: 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 DesiredState array of preconditions) and a scoring policy (a UIntentGoalConsiderationSet).
  • An Action declares preconditions, effects, an ExecutorClass, parameter defaults, and an optional UIntentCostStrategy.
  • 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 on UIntentForgePlannerSubsystem.

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:

  1. State changedWorldState.StateVersion != LastPlannedStateVersion.
  2. ForcedRequestReplan() or NotifyActionCompleted(Failed).
  3. Safety net — at least ReplanSafetyNetInterval seconds since last replan.

Throttles:

  • Coalescing: N ScheduleNextTickReplan calls inside one frame produce exactly one EvaluateReplan on the next tick, via FTimerManager::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 MinActionCommitTime seconds unless RequestReplan has set the internal bBypassCommitGate flag.
  • Goal momentum: the currently-active goal gets a +GoalMomentumBonus multiplier 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

Dispatcher / executor sequence diagram: Component → Dispatcher → Executor with EnterAction, TickAction loop, FinishAction

The flowchart below maps the same contract to the actual method calls and life-cycle transitions:

Running Succeeded · Failed on Succeeded on Failed "Component sets CurrentAction =Plan.Steps[CurrentStepIndex Dispatcher task notices viaGetCurrentAction() Task creates NewObject<UIntentActionExecutor>(Component, Action->ExecutorClass) Executor.EnterAction(OwnerActor,Action->DefaultParams) Executor.TickAction(DeltaTime) Executor.ExitAction(status) Component.NotifyActionCompleted(status) Apply effects · increment step index ·fire next step Clear plan · force replan next tick

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:

OperationBudget
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), every NotifyActionCompleted, and every RequestReplan call routes through the internal ScheduleNextTickReplan(). Requests coalesce via SetTimerForNextTick — N changes in one frame produce one EvaluateReplan next frame.
  • Safety-net timer — a single recurring FTimerHandle calls ScheduleNextTickReplan every ReplanSafetyNetInterval seconds. Catches drift that didn't trigger an explicit event (e.g. an external system mutating FWorldState directly 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)

ToolWhen 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.DumpAllAgentsOne-line summary per live agent.
IntentForge.DumpAgent <PartialName>Deep dump for a specific agent (or any matching the substring).

Retrospection (after-the-fact)

ToolWhen 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

ToolWhen 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 testsCatch regressions against the perf contract. Run from Window → Test Automation.

Editor authoring

ToolWhen 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.
◢ Next

Try this next

On this page