Skip to content

Full Example Walkthrough

AzureDoom edited this page Aug 29, 2026 · 1 revision

Full Example Walkthrough

AzureCortex ships three complete, working example entities purely to demonstrate the framework: com.azure.azurecortex.example.zombie.CortexZombieEntity (melee), com.azure.azurecortex.example.skeleton.CortexSkeletonEntity (ranged, via the bow-charge/melee split inside UseItemAction), and com.azure.azurecortex.example.spider.CortexSpiderEntity (wall/ceiling-crawling). This page walks through the zombie end to end, every piece introduced on the other reference pages, assembled into one working brain.

Spawn one holding a golden apple in its offhand (e.g. via a modified spawn/loot table) to see the emergency-healing branch trigger.

1. The goal-type enum

public enum CortexZombieGoal implements Goal {
    NONE,          // nothing chosen yet
    WANDER,        // no target, nothing else to do
    INVESTIGATE,   // lost sight of a target recently
    HUNT_TARGET,   // a live target is being pursued
    EAT_TO_HEAL;   // critically wounded, carrying a golden apple

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

Five states, in roughly ascending "how urgently does this want attention" order. See GOAP Planning for Goal.

2. The goal planner

public final class CortexZombieGoalPlanner implements GoalPlanner<CortexZombieEntity, CortexZombieGoal> {

    static final float EAT_HEALTH_FRACTION = 0.4f;
    private static final int INVESTIGATE_MAX_AGE_TICKS = 100;
    private static final int MIN_COMMIT_TICKS = 20;
    private static final int MAX_COMMIT_TICKS = 200;

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

        // Highest priority: eat if critically wounded and able to.
        var healthFraction = agent.getMaxHealth() > 0f ? agent.getHealth() / agent.getMaxHealth() : 1f;
        if (healthFraction <= EAT_HEALTH_FRACTION && agent.getOffhandItem().is(Items.GOLDEN_APPLE)) {
            return PlannedGoal.of(CortexZombieGoal.EAT_TO_HEAL, 100f, currentTick, 10, 100,
                null, null, GoalUrgency.EMERGENCY, true,
                "Wounded below " + (int) (EAT_HEALTH_FRACTION * 100) + "% health and carrying a golden apple");
        }

        // Apply failure-cooldown penalties from recent HUNT_TARGET failures.
        var failureCooldowns = GoalFailureCooldowns.<CortexZombieGoal>getOrCreate(blackboard);
        failureCooldowns.evictExpired(currentTick);

        var feedback = (PlanFeedback<CortexZombieGoal>) 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(CortexZombieGoal.HUNT_TARGET, currentTick);
        }

        // Live target -> hunt.
        var target = blackboard.get(CommonBlackboardKeys.TARGET);
        if (target != null && target.isAlive()) {
            var score = 50f - failureCooldowns.getPenalty(CortexZombieGoal.HUNT_TARGET, currentTick);
            return PlannedGoal.of(CortexZombieGoal.HUNT_TARGET, score, currentTick,
                MIN_COMMIT_TICKS, MAX_COMMIT_TICKS, target, null, GoalUrgency.NORMAL, true,
                "Live target acquired");
        }

        // Recent sighting -> investigate.
        var lastSeenPos = blackboard.get(CommonBlackboardKeys.LAST_SEEN_POS);
        var lastSeenTick = blackboard.get(CommonBlackboardKeys.LAST_SEEN_TICK);
        if (lastSeenPos != null && lastSeenTick != null
                && currentTick - lastSeenTick <= INVESTIGATE_MAX_AGE_TICKS) {
            return PlannedGoal.of(CortexZombieGoal.INVESTIGATE, 30f, currentTick,
                MIN_COMMIT_TICKS, MAX_COMMIT_TICKS, null, lastSeenPos, GoalUrgency.NORMAL, true,
                "Lost sight of a target recently");
        }

        // Nothing else -> wander.
        return PlannedGoal.of(CortexZombieGoal.WANDER, 10f, currentTick,
            MIN_COMMIT_TICKS, MAX_COMMIT_TICKS, null, null, GoalUrgency.LOW, true,
            "Nothing better to do");
    }
}

A deliberately simple, linear priority chain, real planners (Ovomorphosis's actual xenomorph planner juggles a dozen goal types with weighted scoring) can be far more elaborate, but this shows every moving part (GoalFailureCooldowns, PlanFeedback, LAST_SEEN_POS) without the scoring logic itself being the point.

3. The behavior tree

public final class CortexZombieTree {

    private static final int EAT_TO_HEAL_PRIORITY = 90;

    public static BehaviorNode<CortexZombieEntity, CortexZombieGoal> create() {
        var idle = new IdleAction<CortexZombieEntity, CortexZombieGoal>();
        var wander = new WanderAction<CortexZombieEntity, CortexZombieGoal>(1.0D, 10.0D, 100);

        var investigate = new InvestigateLastSeenTargetAction<CortexZombieEntity, CortexZombieGoal>(
            AStarPathfinder.INSTANCE, 1.1D, 2, 60, 100, 60, 0.02D, 2.0D, 8.0D);

        var eatGoldenApple = UseItemAction.<CortexZombieEntity, CortexZombieGoal>autoComplete(
            InteractionHand.OFF_HAND,
            agent -> agent.getOffhandItem().is(Items.GOLDEN_APPLE),
            "zombie_eat_cooldown", 600,
            EAT_TO_HEAL_PRIORITY
        );

        var hunt = getHunt();

        return PrioritySelector.of(
            new ActionNode<>(idle, 0),
            new Condition<>((agent, bb, cd) -> bb.get(CommonBlackboardKeys.ACTIVE_GOAL_TYPE)
                == CortexZombieGoal.WANDER, new ActionNode<>(wander, 5)),
            new Condition<>((agent, bb, cd) -> bb.get(CommonBlackboardKeys.ACTIVE_GOAL_TYPE)
                == CortexZombieGoal.INVESTIGATE, new ActionNode<>(investigate, 8)),
            new Condition<>((agent, bb, cd) -> bb.get(CommonBlackboardKeys.ACTIVE_GOAL_TYPE)
                == CortexZombieGoal.EAT_TO_HEAL, new ActionNode<>(eatGoldenApple, EAT_TO_HEAL_PRIORITY)),
            hunt
        );
    }

    private static HuntTargetNode<CortexZombieEntity, CortexZombieGoal> getHunt() {
        var melee = new AttackProfile<CortexZombieEntity, CortexZombieGoal>(
            "melee",
            new TimedAttackAction<>("zombie_melee", 6, 2.0D, "zombie_melee_cooldown", 20),
            "zombie_melee_cooldown", 0.0D, 2.0D, 20
        );

        var chase = new MoveToDestinationAction<CortexZombieEntity, CortexZombieGoal>(
            AStarPathfinder.INSTANCE, 1.15D, 1, 10, 60);

        return new HuntTargetNode<>(CortexZombieGoal.HUNT_TARGET, chase, List.of(melee));
    }
}

Notice the eat-to-heal priority (90) sits well above every other branch (melee's fixed 20, wander's 5), this is what lets it actually hold once committed under GoalUrgency.EMERGENCY: without a high enough priority number, the emergency urgency would win the initial goal commitment, but the resulting action could still be immediately preempted by an ordinary higher-priority branch on the very next tick. Priority (tree-side) and urgency (GOAP-side) are two different axes that both need to line up for an emergency to actually stick.

HUNT_TARGET uses HuntTargetNode rather than a plain ActionNode+Condition pair, because deciding between chasing and attacking has to be re-evaluated every tick against the target's live distance, see Behavior Trees for why a static tree shape can't express that alone.

4. The entity class

public class CortexZombieEntity extends Zombie {

    private final CortexRuntime<CortexZombieEntity, CortexZombieGoal> runtime;
    private final CortexZombieGoalPlanner goalPlanner = new CortexZombieGoalPlanner();
    private final List<EmergencyDetector.EmergencyProbe<CortexZombieEntity>> emergencyProbes = buildEmergencyProbes();

    public CortexZombieEntity(EntityType<? extends Zombie> entityType, Level level) {
        super(entityType, level);

        var validity = VanillaTargetPredicates.players()
            .or(VanillaTargetPredicates.abstractVillagers())
            .or(VanillaTargetPredicates.ironGolems())
            .or(VanillaTargetPredicates.babyTurtlesOnLand());

        var targetSensor = new TargetSensor<CortexZombieEntity>(
            TargetSensor.nearestMatching(24.0D, validity), 10, TargetSensor.lineOfSight());

        this.runtime = new CortexRuntime<>(this, targetSensor, CortexZombieTree.create());

        this.runtime.addPeriodicHook("zombie_sync_chase_destination", 5, (agent, blackboard) -> {
            var goalType = blackboard.get(CommonBlackboardKeys.ACTIVE_GOAL_TYPE);
            var target = blackboard.get(CommonBlackboardKeys.TARGET);
            if (goalType == CortexZombieGoal.HUNT_TARGET && target != null) {
                blackboard.set(CommonBlackboardKeys.DESTINATION, target.blockPosition());
            }
        });
    }

    private static List<EmergencyDetector.EmergencyProbe<CortexZombieEntity>> buildEmergencyProbes() {
        var probes = new ArrayList<>(EmergencyDetector.<CortexZombieEntity>defaultProbes()); // fire + critical health
        probes.add(agent -> {
            var fraction = agent.getMaxHealth() > 0f ? agent.getHealth() / agent.getMaxHealth() : 1f;
            return fraction <= CortexZombieGoalPlanner.EAT_HEALTH_FRACTION
                && agent.getOffhandItem().is(Items.GOLDEN_APPLE);
        });
        return probes;
    }

    @Override
    protected void registerGoals() {
        // Only vanilla's own non-targeting goals remain, float, look-at-player, random-look-around.
        // Targeting/movement/attack decisions are fully replaced by the AzureCortex runtime.
        this.goalSelector.addGoal(0, new FloatGoal(this));
        this.goalSelector.addGoal(1, new LookAtPlayerGoal(this, Player.class, 8.0F));
        this.goalSelector.addGoal(2, new RandomLookAroundGoal(this));
    }

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

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

        var activeGoalType = blackboard.get(CommonBlackboardKeys.ACTIVE_GOAL_TYPE);
        var isPassive = activeGoalType == null
            || activeGoalType == CortexZombieGoal.NONE
            || activeGoalType == CortexZombieGoal.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);
    }
}

Note what's conspicuously absent from registerGoals(): no NearestAttackableTargetGoal, no MeleeAttackGoal, no WaterAvoidingRandomStrollGoal. Vanilla's own housekeeping goals (floating, looking at players, random glancing) stay — they're harmless and orthogonal, but every goal that would normally drive targeting, movement, or attacking is removed in favor of the AzureCortex runtime. Everything else about Zombie (sunlight burning, drowning, baby scaling, loot, ...) keeps working completely unmodified, since this only replaces the AI layer.

5. What actually happens on a given tick

Putting it all together, here's the flow once a player wanders into range:

  1. tick() runs. tickGoalPlanner() checks: is the zombie currently passive (WANDER/NONE) and does it now have a TARGET? If so, reactiveReplan is true and the cooldown check is bypassed, no waiting out a stale 20-tick timer.
  2. goalPlanner.chooseGoal(...) runs. No golden-apple emergency, no fresh failure feedback yet. TARGET is set and alive, so it returns PlannedGoal(HUNT_TARGET, score=50, urgency=NORMAL, target=player, ...).
  3. GoalExecutor.apply(...) writes ACTIVE_GOAL_TYPE = HUNT_TARGET, GOAL_TARGET = player, and a fresh WorldStateSnapshot to the blackboard.
  4. runtime.tick() runs. The TargetSensor was already ticked as part of this same call (before the current action's tick), keeping TARGET/LAST_KNOWN_TARGET_POS/LAST_SEEN_POS current. The tree is evaluated: the WANDER and INVESTIGATE Conditions fail (wrong active goal type), hunt (a HuntTargetNode) checks, active goal type matches HUNT_TARGET, target is alive, AttackSelector.select(...) finds no legal attack yet (player too far), so it returns the chase action (MoveToDestinationAction) at its own priority.
  5. Since nothing was previously running (or the previous action's priority is lower), CortexRuntime starts chase. The periodic hook (registered in the constructor) keeps DESTINATION synced to the player's position every 5 ticks, which chase reads to repath.
  6. Once in melee range, HuntTargetNode starts returning the melee AttackProfile's action instead of chase on the ticks it's off cooldown, the runtime preempts chase for TimedAttackAction, which winds up, faces the target, and strikes via MeleeHitResolver.
  7. If the player breaks line of sight, TargetSensor stops updating LAST_SEEN_POS but keeps LAST_KNOWN_TARGET_POS current. Once the player is gone entirely (dead, out of range), TARGET clears. The next planning cycle finds no live target but a recent LAST_SEEN_POS/LAST_SEEN_TICK, and commits to INVESTIGATE instead — InvestigateLastSeenTargetAction walks toward TargetPrediction's extrapolated search point.
  8. If nothing pans out and the sighting goes stale, the planner falls back to WANDER.

Every arrow in that chain is one of the pieces documented elsewhere in this wiki: Sensing (step 4), GOAP Planning (steps 1–3, 7–8), Behavior Trees (steps 4–6), Built-in Actions (steps 5–6), and Navigation and Pathfinding (underneath step 5's chase action).

Adapting this to your own creature

The mechanical shape above is reusable almost as-is:

  1. Declare your own Goal-implementing enum.
  2. Write a GoalPlanner scoring whatever goals matter for your creature.
  3. Build a BehaviorNode tree, a PrioritySelector of Condition-gated ActionNodes is enough for most cases; reach for a custom BehaviorNode (like HuntTargetNode) only when a branch's choice genuinely depends on live data, not just a static precondition.
  4. Wire a TargetSensor (if your creature targets anything) and a CortexRuntime in your entity's constructor.
  5. Call your planner from tick(), gated by GoalExecutor.shouldReplan, before runtime.tick().
  6. Remove the vanilla goals your new AI replaces from registerGoals(); keep the ones that are orthogonal (floating, looking around, breeding, ...).

See Ranged Example Walkthrough (Skeleton) for the same shape applied to a ranged attacker, the interesting difference is entirely inside its HUNT_TARGET branch, which splits between closing distance, drawing the bow (UseItemAction's charge-and-release mode), and falling back to melee at point-blank range. See Wall-Climbing Example (Spider) for the same shape again, this time combined with the wall-crawling movement model from Wall Crawling.

Clone this wiki locally