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

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

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](https://xichil.dev/docs/xis-mvui/v1/getting-started/installation/index.md) done: the API resolves from `https://maven.xichil.dev`.
- A menu to ship. [Your first menu](https://xichil.dev/docs/xis-mvui/v1/getting-started/first-menu/index.md) 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](#5-keep-minecraft-out-of-the-shared-code)).

## 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.json` and call a `ClientModInitializer`, 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 the `net.minecraftforge` packages),
  load `ForgeEntry`; NeoForge 1.20.2 and newer loads `NeoForgeEntry`. Forge and NeoForge up to 1.20.4 read
  `mods.toml`; NeoForge 1.20.5 and newer reads `neoforge.mods.toml`. Ship both files.
- **Forge 1.8 – 1.12.2 and Forge 1.7.10** have `@Mod` in different packages (`net.minecraftforge.fml.common`
  and `cpw.mods.fml.common`) and need `guiFactory` for 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:

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

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

/** 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.

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

@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`.

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

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

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

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

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

`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](https://xichil.dev/docs/xis-mvui/v1/guides/registering/index.md#dedicated-servers) 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](https://xichil.dev/docs/xis-mvui/v1/examples/multi-loader-mod/index.md) has every file in full. The parts that matter for the
library:

- `fabric.mod.json`: `"environment": "client"`, the `client` entrypoint, and `"xis_mvui": ">=1.0.0"` in `depends`.
- `META-INF/mods.toml`: a `[[dependencies.mymod]]` entry with `modId = "xis_mvui"`, `mandatory = true`,
  `side = "CLIENT"`.
- `META-INF/neoforge.mods.toml`: the same entry with `type = "required"` in place of `mandatory = 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:

```kotlin title="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:

- **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 `compileOnly` class path and is never packed into a
  jar. The real class is there at runtime. For Forge 1.13.2+:

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

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

> **Tip:** The stub approach compiles every family without downloading any Minecraft or Forge, which keeps a mod that is
> only a menu fast to build. It only works because the entry classes touch nothing but `@Mod`. As soon as an
> entry source set calls real Forge or Minecraft code, compile it against the real jar.

### 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`**: use `host.game()` from inside a menu ([Read and write options.txt](https://xichil.dev/docs/xis-mvui/v1/guides/game-options/index.md)),
  or `MultiversionUi.reloadGameOptions()` from anywhere.
- **Key bindings**: store a [Key](https://xichil.dev/javadoc/xis-mvui/v1/dev/xichil/mvui/Key.html) in shared code; compare it with the game's input
  in the game layer ([Key bindings](https://xichil.dev/docs/xis-mvui/v1/guides/keybinds/index.md)).
- **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`:

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

```java title="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](https://xichil.dev/docs/xis-mvui/v1/guides/testing/index.md).

## Checklist

- [ ] `src/main` imports nothing outside `dev.xichil.mvui` and the JDK, and compiles for Java 8.
- [ ] The API is `compileOnly` in 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_mvui` dependency.
- [ ] The Forge 1.7.10 – 1.12.2 entries set `guiFactory` for 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](https://xichil.dev/docs/xis-mvui/v1/examples/multi-loader-mod/index.md): every file of a working project with this layout.
- [Register your menu](https://xichil.dev/docs/xis-mvui/v1/guides/registering/index.md): details of the call and of dedicated servers.
- [Backends and versions](https://xichil.dev/docs/xis-mvui/v1/concepts/backends/index.md): how the library picks the right code at runtime.
