Skip to content

Core Concepts

AzureDoom edited this page Aug 29, 2026 · 1 revision

Core Concepts

This page covers the mental model you need before touching any specific package: what drives an agent's brain each tick, and how the pieces fit together.

The pieces, at a glance

Concept Type Role
CortexRuntime<E, G> class The per-agent AI driver. One instance per entity. Owns the tick loop.
Blackboard class Per-agent key/value store shared between sensors, the planner, the tree, and actions.
Sensor<E> interface Periodically refreshes some piece of perception onto the blackboard (e.g. current target).
BehaviorNode<E, G> interface One node of a behavior tree; evaluated every tick, may propose an Action.
Action<E, G> interface A discrete, stateful behavior an agent performs over one or more ticks.
GoalPlanner<E, G> interface Your mod's GOAP scoring logic: decides which PlannedGoal to commit to.
Goal interface Marker your mod's goal-type enum implements (e.g. HUNT_TARGET, WANDER).

E is your agent's entity type (must extend Mob); G is your mod's own goal-type enum. Every generic type in the framework is parameterized over these two so the compiler catches you wiring the wrong tree/action/planner to the wrong entity.

Wiring one agent

A typical entity constructor builds exactly three things and gives them to one CortexRuntime:

public class MyMonsterEntity extends Monster {

    private final CortexRuntime<MyMonsterEntity, MyGoalType> runtime;
    private final MyGoalPlanner goalPlanner = new MyGoalPlanner();

    public MyMonsterEntity(EntityType<? extends Monster> type, Level level) {
        super(type, level);

        var targetSensor = new TargetSensor<MyMonsterEntity>(
            TargetSensor.nearestMatching(24.0D, VanillaTargetPredicates.players()),
            10,                              // retarget every 10 ticks
            TargetSensor.lineOfSight()        // enables last-seen tracking
        );

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

    @Override
    public void tick() {
        super.tick();
        if (!level().isClientSide() && isAlive() && !isNoAi()) {
            tickGoalPlanner();   // your GOAP glue, see GOAP Planning
            runtime.tick();
        }
    }
}

Everything downstream: targeting, planning, tree evaluation, action execution, pathfinding happens inside runtime.tick(), driven entirely server-side. Client-side ticking is intentionally skipped.

What happens inside runtime.tick()

CortexRuntime.tick() runs the following steps, in order, every game tick:

  1. Decrement cooldowns (CooldownTracker.tick()).
  2. Run the sensor, if any, unless the current action is LOCKED/EMERGENCY (see below) — this keeps a fully-committed action from having its target silently swapped out from under it mid-execution.
  3. Tick the current action (if one is running), translating its ActionOutcome into blackboard feedback for GOAP and, on Success/Failed, stopping and clearing it.
  4. Run any due periodic hooks (see below).
  5. Evaluate the behavior tree. Even if an action is currently LOCKED, the tree is still evaluated (cheaply — only the winning branch actually runs anything) so an EMERGENCY candidate can still break through. The actual switch is gated separately by InterruptController.

If anything throws inside this loop, CortexRuntime catches it, logs a diagnostic including the entity type, UUID, and position, and resets the agent to a safe passive state (clearing target/goal/destination blackboard keys) rather than letting one bad tick corrupt the agent permanently or crash the server.

Actions vs. the behavior tree

These are deliberately separate concerns:

  • The tree (BehaviorNode) is evaluated every tick and answers "given the current blackboard state, what's the best action to be running right now, and at what priority?"
  • An action (Action) is a stateful thing that runs across many ticks once selected, it owns its own internal state (e.g. TimedAttackAction's windup counter) between start() and stop().

The tree doesn't run actions directly; it returns a BehaviorResult describing which action it wants and at what priority, and CortexRuntime decides whether that's actually allowed to preempt whatever's currently running. See Behavior Trees for the composable node types and Built-in Actions for the ready-made actions.

Periodic hooks

Some bookkeeping isn't really an "action", it doesn't compete for priority, it just needs to run every so often regardless of what the agent is currently doing. Register these with addPeriodicHook:

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

Hooks are cooldown-gated by a private key you supply and run every tick regardless of which action is locked/running.

The Blackboard

A per-agent HashMap-backed store, accessible two ways:

// String-keyed (matches historical API, readable in debug output)
blackboard.set("some_key", value);
var value = blackboard.get("some_key", MyType.class);

// Typed BlackboardKey (no repeated Class token, one declaration per key)
public static final BlackboardKey<LivingEntity> MY_KEY = BlackboardKey.of("my_key", LivingEntity.class);
blackboard.set(MY_KEY, entity);
var entity = blackboard.get(MY_KEY);

CommonBlackboardKeys defines every key the framework itself reads or writes (current target, last-seen position, active goal, plan feedback, cooldown key names, and more), see its Javadoc for the full list. Your mod should declare its own key class for anything agent-specific (a current-hive reference, a role assignment, etc) rather than editing CommonBlackboardKeys.

All blackboard state is transient, it's discarded when the entity is removed from the world. Nothing here is saved to NBT automatically; if you need something to survive a save/reload, persist it yourself in the entity's own addAdditionalSaveData/readAdditionalSaveData and re-populate the blackboard on load.

Interrupt categories

Every Action reports an InterruptCategory:

  • NORMAL (the default) — ordinary priority-based preemption: a higher-priority candidate takes over.
  • LOCKED — immune to ordinary preemption; only an EMERGENCY candidate can interrupt it.
  • EMERGENCY — reserved for genuinely critical situations (on fire, about to explode, critical health). As the running action, resists everything except a higher-priority emergency. As a candidate, can preempt LOCKED or NORMAL regardless of their priority.

This exists so "resistant to normal preemption" and "resistant to everything, even emergencies" are different guarantees, a LOCKED eating-a-golden-apple action, say, shouldn't be interruptible by a slightly-higher-priority wander branch, but should still yield if the agent catches fire.

Old code that only implements the boolean isInterruptible() still works: true maps to NORMAL, false maps to LOCKED. Override interruptCategory() directly for EMERGENCY actions or other fine-grained control.

Where to go next

Clone this wiki locally