-
Notifications
You must be signed in to change notification settings - Fork 0
Soft Dependency Integrations
PlayerMob ships a clean pattern for integrating with an optional mod — code that compiles against another mod's API and runs only when that mod is present, while the published jar loads identically with or without it. The live example is the Dungeon Train integration. This page is the recipe.
It also documents the related back-fill seam (MENU_OPENER) for the more general
"common code needs loader-specific behaviour" problem.
You want PlayerMob to behave differently when mod X is installed, but:
- your jar must still load when X is absent (no
NoClassDefFoundError), and - you must not bundle X or hard-depend on it.
The trick has three parts: a vanilla-only interface in common, a set-once
holder with a no-op default, and a loaded-check at boot that installs the real
implementation — with the X-importing class kept behind the guard so it's never
classloaded when X is missing.
Two small types in games.brennan.playermob.compat, both referencing only vanilla
classes so they compile on every loader:
public interface TrainEnvironment {
boolean isOnTrain(Entity self);
boolean sameTrain(Entity self, Entity candidate);
boolean sameTrain(Entity self, BlockPos candidatePos);
// No-op default used whenever no integration is installed.
TrainEnvironment ABSENT = new TrainEnvironment() {
public boolean isOnTrain(Entity self) { return false; }
public boolean sameTrain(Entity self, Entity candidate) { return false; }
public boolean sameTrain(Entity self, BlockPos pos) { return false; }
};
}public final class TrainConfinement {
private static volatile TrainEnvironment environment = TrainEnvironment.ABSENT;
public static void install(TrainEnvironment env) { environment = env; }
public static boolean isConfined(Entity self) { return environment.isOnTrain(self); }
// Allowed unless self is confined to a train and the candidate isn't on it.
public static boolean allowsTarget(Entity self, Entity candidate) {
TrainEnvironment env = environment;
return !env.isOnTrain(self) || env.sameTrain(self, candidate);
}
public static boolean allowsTarget(Entity self, BlockPos pos) { /* BlockPos variant */ }
}The default is ABSENT, whose every method says "not on a train", so every
allowsTarget short-circuits to true at zero cost. The AI consults
TrainConfinement.allowsTarget(...) in its target predicate and in a
customServerAiStep sweep (see AI Goals) — with no integration installed, those
calls are free and behaviour is identical to a build without the seam.
The real implementation compiles against the optional mod's API as
compileOnly / modCompileOnly — on the classpath at compile time, never
shipped, never a runtime requirement. From
neoforge/build.gradle:
repositories {
maven { name = "Modrinth"; url = "https://api.modrinth.com/maven"
content { includeGroup "maven.modrinth" } }
}
dependencies {
// Compile-only against Dungeon Train's API: NOT bundled, NOT a runtime requirement.
// transitive=false keeps DT's own deps off our classpath — we only need its classes.
modCompileOnly("maven.modrinth:dungeon-train:${rootProject.dungeon_train_version}") { transitive = false }
// DT's API names JOML types in its signatures, so these must resolve at compile
// time even though we call none of those methods. Plain libs — not remapped, not shipped.
compileOnly "org.joml:joml:1.10.5"
compileOnly "org.joml:joml-primitives:1.10.0"
}Note the integration lives in the NeoForge module only — Dungeon Train is NeoForge-only, so there's nothing to integrate on Fabric/Forge. Put your integration in whichever loader module(s) the optional mod can actually load on.
At boot, install the real environment only when the mod is present — and keep the
optional-mod-importing class referenced only from behind the guard, in its own
method, so the JVM never classloads it (and thus never touches the missing X classes)
when X is absent. From
PlayerMobNeoForge:
private static void onCommonSetup(FMLCommonSetupEvent event) {
// ...
if (ModList.get().isLoaded("dungeontrain")) {
installDungeonTrain(); // isolated — see below
}
PlayerMob.init();
}
// The ONLY reference to DungeonTrainEnvironment (which imports DT classes).
// Reached only when dungeontrain is loaded, so DT classes are never loaded otherwise.
private static void installDungeonTrain() {
TrainConfinement.install(new DungeonTrainEnvironment());
}DungeonTrainEnvironment (the TrainEnvironment impl that actually imports Dungeon
Train types) lives in
neoforge/.../compat/DungeonTrainEnvironment.java and is named nowhere else.
Why the separate method matters: if
installDungeonTrain()'s body were inlined intoonCommonSetup, the verifier could classloadDungeonTrainEnvironment(and its DT imports) while resolvingonCommonSetup— crashing when DT is absent. Isolating the reference behind a method call that only runs inside theisLoadedguard prevents that.
-
Define a vanilla-only interface in
commondescribing what you need from the other mod, with anABSENTno-op default constant. -
Add a set-once holder (
volatilefield defaulting toABSENT) with aninstall(...)method and the query helpers your code calls. - Consult the holder from your gameplay code — cheap no-op when nothing's installed.
- Implement the interface in the loader module(s) that can load the optional mod, importing its API there (and only there).
-
Wire the build:
modCompileOnly(...) { transitive = false }against the mod's Maven artifact; add anycompileOnlylibs its public signatures name. -
Gate at boot:
if (ModList.get().isLoaded("<modid>")) install(new YourImpl());, with the impl-constructing call isolated in its own method so the impl class is never classloaded when the mod is absent.
(Fabric uses FabricLoader.getInstance().isModLoaded("<modid>"); Forge uses
ModList.get().isLoaded("<modid>").)
TrainConfinement.install(...) is public static, so a different mod could supply
its own TrainEnvironment (e.g. for another moving-platform mod) by calling it at
boot. Caveat: the holder is set-once / last-writer-wins (a single field), and the
confinement semantics are specific to "keep the mob on its current train" — so it's
best suited to one train integration at a time.
Not every seam is about an optional mod — some are about doing something each
loader expresses differently from shared common code. Opening a container menu
with a custom data payload is one: Fabric uses an ExtendedScreenHandlerFactory,
Forge/NeoForge use ServerPlayer.openMenu(provider, bufWriter).
The solution mirrors the back-fill from Architecture Overview: a functional
interface in common (PlayerMobMenuOpener),
a holder field (PlayerMobRegistry.MENU_OPENER), and a per-loader assignment at boot:
// common — the entity just calls the seam, knowing nothing about loaders:
PlayerMobRegistry.MENU_OPENER.open(serverPlayer, this);
// each loader's entry assigns its own implementation during boot:
PlayerMobRegistry.MENU_OPENER = (serverPlayer, mob) -> /* loader-specific open call */;When you add common-side behaviour that needs a loader-specific call, follow this
shape: interface + holder in common, assignment per loader. It's the same
mechanism as PlayerMobRegistry.PLAYER_MOB and TrainConfinement.install — just for
behaviour instead of a registered object.
Getting started
No-code (datapacks)
Java API
Patterns