Skip to content

GOAP Planning

AzureDoom edited this page Aug 29, 2026 · 1 revision

GOAP Planning

AzureCortex's GOAP (Goal-Oriented Action Planning) layer is intentionally thin: it ships no scoring logic at all — every mod's notion of "what's a good goal right now" is domain-specific. What it does provide is the bookkeeping around committing to a goal, deciding when to reconsider, and feeding failure information back into scoring.

The pieces

Type Role
Goal Marker interface your goal-type enum implements.
GoalPlanner<E, G> Your scoring strategy: chooseGoal(agent, blackboard, cooldowns) -> PlannedGoal<E, G>.
PlannedGoal<E, G> An immutable committed goal: type, score, commit-time window, target/destination, urgency, reason.
GoalExecutor Writes a PlannedGoal to the blackboard; provides the shouldReplan gate.
GoalUrgency LOW / NORMAL / HIGH / EMERGENCY, only EMERGENCY has special handling (bypasses min-commit).
PlanFailureReason Structured codes an action reports back when it can't make progress.
PlanFeedback<G> What CortexRuntime writes to the blackboard from an action's non-NONE failure reason.
GoalFailureCooldowns<G> Per-goal-type score penalty that decays over time after a recorded failure.
WorldStateSnapshot / PlanInvalidation Coarse, goal-agnostic "does the world still look like it did when I committed to this?" check.
EmergencyDetector Cheap, pre-planner probes (on fire, critical health, ...) that can force an immediate replan.

Declaring your goal type

public enum MyGoalType implements Goal {
    NONE,
    WANDER,
    INVESTIGATE,
    HUNT_TARGET,
    EAT_TO_HEAL;

    @Override
    public boolean isNone() {
        return this == NONE;
    }
}

isNone() matters: PlannedGoal.isNone() checks it, and framework code (like the emergency-vs-passive check in the example entity) relies on being able to tell "no real goal yet" apart from an ordinary low-priority goal.

Writing a GoalPlanner

A planner is a plain priority chain, or a weighted scorer, AzureCortex doesn't care. The bundled example (CortexZombieGoalPlanner) is a simple linear chain:

public final class MyGoalPlanner implements GoalPlanner<MyEntity, MyGoalType> {

    @Override
    public PlannedGoal<MyEntity, MyGoalType> chooseGoal(MyEntity agent, Blackboard blackboard, CooldownTracker cooldowns) {
        var currentTick = (int) agent.level().getGameTime();

        // 1. Emergency-tier check first (highest priority, bypasses commit locks)
        if (isCriticallyWoundedAndCanHeal(agent)) {
            return PlannedGoal.of(
                MyGoalType.EAT_TO_HEAL, 100f, currentTick,
                10, 100,                       // minCommitTicks, maxCommitTicks
                null, null,
                GoalUrgency.EMERGENCY, true,
                "Wounded and carrying a golden apple"
            );
        }

        // 2. Read failure feedback and apply score penalties
        var failureCooldowns = GoalFailureCooldowns.<MyGoalType>getOrCreate(blackboard);
        failureCooldowns.evictExpired(currentTick);

        var feedback = (PlanFeedback<MyGoalType>) blackboard.get(CommonBlackboardKeys.LAST_PLAN_FEEDBACK);
        if (feedback != null && feedback.isFresh(currentTick)
                && (feedback.reason() == PlanFailureReason.FAILED_STUCK
                    || feedback.reason() == PlanFailureReason.FAILED_NO_PATH)) {
            failureCooldowns.recordFailure(MyGoalType.HUNT_TARGET, currentTick);
        }

        // 3. Ordinary candidates, in priority order
        var target = blackboard.get(CommonBlackboardKeys.TARGET);
        if (target != null && target.isAlive()) {
            var score = 50f - failureCooldowns.getPenalty(MyGoalType.HUNT_TARGET, currentTick);
            return PlannedGoal.of(MyGoalType.HUNT_TARGET, score, currentTick, 20, 60,
                target, null, GoalUrgency.NORMAL, true, "Live target acquired");
        }

        // ... investigate, wander, etc.

        return PlannedGoal.of(MyGoalType.WANDER, 10f, currentTick, 20, 200,
            null, null, GoalUrgency.LOW, true, "Nothing better to do");
    }
}

Wiring the planner into your entity's tick

The planner isn't ticked by CortexRuntime automatically, you call it yourself, gated by GoalExecutor.shouldReplan, typically right before runtime.tick():

@Override
public void tick() {
    super.tick();
    if (!level().isClientSide() && isAlive() && !isNoAi()) {
        tickGoalPlanner();
        runtime.tick();
    }
}

private void tickGoalPlanner() {
    var blackboard = runtime.getBlackboard();
    var cooldowns = runtime.getCooldowns();
    var currentTick = (int) level().getGameTime();

    var activeGoalType = blackboard.get(CommonBlackboardKeys.ACTIVE_GOAL_TYPE);
    var isPassive = activeGoalType == null || activeGoalType == MyGoalType.NONE || activeGoalType == MyGoalType.WANDER;
    var reactiveReplan = isPassive && blackboard.has(CommonBlackboardKeys.TARGET);

    var preplanUrgency = EmergencyDetector.detectPreplanUrgency(this, emergencyProbes);

    if (!reactiveReplan && preplanUrgency == null && cooldowns.isOnCooldown(CommonBlackboardKeys.GOAL_REPLAN))
        return;

    if (!reactiveReplan && !GoalExecutor.shouldReplan(blackboard, currentTick, preplanUrgency, this))
        return;

    cooldowns.set(CommonBlackboardKeys.GOAL_REPLAN, 20);

    var newGoal = goalPlanner.chooseGoal(this, blackboard, cooldowns);
    GoalExecutor.apply(this, blackboard, newGoal);
}

This shape, reactiveReplan for "was passively wandering and just noticed a target," an EmergencyDetector check, then falling back to a plain replan cooldown, is a convention from the bundled example, not something the framework enforces. It exists so an idle agent reacts to a newly-acquired target immediately rather than waiting out a stale 20-tick cooldown.

The replan gate: GoalExecutor.shouldReplan

This is the actual policy governing when the planner is allowed to run:

  1. Emergency override, a candidate urgency of EMERGENCY always forces a replan, ignoring commit timers.
  2. Min-commit lock, suppressed until PlannedGoal.canReplan(tick) (i.e. minCommitTicks have elapsed). Prevents thrashing between two closely-scored goals.
  3. Max-commit expiry, PlannedGoal.isExpired(tick) forces a replan regardless, so a goal can't run forever if the agent gets stuck in a state that never self-terminates.
  4. World-state invalidation, see below; forces a replan the moment the facts that justified the current plan stop being true, even if nothing has technically failed yet.
  5. Normal replan, otherwise, gated by whatever cooldown the caller maintains (CommonBlackboardKeys.GOAL_REPLAN is the conventional one).

Reactive feedback: PlanFeedback / PlanFailureReason

Actions never write failure feedback to the blackboard themselves. Instead, Action.tick() returns ActionOutcome.blocked(reason, ...) (still running, early signal) or ActionOutcome.failed(reason, ...) (terminating), and CortexRuntime is the single place that translates that into CommonBlackboardKeys.LAST_PLAN_FEEDBACK:

if (path == null) {
    return ActionOutcome.failed(PlanFailureReason.FAILED_NO_PATH, mob.blockPosition());
}

PlanFailureReason is a fixed, domain-agnostic enum: FAILED_NO_PATH, FAILED_STUCK, FAILED_TARGET_LOST, FAILED_MISSING_INFRASTRUCTURE, FAILED_UNSUITABLE_CONDITIONS, FAILED_BLOCKED, FAILED_DANGER, FAILED_COOLDOWN, FAILED_PRECONDITION, FAILED_OBSTACLE_UNBREAKABLE, FAILED_NO_VALID_PLACEMENT. Your planner decides what each one means for your goals (e.g. bias toward an "investigate" goal on FAILED_TARGET_LOST).

PlanFeedback.isFresh(tick) returns true for feedback recorded within the last 80 ticks, check this before acting on it so stale feedback from several planning cycles ago doesn't keep influencing scores.

Suppressing repeatedly-failing goals: GoalFailureCooldowns

var gfc = GoalFailureCooldowns.<MyGoalType>getOrCreate(blackboard);
gfc.evictExpired(currentTick);

huntScore -= gfc.getPenalty(MyGoalType.HUNT_TARGET, currentTick);

// on failure:
gfc.recordFailure(MyGoalType.HUNT_TARGET, currentTick, 100); // suppress for 100 ticks

The penalty isn't binary, it decays linearly from 60 at the moment of failure to 0 at expiry, so a goal that just failed is heavily suppressed but recovers gradually rather than snapping back on.

Proactive invalidation: WorldStateSnapshot / PlanInvalidation

PlanFeedback is reactive, it only exists if the running action itself notices something went wrong on its own tick. WorldStateSnapshot/PlanInvalidation sit above that: a cheap, coarse snapshot (target identity, distance bucket, health bucket, in-darkness) is captured the moment a plan commits (GoalExecutor.apply does this automatically), and compared against live state every tick. If they've diverged enough, target changed, health dropped a bucket, darkness state flipped, distance changed by two-or-more buckets, a replan is forced even though no action has failed yet.

This is deliberately coarse (buckets, not exact values) so ordinary noise never trips it, it should only fire on changes big enough that "does this plan still make sense" is a legitimate question.

Pre-planner emergencies: EmergencyDetector

Scoring candidates requires running the planner, but shouldReplan's emergency override needs to know the candidate's urgency before the planner runs. EmergencyDetector breaks this chicken-and-egg problem with cheap, pluggable probes that run every tick without allocating or scoring anything:

private List<EmergencyDetector.EmergencyProbe<MyEntity>> buildEmergencyProbes() {
    var probes = new ArrayList<>(EmergencyDetector.<MyEntity>defaultProbes()); // on-fire + critical-health
    probes.add(agent -> agent.getHealth() / agent.getMaxHealth() <= 0.4f
        && agent.getOffhandItem().is(Items.GOLDEN_APPLE));
    return probes;
}

// each tick:
var urgency = EmergencyDetector.detectPreplanUrgency(this, emergencyProbes); // EMERGENCY or null

defaultProbes() bundles the two conditions that apply to virtually any mob (on fire, critical health); add your own mod-specific probes (nearby explosives, a specific predator, ...) to the list.

Writing the committed goal: GoalExecutor.apply

GoalExecutor.apply(this, blackboard, newGoal);

This writes ACTIVE_GOAL, ACTIVE_GOAL_TYPE, LAST_GOAL_REASON, and (if present) GOAL_TARGET/GOAL_DESTINATION to the blackboard; clears stale LAST_PLAN_FEEDBACK/LAST_FAILURE_REASON; and captures a fresh WorldStateSnapshot for PlanInvalidation to compare against going forward.

Next

Clone this wiki locally