Skip to content

Soft Dependency Integrations

Brennan Hatton edited this page Jun 7, 2026 · 1 revision

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.


The 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.


The seam

Two small types in games.brennan.playermob.compat, both referencing only vanilla classes so they compile on every loader:

TrainEnvironment — the interface

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; }
    };
}

TrainConfinement — the holder + the gate the AI consults

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.


Build wiring (compile-only, not bundled)

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.


Runtime gate + classload isolation

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 into onCommonSetup, the verifier could classload DungeonTrainEnvironment (and its DT imports) while resolving onCommonSetup — crashing when DT is absent. Isolating the reference behind a method call that only runs inside the isLoaded guard prevents that.


The recipe (for your own integration)

  1. Define a vanilla-only interface in common describing what you need from the other mod, with an ABSENT no-op default constant.
  2. Add a set-once holder (volatile field defaulting to ABSENT) with an install(...) method and the query helpers your code calls.
  3. Consult the holder from your gameplay code — cheap no-op when nothing's installed.
  4. Implement the interface in the loader module(s) that can load the optional mod, importing its API there (and only there).
  5. Wire the build: modCompileOnly(...) { transitive = false } against the mod's Maven artifact; add any compileOnly libs its public signatures name.
  6. 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>").)

Reusing the train seam from another mod

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.


Related pattern: loader-specific behaviour from common (MENU_OPENER)

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.