Instinct GOAP
Describe facts, actions and goals. The agent plans the rest — and tells you exactly why it chose what it chose.
What you get
A GOAP planner for Unity. You write facts, actions and goals in C#. Each tick it searches for the cheapest sequence of actions that reaches the goal worth the most right now, runs the first action of that sequence, and can print the whole calculation.
Priority − Cost wins.Features
Fact<T>, ActionKey, GoalKey. No strings: a rename is a compile error.bool, int, float and any 32-bit enum. All facts of one agent live in a single array, so copying a state during the search is one array copy.async code with if, while and await. When the plan changes, its cancellation token is cancelled and every await simply stops.UnityEngine type, so you can unit-test a domain in a normal .NET project — no Editor, no play mode, milliseconds per run.Where to look
Samples/Farmer.Samples in the package
| Sample | What it shows | Read it when |
|---|---|---|
| Chef planner only | A domain with no MonoBehaviour and no scene | First — it is the shortest complete example |
| Guard full NPC | Senses → facts, actions, goals, executor, policy, host | You are integrating the command API |
| Stalker large | Hunger, radiation, artifacts, combat, trade — many goals competing | You want to see how a domain behaves once it grows |
| Farmer flow API | Bindings, async action classes, GoapBrain, one-line host | You are building a new agent |
Install
Copy the folder, reference the assembly, done. Nothing inside depends on where the folder lives.
Drop the package into your project
Put InstinctGOAP/ anywhere under Assets/. Samples and tests are separate assemblies — deleting either cannot break the framework.
Install UniTask — only for async actions
Package Manager → Add package from git URL:
Reference the assemblies
Add Instinct.GOAP to your gameplay asmdef, plus Instinct.GOAP.Unity if you use async actions, IMoveContext or the MonoBehaviour host. Code without an asmdef of its own already sees both.
Verify in four lines
public sealed class Facts { public static readonly Fact<bool> Done = Fact<bool>.Declare(); } var action = ActionBuilder.Create(ActionKey.Named("Finish")).Effect(Facts.Done, true).Cost(1f).Build(); var goal = GoalBuilder.Create(GoalKey.Named("BeDone")).Satisfy(Facts.Done, true).Priority(1f).Build(); Debug.Log(GoapExplain.Chain(new GoapPlanner().BuildPlan(new[]{ action }, goal, WorldState.For<Facts>()))); // Finish
Then open Window → Analysis → GOAP Graph; it lists every domain in the project.
Without UniTask
Instinct.GOAP.Unity references UniTask unconditionally, so that assembly will not compile without it. The core is unaffected — it has no dependencies at all.Delete Runtime/Unity/GoapActionAsync.cs and GoapAsync.cs, drop "UniTask" from Instinct.GOAP.Unity.asmdef, and remove the Guard and Stalker samples' reference to it. You keep the planner, the agent, step-based behaviours (Instant, Run, Wait, Sequence), UnitySteps.MoveTo, the host and the graph window — only async bodies are lost.
As a UPM package
https://github.com/<user>/<repo>.git?path=Assets/InstinctGOAP
The manifest lives at Assets/InstinctGOAP/package.json. For a UPM-only distribution, rename Samples to Samples~ so consumers import them from Package Manager instead of compiling them.
Quick start — a farmer in 15 minutes
An agent that harvests until it runs out of energy, then walks home to sleep. You never write “if tired, go home” — it plans that itself.
The context — your object
The framework never owns your game state; it reads it and writes back through you. IMoveContext unlocks MoveTo(...) inside actions, ITickContext unlocks Steps.Wait(...).
public sealed class FarmerContext : IMoveContext, ITickContext { public Transform Self, Home, Field; public int Energy = 100; public int CropsGrown; public Vector3 Position => Self.position; public float DeltaTime => Time.deltaTime; public float DistanceTo(Transform t) => Self && t ? Vector3.Distance(Self.position, t.position) : 999f; // The one movement implementation in the project. Actions only say "go there". public bool MoveTowards(Vector3 target) { /* step; true on arrival */ } }
The facts — what the planner may reason about
Each fact is one slot in the agent's state. Declare a fact only if an action changes it or a goal tests it — everything else stays in your own class.
public sealed class FarmerFacts { public static readonly Fact<int> Energy = Fact<int>.Declare(); public static readonly Fact<float> DistanceToHome = Fact<float>.Declare(); public static readonly Fact<float> DistanceToField = Fact<float>.Declare(); public static readonly Fact<int> CropsGrown = Fact<int>.Declare(); }
Fact<T>[] array is invisible to schema discovery, and the failure shows up later as “does not belong to this state's schema”.Keys — identity without strings
public static class FarmerActionKeys { public static readonly ActionKey Harvest = ActionKey.Declare(); public static readonly ActionKey Rest = ActionKey.Declare(); public static readonly ActionKey WalkToField = ActionKey.Declare(); public static readonly ActionKey WalkToHome = ActionKey.Declare(); } public static class FarmerGoalKeys { public static readonly GoalKey WorkTheField = GoalKey.Declare(); public static readonly GoalKey Recover = GoalKey.Declare(); }
Actions — what it changes, and what it does
Setup() is what the action promises the planner. Run() is what it does in the game.
public sealed class Harvest : GoapAction<FarmerContext> { protected override ActionKey Key => FarmerActionKeys.Harvest; protected override void Setup() { Require(FarmerFacts.DistanceToField, Compare.LessOrEqual, 1f); Require(FarmerFacts.Energy, Compare.GreaterOrEqual, 25); Add(FarmerFacts.Energy, -25, min: 0, max: 100); Add(FarmerFacts.CropsGrown, +1); Cost(1f); } protected override async UniTask Run(FarmerContext c) => await Wait(0.5f); } public sealed class WalkToField : GoapAction<FarmerContext> { protected override ActionKey Key => FarmerActionKeys.WalkToField; protected override void Setup() { Require(FarmerFacts.DistanceToField, Compare.Greater, 1f); Effect(FarmerFacts.DistanceToField, 0f); Cost(1f); } protected override async UniTask Run(FarmerContext c) { if (c.Field == null) Fail("field is not assigned"); await MoveTo(c, () => c.Field.position); // target re-read every frame } }
Inside Run | Means |
|---|---|
| Reaching the end of the method | Success — the plan advances |
Fail("reason") | Failure — the plan is dropped and the agent replans |
| A replan happening elsewhere | The token is cancelled; every await stops where it stood. No cleanup code |
| Available awaits | Wait, NextFrame, Until, While, MoveTo, Timeout |
The domain — bind, register, declare goals
public static GoapDomain<FarmerContext> Build() { var d = GoapDomainBuilder<FarmerContext>.For<FarmerFacts>(); // Two-way: world -> state before planning, effects -> world after success. d.Bind(FarmerFacts.Energy, c => c.Energy, (c, v) => c.Energy = v); d.Bind(FarmerFacts.CropsGrown, c => c.CropsGrown, (c, v) => c.CropsGrown = v); // Derived values are read-only: movement changes them, not an effect. d.Bind(FarmerFacts.DistanceToHome, c => c.DistanceTo(c.Home)); d.Bind(FarmerFacts.DistanceToField, c => c.DistanceTo(c.Field)); d.Use(new WalkToField(), new WalkToHome(), new Harvest(), new Rest()); d.Goal(FarmerGoalKeys.WorkTheField) .Satisfy(FarmerFacts.Energy, Compare.LessOrEqual, 15) .RelevantWhen(s => s.Get(FarmerFacts.Energy) >= 45) .Priority(40) .Heuristic(s => (s.Get(FarmerFacts.Energy) - 15) / 60f); d.Goal(FarmerGoalKeys.Recover) .Satisfy(FarmerFacts.Energy, Compare.GreaterOrEqual, 100) .RelevantWhen(s => s.Get(FarmerFacts.Energy) < 45) .Priority(80) .Heuristic(s => (100 - s.Get(FarmerFacts.Energy)) / 100f); return d.Build(); }
>= 45 works, < 45 rests, so every value belongs to some goal. And build the domain per agent: actions hold per-run state, so one shared domain mixes ten NPCs' timers.The host — one call per frame
private void Awake() { _ctx = new FarmerContext { Self = transform, Home = home, Field = field }; _brain = new GoapBrain<FarmerContext>(FarmerBrain.Build(), _ctx, new GoapPlanner(maxIterations: 100, maxDepth: 6), new FarmerPolicy()); var issues = _brain.Domain.Describe(); // null when the domain is clean if (!string.IsNullOrEmpty(issues)) Debug.LogWarning(issues); } private void Update() => _brain.Tick();
Press Play, then open the graph window or log a decision:
Concepts
Each tick: snapshot the world, score every relevant goal by Priority − PlanCost, run the first action of the winner.
Facts & world state
A fact is a typed handle to one slot. The class that declares them is the schema — WorldState.For<GuardFacts>() allocates exactly that many slots.
public static readonly Fact<bool> IntruderVisible = Fact<bool>.Declare(); public static readonly Fact<int> Ammo = Fact<int>.Declare(); public static readonly Fact<Alert> AlertLevel = Fact<Alert>.Declare(); // any enum // Enums compare like real numbers, because they are stored as one: .Require(GuardFacts.AlertLevel, Compare.GreaterOrEqual, Alert.Suspicious) .Effect(GuardFacts.AlertLevel, Alert.Hunting)
| Rule | Why |
|---|---|
Supported types: bool, int, float, 32-bit enums | The state is a plain array of numbers, so reference types and long-backed enums are rejected when you declare them |
| Only what actions write or goals test | Facts size every clone, hash and comparison in the search. Everything else stays in your context |
| Never mutate a state the planner holds | ApplyTo clones first; closed-set states are frozen and throw on write in the Editor and dev builds |
Actions
An action declares three things for the planner: what must be true before it can run, what it changes, and what it costs at this moment. If those three match what the action really does in the game, plans come out correct.
| Effect | Applied as |
|---|---|
| Effect(fact, value) | fact := value |
| Add(fact, delta, min, max) | fact := clamp(pre + delta) — int only |
| Copy(target, source) | target := pre.source |
| Computed(fact, f) | fact := f(pre) |
| DynamicEffect((pre,next) => …) | Anything — but invisible to validation and the graph window |
Every effect resolves against the pre-action state, so declaration order never changes the outcome. Cost runs at every expansion during the search, so keep it cheap and pure; it is clamped to ≥ 0.01, and returning NaN or Infinity is the supported way to say “this action is unavailable right now”.
Goals
| Piece | Role |
|---|---|
| Satisfy(…) | What ends the search. Several calls are ANDed |
| RelevantWhen(…) | The gate — an irrelevant goal is never planned. Your cheapest optimisation |
| Priority(…) | What achieving it is worth; constant or a function of state |
| Heuristic(…) | Estimated remaining cost. Optional, but the single biggest speed factor |
0.05 — admissible, but nearly uninformed, so the search degenerates towards Dijkstra. Assert it in a test: heuristic(start) ≤ plan.TotalCost.The planner
Forward A* from the current state towards the goal condition: binary min-heap on f = g + h, a best-known-cost map per state, pooled nodes. maxDepth caps plan length — above it you get no plan, never a truncated one.
| PlanFailure | Meaning | First thing to check |
|---|---|---|
AlreadySatisfied | The goal was already true | A goal that should have been gated by RelevantWhen |
Unreachable | The search space was exhausted | No action writes the fact the goal tests, or a precondition waits on the world |
DepthLimit | Every branch hit maxDepth | Count the steps the plan really needs; raise maxDepth, or clamp the counter that makes the chain long |
IterationLimit | Budget spent before reaching the goal | A weak or missing heuristic, far more often than a real dead end |
Goal selection & policy
Irrelevant goals are dropped, the rest are sorted by Priority + PolicyBias and planned in that order. The pass stops early as soon as the next goal's best possible score cannot beat the current winner — exact pruning, because cost is never negative.
Commitment — UtilityBias
Bias the current goal so the agent stops oscillating between two near-equal ones.
public float UtilityBias(IGoal g, IGoal current, WorldState s) => current != null && g.Key == current.Key ? (g.Key == GoalKeys.Chase ? 6f : 2f) : 0f;
Interrupts — ShouldAbandonPlan
Drop a still-legal plan the moment something more important happens.
public bool ShouldAbandonPlan(IPlan plan, int step, WorldState s) => s.Get(Facts.PlayerVisible) && plan.Goal.Key != GoalKeys.Chase;
The agent replans when there is no plan, the plan finished, the policy says so, the goal stopped being relevant, or the current step's preconditions no longer hold — and never otherwise.
Five rules that keep plans correct
| Rule | What happens if you break it |
|---|---|
| An effect states what the action really changes | AllShelvesMapped := true on an action that visits one shelf makes every plan one action long |
| A heuristic must never overestimate | Plans stop being optimal, silently |
maxDepth covers the longest chain you expect | You get no plan instead of a shorter one |
| Never ask “which action is this?” with a string | A renamed action stops firing without a compile error |
| Relevance windows tile the state space | A value where no goal is relevant is an agent standing still |
Choose an API
The same planner, two entry points. Pick one per agent; do not mix them inside a single agent.
| Flow API default | Command API | |
|---|---|---|
| You write | One domain file plus action classes | Facts, state provider, executor, command type, host |
| Behaviour | async UniTask Run(ctx) or IStep<TCtx> | Your own code, driven by a command you translate |
| World ↔ facts | Bind(fact, read, write) | A hand-written IWorldStateProvider |
| Entry point | GoapBrain<TCtx> | GoapAgent<TCommand> + GoapAgentHost |
| Dependencies | UniTask, for async bodies | Core only |
| Reach for it when | You are building new AI and own the gameplay code | A command bus, animation FSM or netcode layer already executes orders |
Flow API — the loop
Effect mirroring removes the classic GOAP duplication — Effect(Energy, -25) here and board.Energy -= 25 there. After a successful action its own effects are written back through the two-way bindings, touching only the facts it declared. Disable it per action with NoMirror() when the world applies the change itself.
Behaviour can also be composed from steps, with no UniTask involved:
d.Action(Keys.Deliver) .Require(Facts.HasParcel, true) .Effect(Facts.ParcelDelivered, true) .Cost(1f) .Run(Steps.Sequence( UnitySteps.MoveTo<AiContext>(c => c.Target.position), Steps.Wait<AiContext>(0.4f), Steps.Instant<AiContext>(c => c.DropParcel())));
Command API — feeding an existing pipeline
var agent = new GoapAgent<GuardCommand>( new GoapPlanner(maxIterations: 200, maxDepth: 6), GuardGoals.All(), GuardActions.All(), new GuardStateProvider(board), // IWorldStateProvider: world -> WorldState new GuardExecutor(board)); // IActionExecutor<T>: IAction -> your command GuardCommand command = agent.Tick();
OnSelected fires every tick, not once per action — compare with the previous ActionKey if you need entry semantics. And GoapAgentHost<TCommand> only calls ExecuteCommand when the command changes by EqualityComparer<TCommand>.Default, so make the command a struct or enum.Integration
Where each piece of your game goes, and the four decisions that matter.
Sensing
Flow API — bindings
d.Bind(Facts.HasWeapon, c => c.Inventory.HasWeapon, (c, v) => c.Inventory.HasWeapon = v); // two-way d.Bind(Facts.DistanceToPlayer, c => c.DistanceTo(c.Player)); // read-only
Command API — state provider
public WorldState GetState() => WorldState.For<GuardFacts>() .Set(GuardFacts.AlertLevel, _board.Alert) .Set(GuardFacts.IntruderVisible, _board.CanSeeIntruder) .Set(GuardFacts.DistanceToIntruder, _board.Distance);
Both run every tick, so keep them to field reads. Raycasts, OverlapSphere and pathfinding belong in a sensor on its own slower schedule, writing into the blackboard.
Movement
Implement IMoveContext once per project; from then on actions only say “go there”.
public bool MoveTowards(Vector3 target) { if (Nav.destination != target) Nav.SetDestination(target); return !Nav.pathPending && Nav.remainingDistance <= Nav.stoppingDistance; }
Many agents
| Rule | Why |
|---|---|
| One domain instance per agent | Actions hold per-run state — cancellation tokens, Wait timers, sequence indices |
| One planner per concurrently-ticking agent | The planner keeps a heap, a dictionary and a node pool as mutable fields |
| Stagger the ticks | There is no built-in replan interval. Give each agent a random phase and tick at 5–20 Hz |
One Facts class per archetype | All agents of an archetype share the schema; mixing two schemas throws |
Guard it in CI
[Test] public void DomainIsClean() => Assert.IsNull(brain.Describe(), brain.Describe()); [Test] public void HeuristicNeverOverestimates() { var plan = new GoapPlanner(500, 10).BuildPlan(actions, goal, start); Assert.LessOrEqual(goal.Heuristic(start, null) ?? 0f, plan.TotalCost); } [Test] public void EveryStateHasARelevantGoal() { for (int energy = 0; energy <= 100; energy++) Assert.IsTrue(goals.Any(g => g.IsRelevant(StateWith(energy))), $"gap at {energy}"); }
Tick() replan. Never serialise WorldState, IPlan or keys.Debugging
Nothing is ever “stuck in a state”. The agent either chose something you did not expect, or chose nothing — and both have a printable answer.
Explain a decision
Debug.Log(brain.ExplainDecision()); // flow API Debug.Log(GoapExplain.Decision(agent.GoalEvaluations, agent.CurrentPlan)); // command API
| Marker | Meaning | When it surprises you |
|---|---|---|
ok | Planned and scored | — |
.. | Pruned — its best case could not beat the winner | Priority too low, or the winner's plan is unrealistically cheap |
!! | Relevant, but no plan | See the PlanFailure table in Concepts |
-- | RelevantWhen returned false | The relevance window has a gap |
| Call | Answers |
|---|---|
| GoapExplain.Decision(evals, plan) | Why this goal won, and why each other lost |
| GoapExplain.Chain(plan) | A -> B -> C |
| GoapExplain.Applicability(actions, state) | Which actions are blocked right now, and by which condition |
| GoapExplain.BlockedBy(action, state) | The unmet preconditions of one action |
| GoapExplain.State<TFacts>(state) | What the agent thinks the world is — enums by member name |
| GoapExplain.Failure(goal, failure) | The failure as a sentence, with its usual cause |
The graph window
Window → Analysis → GOAP Graph — actions → facts → goals, with the running plan highlighted while you play.
IGoapGraphSource — about 15 lines — and it appears in the dropdown.Alt+S saves, Alt+R resets.public sealed class GuardGraphSource : GoapGraphSource { public override IReadOnlyList<IAction> Actions => GuardActions.All(); public override IReadOnlyList<IGoal> Goals => GuardGoals.All(); public override IGoapAgentView FindLiveAgent() => Object.FindObjectOfType<GuardHost>()?.Agent; }
PredicateCondition and DynamicEffect have no subject fact, so they produce no edge. A node that looks disconnected but works is usually one of those.Validate the domain
var report = new DomainBuilder() .AddActions(GuardActions.All()) .AddGoals(GuardGoals.All()) .DeclaredGoalsIn(typeof(GuardGoalKeys)) // declared but never registered -> error .DeclaredActionsIn(typeof(GuardActionKeys)) .Describe(); // null when clean var report = domain.Describe(); // flow API: already wired
Catches duplicate keys, keys declared but never registered, actions with no effects, goals with no conditions, and goals that no action can satisfy.
Performance
Numbers below come from the shipped core compiled as a plain .NET 8 library and run on a desktop CPU. Unity's Mono and IL2CPP are roughly 1.5–3× slower, so read these as the best case.
| Domain | Situation | Time | Allocated | How often |
|---|---|---|---|---|
| 10 facts · 10 actions · 1 goal | One plan, 8 actions long | 0.50 ms | 54 KB | per plan |
| 57 facts · 18 actions · 15 goals a real agent this size exists | Full replan — 7 of 15 goals survived pruning | 0.35 ms | 34 KB | when the plan becomes invalid |
| Steady tick, plan still valid | 6 µs | 752 B | every tick | |
| No plan exists | 0.85 ms | 89 KB | every single tick |
Time is rarely the problem
A dozen agents replanning a few times a second costs well under a millisecond per frame in total.
Garbage is
Each step of the search copies the state, and each tick builds a fresh snapshot. Fifty agents at 60 fps produce about 2 MB per second from snapshots alone, before any replanning.
Idle, Patrol), and this cannot happen.Budgets
| Dial | How to pick it |
|---|---|
maxDepth | Count the longest chain you expect the agent to build, and add 2. Above the limit you get no plan, not a shorter one. Worst-case cost grows with (applicable actions)depth — this is the dangerous dial |
maxIterations | Start at ~50 × maxDepth, then check LastExpandedNodes in your worst case and leave 2–3× headroom |
How to make planning cheaper
- Give every goal a heuristic. Without one the search checks far more states than it needs to. This is the largest single win in any domain past a handful of actions.
- Declare fewer facts. The fact count is the size of the state, and the state is copied, hashed and compared at every step of the search.
- Use
RelevantWhenaggressively. It is checked before any search starts, so an irrelevant goal costs nothing at all. - Prefer
Require(fact, …)over a lambda. A lambda is a delegate call at every step, and neither the validator nor the graph window can see through it. - Keep
Costcheap. It runs at every step of the search, not once per frame — no distance loops, noGetComponent, no allocation. - Give each NPC type its own domain. One 40-action domain shared by four types makes every agent search through actions it can never use.
- Spread the ticks out. Give each agent a random phase offset and tick the distant ones at 5–10 Hz; keep full rate only near the player.
Threads. A planner instance keeps mutable working memory, so it cannot be used from two threads at once. Give every agent that ticks concurrently its own GoapPlanner; agents that tick one after another on the main thread can share a single instance.
API reference
Three assemblies: Instinct.GOAP is the planner and needs no Unity reference, Instinct.GOAP.Unity adds async actions, movement and the MonoBehaviour host, Instinct.GOAP.EditorTools is the graph window.
Facts & state
| Member | Description |
|---|---|
| Fact<T>.Declare() | Declares a fact, named after the field. T = bool, int, float or a ≤32-bit enum, otherwise it throws |
| WorldState.For<TFacts>() · For(Type) | A state sized to that schema |
| T Get<T>(Fact<T>) | Reads a slot; throws if the fact belongs to another schema |
| WorldState Set<T>(Fact<T>, T) | Writes and returns this; throws if the state is frozen (Editor / dev / DEBUG) |
| bool Has<T>(Fact<T>) | Slot exists and was written |
| Clone() · Freeze() · IsFrozen · SlotCount | Deep copy · mark read-only · query · slot count |
| FactSchema.Of(Type) · FactSchema<T>.Facts | The ordered IFact[] behind a schema |
Identity
| Member | Description |
|---|---|
| ActionKey.Declare() · GoalKey.Declare() | A new unique key, named after the field |
| ActionKey.Of<T>() · Of(Type) | The class is the identity; cached per type |
| ActionKey.Named(string) | Interned — the same string returns the same key |
| Id · DebugName · IsNone | Id == 0 means “none” |
| KeyRegistry.GoalKeysIn(Type) · ActionKeysIn(Type) | Every key declared in a keys class, cached |
Builders
| ActionBuilder | Description |
|---|---|
| Create(ActionKey) · Create() · For<T>() | Start a builder; Create() names it after the calling member |
| Require(fact, value) · (fact, op, value) · (predicate) | Preconditions |
| Effect · Add · Copy · Computed · DynamicEffect | Effects |
| Cost(float) · Cost((state, ctx) => float) | Clamped ≥ 0.01; NaN/∞ makes the planner skip the action |
| Build() | IAction |
| GoalBuilder | Description |
|---|---|
| Create(GoalKey) · Create() | Start a builder |
| Satisfy(fact, value) · (fact, op, value) · (predicate) | Goal conditions, ANDed |
| RelevantWhen(state => bool) | Gate; default true |
| Priority(float) · Priority(state => float) | Default 1 |
| Heuristic(state => float?) · (state, ctx) => float? | Default null → the planner substitutes 0.05 |
| Build() | IGoal, also IInspectableGoal |
| DomainBuilder | Description |
|---|---|
| AddAction(s) · AddGoal(s) | Register what the domain contains |
| DeclaredGoalsIn(Type) · DeclaredActionsIn(Type) | Cross-check against a keys class |
| Validate() · Describe() | IReadOnlyList<Issue> · a string, null when clean |
Planner & agent
| Member | Description |
|---|---|
| new GoapPlanner(maxIterations = 200, maxDepth = 6) | Forward A*. Not thread-safe |
| BuildPlan(actions, goal, start, ctx = null) | null on failure — read LastFailure |
| LastFailure · LastExpandedNodes | Why it failed · how hard it worked |
| new GoapAgent<TCommand>(planner, goals, actions, provider, executor, ctx, planningCtx) | The command-API agent |
| TCommand Tick() | Snapshot → replan if needed → OnSelected → Translate |
| NotifyActionComplete(bool) · ForceReplan() | Advance the plan · clear it |
| CurrentPlan · CurrentGoal · CurrentAction · PlanStep | Live view of the agent |
| GoalEvaluations · LastPlannedGoals | The last decision per goal · how many goals ran A* |
| Policy · Fallback | IAgentPolicy · the command returned when there is no plan |
Flow API
| Member | Description |
|---|---|
| GoapDomainBuilder<TCtx>.For<TFacts>() · For(Type) | Picks the fact schema |
| Bind(fact, read) · Bind(fact, read, write) | Read-only · two-way (two-way enables effect mirroring) |
| Action(ActionKey) · Goal(GoalKey) | Fluent action and goal builders inside the domain |
| Use(params GoapAction<TCtx>[]) | Register action classes |
| Run · Instant · RunAsync · OnDone · NoMirror | The game side of an action |
| GoapDomain<TCtx>: Actions · Goals · Bindings · Goal(key) · Action(key) · Describe() | Build one per agent |
| new GoapBrain<TCtx>(domain, ctx, planner = null, policy = null) | The whole agent under one roof |
| ActionStatus Tick() · ForceReplan() · PlanChain() · ExplainDecision() | One call per frame, plus debug strings |
| Steps.Instant · Run · Wait · Sequence | Behaviour steps that need no Unity types (Wait needs ITickContext) |
| UnitySteps.MoveTo(ctx => Vector3 · Transform) | Needs IMoveContext |
Enums you read and return
| Type | Values | Where it shows up |
|---|---|---|
| Compare | Equal · NotEqual · Greater · GreaterOrEqual · Less · LessOrEqual | Require and Satisfy. Ordered operators on a bool fact throw when you build the condition |
| PlanFailure | None · AlreadySatisfied · Unreachable · IterationLimit · DepthLimit | planner.LastFailure after BuildPlan returned null |
| ActionStatus | Running · Success · Failure | What a step or GoapBrain.Tick() returns each frame |
| Issue.Level | Warning · Error | Each entry from DomainBuilder.Validate() |
Interfaces you implement
| Interface | Members | You write one when |
|---|---|---|
| IWorldStateProvider | WorldState GetState() | Using the command API — it runs every tick, so keep it to field reads |
| IActionExecutor<TCommand> | Translate(state, action, ctx) · OnSelected(...) · OnCompleted(action, ctx, success) | Using the command API. OnSelected fires every tick, not once per action |
| IAgentPolicy | ShouldAbandonPlan(plan, step, state) · UtilityBias(goal, current, state) · OnPlanCleared(ctx) | You need interrupts, or the agent keeps switching goals |
| IStep<TCtx> | OnEnter(ctx) · Tick(ctx) · OnExit(ctx, success) | Writing a reusable behaviour brick without UniTask |
| IMoveContext | Vector3 Position · bool MoveTowards(target) | Once per project, so actions can say MoveTo(...) |
| ITickContext | float DeltaTime | You use Steps.Wait |
| IGoapGraphSource | Actions · Goals · FindLiveAgent() · BadgesFor(goal) | You want your domain in the graph window (about 15 lines) |
| IAgentContext | marker, no members | You want your own context object passed through to the executor |
Unity layer
| Member | Description |
|---|---|
| abstract GoapAction<TCtx> | Setup() for the planner, Run(ctx) for the game. Requires UniTask |
| Fail(reason) · Ct | Report failure · the cancellation token of this run |
| Wait · NextFrame · Until · While · MoveTo · Timeout | Awaits bound to Ct |
| AsyncStep<TCtx> | Runs an async body as a step; turns GoapActionFailed and unhandled exceptions into Failure |
| IMoveContext | Position · MoveTowards(target) — true on arrival |
| abstract GoapAgentHost<TCommand> | MonoBehaviour host for the command API; executes only when the command changes |
Troubleshooting
Symptom → cause → fix, in the order these actually happen.
| Symptom | Cause | Fix |
|---|---|---|
| The agent stands still and the frame time spikes | No goal produces a plan, so it replans every frame | Log Explain; add an always-satisfiable floor goal |
Every goal prints -- not relevant | The relevance windows leave a gap at this value | Make the windows tile the whole range |
| “Fact 'x' does not belong to this state's schema” | Wrong Facts class — or facts declared in an array, which schema discovery cannot see | Build the state from the right schema; give each fact its own static field |
| Every plan is one action long | An effect claims the whole job is done | Make the effect describe the real result: Add(+1) plus a GreaterOrEqual goal |
| The plan is not the cheapest one | The heuristic overestimates, or Cost is not a pure function of state | Assert admissibility in a test; remove randomness and Time.time from cost |
| The agent flickers between two goals | Near-equal utilities | UtilityBias stickiness; add hysteresis in the sensor, not the planner |
| The agent ignores an emergency | No interrupt configured | ShouldAbandonPlan in the policy |
| Effects never reach the world (flow API) | Read-only binding, NoMirror(), or the action failed | Give the binding a writer; mirroring happens on success only |
| An async action never finishes | The body never returns, or failure was signalled the wrong way | Return for success, Fail() for failure; never swallow OperationCanceledException |
| “This WorldState is frozen” | You wrote to a state the planner holds | Write to the next state in Apply, or Clone() first |
| Two actions behave as one | ActionKey.Named interns by string | Use Declare() or Of<T>(); run Validate() |
| A goal never fires though it looks right | It was written but never added to the goal list | DeclaredGoalsIn(typeof(MyGoalKeys)) |
| “The type or namespace 'Cysharp' could not be found” | Instinct.GOAP.Unity requires UniTask | Install it, or strip the async layer |
| The graph window is empty | No IGoapGraphSource, or one threw while constructing | Add one; check the console for the warning |
| Ten NPCs share one NPC's timers | A single static domain handed to every agent | Build one domain per agent in Awake |
Still stuck — collect this first
Four values explain almost every misbehaviour, and all four are one line each. Log them together at the moment the agent does the wrong thing:
Debug.Log(brain.ExplainDecision()); // what every goal scored, and why it lost Debug.Log(GoapExplain.State<FarmerFacts>(provider.GetState())); // the world as the planner sees it Debug.Log(brain.Domain.Describe() ?? "domain is clean"); // mistakes in the domain itself Debug.Log($"planned {agent.LastPlannedGoals} goals, expanded {planner.LastExpandedNodes} nodes");
| What you see there | What it means |
|---|---|
| The state does not match the game | A sensing bug. Nothing after this point is worth reading until it is fixed |
Describe() is not null | The domain itself is broken — fix that before looking at behaviour |
LastPlannedGoals equals your goal count every tick | Nothing is being pruned, and probably nothing is planning either |
LastExpandedNodes near maxIterations | The search is running out of budget — add a heuristic before raising the limit |