API - MarkusBordihn/BOs-Easy-NPC GitHub Wiki

Easy NPC API 🧩

Easy NPC exposes a public API for mods that want to create their own NPC types, control existing NPCs, or reuse the rendering pipeline. Everything below lives under de.markusbordihn.easynpc.api.

The API is still evolving. Breaking changes can happen in minor versions until it is declared stable; if a source file and this page disagree, the source code is the current truth.

What You Can Do

Goal Start at
Create your own NPC entity type api.npc raw classes, see below
Query, spawn, or despawn NPCs from code api.handler.EasyNPCEntityHandler
Add, remove, or list NPC objectives api.handler.EasyNPCObjectiveHandler
Create a preset item from an NPC api.handler.EasyNPCPresetHandler
Seat an NPC on a player's shoulder api.handler.EasyNPCShoulderHandler
Let an NPC talk or act from code api.action.EasyNPCActionHandler
Register your own action or condition api.action.ActionRegistry, api.condition
React to dialogs, actions, and states api.event.EasyNPCEventRegistry
Set poses programmatically api.pose.ModelPoseAPI
Give an entity variant-based textures api.skin, see API-Variant-System
Add or replace model geometry api.model, see API-Custom-Models

Custom NPC Types

Extend one of the raw NPC classes in de.markusbordihn.easynpc.api.npc.raw. They already contain the Easy NPC data, sync, and configuration handling:

public class MyCustomHorse extends HorseRaw {

  public MyCustomHorse(EntityType<? extends Horse> entityType, Level level) {
    super(entityType, level);
  }
}

Then register the entity type with your mod loader (Forge, Fabric, or NeoForge) as usual.

Raw classes are available for most vanilla mob families - humanoid, zombie, skeleton, villager, illager, piglin, horse, spider, slime, ghast, wolf, cat, fox, pig, chicken, allay, vex, creeper, enderman, iron golem, and witch. PathfinderMobRaw is the generic base.

Override getConfigurationData() from api.npc.BaseEasyNPC to control which configuration screens your NPC offers.

Managing NPCs from Code

EasyNPCEntityHandler works on the server-side NPC index, including NPCs that are currently despawned:

Collection<SavedNPCEntityEntry> all = EasyNPCEntityHandler.getAll();
Collection<SavedNPCEntityEntry> mine = EasyNPCEntityHandler.getByOwner(ownerUUID);
Collection<SavedNPCEntityEntry> here = EasyNPCEntityHandler.getByDimension("minecraft:overworld");

EasyNPCEntityHandler.spawn(uuid, serverLevel);
EasyNPCEntityHandler.spawn(uuid, serverLevel, position);
EasyNPCEntityHandler.despawn(uuid, serverLevel, NPCRemovalReason.DESPAWNED);
EasyNPCEntityHandler.revive(uuid, serverLevel);
EasyNPCEntityHandler.revive(uuid, serverLevel, position);
EasyNPCEntityHandler.respawn(easyNPC, serverLevel);

Lookups are also available by entity type and by custom identifier.

Since 7.15.0, spawn refuses an NPC that was removed with the reason KILLED. Pass force = true to spawn it with its saved state, or use revive: it brings the NPC back with full health and without its death, hurt, or fire state. revive refuses NPCs that were deleted, sit on a player's shoulder, or are already loaded. getSpawnRejection(uuid, force) and getReviveRejection(uuid) return the reason why a call would fail, or an empty Optional. respawn replaces a loaded NPC with a fresh entity built from its current data, for example to restart its goals.

Remove a loaded NPC with EasyNPCEntityHandler.despawn(easyNPC, reason) instead of discard(). It saves the NPC first; a plain discard() loses its latest changes.

EasyNPCPauseHandler pauses and resumes single NPCs or all of them. isPaused also works on the client for a single NPC; the global pause is only known on the server.

Objectives

EasyNPCObjectiveHandler adds, removes, and lists the objectives of a loaded NPC on the server:

EasyNPCObjectiveHandler.addObjective(easyNPC, ObjectiveType.FOLLOW_OWNER);
EasyNPCObjectiveHandler.addObjective(easyNPC, new ObjectiveDataEntry(ObjectiveType.OWNER_HURT_TARGET));
EasyNPCObjectiveHandler.hasObjective(easyNPC, ObjectiveType.FOLLOW_OWNER);
EasyNPCObjectiveHandler.removeObjective(easyNPC, ObjectiveType.FOLLOW_OWNER);
EasyNPCObjectiveHandler.setPriority(easyNPC, ObjectiveType.FOLLOW_OWNER.name(), 4);
ObjectiveDataEntry followOwner = EasyNPCObjectiveHandler.getObjective(easyNPC, "FOLLOW_OWNER");
Set<ObjectiveDataEntry> objectives = EasyNPCObjectiveHandler.getObjectives(easyNPC);

addObjective returns true once the objective is stored, even if its goal is only created later, for example when the owner is not online yet. Removing an objective that does not exist returns false. All methods return false or an empty set for client-side NPC instances. Adding an objective with the same id again replaces the stored one and its running goal.

setPriority and getObjective take the objective id, which is the type name for built-in types. setPriority moves the running goal to the new priority and returns false for an unknown id.

Built-in objective types use their default priority unless one is passed explicitly, for example with new ObjectiveDataEntry(ObjectiveType.FOLLOW_OWNER, 4). An explicit priority is saved with the NPC and kept in presets. Editing the objective in the configuration screens resets it to the default. CUSTOM objectives without a stored priority use the default of their factory. OWNER_HURT_TARGET ("Assist Owner", assist_owner in commands) attacks whatever the owner attacks. Like OWNER_HURT_BY_TARGET, it skips team members, pets and NPCs of the same owner, and players the owner may not hurt.

EasyNPCPresetHandler.createPresetItem(easyNPC) returns the same preset item the empty preset item creates, or ItemStack.EMPTY on the client. Like the item, it leaves out the inventory contents.

Shoulder

EasyNPCShoulderHandler seats a loaded NPC on a player's shoulder and drops it off again on the server:

EasyNPCShoulderHandler.sitOnShoulder(easyNPC, player);
EasyNPCShoulderHandler.dismount(player);
EasyNPCShoulderHandler.dismount(player, ShoulderSide.LEFT);
EasyNPCShoulderHandler.dismount(player, ShoulderSide.LEFT, remountCooldownTicks);
Optional<UUID> seatedNPC = EasyNPCShoulderHandler.getShoulderNPCUUID(player, ShoulderSide.RIGHT);
boolean seated = EasyNPCShoulderHandler.isOnShoulder(npcUUID);

The NPC is stored in the player data like a parrot, so getShoulderNPCData returns its saved data instead of an entity. sitOnShoulder returns false when both shoulders are taken or vanilla refuses the seat, for example while the player is in the air or in water. Unlike the "Sit on Shoulder" ability, it does not check owner or size. dismount only drops Easy NPC entities and leaves a parrot on the other shoulder in place.

dismount places the NPC above the player, or at the player's position when that space is blocked. If both are blocked or the NPC cannot be added to the level, it stays on the shoulder and dismount returns false. With remountCooldownTicks, the "Sit on Shoulder" ability waits that many ticks before the NPC jumps back on, for example after the owner dismounts it while sneaking. The cooldown is saved with the NPC and survives a chunk reload.

A seated NPC is marked with the removal reason ON_SHOULDER. EasyNPCEntityHandler.spawn and revive refuse it, so the NPC cannot exist twice.

The SIT_ON_OWNER_SHOULDER objective reads these settings from its ObjectiveDataEntry:

Setting Default Effect
setStartDistance 0 Maximum distance to the owner, 0 uses 16 blocks
setCooldownTicks 100 Wait time after joining, dismounting, or giving up
setOnlyOutOfCombat false Waits while the owner recently hurt something or was hurt

The NPC gives up when the owner moves out of range or when it gets no closer for 100 ticks, and retries after the cooldown.

Default Data for Addon NPC Types

Override registerEasyNPCDefaultData() in your NPC class to give new NPCs of that type their own objectives, attributes, or other defaults. Call the Easy NPC defaults first:

@Override
public void registerEasyNPCDefaultData() {
  EasyNPC.super.registerEasyNPCDefaultData();
  this.getEasyNPCObjectiveData().addOrUpdateCustomObjective(
      new ObjectiveDataEntry(ObjectiveType.SIT_ON_OWNER_SHOULDER));
}

It runs for every new NPC, whether it comes from a spawn egg, a spawner, a preset item, or a preset import. Chunk loading, EasyNPCEntityHandler.spawn, revive, and dismount restore the saved data of the NPC, so a default that was removed later does not come back. For logic on every join, use EntityLifecycleListener.onNPCSpawned.

Letting an NPC Act

EasyNPCActionHandler uses the same executors as preset actions, including sender name validation, dialog conditions, and trading checks:

EasyNPCActionHandler.say(easyNPC, "Good to see you again!");
EasyNPCActionHandler.say(easyNPC, List.of("Hello!", "Welcome back!"));
EasyNPCActionHandler.sayTo(easyNPC, serverPlayer, "This one is only for you.");
EasyNPCActionHandler.showSpeechBubble(easyNPC, "text.my_mod.npc.greeting");
EasyNPCActionHandler.showSpeechBubble(easyNPC, List.of("Look up!", "Over here!"));

EasyNPCActionHandler.setState(easyNPC, questStateId, StateEntry.of(3), serverPlayer);
EasyNPCActionHandler.openDialog(easyNPC, serverPlayer, "quest_start");
EasyNPCActionHandler.trigger(easyNPC, ActionEventType.ON_INTERACTION, ActionContext.of(serverPlayer));

A text matching the translation key format is translated on the client; all other text is sent as written. The talk and speech bubble methods accept one text or a list of up to six texts and select one entry per call. Invalid input is logged and returns false. Client-side NPC instances are ignored.

Command actions have no convenience method because their permission level is stored on the action entry. Pass an ActionDataEntry to EasyNPCActionHandler.execute(...) for these actions.

Event Audiences

An event that applies to several players provides an ActionContext:

public record ActionContext(
    ActionEventType eventType,
    ServerPlayer initiator,
    List<ServerPlayer> audience,
    ResourceLocation sourceId) {}

initiator is the single player macros like @initiator resolve against; audience lists every player the event applies to, so an integration can filter it. For a time based, environment, or spawn event the audience is every player within 16 blocks, and the owner of the NPC is preferred as the initiator. For ON_STATE_CHANGE the sourceId names the state that changed.

Listeners keep their old signature and can override the context variant instead:

EasyNPCEventRegistry.registerActionEventListener(
    (easyNPC, actionDataEntry, actionContext) -> {
      for (ServerPlayer serverPlayer : actionContext.audience()) {
        MyMod.noticed(easyNPC, serverPlayer);
      }
    });

NPC Events

Owner and objective changes have their own listeners:

EasyNPCEventRegistry.registerOwnerEventListener(
    (easyNPC, previousOwnerUUID, ownerUUID) -> MyMod.ownerChanged(easyNPC, ownerUUID));

EasyNPCEventRegistry.registerObjectiveEventListener(
    new ObjectiveEventListener() {
      @Override
      public void onObjectiveAdded(EasyNPC<?> easyNPC, ObjectiveDataEntry objectiveDataEntry) {}

      @Override
      public void onObjectiveRemoved(EasyNPC<?> easyNPC, ObjectiveDataEntry objectiveDataEntry) {}
    });

Both fire only on the server and only for real changes. Loading an NPC from disk fires no owner event. An owner set before the first spawn of an NPC fires one owner event with a null previous owner when the NPC spawns, right after the spawn event. Objective events fire for every objective added or removed at runtime, including the standard objectives of a newly spawned NPC and objectives that follow attribute changes. Replacing the whole objective data, for example by a preset import, fires none.

Shoulder listeners learn when an NPC sits down and when it is back in the world:

EasyNPCEventRegistry.registerShoulderEventListener(
    new ShoulderEventListener() {
      @Override
      public void onShoulderMounted(EasyNPC<?> easyNPC, Player player, ShoulderSide side) {}

      @Override
      public void onShoulderDismounted(EasyNPC<?> easyNPC) {}
    });

onShoulderDismounted fires when the NPC joins the level again, no matter whether damage, a fall, sleep or dismount dropped it off.

Custom Actions and Conditions

ActionRegistry.register(
    new ResourceLocation("my_mod", "teleport"),
    (actionDataEntry, easyNPC, serverPlayer, arguments) -> MyMod.teleport(easyNPC, arguments));

ConditionRegistry.register(
    new ResourceLocation("my_mod", "has_quest"),
    (conditionDataEntry, serverPlayer, npcContext) -> MyQuests.isActive(serverPlayer));

A preset then calls the action as my_mod:teleport home 3. An event without a triggering player, such as On Spawn or On State Change, passes null as the server player. A condition that needs a player must return false.

Poses

ModelPoseAPI is the supported way to change a pose without touching internal data classes:

ModelPoseAPI.setPose(npc, new ResourceLocation("easy_npc", "pose/humanoid/sitting"));
ModelPoseAPI.setPose(npc, "sitting");
ModelPoseAPI.setVanillaPose(npc, Pose.CROUCHING);
ModelPoseAPI.resetPose(npc);

String currentPose = ModelPoseAPI.getCurrentPoseName(npc);
ModelPose mode = ModelPoseAPI.getCurrentPoseMode(npc);
Set<ResourceLocation> available = ModelPoseAPI.getAvailablePoses(skinModel);

setPose(npc, "sitting") resolves the pose against the NPC's own skin model, which is the easier call when you do not want to build the ResourceLocation yourself.

setVanillaPose(...) clears custom rotation and position data, so it is also the way back to plain vanilla behavior.

Entity Data

Per-NPC data is reached through the getEasyNPC…Data() accessors on EasyNPC<?>, for example:

SkinDataCapable<?> skinData = npc.getEasyNPCSkinData();
ModelDataCapable<?> modelData = npc.getEasyNPCModelData();
ProgressionDataCapable<?> progression = npc.getEasyNPCProgressionData();

These interfaces live in de.markusbordihn.easynpc.entity.easynpc.data. They are stable enough to build on, but they are not part of the api package - not every internal class is meant to be an extension point.

Saving Changed Data

The SavedNPCEntityEntry results of EasyNPCEntityHandler.getAll() and its siblings come from a copy Easy NPC keeps of every NPC, which is what makes despawned NPCs findable at all.

That copy is refreshed from a dirty flag, not on every unload. The flag is set automatically when you go through the normal setters, which covers everything the API and the data interfaces offer:

npc.setSynchedEntityData(index, value);              // persistent entries only
npc.setServerEntityData(accessor, value);

If you change NPC state some other way - writing fields directly, patching the entity NBT, or restoring data from your own storage - tell Easy NPC about it, otherwise the registry copy stays on the last known state while the NPC is unloaded:

npc.getEasyNPCStatusData().markNPCDataUpdated();     // include it in the next save

NPCEntityManager.saveNPC(npc) writes the entry out right away, but like the data interfaces above it is not part of the api package.

The live entity itself is never affected by this - it is saved with its chunk like any other entity.

Related Pages

⚠️ **GitHub.com Fallback** ⚠️