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().

MethodRuns on
run, runLater, runTimerwherever global work belongs
atEntity, atEntityTimer, atEntityLaterthe thread owning that entity, following it between regions
atLocationthe thread owning that region
asyncoff the server threads; must not touch world state
teleportsafely on both backends

Events

EventWhen
BestiaryMobSpawnEventafter a mob is spawned and fully configured; cancelling removes it
BestiaryMobDeathEventon death, before drops are rolled, with every damage contributor
BestiaryPhaseChangeEventwhen 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.