Unity · GOAP planner · v1.0

Instinct GOAP

Describe facts, actions and goals. The agent plans the rest — and tells you exactly why it chose what it chose.

Unity 2022.3+ Core runs without Unity UniTask for async actions MIT
01Overview

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.

Sense
World → facts
A snapshot of the few values the planner may reason about.
Score
Pick a goal
Every relevant goal is planned; best Priority − Cost wins.
Plan
Forward A*
The cheapest chain of actions from now to that goal.
Act
Run step one
Only the first action runs; the rest is re-derived as the world moves.

Features

Planner
Forward A*
Searches from the current state towards the goal with fixed depth and iteration budgets. When it cannot finish it says which limit stopped it, and never returns half a plan.
Core
Typed identity
Fact<T>, ActionKey, GoalKey. No strings: a rename is a compile error.
Core
Typed facts
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.
Flow API
Two-way bindings
One declaration feeds the planner and writes an action's effects back into your world.
Flow API
Async actions
An action body is ordinary async code with if, while and await. When the plan changes, its cancellation token is cancelled and every await simply stops.
Tooling
Explain & validate
Prints what every goal scored and why the others lost, and reports domain mistakes — goals nothing can reach, actions that change nothing — before you press Play.
Tooling
Graph window
Actions → facts → goals, with the running plan highlighted while you play.
Core
Runs without Unity
The core references no 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 in the package

SampleWhat it showsRead it when
Chef planner onlyA domain with no MonoBehaviour and no sceneFirst — it is the shortest complete example
Guard full NPCSenses → facts, actions, goals, executor, policy, hostYou are integrating the command API
Stalker largeHunger, radiation, artifacts, combat, trade — many goals competingYou want to see how a domain behaves once it grows
Farmer flow APIBindings, async action classes, GoapBrain, one-line hostYou are building a new agent
02Setup

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:

https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask#2.5.9

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

SmokeTest.cs
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

Heads up: 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

Package Manager → Add package from git URL
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.

03Tutorial

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(...).

FarmerContext.cs
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.

FarmerFacts.cs
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();
}
Declare each fact as its own static field. A 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

FarmerKeys.cs
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.

FarmerActions.cs
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 RunMeans
Reaching the end of the methodSuccess — the plan advances
Fail("reason")Failure — the plan is dropped and the agent replans
A replan happening elsewhereThe token is cancelled; every await stops where it stood. No cleanup code
Available awaitsWait, NextFrame, Until, While, MoveTo, Timeout

The domain — bind, register, declare goals

FarmerBrain.cs
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();
}
Two rules to internalise now. Relevance windows must not leave a gap — >= 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

FarmerHost.cs
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:

chosen: WorkTheField cost=2 plan=WalkToField -> Harvest ok WorkTheField 40 - 2 = 38 (len 2) -- Recover not relevant
04Concepts

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.

Declaring facts
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)
RuleWhy
Supported types: bool, int, float, 32-bit enumsThe 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 testFacts size every clone, hash and comparison in the search. Everything else stays in your context
Never mutate a state the planner holdsApplyTo 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.

EffectApplied 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

PieceRole
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
The heuristic contract. A* returns an optimal plan only while the heuristic never overestimates the true remaining cost. With no heuristic the planner substitutes 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.

PlanFailureMeaningFirst thing to check
AlreadySatisfiedThe goal was already trueA goal that should have been gated by RelevantWhen
UnreachableThe search space was exhaustedNo action writes the fact the goal tests, or a precondition waits on the world
DepthLimitEvery branch hit maxDepthCount the steps the plan really needs; raise maxDepth, or clamp the counter that makes the chain long
IterationLimitBudget spent before reaching the goalA 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.

Policy
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.

Policy
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

RuleWhat happens if you break it
An effect states what the action really changesAllShelvesMapped := true on an action that visits one shelf makes every plan one action long
A heuristic must never overestimatePlans stop being optimal, silently
maxDepth covers the longest chain you expectYou get no plan instead of a shorter one
Never ask “which action is this?” with a stringA renamed action stops firing without a compile error
Relevance windows tile the state spaceA value where no goal is relevant is an agent standing still
05Architecture

Choose an API

The same planner, two entry points. Pick one per agent; do not mix them inside a single agent.

Flow API defaultCommand API
You writeOne domain file plus action classesFacts, state provider, executor, command type, host
Behaviourasync UniTask Run(ctx) or IStep<TCtx>Your own code, driven by a command you translate
World ↔ factsBind(fact, read, write)A hand-written IWorldStateProvider
Entry pointGoapBrain<TCtx>GoapAgent<TCommand> + GoapAgentHost
DependenciesUniTask, for async bodiesCore only
Reach for it whenYou are building new AI and own the gameplay codeA command bus, animation FSM or netcode layer already executes orders

Flow API — the loop

GoapBrain.Tick() └── GoapAgent.Tick() snapshot → replan if needed → current action ├── executor.OnSelected(...) new action? OnExit(previous, false) + OnEnter(new) └── returns the action └── action.Tick(ctx) → Running | Success | Failure └── on Success/Failure: agent.NotifyActionComplete(...) ├── action.OnExit(ctx, success) ├── on success: effects mirrored into the world through write-bindings └── on failure: plan cleared → replan next frame

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:

Step-composed action
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

GuardAgent.cs
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();
Two gotchas. 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.
06Guide

Integration

Where each piece of your game goes, and the four decisions that matter.

your world framework ────────── ───────── Transforms, NavMesh, HP, → Context (your class) never touched by the planner inventory, senses, memory the few values worth → Fact<T> declarations one slot each in the agent state reasoning about sensing → Bind(fact, read) or IWorldStateProvider.GetState() acting → GoapAction<TCtx>.Run or IActionExecutor.Translate writing results back → Bind(fact, read, write) or your own OnCompleted interrupts / commitment → IAgentPolicy per-frame drive → GoapBrain.Tick() or GoapAgentHost<TCommand>

Sensing

Flow API — bindings

Domain
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

GuardStateProvider.cs
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”.

AiContext.cs
public bool MoveTowards(Vector3 target)
{
    if (Nav.destination != target) Nav.SetDestination(target);
    return !Nav.pathPending && Nav.remainingDistance <= Nav.stoppingDistance;
}

Many agents

RuleWhy
One domain instance per agentActions hold per-run state — cancellation tokens, Wait timers, sequence indices
One planner per concurrently-ticking agentThe planner keeps a heap, a dictionary and a node pool as mutable fields
Stagger the ticksThere is no built-in replan interval. Give each agent a random phase and tick at 5–20 Hz
One Facts class per archetypeAll agents of an archetype share the schema; mixing two schemas throws

Guard it in CI

DomainTests.cs
[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}");
}
Saving and loading: the agent holds nothing worth serialising — a plan is rebuilt from the world in one tick. Save your context, restore it, let the next Tick() replan. Never serialise WorldState, IPlan or keys.
07Tooling

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

Anywhere in your host
Debug.Log(brain.ExplainDecision());                                  // flow API
Debug.Log(GoapExplain.Decision(agent.GoalEvaluations, agent.CurrentPlan)); // command API
chosen: CatchIntruder cost=2.75 plan=ChaseIntruder -> GrabIntruder ok CatchIntruder 100 - 2.8 = 97.2 (len 2) .. Patrol skipped, best case 10 could not beat the winner !! CallBackup no plan (Unreachable) -- InvestigateNoise not relevant
MarkerMeaningWhen it surprises you
okPlanned and scored—
..Pruned — its best case could not beat the winnerPriority too low, or the winner's plan is unrealistically cheap
!!Relevant, but no planSee the PlanFailure table in Concepts
--RelevantWhen returned falseThe relevance window has a gap
CallAnswers
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
Start with the state. Nine times out of ten a baffling decision is a sensing bug: the snapshot the planner saw was not the world you were looking at.

The graph window

Window → Analysis → GOAP Graph — actions → facts → goals, with the running plan highlighted while you play.

Discovery
Every domain
Implement IGoapGraphSource — about 15 lines — and it appears in the dropdown.
Inspect
Node details
Click any node for its full preconditions, effects and traits.
Focus
Search & hover
Search highlights matches; hover fades everything a node is not wired to.
Layout
Yours to arrange
Drag nodes; positions persist per domain. Alt+S saves, Alt+R resets.
GuardGraphSource.cs
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;
}
What the graph cannot draw: 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

Awake, and again in an EditMode test
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.

08Budgets

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.

DomainSituationTimeAllocatedHow often
10 facts · 10 actions · 1 goalOne plan, 8 actions long0.50 ms54 KBper plan
57 facts · 18 actions · 15 goals
a real agent this size exists
Full replan — 7 of 15 goals survived pruning0.35 ms34 KBwhen the plan becomes invalid
Steady tick, plan still valid6 µs752 Bevery tick
No plan exists0.85 ms89 KBevery 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.

When no plan exists, the cost repeats every frame. The agent replans whenever it has no plan. If no goal can produce one, that means a full search over every relevant goal, every tick, for as long as the situation lasts — the most expensive path in the whole system, on repeat. On screen it looks like a stuck NPC and a stuttering frame time. Keep one low-priority goal that is always relevant and always satisfiable (Idle, Patrol), and this cannot happen.

Budgets

DialHow to pick it
maxDepthCount 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
maxIterationsStart at ~50 × maxDepth, then check LastExpandedNodes in your worst case and leave 2–3× headroom

How to make planning cheaper

  1. 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.
  2. 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.
  3. Use RelevantWhen aggressively. It is checked before any search starts, so an irrelevant goal costs nothing at all.
  4. 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.
  5. Keep Cost cheap. It runs at every step of the search, not once per frame — no distance loops, no GetComponent, no allocation.
  6. 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.
  7. 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.

09Reference

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

MemberDescription
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 · SlotCountDeep copy · mark read-only · query · slot count
FactSchema.Of(Type) · FactSchema<T>.FactsThe ordered IFact[] behind a schema

Identity

MemberDescription
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 · IsNoneId == 0 means “none”
KeyRegistry.GoalKeysIn(Type) · ActionKeysIn(Type)Every key declared in a keys class, cached

Builders

ActionBuilderDescription
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 · DynamicEffectEffects
Cost(float) · Cost((state, ctx) => float)Clamped ≥ 0.01; NaN/∞ makes the planner skip the action
Build()IAction
GoalBuilderDescription
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
DomainBuilderDescription
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

MemberDescription
new GoapPlanner(maxIterations = 200, maxDepth = 6)Forward A*. Not thread-safe
BuildPlan(actions, goal, start, ctx = null)null on failure — read LastFailure
LastFailure · LastExpandedNodesWhy 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 · PlanStepLive view of the agent
GoalEvaluations · LastPlannedGoalsThe last decision per goal · how many goals ran A*
Policy · FallbackIAgentPolicy · the command returned when there is no plan

Flow API

MemberDescription
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 · NoMirrorThe 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 · SequenceBehaviour steps that need no Unity types (Wait needs ITickContext)
UnitySteps.MoveTo(ctx => Vector3 · Transform)Needs IMoveContext

Enums you read and return

TypeValuesWhere it shows up
CompareEqual · NotEqual · Greater · GreaterOrEqual · Less · LessOrEqualRequire and Satisfy. Ordered operators on a bool fact throw when you build the condition
PlanFailureNone · AlreadySatisfied · Unreachable · IterationLimit · DepthLimitplanner.LastFailure after BuildPlan returned null
ActionStatusRunning · Success · FailureWhat a step or GoapBrain.Tick() returns each frame
Issue.LevelWarning · ErrorEach entry from DomainBuilder.Validate()

Interfaces you implement

InterfaceMembersYou write one when
IWorldStateProviderWorldState 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
IAgentPolicyShouldAbandonPlan(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
IMoveContextVector3 Position · bool MoveTowards(target)Once per project, so actions can say MoveTo(...)
ITickContextfloat DeltaTimeYou use Steps.Wait
IGoapGraphSourceActions · Goals · FindLiveAgent() · BadgesFor(goal)You want your domain in the graph window (about 15 lines)
IAgentContextmarker, no membersYou want your own context object passed through to the executor

Unity layer

MemberDescription
abstract GoapAction<TCtx>Setup() for the planner, Run(ctx) for the game. Requires UniTask
Fail(reason) · CtReport failure · the cancellation token of this run
Wait · NextFrame · Until · While · MoveTo · TimeoutAwaits bound to Ct
AsyncStep<TCtx>Runs an async body as a step; turns GoapActionFailed and unhandled exceptions into Failure
IMoveContextPosition · MoveTowards(target) — true on arrival
abstract GoapAgentHost<TCommand>MonoBehaviour host for the command API; executes only when the command changes
10Support

Troubleshooting

Symptom → cause → fix, in the order these actually happen.

SymptomCauseFix
The agent stands still and the frame time spikesNo goal produces a plan, so it replans every frameLog Explain; add an always-satisfiable floor goal
Every goal prints -- not relevantThe relevance windows leave a gap at this valueMake 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 seeBuild the state from the right schema; give each fact its own static field
Every plan is one action longAn effect claims the whole job is doneMake the effect describe the real result: Add(+1) plus a GreaterOrEqual goal
The plan is not the cheapest oneThe heuristic overestimates, or Cost is not a pure function of stateAssert admissibility in a test; remove randomness and Time.time from cost
The agent flickers between two goalsNear-equal utilitiesUtilityBias stickiness; add hysteresis in the sensor, not the planner
The agent ignores an emergencyNo interrupt configuredShouldAbandonPlan in the policy
Effects never reach the world (flow API)Read-only binding, NoMirror(), or the action failedGive the binding a writer; mirroring happens on success only
An async action never finishesThe body never returns, or failure was signalled the wrong wayReturn for success, Fail() for failure; never swallow OperationCanceledException
“This WorldState is frozen”You wrote to a state the planner holdsWrite to the next state in Apply, or Clone() first
Two actions behave as oneActionKey.Named interns by stringUse Declare() or Of<T>(); run Validate()
A goal never fires though it looks rightIt was written but never added to the goal listDeclaredGoalsIn(typeof(MyGoalKeys))
“The type or namespace 'Cysharp' could not be found”Instinct.GOAP.Unity requires UniTaskInstall it, or strip the async layer
The graph window is emptyNo IGoapGraphSource, or one threw while constructingAdd one; check the console for the warning
Ten NPCs share one NPC's timersA single static domain handed to every agentBuild 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:

Diagnostics.cs
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 thereWhat it means
The state does not match the gameA sensing bug. Nothing after this point is worth reading until it is fixed
Describe() is not nullThe domain itself is broken — fix that before looking at behaviour
LastPlannedGoals equals your goal count every tickNothing is being pruned, and probably nothing is planning either
LastExpandedNodes near maxIterationsThe search is running out of budget — add a heuristic before raising the limit