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

URL: https://xichil.dev/docs/xis-mvui/v1/examples/multi-loader-mod/ · Updated: 2026-11-06 · Applies to: Fabric, Quilt, Legacy Fabric, Forge, NeoForge, MC 1.7.10 - 26.3

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](https://xichil.dev/docs/xis-mvui/v1/guides/multiversion-mod/index.md), 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

```text
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

```kotlin title="settings.gradle.kts"
rootProject.name = "mymod"
```

```kotlin title="build.gradle.kts"
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.

```java title="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.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));
    }
}
```

```java title="src/main/java/com/example/mymod/MyConfig.java"
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");
        }
    }
}
```

```java title="src/main/java/com/example/mymod/MyMenu.java"
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();
    }
}
```

```json title="src/main/resources/assets/mymod/lang/en_us.json"
{
  "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"
}
```

```java title="src/test/java/com/example/mymod/MyMenuTest.java"
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

```java title="src/fabric/java/com/example/mymod/loader/FabricEntry.java"
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();
    }
}
```

```json title="src/fabric/resources/fabric.mod.json"
{
  "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

```java title="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;

/** Forge 1.13.2+ and NeoForge 1.20.1. */
@Mod(MyMod.MOD_ID)
public final class ForgeEntry {
    public ForgeEntry() {
        MyMod.init();
    }
}
```

```java title="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;

/** NeoForge 1.20.2+. */
@Mod(MyMod.MOD_ID)
public final class NeoForgeEntry {
    public NeoForgeEntry() {
        MyMod.init();
    }
}
```

```toml title="src/modlauncher/resources/META-INF/mods.toml"
# 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"
```

```toml title="src/modlauncher/resources/META-INF/neoforge.mods.toml"
# 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"
```

```json title="src/modlauncher/resources/pack.mcmeta"
{
  "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

```java title="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;

/** 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();
    }
}
```

```json title="src/forgeLegacy/resources/mcmod.info"
[
  {
    "modid": "mymod",
    "name": "My Mod",
    "description": "A settings menu on every loader and version.",
    "version": "${version}",
    "requiredMods": ["xis_mvui"],
    "dependencies": ["xis_mvui"]
  }
]
```

```properties title="src/forgeLegacy/resources/version.properties"
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/`.

```java title="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();
    }
}
```

## Stubs

Each stub declares only what the entry classes use. The real annotations are there at runtime.

```java title="src/stubsModlauncher/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. FML finds mods by scanning class files for this annotation. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Mod {
    String value();
}
```

```java title="src/stubsModlauncher/java/net/neoforged/fml/common/Mod.java"
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();
}
```

```java title="src/stubsForgeLegacy/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 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/main` holds the whole mod. It compiles against the API only, so it can't
  reach Minecraft by accident, and one `FakeHost` test 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 `…Jar` task packs the shared output with one family's output. The plain `jar` task is
  off, because the shared classes alone have no entry point.
- **One `version`.** `processResources` writes the Gradle `version` into 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](https://xichil.dev/docs/xis-mvui/v1/guides/multiversion-mod/index.md#5-keep-minecraft-out-of-the-shared-code)).
