-
Notifications
You must be signed in to change notification settings - Fork 0
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.
| 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.
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.
CortexRuntime.tick() runs the following steps, in order, every game tick:
-
Decrement cooldowns (
CooldownTracker.tick()). -
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. -
Tick the current action (if one is running), translating its
ActionOutcomeinto blackboard feedback for GOAP and, onSuccess/Failed, stopping and clearing it. - Run any due periodic hooks (see below).
-
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 anEMERGENCYcandidate can still break through. The actual switch is gated separately byInterruptController.
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.
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) betweenstart()andstop().
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.
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.
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.
Every Action reports an InterruptCategory:
-
NORMAL(the default) — ordinary priority-based preemption: a higher-priority candidate takes over. -
LOCKED— immune to ordinary preemption; only anEMERGENCYcandidate 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 preemptLOCKEDorNORMALregardless 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.
-
Behavior Trees for how to compose
BehaviorNodes. - GOAP Planning for goal selection, replanning, and failure feedback.
- Full Example Walkthrough to see all of this assembled into one working entity.