[Slide: Title] Hello everyone, I'm really happy to meet you all here at BlanketCon '25. As you can see, you’ve signed up for a keynote about Cross-Version and Cross-Loader Mod Development. Over the next… however many minutes, I’ll share why multi-version and multi-loader support matters, walk through several real-world strategies, and close with a case study. Let’s dive in.
Next slide.
[Slide: Define the topic] This talk was inspired by Minecraft Transit Railway—a mod that maintained both Fabric and Forge builds across versions 1.16.5 through 1.20.1. Let’s define our topic first. When we say Cross-Version, we mean shipping one mod that compiles and runs on multiple Minecraft versions, making sure every update still benefits players on each version. Cross-Loader means having one codebase that targets both Fabric (and Quilt) and Forge variants like NeoForge or LexForge, so players on either loader can enjoy your mod.
Next slide.
[Slide: Why Bother?] Why add all this extra complexity?
[Slide: My Experience] I’ve put these principles into practice on several projects:
Vector3f
and JOML.RegistryWrapper
abstraction and polyfills for GUI and network changes.[Slide: You think I’m gonna give you SILVER BULLETS?] You might hope there’s a silver bullet, one best practice that solves everything. Sadly, as these pictures of me raging at my project setup would show you, there isn’t. My goal today is to walk you through all the practical approaches I’ve learned—each with their own trade-offs—so you can choose what fits your project.
Next slide.
[Slide: Cross-Loader Challenges] Tackling multiple loaders brings some core challenges:
You have to split your code into “common” logic and loader-specific implementations, which adds boilerplate. Fabric’s mixin-based callbacks and Forge’s patch-based event bus are two totally different systems. Sometimes a mixin will fail to inject because Forge has patched the method into something… else entirely. Dependency bloat can also be an issue. Even if modern computers have plenty of memory and storage, some players and developers still really dislike heavy bridging dependencies.
Next slide.
[Slide: Cross-Version Challenges] Supporting multiple Minecraft versions is a whole other level:
You’ll have a non-standard setup—custom Gradle subprojects, branches, or compiler plugins instead of a straightforward project. And when things go wrong, good luck finding someone else who knows what’s happening.
Mojang refactors core systems constantly, and it’s been getting faster. Things like the switch from PoseStack
to GuiGraphics
, changes to RegistryAccess
, the move to JOML… every version brings something new. And these refactors has created a completely new category of chore. You'll need to make sure very code change must be able to compile and run on every supported version.
And that means maintenance cost grows faster than your new feature development. Especially now—supporting 1.16 through 1.19 was doable. What about supporting 1.19, 1.20, and 1.21? That's very painful. Tons of subtle API differences, and each version has their own quirks.
Next slide.
[Slide: Sinytra Connector] First up, Sinytra Connector:
But it’s not perfect. You can’t hook into Forge’s event bus easily. Inter-operating with other Forge mods (like energy systems) can be messy.
Runtime mixin conflicts can happen due to Forge patching methods. And the added dependency footprint still bothers some users. As mentioned above, some players and developers still really dislike heavy bridging dependencies.
Next slide.
[Slide: Architectury Loom + FFAPI] Next up, Architectury Loom plus the Forgified Fabric API (FFAPI):
forgified-fabric-api
, either as dependency or via JiJ (Jar-in-Jar).[Slide: Architectury Loom + Architectury API] Another option is plain old Architectury API—the cross-platform layer before FFAPI existed. Honestly, I don’t know any advantage it has over FFAPI nowadays, other than being more “native.” Correct me if I’m wrong.
Next slide.
[Slide: Architectury + DIY Integration] The most flexible pattern is DIY integration with Architectury Loom:
@ExpectPlatform
to declare methods you implement separately per loader.
Next slide.
[Slide: Cross-Loader Comparison] Now let’s compare the approaches:
Next slide.
[Slide: Ideal (Not Yet Available)] Yup, we're done about cross-loader builds. Now let's talk about cross-verison builds.
On the cross-version side, the ideal would be a Bukkit-style unified API. It'd be even better if it works at compile time and compiles away all the differences at zero cost.
Jonathan Ho’s Minecraft-Mappings project tried this between 1.16 and 1.20, by wrapping registries, entities, graphics, and more. But it was abandoned because of the maintenance burden. And also, no wrapper will ever be truly exhaustive, meaning you'll occasionally run into something that they have not included in the abstraction.
So unfortunately, nothing like that fully exists yet, I hope one day there could be such a thing, we'll see.
Next slide.
[Slide: Subprojects per Version] Now let’s talk real-world approaches.
Subprojects per Version means one Gradle subproject per Minecraft version:
Next slide.
[Slide: Branches per Version] Branches per Version uses Git:
git cherry-pick
commits to other branches.Next slide.
[Slide: Preprocessor-Based Development] Preprocessor-Based development keeps everything in one source tree with conditional compile directives.
Btw this the way I finally went with, so I'm going to be putting the most attention on this. Do note that me using it doesn't mean it is good! I consider it useful, as codes for different versions all next to each other, which is easy for maintenance, and it's (less work than copying between folders / working with git cherry pick). Also you can have some mini wrapper abstraction classes in your project and have their impl swapped out for each version by preprocessor.
Now let's talk about the ugly side.
When developing new features it's easy to write codes that doesn't work in older versions, as we can't remember the entire Minecraft codebase changelog in our brains And because you only compile for one version when debugging locally, there's no way to immediately know you've done it wrong Plus sometimes Gradle cache gets invalidated when switching Minecraft version and resyncing, so you'd be relunctant to switch to different versions and test locally Now, you know, You can set up CI workflow to catch your mistakes after a push, this is what CI is for right? Well, CI workflows are good, and they will result in this:
This is some daily thing when I write codes. And waiting for a CI run to finish is tedious.
And the even worst part is that, it can also quickly result in veeeeery cursed code, and the more version you are supporting the more cursed it will become.
Now I'll give you a few seconds to appreciate whatever this stuff is. You guys need any explainations on this?
Next slide.
[Slide: Manifold Preprocessor] Manifold Preprocessor is a compiler plugin. It's also accompanied by an IDE plugin.
[Slide: ModMultiVersion by kitUIN] ModMultiVersion is an IntelliJ plugin:
Next slide.
[Slide: Cross-Version Comparison] I thought it'd make my speech look more professional if I just throws more tables at it.
Next slide.
[Slide: WorldComment Overview] Okay, now, Let’s look at WorldComment, my multi-loader, multi-version project:
[Slide: Dynamic Gradle build.gradle] In build.gradle
, I include build_dep_ver.gradle
, which reads a -PbuildVersion
property. This lets one build file adapt dynamically to different Minecraft versions and API setups.
Next slide.
[Slide: CI Matrix for Multi-Version] I use GitHub Actions with a version matrix. Each job checks out the repo, sets up Java, and runs ./gradlew build -PbuildVersion=${{ matrix.buildVersion }}
. This catches cross-version breakages I might have missed locally.
Next slide.
[Slide: (Failing Builds)] Which results in this, lemme remind you.
Next slide.
[Slide: RegistryWrapper Abstraction] (To be explained.)
Next slide.
[Slide: Polyfills: GuiGraphics] To handle GUI API drift, I wrote a GuiGraphics
polyfill:
GuiGraphics
.GuiComponents
.
Next slide.
[Slide: Polyfills: Networking] For networking, I created CompatPacket
in the NeoForge module:
Next slide.
[Slide: Pitfalls & Gotchas] Some real issues from this project:
[Slide: Final Thoughts] Wrapping up:
Next slide.
[Slide: Thank You] So, Thank you for your attentions! I hope these approaches we just covered can help your modding projects. I’m happy to take any questions now.