Headshot

Evan Pratten

Software Developer



Using Bazel to create Minecraft modpacks

All content of this post is based around the work I did here

Back in 2012, I got in to Minecraft mod development, and soon after, put together an almost-vanilla client-side modpack for myself that mainly contained rendering, UI, and quality-of-life tweaks. While this modpack never got published, or was even given a name, I kept maintaining it for years until I eventually stopped playing Minecraft just before the release of Minecraft 1.9 (in 2016). I had gotten so used to the features of this modpack, that playing truly vanilla Minecraft didn't feel correct.

Recently, a few friends invited me to join their private Minecraft server, and despite having not touched the game for around four years, I decided to join. This was a bit of a mistake on their part, as they now get the pleasure of someone who used to main 1.6.4 constantly walking up to things and asking "What is this and how does it work?". I have started to get used to the very weird new collection of blocks, completely reworked command system, over-complicated combat system, and a new rendering system that makes everything "look wrong".

One major thing was still missing though, where was my modpack? I set out to rebuild my good old modpack (and finally give it a name, CorePack). Not much has changed, most of the same rendering and UI mods are back, along with the same GLSL shaders, and similar textures. Although, I did decide to take a "major step" and switch from the Forge Mod Loader to the Fabric Loader, since I prefer Fabric's API.

Curseforge & Bazel

I don't remember Curseforge existing back when I used to play regularly. It is a huge improvement over the PlanetMinecraft forums, as curse provides a clean way to access data about published Minecraft mods, and even has an API! Luckily, since I switched the modpack to Fabric, every mod I was looking for was available through curse (although, it seems NEI is a thing of the past).

My main goal for the updated version of CorePack was to design it in such a way I could make a CI pipeline generate new releases for me when mods are updated. This requires programmatically pulling information about mods, and their JAR files using a buildsystem script. Since this project involves working with a large amount of data from various external sources, I once-again chose to use Bazel, a buildsystem that excels at these kinds of projects.

While Curseforge provides a very easy to use API for working with mod data, @Wyn-Price (a fellow mod developer) has put together an amazing project called Curse Maven that I decided to use instead. Curse Maven is a serverless API that acts much like my Ultralight project. Any request for an artifact to Curse Maven will be redirected, and served from the Curseforge Maven server without the need for me to figure out the long-form artifact identifiers used internally by curse.

Curse Maven makes loading a mod (in this case, fabric-api) into Bazel as easy as:

# WORKSPACE
# Load bazel_maven_repository
http_archive(
    name = "maven_repository_rules",
    strip_prefix = "bazel_maven_repository-1.2.0",
    type = "zip",
    urls = ["https://github.com/square/bazel_maven_repository/archive/1.2.0.zip"],
)
load("@maven_repository_rules//maven:maven.bzl", "maven_repository_specification")
load("@maven_repository_rules//maven:jetifier.bzl", "jetifier_init")
jetifier_init()

# Declare any mods as maven artifacts
maven_repository_specification(
    name = "maven",
    artifacts = {
        "curse.maven:fabric-api:3049174": {"insecure": True}
    },
    repository_urls = [
        "https://www.cursemaven.com",
    ],
)

The above snippet uses a Bazel ruleset developed by Square, Inc. called bazel_maven_repository.

Modpack configuration

Since my pack is designed for use with MultiMC, two sets of configuration files are needed. The first set tells MultiMC which versions of LWJGL, Minecraft, and Fabric to use, and the second set are the in-game config files. Many of these files contain information that I would like to modify from Bazel during the modpack build step. Luckily, the Starlark core library comes with an action called expand_template. expand_template is basically a find-and-replace tool that will perform substitutions on files. Since this is an action, and not a rule, it must be wrapped with a small rule declaration:

# tools/template.bzl
def expand_template_impl(ctx):
    ctx.actions.expand_template(
        template = ctx.file.template,
        output = ctx.outputs.out,
        substitutions = {
            k: ctx.expand_location(v, ctx.attr.data)
            for k, v in ctx.attr.substitutions.items()
        },
        is_executable = ctx.attr.is_executable,
    )

expand_template = rule(
    implementation = expand_template_impl,
    attrs = {
        "template": attr.label(mandatory = True, allow_single_file = True),
        "substitutions": attr.string_dict(mandatory = True),
        "out": attr.output(mandatory = True),
        "is_executable": attr.bool(default = False, mandatory = False),
        "data": attr.label_list(allow_files = True),
    },
)

In a BUILD file, template rules can be defined as follows:

# BUILD
load("//tools:template.bzl", "expand_template")

expand_template(
    name = "my_config",
    template = "config.json.in",
    out = "config.json",
    substitutions = {
        "TEST_SUBS": "hello world"
    }
)

Using the following example file as config.json.in, this rule would have the following effect:

// config.json.in
{
    "key": "TEST_SUBS"
}

// config.json
{
    "key": "hello world"
}

Packaging

Once mods are loaded, and configuration files are defined in the buildsystem, I use a large number of filegroup and genrule rules to set up a directory hierarchy in the workspace, and wrap everything in a call to zipper to package the modpack into a ZIP file.

Finally, I use GitHub Actions to automatically run the buildscript, and publish the resulting MultiMC instance zip to the GitHub repo for this project.