Addon API
Another mod reaches LethalBreed through one entry point and a handful of small
classes and packages, all under oas.dreyka.lethalbreed.api. Nothing here needs a mixin: an
addon declares options, listens for events, reads what the mod currently believes, and adds its
own special zombies, through calls that stay stable across a release the way the rest of this
package's javadoc already promises.
| Class | What it is for |
|---|---|
| LethalBreedAddon | your entry point, called twice at startup |
| LethalBreedApi | declare an AI namespace, listen for a phase change |
| LethalBreedConfigApi | register your own options in the same config file |
| LethalBreedState | read-only: phase, tracked count, a zombie's variant, packs |
| api.event.* | six Fabric events: zombie adoption, contamination, mood, pack join, spawn cull, targeting |
| api.variant.* | add a special zombie of your own, passive, active or on death |
The entry point
LethalBreedAddon is a functional interface with one
required method and one optional one, called at two different moments of startup. Declare the
implementing class under the lethalbreed:addon entrypoint of your own
fabric.mod.json:
fabric.mod.json"entrypoints": {
"lethalbreed:addon": ["com.example.mymod.MyAddon"]
}
onLethalBreedReady() runs before the config file is
read and before anything reads a registry: this is where LethalBreedConfigApi.register
goes, along with any allowed AI namespace and phase listener, early enough to be known before the
first zombie loads. onConfigLoaded() is a default empty method, called
once the file has actually been read and clamped; override it only when a decision of yours
depends on the real value of an option rather than its default.
A whole addon
Everything below compiles against the package as it stands: an allowed namespace, an option registered next to this mod's own, a phase listener, a special variant, and one event listener that spares a boss from the mod's spawn cull.
MyAddon.javapackage com.example.mymod;
import oas.dreyka.lethalbreed.api.LethalBreedAddon;
import oas.dreyka.lethalbreed.api.LethalBreedApi;
import oas.dreyka.lethalbreed.api.LethalBreedConfigApi;
import oas.dreyka.lethalbreed.api.OptionBounds;
import oas.dreyka.lethalbreed.api.event.SpawnCullCallback;
import oas.dreyka.lethalbreed.api.variant.SpecialVariant;
import oas.dreyka.lethalbreed.api.variant.SpecialVariantRegistry;
public final class MyAddon implements LethalBreedAddon {
@Override
public void onLethalBreedReady() {
LethalBreedApi.allowAiNamespace("com.example.mymod.");
LethalBreedConfigApi.register("mymod", "MyMod", MyOptions.class,
new OptionBounds("mymodSpawnChance", 0.0, 1.0));
LethalBreedApi.onPhaseChanged((from, to) ->
MyMod.LOGGER.info("phase moved from {} to {}", from, to));
SpecialVariantRegistry.register(SpecialVariant.of("mymod:frostbite",
SpecialVariant.Kind.ACTIVE, 10, new FrostbiteBehavior()));
SpawnCullCallback.EVENT.register((entity, phase, proposed) ->
entity.getType() != MyEntities.SWAMP_BOSS && proposed);
}
}
MyOptions.javapackage com.example.mymod;
public final class MyOptions {
public static double mymodSpawnChance = 0.2;
}
Every field on the holder class has to start with the claimed prefix, checked
rather than trusted: a stray field that missed it would otherwise land in the Misc tab beside a
stranger's option. The prefix itself is at least three characters, and taking one already claimed,
or one that would capture an option that already exists, throws
IllegalArgumentException before anything is written down.
Reading what the mod knows
LethalBreedState is the read-only half of the surface:
nothing on it changes anything, and nothing on it needs a callback registered first. Everything
crossing the boundary is a vanilla type, a primitive, or LethalBreedState.Pack,
so an addon compiled against it keeps compiling when the mod's own internal state classes change
shape. Server thread only, same as the rest of the mod.
reading stateint phase = LethalBreedState.phase();
int tracked = LethalBreedState.trackedZombieCount();
SpecialVariant variant = LethalBreedState.variantOf(zombie);
List<LethalBreedState.Pack> packs = LethalBreedState.packs(level);
| Method | Answers |
|---|---|
| phase() | the night progression, zero on a fresh world, no upper bound |
| trackedZombieCount() | zombies the mod is driving, every dimension counted together |
| variantOf(Zombie) | the special variant it carries, or null for an ordinary one |
| packs(ServerLevel) | every pack in that dimension, as a snapshot taken now |
Events
Six Fabric events, each with a single question and a chained answer: a listener
is handed the verdict as it stands and returns the one it wants, so one with no opinion about a
given case returns proposed and changes nothing. A listener that throws
is reported once, in the server log, and treated from then on as if it had abstained; the mod does
not stop for a broken addon.
| Event | Method | Decides |
|---|---|---|
| ContaminationCallback | boolean allowContamination(LivingEntity, boolean) | whether a creature catches the plague |
| MoodCallback | MoodState mood(Zombie, MoodState) | which of the five moods a zombie holds for one activation |
| PackJoinCallback | boolean allowJoin(Zombie, long, boolean) | whether a zombie is let into a pack, pack id 0 (PackJoinCallback.FOUNDING) means founding rather than joining |
| SpawnCullCallback | boolean allowCull(Entity, int, boolean) | whether a hostile is culled (removed); return true to cull it, false to let it survive |
| TargetCandidateCallback | boolean isValidPrey(Mob, LivingEntity, boolean) | whether one creature is prey for one zombie |
| ZombieAdoptCallback | boolean allowAdopt(Zombie, boolean) | whether a zombie is taken over by the mod's brain; returning false leaves it entirely to vanilla |
ZombieAdoptCallback is the way out for an addon with a zombie of its own.
Everything that loads as a Zombie is taken over: husk, drowned, zombie
villager, and whatever subclass another mod wrote. That is the right default, since the whole
horde is what the mod is for, but a creature written to do something else ends up with its
goals stripped, its navigation driven by the flow field and a pack marching it away. Refusing
is total: the zombie keeps its own AI, joins no pack, is never scheduled. It is still a hostile
in a world where the mod culls hostiles, so that refusal almost always comes with a
SpawnCullCallback. The question is asked on every load rather than
once in the life of the world, because the addon that claimed it may well have been absent the
time before.
TargetCandidateCallback fires on the broad phase. Several hundred times a tick
on a busy server, inside the scan that already prices a horde. Read an attachment, compare an
entity type, return: anything that allocates, walks a chunk or takes a lock belongs somewhere
else. A datapack that only wants a fixed list of entity types spared from the spawn cull wants
the lethalbreed:spawn_protected tag instead, no code required.
Special variants
The eight shipped variants come in through the same door as an addon's own: the
roll, the save and the tick all read SpecialVariantRegistry and cannot
tell whose variant they are looking at. SpecialVariant.of(id, kind, weight,
behavior) is the short way in, available from phase zero at a fixed weight; the record's
full constructor accepts a live-read unlock phase and weight instead, for a variant whose
availability follows a config option rather than a constant.
A namespaced id ("mymod:frostbite") keeps two mods from
colliding; the eight shipped ones are bare words because saves already hold them that way. Register
from onLethalBreedReady() and never unregister: a variant that vanished
while a world holds zombies carrying its id would turn those zombies into a save entry that
resolves to nothing. Renaming one keeps the old id readable through
SpecialVariantRegistry.alias(oldId, id).
| Method | Called |
|---|---|
| onSpawn(Zombie) | PASSIVE, once, as the zombie finishes spawning |
| onUnassign(Zombie) | when the variant is taken back off, to undo onSpawn |
| tick(VariantContext) | ACTIVE, on the zombie's own staggered activation |
| onDeath(Zombie, ServerLevel) | DEATH, while the zombie is still in the world |
All four default to doing nothing, so an addon overrides the one its
Kind calls for and leaves the rest alone. One
VariantBehavior instance is shared by every zombie carrying that
variant: per-zombie state belongs on the zombie itself, through a Fabric data attachment of the
addon's own, not on a field of the behavior.
onUnassign covers a variant taken off a zombie, not an addon taken
out of the folder. A jar pulled from mods/ calls nothing: whatever
onSpawn made permanent stays in the NBT of the zombies already born,
and the mod loads them without a word because it is an ordinary effect. Applying an effect with
no duration is therefore a decision that outlives the addon, and an addon that wants to be able
to leave cleanly applies finite durations it renews in tick.
Where to report what breaks
The repository issues, github.com/Dreyka-Oas/LethalBreed/issues, with a GitHub account. The mod is under the MIT license, detailed on Installation: writing an addon against the surface above needs no permission beyond it, that is what it is for.