Example: a multi-loader mod
Every file of a mod that builds four jars from one codebase, for Fabric, Quilt, Legacy Fabric, Forge and NeoForge from Minecraft 1.7.10 to 26.3.
On this page
A complete Gradle project for a mod whose only feature is a settings menu, built as four jars that together cover every loader and version the library supports. It follows Build one mod for every loader, which explains the reasoning; this page is the copyable result.
It compiles without downloading Minecraft or Forge: the entry classes only touch each loader's @Mod
annotation, which comes from small compile-only stubs.
Files#
mymod/
├── settings.gradle.kts
├── build.gradle.kts
└── src/
├── main/java/com/example/mymod/ shared: compiled against the API only
│ ├── MyMod.java
│ ├── MyConfig.java
│ └── MyMenu.java
├── main/resources/assets/mymod/lang/en_us.json
├── test/java/com/example/mymod/MyMenuTest.java
├── fabric/ Fabric, Quilt, Legacy Fabric
│ ├── java/com/example/mymod/loader/FabricEntry.java
│ └── resources/fabric.mod.json
├── modlauncher/ Forge 1.13.2+, NeoForge
│ ├── java/com/example/mymod/loader/ForgeEntry.java
│ ├── java/com/example/mymod/loader/NeoForgeEntry.java
│ └── resources/META-INF/mods.toml, META-INF/neoforge.mods.toml, pack.mcmeta
├── forgeLegacy/ Forge 1.8 – 1.12.2
│ ├── java/com/example/mymod/loader/ForgeLegacyEntry.java
│ └── resources/mcmod.info, version.properties
├── forgeCpw/ Forge 1.7.10
│ ├── java/com/example/mymod/loader/ForgeCpwEntry.java
│ └── resources/mcmod.info, version.properties
├── stubsModlauncher/java/… compile-only @Mod stubs, never in a jar
├── stubsForgeLegacy/java/…
└── stubsForgeCpw/java/…
./gradlew build runs the tests and writes build/libs/mymod-1.0.0-fabric.jar, -modlauncher.jar,
-forge-1.8-1.12.2.jar and -forge-1.7.10.jar.
Build scripts#
rootProject.name = "mymod"
plugins {
java
}
group = "com.example"
version = "1.0.0"
repositories {
mavenCentral()
maven("https://maven.xichil.dev")
maven("https://maven.fabricmc.net/")
}
val mvuiApi = "dev.xichil.mvui:xis-mvui-api:1.0.0"
// 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(mvuiApi) // never bundled: the library's jar provides it
testImplementation(mvuiApi)
testImplementation(testFixtures(mvuiApi)) // FakeHost
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform()
}
// Compile-only stubs of the Forge annotations: on the entry classes' class path, never in a jar.
val stubsModlauncher: SourceSet = sourceSets.create("stubsModlauncher")
val stubsForgeLegacy: SourceSet = sourceSets.create("stubsForgeLegacy")
val stubsForgeCpw: SourceSet = sourceSets.create("stubsForgeCpw")
/** One jar per loader family: its source set, the jar's classifier, and what its entry class compiles against. */
data class Family(val sourceSet: String, val classifier: String, val loaderApi: Any)
val families = listOf(
Family("fabric", "fabric", "net.fabricmc:fabric-loader:0.19.5"),
Family("modlauncher", "modlauncher", stubsModlauncher.output),
Family("forgeLegacy", "forge-1.8-1.12.2", stubsForgeLegacy.output),
Family("forgeCpw", "forge-1.7.10", stubsForgeCpw.output),
)
families.forEach { family ->
val set = sourceSets.create(family.sourceSet) {
compileClasspath += main.output
}
dependencies {
add(set.compileOnlyConfigurationName, mvuiApi)
add(set.compileOnlyConfigurationName, family.loaderApi)
}
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>("${family.sourceSet}Jar") {
group = "build"
description = "The mod's jar for ${family.classifier}."
archiveClassifier = family.classifier
from(main.output, set.output) // shared classes and lang files + this family's entry and metadata
}
tasks.assemble {
dependsOn(familyJar)
}
}
// The shared classes alone load on no loader.
tasks.jar {
enabled = false
}
Shared code#
These three files and the test are all the mod's logic. They import only dev.xichil.mvui and the JDK.
package com.example.mymod;
import dev.xichil.mvui.MultiversionUi;
import dev.xichil.mvui.Text;
import java.nio.file.Path;
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";
private MyMod() {
}
public static void init() {
// The game's working directory is the game directory on every loader.
Path file = Paths.get("config", MOD_ID + ".properties");
MultiversionUi.register(MOD_ID, Text.translatable("mymod.name", "My Mod"),
new MyMenu(MyConfig.load(file), file));
}
}
package com.example.mymod;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
public final class MyConfig {
public boolean sounds = true;
public int volume = 80;
public static MyConfig load(Path file) {
MyConfig c = new MyConfig();
if (!Files.exists(file)) {
return c;
}
Properties p = new Properties();
try (InputStream in = Files.newInputStream(file)) {
p.load(in);
c.sounds = Boolean.parseBoolean(p.getProperty("sounds", "true"));
c.volume = Integer.parseInt(p.getProperty("volume", "80").trim());
} catch (IOException | NumberFormatException e) {
return new MyConfig(); // unreadable: start from the defaults
}
return c;
}
public void save(Path file) throws IOException {
Properties p = new Properties();
p.setProperty("sounds", String.valueOf(sounds));
p.setProperty("volume", String.valueOf(volume));
Files.createDirectories(file.toAbsolutePath().getParent());
try (OutputStream out = Files.newOutputStream(file)) {
p.store(out, "My Mod settings");
}
}
}
package com.example.mymod;
import dev.xichil.mvui.Button;
import dev.xichil.mvui.MenuScreen;
import dev.xichil.mvui.Screen;
import dev.xichil.mvui.ScreenFactory;
import dev.xichil.mvui.Slider;
import dev.xichil.mvui.StatusLine;
import dev.xichil.mvui.Text;
import dev.xichil.mvui.Toggle;
import dev.xichil.mvui.UiHost;
import dev.xichil.mvui.UiTexts;
import java.nio.file.Path;
public final class MyMenu implements ScreenFactory {
private final MyConfig config;
private final Path file;
public MyMenu(MyConfig config, Path file) {
this.config = config;
this.file = file;
}
@Override
public Text buttonLabel() {
return Text.translatable("mymod.menu", "My Mod...");
}
@Override
public Screen create(UiHost host) {
StatusLine status = StatusLine.builder("status").build();
return MenuScreen.builder("main")
.title(Text.translatable("mymod.title", "My Mod"))
.add(Toggle.builder("sounds")
.label(Text.translatable("mymod.sounds", "Sounds"))
.value(() -> config.sounds)
.onChange(v -> config.sounds = v)
.build())
.add(Slider.builder("volume")
.label(Text.translatable("mymod.volume", "Volume"))
.range(0, 100).step(5)
.value(() -> config.volume)
.onChange(v -> config.volume = (int) v)
.enabled(() -> config.sounds)
.build())
.add(status)
.footer(Button.builder("done")
.label(UiTexts.DONE)
.onPress(() -> host.runAsync(() -> {
config.save(file);
return null;
}, (ignored, error) -> {
if (error != null) {
status.show(StatusLine.Kind.ERROR,
Text.translatable("mymod.save.failed", "Couldn't save the settings"));
} else {
host.back();
}
}))
.build())
.build();
}
}
{
"mymod.name": "My Mod",
"mymod.menu": "My Mod...",
"mymod.title": "My Mod",
"mymod.sounds": "Sounds",
"mymod.volume": "Volume",
"mymod.save.failed": "Couldn't save the settings"
}
package com.example.mymod;
import dev.xichil.mvui.fake.FakeHost;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MyMenuTest {
@TempDir
Path dir;
@Test
void soundsOffGreysOutVolumeAndDoneSaves() {
Path file = dir.resolve("mymod.properties");
MyConfig config = new MyConfig();
FakeHost host = new FakeHost();
host.open(new MyMenu(config, file).create(host));
host.click("sounds");
assertTrue(host.render().contains("[slider volume] Volume: 80 (disabled)"));
host.click("done");
assertEquals(0, host.depth());
assertFalse(MyConfig.load(file).sounds);
}
}
Fabric, Quilt and Legacy Fabric#
package com.example.mymod.loader;
import com.example.mymod.MyMod;
import net.fabricmc.api.ClientModInitializer;
public final class FabricEntry implements ClientModInitializer {
@Override
public void onInitializeClient() {
MyMod.init();
}
}
{
"schemaVersion": 1,
"id": "mymod",
"version": "${version}",
"name": "My Mod",
"description": "A settings menu on every loader and version.",
"license": "All Rights Reserved",
"environment": "client",
"entrypoints": {
"client": ["com.example.mymod.loader.FabricEntry"]
},
"depends": {
"fabricloader": ">=0.12.0",
"xis_mvui": ">=1.0.0"
}
}
Forge 1.13.2+ and NeoForge#
package com.example.mymod.loader;
import com.example.mymod.MyMod;
import net.minecraftforge.fml.common.Mod;
/** Forge 1.13.2+ and NeoForge 1.20.1. */
@Mod(MyMod.MOD_ID)
public final class ForgeEntry {
public ForgeEntry() {
MyMod.init();
}
}
package com.example.mymod.loader;
import com.example.mymod.MyMod;
import net.neoforged.fml.common.Mod;
/** NeoForge 1.20.2+. */
@Mod(MyMod.MOD_ID)
public final class NeoForgeEntry {
public NeoForgeEntry() {
MyMod.init();
}
}
# Read by Forge 1.13.2+ and NeoForge 1.20.1 - 1.20.4.
modLoader = "javafml"
loaderVersion = "[1,)"
license = "All Rights Reserved"
[[mods]]
modId = "mymod"
version = "${version}"
displayName = "My Mod"
description = '''A settings menu on every loader and version.'''
displayTest = "IGNORE_ALL_VERSION"
[[dependencies.mymod]]
modId = "xis_mvui"
mandatory = true
versionRange = "[1.0.0,)"
ordering = "NONE"
side = "CLIENT"
# Read by NeoForge 1.20.5+.
modLoader = "javafml"
loaderVersion = "[1,)"
license = "All Rights Reserved"
[[mods]]
modId = "mymod"
version = "${version}"
displayName = "My Mod"
description = '''A settings menu on every loader and version.'''
displayTest = "IGNORE_ALL_VERSION"
[[dependencies.mymod]]
modId = "xis_mvui"
type = "required"
versionRange = "[1.0.0,)"
ordering = "NONE"
side = "CLIENT"
{
"pack": {
"description": "My Mod resources",
"pack_format": 6
}
}
loaderVersion = "[1,)" matches both Forge's and NeoForge's loader versions. displayTest = "IGNORE_ALL_VERSION"
means a server doesn't need the mod, or a matching version of it, for the client to join.
Forge 1.8 – 1.12.2#
package com.example.mymod.loader;
import com.example.mymod.MyMod;
import dev.xichil.mvui.MultiversionUi;
import net.minecraftforge.fml.common.Mod;
/** Name and version come from mcmod.info and version.properties. */
@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();
}
}
[
{
"modid": "mymod",
"name": "My Mod",
"description": "A settings menu on every loader and version.",
"version": "${version}",
"requiredMods": ["xis_mvui"],
"dependencies": ["xis_mvui"]
}
]
mymod.version=${version}
Forge 1.7.10#
The same as Forge 1.8 – 1.12.2, with the cpw.mods.fml annotation and the 1.7.10 factory. mcmod.info and
version.properties are identical; put copies in src/forgeCpw/resources/.
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();
}
}
Stubs#
Each stub declares only what the entry classes use. The real annotations are there at runtime.
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. FML finds mods by scanning class files for this annotation. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Mod {
String value();
}
package net.neoforged.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 of NeoForge's mod annotation. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Mod {
String value();
}
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 of Forge 1.8 - 1.12.2's mod annotation: 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 "";
}
src/stubsForgeCpw/java/cpw/mods/fml/common/Mod.java is the same as the Forge 1.8 – 1.12.2 stub, with
package cpw.mods.fml.common;.
How it works#
- One shared source set.
src/mainholds the whole mod. It compiles against the API only, so it can't reach Minecraft by accident, and oneFakeHosttest covers every version. - Four tiny entry source sets. Each adds one or two entry classes and a metadata file. Each compiles against the shared classes, the API and its loader's entry API (Fabric Loader, or a stub).
- Four jars. Each
…Jartask packs the shared output with one family's output. The plainjartask is off, because the shared classes alone have no entry point. - One
version.processResourceswrites the Gradleversioninto every metadata file. - When the mod grows game code, give that code its own source sets compiled against the real Minecraft and loader jars, and keep it behind an interface the shared code defines (Step 5 of the guide).