The API
bestiary-api is published separately from the plugin and depends on nothing but the Bukkit API.
<dependency>
<groupId>dev.bwmp</groupId>
<artifactId>bestiary-api</artifactId>
<version>1.0.0</version>
<scope>provided</scope>
</dependency>
Getting it
Published through Bukkit's service manager, because it is the one registry that genuinely spans plugin boundaries and unregisters cleanly.
BestiaryAPI bestiary = BestiaryAPI.get().orElseThrow();
Add Bestiary to your softdepend (or depend) so it has enabled first.
Identifying a mob
resolve is the only supported way to ask what an entity is. It reads the bestiary:id persistent-data key, adopting the mob if the chunk-load adopter has not reached it yet, so it is correct regardless of event ordering.
bestiary.resolve(event.getEntity()).ifPresent(mob -> {
getLogger().info(mob.definition().id() + " at level " + mob.level());
});
BestiaryMob exposes the level, the current phase, mob-scoped variables, the owning anchor, threat and damage-share for a player, and cast, signal and remove.
Spawning
bestiary.spawn(new NamespacedKey("aether", "valkyrie_champion"), location, 3);
Registering a mechanic
A mechanic is a factory plus a body. The factory runs once per config line at load; the body runs once per resolved target. Everything the body needs from config is read in the factory, so parsing never happens on a hot path.
public final class HealingWindType implements MechanicType {
private static final MechanicMeta META = MechanicMeta.builder("healing_wind")
.description("Heals everything it touches for a share of its maximum health.")
.requires(TargetKind.ENTITY)
.param("percent", "share of maximum health", "5", "p", "amount")
.build();
@Override
public MechanicMeta meta() {
return META;
}
@Override
public Mechanic create(MechanicConfig config) {
Expression percent = config.number("percent", 5);
return new Mechanic() {
@Override
public MechanicMeta meta() {
return META;
}
@Override
public MechanicResult execute(SkillContext context, Target target) {
LivingEntity entity = target.living();
if (entity == null) {
return MechanicResult.FAIL;
}
double amount = entity.getMaxHealth() * percent.asDouble(context, target) / 100.0;
entity.setHealth(Math.min(entity.getMaxHealth(), entity.getHealth() + amount));
return MechanicResult.SUCCESS;
}
};
}
}
bestiary.registerMechanicType(this, new NamespacedKey(this, "healing_wind"), new HealingWindType());
The id's namespace must match your plugin, which is what stops two plugins quietly overriding each other's content. The registration is dropped automatically when your plugin disables, so a reload cannot leave handlers pointing at a dead classloader.
Config can then use it in either form:
- healing_wind{percent=8} @playersInRadius{r=6}
- type: myplugin:healing_wind
percent: 8
targeter: { type: players_in_radius, radius: 6 }
Targeters and conditions follow the same shape through registerTargeterType and registerConditionType.
Parameters and aliases
MechanicMeta declares the parameters and their shorthand aliases. The alias table lives with the mechanic, never in the parser, which is what lets p= mean percent on your mechanic and something else on someone else's.
The parser reads the declaration before it builds anything, so a misspelled key is a load-time warning naming the parameters that do exist, rather than a silent default.
Keys normalise by lowercasing and stripping underscores, so ignore_armor, ignoreArmor and IgnoreArmor are one key.
Pick the narrowest TargetKind
NONE means the mechanic runs once per line rather than once per resolved target. That is what delay and skill need, and what damage must not have.
Expressions, not values
Every numeric and string parameter is an Expression. Constants are just expressions that ignore their input, so a mechanic never branches on whether its parameter happened to be literal.
Resolution order is pinned: placeholders are substituted first, then, in numeric contexts only, the result is parsed as an infix expression. String contexts get substitution only.
Evaluate per target, not per line: the same <caster.level> * 2 means something different for each one.
Registering an AI goal
Goals compile against bestiary-api and never against a Paper class, so a goal still loads on a server where the Goal API is absent and simply never runs.
bestiary.registerGoalType(this, new NamespacedKey(this, "spin"), (context, args) -> new AiGoal() {
@Override
public boolean shouldActivate() {
return context.target() != null;
}
@Override
public void tick() {
Location location = context.mob().getLocation();
context.mob().setRotation(location.getYaw() + 20, location.getPitch());
}
@Override
public Set<GoalCategory> categories() {
return Set.of(GoalCategory.LOOK);
}
});
Scheduling
Use bestiary.scheduler().
| Method | Runs on |
|---|---|
run, runLater, runTimer | wherever global work belongs |
atEntity, atEntityTimer, atEntityLater | the thread owning that entity, following it between regions |
atLocation | the thread owning that region |
async | off the server threads; must not touch world state |
teleport | safely on both backends |
Events
| Event | When |
|---|---|
BestiaryMobSpawnEvent | after a mob is spawned and fully configured; cancelling removes it |
BestiaryMobDeathEvent | on death, before drops are rolled, with every damage contributor |
BestiaryPhaseChangeEvent | when a mob advances a phase |
Statistics
killCount, totalKillCount and anchorCooldownMillis are served from the in-memory view maintained alongside the storage writes, never from a query.
They are safe to call on the main thread as often as you like, which is the same guarantee the %bestiary_*% placeholders rely on.
Guards apply to you too
Depth, mechanic count, target count and tick budget are charged on the one path every mechanic goes through, so a third-party mechanic is bounded by them without doing anything.
The max_targets cap is applied by the engine rather than by each targeter, which means a registered targeter cannot forget it. See Performance.
The rest of the runtime surface is read-only or explicit: mobs() returns compiled definitions,
activeMobs() returns managed live instances, and skillIds() and dropTableIds() expose loaded
registries. castSkill(...) accepts an arbitrary caster, initial targets and power; an empty target
list lets each line resolve its own targeter.
Acquire and register through the API during or after onEnable, once a declared dependency has
allowed Bestiary to enable. Do not resolve the service in a static initializer. Registrations are
owner-scoped and ignored after their plugin disables.