-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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.
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.
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.
Putting it all together, here's the flow once a player wanders into range:
-
tick()runs.tickGoalPlanner()checks: is the zombie currently passive (WANDER/NONE) and does it now have aTARGET? If so,reactiveReplanis true and the cooldown check is bypassed, no waiting out a stale 20-tick timer. -
goalPlanner.chooseGoal(...)runs. No golden-apple emergency, no fresh failure feedback yet.TARGETis set and alive, so it returnsPlannedGoal(HUNT_TARGET, score=50, urgency=NORMAL, target=player, ...). -
GoalExecutor.apply(...)writesACTIVE_GOAL_TYPE = HUNT_TARGET,GOAL_TARGET = player, and a freshWorldStateSnapshotto the blackboard. -
runtime.tick()runs. TheTargetSensorwas already ticked as part of this same call (before the current action's tick), keepingTARGET/LAST_KNOWN_TARGET_POS/LAST_SEEN_POScurrent. The tree is evaluated: theWANDERandINVESTIGATEConditions fail (wrong active goal type),hunt(aHuntTargetNode) checks, active goal type matchesHUNT_TARGET, target is alive,AttackSelector.select(...)finds no legal attack yet (player too far), so it returns thechaseaction (MoveToDestinationAction) at its own priority. - Since nothing was previously running (or the previous action's priority is lower),
CortexRuntimestartschase. The periodic hook (registered in the constructor) keepsDESTINATIONsynced to the player's position every 5 ticks, whichchasereads to repath. - Once in melee range,
HuntTargetNodestarts returning themeleeAttackProfile's action instead ofchaseon the ticks it's off cooldown, the runtime preemptschaseforTimedAttackAction, which winds up, faces the target, and strikes viaMeleeHitResolver. - If the player breaks line of sight,
TargetSensorstops updatingLAST_SEEN_POSbut keepsLAST_KNOWN_TARGET_POScurrent. Once the player is gone entirely (dead, out of range),TARGETclears. The next planning cycle finds no live target but a recentLAST_SEEN_POS/LAST_SEEN_TICK, and commits toINVESTIGATEinstead —InvestigateLastSeenTargetActionwalks towardTargetPrediction's extrapolated search point. - 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).
The mechanical shape above is reusable almost as-is:
- Declare your own
Goal-implementing enum. - Write a
GoalPlannerscoring whatever goals matter for your creature. - Build a
BehaviorNodetree, aPrioritySelectorofCondition-gatedActionNodes is enough for most cases; reach for a customBehaviorNode(likeHuntTargetNode) only when a branch's choice genuinely depends on live data, not just a static precondition. - Wire a
TargetSensor(if your creature targets anything) and aCortexRuntimein your entity's constructor. - Call your planner from
tick(), gated byGoalExecutor.shouldReplan, beforeruntime.tick(). - 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.