Build one mod for every loader

Structure a mod so one shared codebase ships as a jar per loader family, for Fabric, Quilt, Forge and NeoForge from Minecraft 1.7.10 to 26.3.

Last updated

Steps

This guide shows how to structure a mod so most of its code is written once and ships to every loader and Minecraft version the library supports. The layout is the one the library's own test mods use: one shared source set, plus a few lines per loader family.

Prerequisites#

The idea: three layers#

Layer What goes in it Compiled against Written
Shared (src/main) menus, config, settings logic, anything that doesn't touch Minecraft the API and the JDK (Java 8) only once
Entry (one source set per loader family) the loader's entry class and mod metadata, a few lines each the API, the shared code and that loader's entry API once per family
Game (only if your mod needs it) code that calls Minecraft itself: rendering, events, game logic that version's Minecraft once per version range that differs

The library removes the third layer for menus: the API has no Minecraft classes, so a menu written against it is the same source for 1.7.10 and 26.3. A mod that is only a menu over its own settings needs no game layer at all. A mod that also changes the game keeps that part behind an interface (see Step 5).

Which jars to build#

Build one jar per loader family, matching the library's own release jars:

Your jar Loaders and versions Entry class Metadata
mymod-<version>-fabric.jar Fabric and Quilt 1.14 – 26.3, Legacy Fabric 1.7.10 – 1.13.2 client entrypoint (ClientModInitializer) fabric.mod.json
mymod-<version>-modlauncher.jar Forge 1.13.2 – 26.3, NeoForge 1.20.1 – 26.3 @Mod class: one for Forge, one for NeoForge META-INF/mods.toml and META-INF/neoforge.mods.toml
mymod-<version>-forge-1.8-1.12.2.jar Forge 1.8 – 1.12.2 @Mod class (net.minecraftforge.fml.common.Mod) mcmod.info
mymod-<version>-forge-1.7.10.jar Forge 1.7.10 @Mod class (cpw.mods.fml.common.Mod) mcmod.info

Why these four:

You don't have to ship all four. Drop the families you don't support.

Steps#

1. Put the menu in the shared source set#

Everything in src/main/java uses only dev.xichil.mvui and the JDK. Give it one static method that the entry classes call:

src/main/java/com/example/mymod/MyMod.java
package com.example.mymod;

import dev.xichil.mvui.MultiversionUi;
import dev.xichil.mvui.Text;

import java.nio.file.Paths;

/** Each loader's entry class calls init() once, on the client. */
public final class MyMod {
    public static final String MOD_ID = "mymod";
    public static final Text NAME = Text.translatable(MOD_ID + ".name", "My Mod");

    private MyMod() {
    }

    public static void init() {
        // The game's working directory is the game directory on every loader.
        MyConfig config = MyConfig.load(Paths.get("config", MOD_ID + ".properties"));
        MultiversionUi.register(MOD_ID, NAME, new MyMenu(config));
    }
}

Compile the shared code for Java 8 (options.release = 8). Minecraft 1.7.10 to 1.16.5 run on Java 8, and newer versions run Java 8 bytecode too, so one build of the shared code fits every version.

2. Add an entry class per loader family#

Each entry class does one thing: call MyMod.init() when the loader starts the mod.

Fabric, Quilt, Legacy Fabric

src/fabric/java/com/example/mymod/loader/FabricEntry.java
package com.example.mymod.loader;

import com.example.mymod.MyMod;
import net.fabricmc.api.ClientModInitializer;

/** Fabric, Quilt and Legacy Fabric: the client entrypoint. Mod Menu's Config button works without more. */
public final class FabricEntry implements ClientModInitializer {
    @Override
    public void onInitializeClient() {
        MyMod.init();
    }
}

The client entrypoint only runs on the client, so nothing more is needed to keep it off servers.

Forge, NeoForge

Forge 1.13.2 and newer, and NeoForge 1.20.1: the @Mod constructor. Registering here also makes the menu the mod list's Config screen.

src/modlauncher/java/com/example/mymod/loader/ForgeEntry.java
package com.example.mymod.loader;

import com.example.mymod.MyMod;
import net.minecraftforge.fml.common.Mod;

@Mod(MyMod.MOD_ID)
public final class ForgeEntry {
    public ForgeEntry() {
        MyMod.init();
    }
}

NeoForge 1.20.2 and newer: the same, with NeoForge's annotation. It lives in the same jar as ForgeEntry.

src/modlauncher/java/com/example/mymod/loader/NeoForgeEntry.java
package com.example.mymod.loader;

import com.example.mymod.MyMod;
import net.neoforged.fml.common.Mod;

@Mod(MyMod.MOD_ID)
public final class NeoForgeEntry {
    public NeoForgeEntry() {
        MyMod.init();
    }
}

Forge 1.8 to 1.12.2: name and version come from mcmod.info. guiFactory makes the mod list's Config button open your menu.

src/forgeLegacy/java/com/example/mymod/loader/ForgeLegacyEntry.java
package com.example.mymod.loader;

import com.example.mymod.MyMod;
import dev.xichil.mvui.MultiversionUi;
import net.minecraftforge.fml.common.Mod;

@Mod(modid = MyMod.MOD_ID, useMetadata = true, acceptableRemoteVersions = "*",
        dependencies = "required-after:" + MultiversionUi.MOD_ID,
        guiFactory = MultiversionUi.LEGACY_FORGE_GUI_FACTORY)
public final class ForgeLegacyEntry {
    public ForgeLegacyEntry() {
        MyMod.init();
    }
}

Forge 1.7.10: the same with the cpw.mods.fml annotation and the 1.7.10 factory.

src/forgeCpw/java/com/example/mymod/loader/ForgeCpwEntry.java
package com.example.mymod.loader;

import com.example.mymod.MyMod;
import cpw.mods.fml.common.Mod;
import dev.xichil.mvui.MultiversionUi;

@Mod(modid = MyMod.MOD_ID, useMetadata = true, acceptableRemoteVersions = "*",
        dependencies = "required-after:" + MultiversionUi.MOD_ID,
        guiFactory = MultiversionUi.CPW_FORGE_GUI_FACTORY)
public final class ForgeCpwEntry {
    public ForgeCpwEntry() {
        MyMod.init();
    }
}

acceptableRemoteVersions = "*" lets the client join servers that don't have the mod, which suits a client-side mod. These constructors also run on dedicated servers; see Register your menu if your mod is installed there.

3. Add the metadata per loader family#

Each family's source set has its own resources folder with that loader's metadata file. Write placeholders like ${version} and let Gradle fill them in (step 4), so the id, name and version are typed once. The multi-loader example has every file in full. The parts that matter for the library:

4. Build one jar per family with Gradle#

Create a source set per family whose class path includes the shared code, and a jar task that packs the shared classes together with that family's classes and resources:

build.gradle.kts
plugins {
    java
}

repositories {
    maven("https://maven.xichil.dev")
    maven("https://maven.fabricmc.net/")
}

// Java 8 bytecode runs on every Minecraft version from 1.7.10 to 26.3.
tasks.withType<JavaCompile>().configureEach {
    options.release = 8
}

val main: SourceSet = sourceSets["main"]

dependencies {
    compileOnly("dev.xichil.mvui:xis-mvui-api:1.0.0")
    testImplementation("dev.xichil.mvui:xis-mvui-api:1.0.0")
    testImplementation(testFixtures("dev.xichil.mvui:xis-mvui-api:1.0.0"))
}

// sourceSet name -> jar classifier
val families = mapOf(
    "fabric" to "fabric",
    "modlauncher" to "modlauncher",
    "forgeLegacy" to "forge-1.8-1.12.2",
    "forgeCpw" to "forge-1.7.10",
)

families.forEach { (name, classifier) ->
    val set = sourceSets.create(name) {
        compileClasspath += main.output
    }
    dependencies {
        add(set.compileOnlyConfigurationName, "dev.xichil.mvui:xis-mvui-api:1.0.0")
    }
    tasks.named<ProcessResources>(set.processResourcesTaskName) {
        val props = mapOf("version" to project.version.toString())
        inputs.properties(props)
        filesMatching(listOf("fabric.mod.json", "META-INF/*.toml", "mcmod.info", "version.properties")) {
            expand(props)
        }
    }
    val familyJar = tasks.register<Jar>("${name}Jar") {
        archiveClassifier = classifier
        from(main.output, set.output)
    }
    tasks.assemble { dependsOn(familyJar) }
}

// The shared classes alone load on no loader.
tasks.jar { enabled = false }

// Each entry source set also needs its loader's entry API at compile time:
dependencies {
    "fabricCompileOnly"("net.fabricmc:fabric-loader:0.19.5")   // ClientModInitializer
    // "modlauncherCompileOnly"(…), "forgeLegacyCompileOnly"(…), "forgeCpwCompileOnly"(…): see below
}

The Forge entry classes need only the @Mod annotation (and nothing else) at compile time, because FML finds mods by scanning class files for the annotation. Two ways to provide it:

stubs/modlauncher/src/main/java/net/minecraftforge/fml/common/Mod.java
package net.minecraftforge.fml.common;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/** Compile-only stub. Never packed into a jar: Forge provides the real annotation. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Mod {
    String value();
}

The NeoForge stub is the same in package net.neoforged.fml.common. For Forge 1.8 – 1.12.2, the stub in net.minecraftforge.fml.common declares the members your entry uses:

stubs/forge-legacy/src/main/java/net/minecraftforge/fml/common/Mod.java
package net.minecraftforge.fml.common;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/** Compile-only stub: only the members the entry class uses. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Mod {
    String modid();
    boolean useMetadata() default false;
    String acceptableRemoteVersions() default "";
    String dependencies() default "";
    String guiFactory() default "";
}

Forge 1.7.10's is identical in package cpw.mods.fml.common.

./gradlew assemble then writes the four jars to build/libs/.

5. Keep Minecraft out of the shared code#

The shared source set compiles against the API only, so the compiler stops any Minecraft class from sneaking in. When your mod needs something from the game:

src/main/java/com/example/mymod/GameAccess.java
package com.example.mymod;

/** What the shared code needs from the game; each version's game layer implements it. */
public interface GameAccess {
    String playerName();
}
src/main/java/com/example/mymod/MyMod.java
public static void init(GameAccess game) {
    MultiversionUi.register(MOD_ID, NAME, new MyMenu(config, game));
}

Suppliers in your menu then call game.playerName() and never see a Minecraft class.

6. Test the shared code once#

Because the menu has no Minecraft classes, one JUnit test with FakeHost covers every loader and version. See Test menus without Minecraft.

Checklist#

Next steps#