Skip to content

Ranged Example Walkthrough Skeleton

AzureDoom edited this page Aug 29, 2026 · 1 revision

Ranged Example Walkthrough (Skeleton)

The bundled com.azure.azurecortex.example.skeleton.CortexSkeletonEntity shows the same integration shape as the zombie in Full Example Walkthrough, applied to a ranged attacker. Read that page first if you haven't — this page only covers what's different: the bow/melee split inside HUNT_TARGET, and the (deliberately simpler) planner around it.

Everything about vanilla Skeleton (sunlight burning, drowning, skeleton-specific loot/drops, ...) keeps working unmodified, only targeting/movement/attack decisions are replaced, exactly as with the zombie.

1. The goal-type enum

public enum CortexSkeletonGoal implements Goal {
    NONE,
    WANDER,
    INVESTIGATE,
    HUNT_TARGET;

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

One fewer state than the zombie, no EAT_TO_HEAL, since this example's point is the ranged-attack split, not planner sophistication.

2. The goal planner

CortexSkeletonGoalPlanner is intentionally simpler than the zombie's: hunt a live target, otherwise investigate a recent sighting, otherwise wander, no emergency branch at all.

public final class CortexSkeletonGoalPlanner implements GoalPlanner<CortexSkeletonEntity, CortexSkeletonGoal> {

    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<CortexSkeletonEntity, CortexSkeletonGoal> chooseGoal(
        CortexSkeletonEntity agent, Blackboard blackboard, CooldownTracker cooldowns
    ) {
        var currentTick = (int) agent.level().getGameTime();

        var failureCooldowns = GoalFailureCooldowns.<CortexSkeletonGoal>getOrCreate(blackboard);
        failureCooldowns.evictExpired(currentTick);

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

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

        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(CortexSkeletonGoal.INVESTIGATE, 30f, currentTick,
                MIN_COMMIT_TICKS, MAX_COMMIT_TICKS, null, lastSeenPos, GoalUrgency.NORMAL, true,
                "Lost sight of a target recently");
        }

        return PlannedGoal.of(CortexSkeletonGoal.WANDER, 10f, currentTick,
            MIN_COMMIT_TICKS, MAX_COMMIT_TICKS, null, null, GoalUrgency.LOW, true,
            "Nothing better to do");
    }
}

Structurally identical to the zombie's planner minus the emergency-eat branch, see GOAP Planning for what each piece (GoalFailureCooldowns, PlanFeedback) is doing.

3. The behavior tree, the bow/melee split

This is the interesting part. HUNT_TARGET uses HuntTargetNode with two AttackProfiles instead of one: a bow (mid-to-long range) and a melee fallback for when the target closes to point-blank range. HuntTargetNode re-evaluates which of the two is legal every tick via AttackSelector, so the skeleton smoothly switches between shooting and swinging with no explicit state machine of your own, see Behavior Trees for why HuntTargetNode exists at all.

public final class CortexSkeletonTree {

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

        var investigate = new InvestigateLastSeenTargetAction<CortexSkeletonEntity, CortexSkeletonGoal>(
            AStarPathfinder.INSTANCE, 1.0D, 2, 60, 100, 60, 0.02D, 2.0D, 8.0D);

        var hunt = getHunt();

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

    private static HuntTargetNode<CortexSkeletonEntity, CortexSkeletonGoal> getHunt() {
        var bowAttack = new UseItemAction<CortexSkeletonEntity, CortexSkeletonGoal>(
            InteractionHand.MAIN_HAND,
            agent -> agent.getMainHandItem().is(Items.BOW),
            20,   // minChargeTicks
            40,   // maxChargeTicks
            (agent, blackboard, ticksCharged) -> {   // readyToRelease
                var target = blackboard.get(CommonBlackboardKeys.TARGET);
                return target != null && target.isAlive() && agent.hasLineOfSight(target);
            },
            (agent, blackboard, ticksCharged) -> {   // onChargeTick, aim while drawing
                var target = blackboard.get(CommonBlackboardKeys.TARGET);
                if (target != null) {
                    agent.getLookControl().setLookAt(target, 30.0F, 30.0F);
                }
            },
            (agent, blackboard, ticksCharged) -> {   // onRelease, actually fire
                var target = blackboard.get(CommonBlackboardKeys.TARGET);
                if (target != null && target.isAlive()) {
                    agent.performRangedAttack(target, BowItem.getPowerForTime(ticksCharged));
                }
            },
            "skeleton_bow_cooldown", 20,
            18   // priority
        );

        var melee = new AttackProfile<CortexSkeletonEntity, CortexSkeletonGoal>(
            "melee",
            new TimedAttackAction<>("skeleton_melee", 6, 2.0D, "skeleton_melee_cooldown", 20),
            "skeleton_melee_cooldown", 0.0D, 2.0D, 25   // minRange 0, maxRange 2, priority 25
        );

        var bow = new AttackProfile<>(
            "bow", bowAttack, "skeleton_bow_cooldown",
            4.0D, 15.0D, 20   // minRange 4, maxRange 15, priority 20
        );

        var chase = new MoveToDestinationAction<CortexSkeletonEntity, CortexSkeletonGoal>(
            AStarPathfinder.INSTANCE, 1.0D, 1, 10, 60);

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

Why the bow needs both onChargeTick and onRelease

This is the one genuinely subtle piece of the whole example, and it's worth understanding rather than just copying.

Vanilla's BowItem#releaseUsing only fires an arrow if (entity instanceof Player). For a Mob, calling Mob.stopUsingItem() alone ends the drawing animation and fires nothing at all. Vanilla's own RangedBowAttackGoal works around this by calling stopUsingItem() and then separately, explicitly calling RangedAttackMob#performRangedAttack(target, power), the arrow only exists because of that second call.

UseItemAction doesn't special-case bows (that would defeat the point of being a generic action), so it exposes two hooks instead:

  • onChargeTick, runs every tick while charging. The skeleton uses this purely to keep aiming (getLookControl().setLookAt(target, ...)) while drawing, mirroring what vanilla's ranged goals do every tick so the mob visibly tracks its target instead of standing frozen mid-draw.
  • onRelease, runs once, immediately after stopUsingItem(). This is where the actual shot happens: agent.performRangedAttack(target, BowItem.getPowerForTime(ticksCharged)). Without this, minChargeTicks/ maxChargeTicks would still animate a full draw-and-release with nothing coming out of the bow.

If you're wiring up a crossbow or trident instead of a bow, the same two-hook pattern applies, only the specific vanilla API you call from onRelease changes.

Reading the AttackProfile numbers

  • melee: range 0.02.0, priority 25.
  • bow: range 4.015.0, priority 20.

There's a deliberate 2.04.0 gap where neither profile is legal, at that distance AttackSelector.select returns null for both, so HuntTargetNode falls back to chase (closing the distance) until the target is inside one band or the other. Melee outranks the bow when both happen to be legal (overlapping ranges never actually occur here given the gap, but the priority order documents the intended tie-break if you widen the bands later). Tune these bands to taste, a "kiting" archer that prefers to back away rather than let a target get close enough for melee would need its own custom node rather than relying on HuntTargetNode's "closest wins" chase fallback.

What to reuse for your own ranged (or multi-attack) creature

  • Use AttackProfile/AttackSelector/HuntTargetNode any time an agent should choose between two or more attacks by range/cooldown rather than a single fixed one, adding a third attack is adding one more AttackProfile entry, no tree changes needed.
  • Use UseItemAction's charge-and-release mode plus the onChargeTick/onRelease hooks for any vanilla "hold-then-release" item (bow, crossbow, trident) on a non-Player Mob, the same bow gotcha above applies to all of them equally.
  • Keep the planner as simple as your goals actually need, the skeleton's is proof that you don't need an EAT_TO_HEAL-style emergency branch just because the zombie example has one.

Next

Clone this wiki locally