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.
Steps
- Prerequisites
- The idea: three layers
- Which jars to build
- Steps
- 1. Put the menu in the shared source set
- 2. Add an entry class per loader family
- 3. Add the metadata per loader family
- 4. Build one jar per family with Gradle
- 5. Keep Minecraft out of the shared code
- 6. Test the shared code once
- Checklist
- Next 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#
- Installation done: the API resolves from
https://maven.xichil.dev. - A menu to ship. Your first menu is enough.
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:
- Fabric, Quilt and Legacy Fabric all read
fabric.mod.jsonand call aClientModInitializer, so one jar covers them. (The library itself ships a separate Legacy Fabric jar only because Modrinth tags files by loader.) - Forge 1.13.2+ and NeoForge share the ModLauncher format. One jar holds two entry classes, each annotated
with its loader's own
@Mod: Forge, and NeoForge 1.20.1 (which still uses thenet.minecraftforgepackages), loadForgeEntry; NeoForge 1.20.2 and newer loadsNeoForgeEntry. Forge and NeoForge up to 1.20.4 readmods.toml; NeoForge 1.20.5 and newer readsneoforge.mods.toml. Ship both files. - Forge 1.8 – 1.12.2 and Forge 1.7.10 have
@Modin different packages (net.minecraftforge.fml.commonandcpw.mods.fml.common) and needguiFactoryfor the mod list's Config button.
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:
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
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.
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.
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.
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.
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:
fabric.mod.json:"environment": "client", thecliententrypoint, and"xis_mvui": ">=1.0.0"independs.META-INF/mods.toml: a[[dependencies.mymod]]entry withmodId = "xis_mvui",mandatory = true,side = "CLIENT".META-INF/neoforge.mods.toml: the same entry withtype = "required"in place ofmandatory = true.mcmod.info(Forge 1.7.10 – 1.12.2):"requiredMods": ["xis_mvui"]and"dependencies": ["xis_mvui"].
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:
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:
- The loader's own jar, from whatever toolchain you already use for that version.
- A compile-only stub of the annotation, which is what the library's test mods do. Put it in a separate
source set or project that is on the entry source set's
compileOnlyclass path and is never packed into a jar. The real class is there at runtime. For Forge 1.13.2+:
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:
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:
- Settings and files: use the JDK. Paths relative to the working directory (
Paths.get("config", …)) are in the game directory on every loader. - The game's
options.txt: usehost.game()from inside a menu (Read and write options.txt), orMultiversionUi.reloadGameOptions()from anywhere. - Key bindings: store a Key in shared code; compare it with the game's input in the game layer (Key bindings).
- Anything else from the game: declare an interface in the shared code and implement it per version in the
game layer. The entry class creates the implementation and passes it to
init:
package com.example.mymod;
/** What the shared code needs from the game; each version's game layer implements it. */
public interface GameAccess {
String playerName();
}
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#
- [ ]
src/mainimports nothing outsidedev.xichil.mvuiand the JDK, and compiles for Java 8. - [ ] The API is
compileOnlyin every source set and never packed into a jar. - [ ] One entry class per family calls the shared
init()once. - [ ] Every metadata file declares the
xis_mvuidependency. - [ ] The Forge 1.7.10 – 1.12.2 entries set
guiFactoryfor the mod list's Config button. - [ ] Each jar contains the shared classes plus exactly one family's classes and resources.
Next steps#
- A multi-loader mod: every file of a working project with this layout.
- Register your menu: details of the call and of dedicated servers.
- Backends and versions: how the library picks the right code at runtime.