Chapter 13, The Scalpel

Beta Minecraft has no events and no hooks. The only way to change anything is to cut into the game itself, so let's learn to cut cleanly.

Every chapter before this one used RetroAPI's friendly front door: RetroBlockAccess, RetroRecipes, RetroEntities. But those APIs exist because someone wrote the mixins underneath them. When you need to change something RetroAPI doesn't wrap, pigs' sounds, fall damage, the color of grass, there is no API. There is only the mixin.

A mixin is a class you write that the loader merges into a vanilla class at launch. Your method becomes part of PlayerEntity; your field becomes a field on ItemEntity. Modern Minecraft has events and Fabric API hooks to spare you most of this. Beta has none of that, there never was an API, so beta modding is extremely mixin-heavy. Nearly every interesting thing your mod does, it does by mixin.

And that has a consequence that defines the whole craft. When every mod in a pack is rewriting the same small 2011 codebase, two mods reaching for the same method is not a freak accident, it's the expected case. Collisions are the norm. So the core skill of beta modding isn't writing a mixin that works; it's writing a mixin that works alongside everyone else's. That discipline is what this chapter teaches, through twelve graded examples shipped in the showcase.

Here are the principles. Read them once now; they'll make sense in your hands by example twelve.

  1. Prefer additive over exclusive. @Inject adds your code beside vanilla's; @Redirect and @Overwrite replace it. Additive injections from many mods coexist. Exclusive ones fight.
  2. Cancel conditionally, never unconditionally. An unconditional ci.cancel() deletes a vanilla behavior for the entire game and silently breaks every other mod hooking it.
  3. Mixins into base classes run for everything. Hook Entity or LivingEntity and your code fires for pigs, arrows, and players alike, always check your target instance first.
  4. One mixin = one job, self-contained. Each file does exactly one thing; delete the file and you lose exactly that feature, nothing else.
  5. Prefix everything with your mod id. Handler methods, @Unique fields, all named yourmodid$whatever. Two mods adding a plain mana field collide; prefixed ones never do.
  6. Sided mixins go in the right list. Client-only mixins belong in the "client" list of your mixins config, put one in the common list and a dedicated server crashes loading a class that doesn't exist there.
  7. When a tool is exclusive, reach for MixinExtras. It ships with this stack and turns every exclusive operation into a chainable one. More on that next.

@Redirect and @Overwrite are LAST RESORTS. They are exclusive: if two mods @Redirect the same call (or @Overwrite the same method), the game crashes at launch, Mixin refuses to apply the second one. In a mixin-heavy ecosystem like beta, "my mod plus any other mod touching this method" is not rare. It is the expected case.

Use MixinExtras instead. It's already on the classpath here, just import com.llamalad7.mixinextras.*. The replacements:

  • @WrapOperation replaces @Redirect, chainable, and you can still call the original.
  • @WrapMethod (or @Inject + ci.cancel()) replaces @Overwrite.
  • @ModifyReturnValue replaces @Inject-at-RETURN + setReturnValue.

Where exclusive tools fight, these nest like onion layers, every mod's handler runs. Docs: MixinExtras wiki, and specifically WrapOperation, WrapMethod, and ModifyReturnValue.

The configuration: three lists

Before the examples, the file that wires them all up. Every mixin must be listed in your mixins config, and which list decides where it loads. Here is the showcase's config in full:

src/main/resources/example_mod.mixins.json
{
    "required": true,
    "minVersion": "0.8",
    "package": "com.example.example_mod.mixin",
    "compatibilityLevel": "JAVA_8",
    "mixins": [
        "LivingEntityJumpMixin",
        "examples.Example02ItemPopMixin",
        "examples.Example03NoSneakFallDamageMixin",
        "examples.Example04PigSoundMixin",
        "examples.Example06JumpInvoker",
        "examples.Example09FastTntMixin",
        "examples.Example10PlayerManaMixin",
        "examples.Example12ModifyReturnValueMixin"
    ],
    "client": [
        "PlayerTickMixin",
        "examples.Example01TitleScreenLogMixin",
        "examples.Example05HudTextMixin",
        "examples.Example07TorchFlameMixin",
        "examples.Example08PurpleGrassMixin",
        "examples.Example11WrapOperationMixin"
    ],
    "server": [
        "ServerPlayerTickMixin"
    ],
    "injectors": {
        "defaultRequire": 1
    }
}

The three lists are the whole sided-loading system:

  • "mixins", common: loaded on both the client and the dedicated server. Only mixins whose target class exists on both sides go here.
  • "client", loaded only on the client. Anything touching TitleScreen, InGameHud, rendering, or other client-only classes lives here.
  • "server", loaded only on the dedicated server. ServerPlayerEntity hooks go here.

The paths are relative to "package", so examples.Example01TitleScreenLogMixin is the class in the mixin.examples sub-package. And defaultRequire: 1 at the bottom means every injector must find its target, if a name is wrong, you crash at launch instead of silently doing nothing. That's a feature; more on it in the practical notes.

The twelve-example course

The showcase ships a graded course at src/main/java/com/example/example_mod/mixin/examples/. Each file teaches one tool, does one visible thing in game, and is fully self-contained, delete any file and you lose only its feature. We'll walk all twelve in order.

1. @Inject at HEAD, observe without changing

In game: a log line prints when the title screen opens. Teaches: the gentlest mixin, run your code when a method runs, change nothing.

mixin/examples/Example01TitleScreenLogMixin.java
@Mixin(TitleScreen.class)
public class Example01TitleScreenLogMixin {

    @Inject(method = "init", at = @At("HEAD"))
    private void example_mod$onInit(CallbackInfo ci) {
        ExampleMod.LOGGER.info("[mixin example 1] the title screen is opening!");
    }
}

Three coordinates define every injection: @Mixin(TitleScreen.class) says which vanilla class to merge into, method = "init" says which method to hook, and at = @At("HEAD") says where, HEAD is the very top. Every @Inject handler takes a CallbackInfo. TitleScreen is client-only, so this mixin lives in the "client" list, a common-list placement would crash a dedicated server.

2. @Inject into a constructor, and the this idiom

In game: dropped items pop upward with a little flourish. Teaches: injecting into a constructor, and getting a usable reference to the instance.

mixin/examples/Example02ItemPopMixin.java
@Mixin(ItemEntity.class)
public class Example02ItemPopMixin {

    @Inject(
        method = "<init>(Lnet/minecraft/world/World;DDDLnet/minecraft/item/ItemStack;)V",
        at = @At("TAIL")
    )
    private void example_mod$pop(World world, double x, double y, double z, ItemStack stack, CallbackInfo ci) {
        ItemEntity self = (ItemEntity) (Object) this;
        self.velocityY += 0.25; // a little extra upward kick
    }
}

Two new ideas. First, method = "<init>(...)" targets a constructor, and you give the full descriptor to pick one overload (here the World, x, y, z, ItemStack one used for real drops, not the NBT-loading one). Constructor injections are only allowed at TAIL/RETURN, the object must be fully built before you touch it. Second, (ItemEntity) (Object) this: inside the mixin this is the mixin class, but at runtime the code lives inside ItemEntity. The double cast is the standard idiom to get a usable reference. Note the handler also receives the constructor's parameters.

3. Cancellable @Inject, and conditional-cancel discipline

In game: players who are sneaking take no fall damage. Teaches: stopping vanilla from running, carefully.

mixin/examples/Example03NoSneakFallDamageMixin.java
@Mixin(LivingEntity.class)
public class Example03NoSneakFallDamageMixin {

    @Inject(method = "onLanding", at = @At("HEAD"), cancellable = true)
    private void example_mod$softLanding(float fallDistance, CallbackInfo ci) {
        LivingEntity self = (LivingEntity) (Object) this;
        if (self instanceof PlayerEntity && self.isSneaking()) {
            ci.cancel(); // skip vanilla's fall-damage code entirely
        }
    }
}

With cancellable = true, the CallbackInfo gains ci.cancel(), which makes the target return immediately. This is where discipline starts to matter, and the mixin demonstrates two of our principles at once. The cancel is conditional, an unconditional cancel here would disable fall damage for every living thing in the game and break other mods. And because onLanding lives on LivingEntity and runs for everything, it checks instanceof PlayerEntity first. HEAD-plus-cancel is still friendlier than @Overwrite: other mods' HEAD injections still run; an overwrite would silently delete them.

4. @Inject at RETURN, changing a return value

In game: pigs make the counter block's custom click sound instead of oinking. Teaches: reading and replacing a method's return value with CallbackInfoReturnable.

mixin/examples/Example04PigSoundMixin.java
@Mixin(PigEntity.class)
public class Example04PigSoundMixin {

    @Inject(method = "getRandomSound", at = @At("RETURN"), cancellable = true)
    private void example_mod$clickyPigs(CallbackInfoReturnable<String> cir) {
        // Sound event ids are strings in beta; modded ones are "<modid>:<path>".
        cir.setReturnValue("example_mod:counter.click");
    }
}

When the target returns a value, your handler gets a CallbackInfoReturnable<T> instead of a plain CallbackInfo. cir.setReturnValue(...) both sets the value and cancels the rest of the method, no separate flag needed. Injecting at RETURN (not HEAD) means vanilla computes its answer first and you get the final word; you could read it first via cir.getReturnValue(). In real mods, prefer the MixinExtras version of this pattern, @ModifyReturnValue chains where setReturnValue can fight. See example 12 for the side-by-side.

5. @Shadow, using the target's private fields

In game: "example mod" is drawn in the top-left of the HUD. Teaches: borrowing a private field from the target class, and casting this to a superclass.

mixin/examples/Example05HudTextMixin.java
@Mixin(InGameHud.class)
public class Example05HudTextMixin {

    // The real field is `private Minecraft minecraft` in InGameHud.
    @Shadow
    private Minecraft minecraft;

    @Inject(method = "render", at = @At("TAIL"))
    private void example_mod$drawBadge(float tickDelta, boolean inScreen, int mouseX, int mouseY, CallbackInfo ci) {
        DrawContext ctx = (DrawContext) (Object) this;
        ctx.drawTextWithShadow(this.minecraft.textRenderer, "example mod", 2, 2, 0x55FF55);
    }
}

A @Shadow field is a declaration that says "this field already exists in the target, let me use it." At runtime the two are the same field, so the name must match exactly. (@Shadow works for methods too.) Also shown: InGameHud extends DrawContext, vanilla's drawing helper, so casting this to it lets you draw in any render hook. Client-only, so it's in the "client" list.

6. @Invoker, an interface mixin to call a hidden method

In game: clicking the counter block makes you hop. Teaches: exposing a protected method as a public bridge via an interface mixin.

mixin/examples/Example06JumpInvoker.java
@Mixin(LivingEntity.class)
public interface Example06JumpInvoker {

    @Invoker("jump")
    void example_mod$jump();
}

LivingEntity.jump() is protected, only subclasses may call it. An @Invoker is an interface mixin: Mixin makes LivingEntity implement this interface, and the annotated method becomes a public bridge to the real one. Cast any living entity to the interface and call it: ((Example06JumpInvoker) player).example_mod$jump(), which is exactly what ExampleCounterBlock does (the Jump Stick in Chapter 5 uses the same trick). Its sibling @Accessor does the same for fields.

A design lesson from this file's comment. If the private member you're exposing is API-shaped, "what's this item's furnace burn time?", the bridge belongs in the shared library, not copy-pasted into every mod. That's exactly why the freezer in Chapter 6 calls RetroRecipes.getTotalFuelTime(...) instead of carrying its own invoker. Build the bridge once, in the library, and every mod benefits.

7. @ModifyArg, rewriting one argument of one call

In game: torches show flame particles instead of smoke. Teaches: surgically changing a single argument passed to a call inside a method.

mixin/examples/Example07TorchFlameMixin.java
@Mixin(TorchBlock.class)
public class Example07TorchFlameMixin {

    @ModifyArg(
        method = "randomDisplayTick",
        at = @At(
            value = "INVOKE",
            target = "Lnet/minecraft/world/World;addParticle(Ljava/lang/String;DDDDDD)V"
        ),
        index = 0 // the first argument: the particle name
    )
    private String example_mod$moreFlames(String particle) {
        // "smoke" becomes "flame"; the existing "flame" call passes through.
        return "smoke".equals(particle) ? "flame" : particle;
    }
}

Instead of hooking a whole method, @ModifyArg targets a call made inside it, the target selector matches the callee by its descriptor. You receive the chosen argument (here index = 0, the particle name) and return its replacement. This is the safer tool than @Redirect when all you want is to change a parameter: multiple mods can @ModifyArg the same call site, but only one mod's @Redirect can win. (Changing several arguments, or deciding whether the call happens at all, is @WrapOperation territory, example 11.) Client-only.

8. @Redirect, the cautionary tale

In game: grass renders purple only inside the example dimension (walk through the example portal to see it); the overworld stays green. Teaches: the tool you are being warned away from.

mixin/examples/Example08PurpleGrassMixin.java
@Mixin(GrassBlock.class)
public class Example08PurpleGrassMixin {

    @Redirect(
        method = "getColorMultiplier",
        at = @At(
            value = "INVOKE",
            target = "Lnet/minecraft/client/color/world/GrassColors;getColor(DD)I"
        )
    )
    private int example_mod$purpleGrass(double temperature, double humidity,
            BlockView view, int x, int y, int z) {
        // GOTCHA worth knowing: during chunk rendering the BlockView is a WorldRegion
        // wrapper, NOT the World, so "view instanceof World" is false right when this
        // code matters most. RetroWorlds.unwrap sees through the wrapper.
        World world = RetroWorlds.unwrap(view);
        if (world != null
            && ExampleMod.EXAMPLE_DIMENSION != null
            && world.dimension.id == ExampleMod.EXAMPLE_DIMENSION.getSerialId()) {
            return 0x9B30FF;
        }
        return GrassColors.getColor(temperature, humidity);
    }
}

A @Redirect swaps a call out for your method entirely, you get the arguments the call would have received and return your own answer; vanilla's getColor is only called here because we choose to call it ourselves outside the example dimension. Two lessons hide in the handler's signature. First, a @Redirect handler can take the enclosing method's own parameters appended after the redirected call's arguments, that is exactly how this one receives BlockView view, int x, int y, int z on top of the double temperature, double humidity that getColor itself was passed. Second, the render-time BlockView is a WorldRegion wrapper, not the World, so a plain view instanceof World fails precisely during chunk rendering; RetroAPI's com.periut.retroapi.world.RetroWorlds.unwrap(view) sees through the wrapper to the real world, which is how we can gate the purple to EXAMPLE_DIMENSION. It works, and it's a trap. The showcase wraps this file in a giant warning box, reproduced here because it is the most important thing in the chapter:

╔═══════════════════════════════════════════════════════════════════════╗
║  ⚠⚠⚠  STOP: @Redirect (and @Overwrite) ARE LAST RESORTS  ⚠⚠⚠           ║
║                                                                        ║
║  They are EXCLUSIVE: if two mods @Redirect the same call (or           ║
║  @Overwrite the same method), the game CRASHES AT LAUNCH, mixin        ║
║  refuses to apply the second one. In a mixin-heavy ecosystem like      ║
║  beta modding, "my mod + any other mod touching this method" is not    ║
║  a rare event, it is the expected case.                                ║
║                                                                        ║
║  USE MIXINEXTRAS INSTEAD, it ships with this stack already:            ║
║    instead of @Redirect   → @WrapOperation (chainable; you can even    ║
║                             still call the original, see               ║
║                             Example11WrapOperationMixin for this       ║
║                             exact grass-color case done right)         ║
║    instead of @Overwrite  → @WrapMethod, or @Inject + ci.cancel()      ║
║    instead of @Inject(RETURN) + CIR → @ModifyReturnValue               ║
║                             (see Example12ModifyReturnValueMixin)      ║
║                                                                        ║
║  Docs: https://github.com/LlamaLad7/MixinExtras/wiki                   ║
║   - Wiki: .../wiki/WrapOperation                                       ║
║   - Wiki: .../wiki/WrapMethod                                          ║
║   - Wiki: .../wiki/ModifyReturnValue                                   ║
╚═══════════════════════════════════════════════════════════════════════╝

The @Redirect stays in the showcase only so you can see the tool you're avoiding. The next example does this exact grass-color job the right way.

9. @ModifyConstant, changing a hard-coded number

In game: TNT explodes after 1 second instead of 4. Teaches: rewriting a literal constant baked into bytecode.

mixin/examples/Example09FastTntMixin.java
@Mixin(TntEntity.class)
public class Example09FastTntMixin {

    @ModifyConstant(
        method = "<init>(Lnet/minecraft/world/World;DDD)V",
        constant = @Constant(intValue = 80)
    )
    private int example_mod$shortFuse(int fuse) {
        return 20; // ticks: 20 = one second of sizzle
    }
}

Vanilla constructs primed TNT with this.fuse = 80, a bare literal in the bytecode, no method call to redirect and no argument to modify. @ModifyConstant finds the constant itself and routes it through your handler. Its pitfalls, worth knowing: it matches every 80 in the chosen method (fine here, there's one; in bigger methods narrow it with @Constant(ordinal = ...) or pick another tool); the value must be a genuine compile-time constant (runtime-computed values can't be caught); and like example 2, targeting a constructor needs the full descriptor.

10. The capstone, @Unique + a duck interface + NBT persistence

In game: every player gains a brand-new "mana" stat that saves with them. Teaches: the pattern real mods are built on, adding state to a vanilla class, exposing it through an interface, and persisting it. This one is two files.

First the duck interface, which lives in the normal source tree (not the mixin package):

src/main/java/com/example/example_mod/ExampleManaAccess.java
public interface ExampleManaAccess {

    int example_mod$getMana();

    void example_mod$setMana(int mana);
}

Then the mixin that adds the field, implements the interface, and hooks the player's own save/load:

mixin/examples/Example10PlayerManaMixin.java
@Mixin(PlayerEntity.class)
public class Example10PlayerManaMixin implements ExampleManaAccess {

    @Unique
    private int example_mod$mana = 0;

    @Override
    public int example_mod$getMana() {
        return this.example_mod$mana;
    }

    @Override
    public void example_mod$setMana(int mana) {
        this.example_mod$mana = mana;
    }

    @Inject(method = "writeNbt", at = @At("TAIL"))
    private void example_mod$saveMana(NbtCompound nbt, CallbackInfo ci) {
        nbt.putInt("example_mod:mana", this.example_mod$mana);
    }

    @Inject(method = "readNbt", at = @At("TAIL"))
    private void example_mod$loadMana(NbtCompound nbt, CallbackInfo ci) {
        this.example_mod$mana = nbt.getInt("example_mod:mana");
    }
}

Three tools work together. @Unique adds a genuinely new field to PlayerEntity, the example_mod$ prefix is convention and collision insurance (two mods adding plain mana would clash; prefixed uniques never do). implements ExampleManaAccess merges the interface into the target, so at runtime every PlayerEntity is an ExampleManaAccess, outside code just casts: ((ExampleManaAccess) player).example_mod$getMana(). And two @Injects piggyback on the player's own NBT save/load, so mana persists exactly as long as the player does (namespace your keys!). This is the same pattern RetroAPI itself uses to bolt fluent APIs onto vanilla classes.

11. MixinExtras @WrapOperation, @Redirect done right

In game: tall grass and ferns get a golden autumn tint. Teaches: wrapping a call so you can still run the original, the chainable replacement for @Redirect.

mixin/examples/Example11WrapOperationMixin.java
@Mixin(TallPlantBlock.class)
public class Example11WrapOperationMixin {

    @WrapOperation(
        method = "getColorMultiplier",
        at = @At(
            value = "INVOKE",
            target = "Lnet/minecraft/client/color/world/GrassColors;getColor(DD)I"
        )
    )
    private int example_mod$autumnPlants(double temperature, double humidity, Operation<Integer> original) {
        // First let vanilla (and any other mod's wrap) compute the real color...
        int color = original.call(temperature, humidity);
        // ...then blend each channel halfway toward gold (0xFFD700).
        int r = (((color >> 16) & 0xFF) + 0xFF) / 2;
        int g = (((color >> 8) & 0xFF) + 0xD7) / 2;
        int b = ((color & 0xFF) + 0x00) / 2;
        return (r << 16) | (g << 8) | b;
    }
}

Same vanilla pattern as example 8, a block asking GrassColors for its tint, but wrapped instead of redirected, and the difference is everything. You receive an Operation<Integer> handle, and original.call(...) runs what the call site would have done: vanilla's code or the next mod's wrap. A @Redirect erases that choice; here we blend with vanilla's answer instead of discarding it. And wraps from different mods nest instead of crashing, each mod's handler wraps the previous one, onion-style. Two @Redirects on this call would refuse to launch; any number of @WrapOperations coexist. MixinExtras is already on the classpath; just import it. Client-only.

12. MixinExtras @ModifyReturnValue, the clean return-value change

In game: hurting a pig plays the counter's click sound. Teaches: the cleaner replacement for example 4's @Inject-at-RETURN.

mixin/examples/Example12ModifyReturnValueMixin.java
@Mixin(PigEntity.class)
public class Example12ModifyReturnValueMixin {

    @ModifyReturnValue(method = "getHurtSound", at = @At("RETURN"))
    private String example_mod$clickOnHurt(String original) {
        return "example_mod:counter.click";
    }
}

The same job as example 4, on a sibling method, written the modern way. The file's comment puts the two side by side:

Example12ModifyReturnValueMixin.java, the comparison
  vanilla mixin:                          mixinextras:
  @Inject(method = "getRandomSound",      @ModifyReturnValue(
      at = @At("RETURN"),                     method = "getHurtSound",
      cancellable = true)                     at = @At("RETURN"))
  void m(CallbackInfoReturnable<String>   String m(String original) {
          cir) {                              return "...";
      cir.setReturnValue("...");          }
  }

The original return value arrives as a plain parameter, no cir juggling, no cancellable flag, you return the replacement, and the important part: multiple mods' @ModifyReturnValue handlers chain, each receiving the previous one's output. With raw setReturnValue, order-dependent fights are easy. Docs: ModifyReturnValue.

Mixins doing real work

The twelve above are a course. The showcase also ships three feature mixins that quietly power earlier chapters, the same tools, pointed at real goals:

  • PlayerTickMixin (client list), hooks PlayerEntity.tick() at HEAD to run the one-shot starter kit in singleplayer, where the world lives inside the client.
  • ServerPlayerTickMixin (server list), the dedicated-server twin, hooking ServerPlayerEntity.tick() to run the starter kit and drive the welcome packet from Chapter 12.
  • LivingEntityJumpMixin (common list), hooks LivingEntity.jump() to grant the "Jump for Joy" achievement (Chapter 9) the first time a player jumps, with a !world.isRemote guard so the grant only fires where physics actually run.

Look at LivingEntityJumpMixin if you want to see every principle in one file: it targets the declaring base class, checks instanceof PlayerEntity, gates on world.isRemote, prefixes its handler and its once-flag, and does exactly one job.

Practical notes

A few facts about how mixins actually resolve, so the error messages make sense:

  • Targets are matched against biny names and remapped at build time. You write method = "onLanding" against the mapped name; Loom rewrites it to the obfuscated name in the built jar. You never see the obfuscated names.
  • A wrong name is a crash at class-load, and that's good. With defaultRequire: 1, if a target method or field doesn't exist, the game fails loudly at launch instead of silently doing nothing. Fail fast, fix the name. (Chapter 22 shows what that crash looks like in the log.)
  • Verify names against the mapped jar in your Gradle cache, or against the decompiled sources Loom generates. Don't guess a method name, open the class and read it.
  • Constructors are targeted as <init>(descriptor) and are only TAIL-injectable, the object must be fully built before you touch it (examples 2 and 9).
  • Interface mixins add zero overhead. @Invoker, @Accessor, and duck interfaces compile down to direct field/method access, they're free.

That's the scalpel. You can now change anything the game does, and, more importantly, do it in a way that survives sitting next to forty other mods. Last stop: actually running all of this, on a client and on a real server.