Anti-Flap Toolkit
Stable GOAP — the five-family stability layer that prevents agents from oscillating between near-equal goals.
[ Deep Dive · Flagship ]
The Anti-Flap Toolkit
The five-family stability layer that prevents agents from oscillating between near-equal goals or actions. This page covers the literature, what flapping is, where it shows up, and how each family kills it at the right layer of the pipeline. Read this once before you ship any non-trivial agent. The defaults assume you understand what they're guarding against.
Background
The defining feature of GOAP, as Jeff Orkin formalised it in F.E.A.R. [1], is that the agent's behaviour space is declared (goals, actions with preconditions and effects) and the sequence is invented by a search algorithm at runtime. Every time the world changes, the planner re-runs. That replan loop is the mechanism that gives GOAP agents their adaptive, emergent feel, and it's also where most GOAP implementations break down in production.
A decade after F.E.A.R. shipped, Éric Jacopin published a formal analysis of its planner in Game AI Pro 2 [2] — an industry-textbook chapter, not just academic work. Among other findings, he showed that the rats in F.E.A.R. used the same GOAP machinery as the soldiers, and kept replanning long after the player had left the level. The system had no mechanism to commit, throttle, or filter: every fact change triggered a fresh A* search, and every search could produce a different plan than the one before. Chris Conway's GDC 2015 retrospective [3] made the same point as a production pattern, not a niche bug, and the GDC programme has returned to GOAP in production AAA titles repeatedly since: Ubisoft on Assassin's Creed Odyssey and Immortals Fenyx Rising [4], Square Enix on multi-agent cooperation [5], Warhorse on Kingdom Come: Deliverance II [6], BioWare's Dragon Age Inquisition utility-scoring architecture [13]. Twenty years on, GOAP-flavored deliberation is a live AAA technique and replan stability is still the recurring open problem in every production retrospective.
The broader symbolic-planning literature has a name for the underlying tension: commitment. Pereira et al. [7] characterise it as the trade-off between responsiveness (replan when the world changes) and coherence (don't abandon a plan that's still on track). Carle Côté [9] frames the same axis as reactivity vs deliberation, and shows that every decision-making system in game AI sits somewhere on that axis. Pure A* over symbolic state, replanned every tick, is maximally responsive and minimally coherent. Production GOAP needs both, and you can't get both from the planner alone — the planner is correct per-replan; coherence has to come from somewhere else in the pipeline.
The shape this problem takes in shipping code is flapping: two goals score near-equal, a tiny signal change flips the winner, the agent visibly oscillates between them every replan tick. F.E.A.R.'s own design discussion calls this out informally; Jacopin's analysis formalises it; every working GOAP developer has shipped a fix at some point.
The interesting question isn't whether the problem exists — it demonstrably does, in the seminal implementation, documented across six GDC talks and an industry textbook chapter spanning twenty years. The interesting question is where in the pipeline you address it. The existing UE5 GOAP ecosystem largely doesn't (most plugins haven't shipped a release in five years). UnityGOAP, the most-active GOAP plugin in any engine today [8], addresses it with goal re-evaluation throttling and priority modulation — a partial solution at the selection layer. Behavior-tree frameworks sidestep the problem by hand-authoring the transitions (the designer commits for you), and BT still gets its own flavor of flap as Francis [12] catalogues: re-entry, transition thrash, decorator oscillation. The same instability emerges; the layer just shifts. Animation systems have it too: "animation twinning" [10] is the same phenomenon at the asset-selection layer. HTN-style planners decompose the problem hierarchically, which dampens it but doesn't eliminate it.
Intent Forge's contribution is layered rather than novel: each of the five families is individually well-known in adjacent fields (Schmitt triggers from analog electronics, EMA filters from DSP, hysteresis bands from controls, minimum commit times from real-time scheduling). What's new is placing them at the five natural pipeline locations in a single declarative toolkit, so a designer can configure stability where the signal it stabilises is born. The rest of this page is the toolkit, framed by that pipeline.
What flapping is
Flapping is the pathology where an AI agent oscillates between two
(or more) goals or actions every replan tick, never committing long
enough to make visible progress on either. A patrolling guard who sees
the player at the exact edge of detection range and switches Patrol → Chase → Patrol → Chase once per planner tick is the canonical
example: from a human's perspective the agent is "stuttering" rather
than acting.
The root cause is almost always that the planner's goal-selection score for two candidates is near-equal and a small amount of noise (sensor jitter, distance crossing a threshold, a tiny utility tweak) is enough to flip the winner every replan. The fix is not to make the planner smarter — A* with admissible heuristics is already optimal for each individual replan. The fix is to add stability on top of it, at the right layer.
Intent Forge's framing is Stable GOAP: classic GOAP optimality preserved per-replan, with a small set of orthogonal stability layers wrapped around it. Each layer lives in its natural API location rather than being collapsed into a single "pick a strategy" enum.
Pipeline
Five anti-flap families layer onto the planner pipeline, each at the natural location where the signal it stabilises is born:
A particular flap may be suppressible at any of these layers. You typically want to attack it at the earliest layer that makes the signal stable, so the downstream layers see a clean input.
Families at a glance
Anatomy
Each family intervenes at a different layer. All five ship in v1.0-alpha.
Family 1 — Score-domain biasingSelection- Default
- MomentumBonus = 0.15
- Lives in
- Planner goal ranking
Family 2 — Latched fact derivationFact derivation- Mechanism
- Schmitt hysteresis band
- Lives in
- FWorldState::SetScalar
Family 3 — Min commit timeExecution- Default
- 0.3 s
- Lives in
- Dispatcher
Family 4 — Signal smoothingFact derivation- Default
- EMA α = 0.25
- Lives in
- FWorldState::SetScalar
Family 5 — Structural commitmentPlan shape- Mechanism
- Plan reuse + executor lifecycle
- Lives in
- AdoptNewPlan
The bundled fact-derivation layer (Families 2 + 4) runs inside
FWorldState::SetScalar. Configuration lives on the
UFactSchemaAsset so every producer (sensors, gameplay events,
blackboard bridges) gets the same treatment without each opting in by
inheritance.
The full stack of five orthogonal stability layers is usable from a single declarative schema; the Patrol/Chase example demonstrates Families 1 + 2 + 4 in one actor with individual toggles.
Layered stack
Inside FWorldState::SetScalar, the filter + latch derivation runs as
a single decision tree. Planner-search copies set
bSuppressDerivation = true so EMA cannot bleed into the
deterministic A* simulation; live-runtime writes carry the full stack.
Planner search nodes set bSuppressDerivation = true on every state
copy so EMA does not bleed into the deterministic action-effect
simulation. Latching is not suppressed in search — the derived bool
is part of the symbolic state the planner reasons about, so action
effects that move the source scalar must also move the derived bool
inside the search.
Families in detail
Family 1 — Score-domain biasing (selection layer)
A multiplicative bonus on the score of the currently-active goal
during replan ranking: score *= 1 + MomentumBonus. Switching to a
different goal requires the new goal to clear the bonus margin.
Ships as FPlannerHints::MomentumBonus, threaded into the planner by
the component as UIntentForgeComponent::GoalMomentumBonus (default
0.15). The 0.15 default matches Apoch's Utility Intelligence
published guidance and the broader Curvature literature: large enough
to suppress flap on near-equal goals, small enough that meaningful
score changes still win.
Special case: when both candidates score exactly 0, the bonus is
inert (0 * 1.15 = 0). The planner's comparator falls back to an
active-goal-wins tiebreak before declaration order, so the previous
goal still keeps the slot.
Family 2 — Latched fact derivation (fact-derivation layer)
Schmitt-style hysteresis on the derivation of a bool fact from a
scalar: LowHealth = true once health drops below 0.25, but stays
true until health rises above 0.40. The dual-threshold band creates
deliberate hysteresis in the derived state.
Ships as FIntentLatchedScalarFactConfig, attached to the schema as a
sibling list (UFactSchemaAsset::LatchedBoolFacts). Each row maps one
source scalar fact id to one output bool fact id with an
EnterThreshold / ExitThreshold band. Latching runs inside
FWorldState::SetScalar so every write to the source scalar drives
the latch automatically — no extra ticking, no per-agent state on the
sensor, no special code in the planner.
Canonical form: Enter <= Exit. The output is true when the
source value is below Enter, and stays true until the source rises
above Exit. The example above (LowHealth) is canonical.
Inverse form: Enter > Exit. "True when high." The output is
true when the source rises above Enter, and stays true until the
source falls below Exit. The validator reports this as an
info/warning so the choice is explicit and not a typo.
Initial state. bInitialValue is what
GetBool(OutputBoolFactId) returns until the first source-scalar
write arrives. Without this, a just-spawned agent would read the
bitset default (false) for the output bool regardless of where the
source value started.
Family 3 — Time-domain (execution layer)
A minimum commit time on every action: once dispatched, an action
cannot be preempted for at least MinActionCommitTime seconds
(default 0.3s) unless the candidate has a higher
EIntentInterruptionLevel. The interruption-level gate is the
emergency bypass — a Critical-level goal can preempt anything
regardless of how recently the current action started.
Family 4 — Signal smoothing (fact-derivation layer)
An exponential moving average on a noisy raw sample before it becomes
a fact: smoothed_n = Alpha * raw_n + (1 - Alpha) * smoothed_{n-1}.
The smoothed value is what the rest of the planner sees. Kevin Dill
formalised this pattern as dual-utility reasoning
[11]: keep a raw signal and a
smoothed signal; let the smoothed signal drive selection. Intent Forge
collapses this to just the smoothed signal in the world state, with the
raw value visible in the Live Inspector for debugging.
Ships as FIntentScalarFilterConfig, attached inline to a
FFactSchemaEntry of Type == Scalar. The filter applies inside
FWorldState::SetScalar so every producer benefits — sensors,
gameplay events, blackboard bridges, BatchMutate; without each
opting in by inheritance.
First-sample passthrough. The first SetScalar for a fact stores
the raw value (no smoothing). Subsequent writes apply EMA. This
avoids a long warm-up where the smoothed value crawls up from zero.
Alpha tuning. Alpha = 1.0 is identity (passthrough). Alpha = 0.05 is heavy lag (slow tracking). The default 0.25 is a
reasonable starting point for distance-style signals. The validator
warns if Alpha is outside (0, 1].
Suppression in search. EMA does not run inside the planner's A*
expansion. Each search node has bSuppressDerivation = true, so
action effects that move a scalar produce deterministic outputs the
search can hash and compare. The live runtime never sets that flag.
Family 5 — Structural commitment (plan-shape layer)
The shape of the plan itself encodes commitment:
- Short plans: the planner's
MaxPlanDepthcap keeps plans atomic enough that "the next replan will pick something different" can't span across weeks of in-fiction time. - Plan reuse: when the new plan's first action matches the
in-flight action, the executor keeps running; only the tail of
the plan rotates. See
bSameFirstStepinUIntentForgeComponent::AdoptNewPlan. - Executor lifecycle: an executor runs to terminal status before the component touches it, regardless of how many replans fire underneath.
Why 2 + 4 are bundled
Both are fact-derivation policies. Keeping them as a single API surface buys three things:
- One schema-side API change. A scalar fact gains both an optional filter config and an optional latched-derivation config in a single schema-asset extension — one editor surface, one place to look.
- The double-smoothing footgun caught by construction. A heavily
smoothed scalar feeding into a tight Schmitt band can oscillate
inside the band forever and never flip the derived bool. The
validator emits a warning (case 10) when the filter's
Alpha < 0.05AND the latch band|Enter - Exit| < 0.05— both halves live together in the same code path so the cross-check is cheap. - Preconditions stay pure. A
FPrecondition_ScalarSchmittwould need per-agent state and would break the planner's "preconditions are pure functions of world state" invariant. Pushing both EMA and hysteresis into fact derivation means everything downstream of the schema is unchanged.
Validator coverage
The archetype validator (UIntentForgeArchetypeValidator) catches the
ten most common authoring footguns:
| # | Condition | Level |
|---|---|---|
| 1 | Filter Alpha outside (0, 1] on an enabled filter | Warning |
| 2 | Filter enabled on a non-Scalar fact | Error |
| 3 | Latch row references a SourceScalarFactId that isn't Scalar | Error |
| 4 | Latch row references an OutputBoolFactId that isn't Bool | Error |
| 5 | Two latch rows share the same OutputBoolFactId | Error |
| 6 | EnterThreshold == ExitThreshold (no hysteresis) | Warning |
| 7 | EnterThreshold > ExitThreshold (inverse "true when high") | Warning |
| 8 | Latch output also written directly by an action effect | Warning |
| 9 | Latch output's bTriggersReplan == false | Warning |
| 10 | Heavy filter (Alpha < 0.05) + tight band (|E-X| < 0.05) | Warning |
Recommended defaults
For a new project, start with the defaults and add fact-layer policies only where the signal is actually noisy:
GoalMomentumBonus = 0.15(component default — Family 1)MinActionCommitTime = 0.3s(component default — Family 3)- Plan reuse (automatic — Family 5)
- Add
FIntentScalarFilterConfigwithAlpha = 0.25to scalar facts whose producers are noisy (raw distances, sensor readings, gameplay events that fire in bursts). — Family 4 - Add a
FIntentLatchedScalarFactConfigrow to the schema where a derived bool oscillates near a threshold (PlayerNear, LowHealth, CanSeeTarget, etc.). Pick a band wide enough that residual filter jitter cannot push the source value across both thresholds. — Family 2
Order of operations when tuning a flap problem: try Family 1 first (one component knob), then Family 4 on the source scalar (one schema knob), then Family 2 to give the derived bool its own committed band (one schema row). If you still see flap after all three, the issue is almost certainly in the goal-scoring functions themselves, not the toolkit.
Worked example
AIntentForgePatrolChaseExample (in IntentForgeExamples) is the
single-actor demonstration of the full layered stack. Drop one in
any level, hit Play.
The agent has two equally-scored goals: Patrol (desired PlayerNear = false) and Chase (desired PlayerNear = true). A
UPatrolChaseDistanceSensor samples the distance to the player every
0.1s. Two actor toggles control how the signal flows from the sensor
to the planner:
| Toggle | Off | On |
|---|---|---|
bEnableFactFiltering | No EMA — raw distance is used. | Distance fact is smoothed with DistanceFilterAlpha. |
bEnableLatchedBool | Sensor writes PlayerNear direct. | Schmitt latch derives PlayerNear from distance. |
To observe pure flap (every safety layer off): set
GoalMomentumBonus = 0 on the AgentComponent, leave both toggles
off. Stand on the threshold and let small position jitter cross it.
The on-screen messages show Goal changed: Patrol → Chase and back
every replan tick — that's signal-layer flap and selection-layer
flap stacking.
Then enable layers one at a time and watch flap disappear. The full walkthrough lives on the Patrol / Chase example page.
Deliberate non-features
References
The full annotated bibliography for this page lives at /docs/research/references — grouped by theme (GOAP foundations, AAA postmortems, cross-paradigm evidence, architectural framings, theoretical context, implementation references, recent practitioner record). Inline citations above link directly to the relevant entry, e.g. [1] for Orkin's 2006 F.E.A.R. paper.
Try this next
The 4-toggle A/B that demonstrates Families 1, 2, 4 in a single drop-in actor.
Position /Ecosystem & PositioningWhy stability is the signature differentiator and what audience it serves.
Paradigm compare /HTN vs GOAPWhere the stability problem manifests differently across the two paradigms.
Multiplayer /NetworkingHow the anti-flap stack interacts with server-authoritative replication.