Skip to content

Architecture

goshi-hider edited this page Jul 24, 2026 · 1 revision

Architecture

This page explains the handful of small, reused pieces that every OmniMixin event is built from. It's worth reading once — after this, every subsystem page will make sense at a glance, because they're all built the same way.

The three layers

Every feature in OmniMixin is really three small pieces working together:

  1. A context object (almost always a record) — a plain data carrier describing what's happening right now: which entity, how much damage, which block, and so on.
  2. An event class (PlayDamage, PlayBlock, PlaySpawn, ...) — a public final class with a private constructor, holding one or more public static final Event<T> fields. This is the only part you, as a consuming mod, ever touch directly.
  3. A mixin — lives under dev.goshi.omnimixin.internal.mixin and is never public API. It hooks the real vanilla method, builds the context object, fires the event, and applies whatever your listener changed.

You only ever interact with layers 1 and 2. Layer 3 is an implementation detail — you should never need to know or care which vanilla method a given event is wired to, only what the event promises to give you and when it fires.

Cancelling something: Cancellable

package dev.goshi.omnimixin.api.common;

public final class Cancellable {
    public void cancel() { }
    public boolean isCancelled() { return false; }
}

That's the entire class. Any context that includes a Cancellable supports vetoing whatever vanilla was about to do. You call .cancel() on it from inside your listener:

PlayBlock.INTERACT.register(ctx -> {
    if (ctx.state().isOf(Blocks.TNT) && !ctx.player().isCreative()) {
        ctx.cancel().cancel();
    }
});

Two things worth internalizing about cancellation:

  • It's cooperative, not magic. Cancelling a context only stops whatever that specific mixin was wrapping. It doesn't undo side effects that already happened before the event fired, and it doesn't retroactively change earlier decisions elsewhere in vanilla's call stack. Each event's own page (and its Javadoc) is explicit about exactly what cancelling does and doesn't affect — for example, Combat explains why cancelling PlayHealth.DEATH doesn't actually revive the entity.
  • Multiple listeners can all see the same Cancellable. If your listener runs after another mod's listener already cancelled the context, checking ctx.cancel().isCancelled() first lets you skip redundant work — though in practice this is rarely necessary, since the mixin itself checks the flag after every phase.

Changing a value: the Mutable* types

Java doesn't let you reassign a caller's local variable from inside a lambda, so OmniMixin uses small mutable boxes instead. There are four: MutableFloat, MutableDouble, MutableInt, and MutableReference<T>. They all follow the same shape:

public final class MutableFloat {
    public MutableFloat(float value) { }
    public float get() { }
    public void set(float value) { }
    public void add(float amount) { }
    public void multiply(float factor) { }
}

(MutableReference<T> only has get()/set(T), since "add" and "multiply" don't make sense for an arbitrary object.)

A context carrying a MutableFloat (or Double/Int/Reference) starts it pre-loaded with whatever vanilla's original value was — you read that starting point, then call set, add, or multiply to change it:

PlayKnockback.MODIFY_FORCE.register(ctx -> {
    // Halve knockback against anything already airborne.
    if (!ctx.entity().isOnGround()) {
        ctx.forceMultiplier().multiply(0.5f);
    }
});

If more than one listener touches the same Mutable*, they stack — each listener sees whatever the previous one left behind, not the original value. This is intentional: it's what lets two unrelated mods both add their own knockback resistance, fall-damage reduction, or FOV shift without having to know about each other, the same way vanilla's own additive enchantment/attribute systems work.

Multi-phase events

Some subsystems split a single vanilla action into two or three named moments instead of firing one event once — PlayDamage is the clearest example:

PRE_CALCULATE  →  MODIFY_AMOUNT  →  (damage applied)  →  POST_APPLY

PRE_CALCULATE fires first, before any adjustment, so listeners there see the truly original number. MODIFY_AMOUNT fires next and is where you actually scale or set the final damage. POST_APPLY fires after, telling you whether the damage actually went through (armor/absorption/game rules can still reduce real damage to zero without anything being "cancelled").

Critically, all three phases share the same context and the same underlying Mutable* — a change made in PRE_CALCULATE is what MODIFY_AMOUNT listeners see as their starting point, and it's what ultimately gets applied. Splitting into phases doesn't mean starting over each time; it means giving you a choice of when to make your change, depending on whether you want to see the truly-original value or whatever earlier listeners already did to it.

Boolean and multi-argument events

Most events are "fire and forget" — your listener returns void. A few return boolean (PlaySprint.CAN_SPRINT, PlaySpawn.CHECK_RULES) — for these, if any listener returns false, the whole thing is vetoed and no further listeners even run:

PlaySprint.CAN_SPRINT.register(ctx -> !isExhausted(ctx.entity()));

A couple of "after the fact" events pass a second, non-context argument alongside the context — for example PlayDamage.POST_APPLY's listener signature is (DamageContext context, boolean damageWasApplied), and PlaySprint.ON_TOGGLE's is (SprintContext context, boolean sprinting). Your lambda just takes two parameters instead of one in those cases:

PlayDamage.POST_APPLY.register((ctx, applied) -> {
    if (applied) {
        logDamageEvent(ctx.victim(), ctx.finalDamage().get());
    }
});

Error isolation

Every event dispatch runs each listener through a small internal helper (OmniMixinLog.safeRun) that catches and logs any exception a listener throws, then moves on to the next listener. In practice this means: a bug in your listener won't crash the game or corrupt the event for other mods' listeners. It'll show up in the log with the event's name attached, and everything else keeps running. This is a safety net, not a substitute for handling your own errors — but it does mean one misbehaving mod can't take down every other mod's OmniMixin listeners along with it.

What this architecture deliberately doesn't give you

  • No listener priority/ordering system. Listeners run in registration order, same as vanilla Fabric API events. If load order matters for your use case, you're responsible for coordinating it (e.g. via FabricLoader.getInstance().getEntrypointContainers() ordering, or a documented compatibility note in your own mod).
  • No automatic re-entrancy protection. If your listener itself triggers the same vanilla action recursively (e.g. a PlayDamage listener that calls entity.damage(...) again), you can cause infinite recursion just as you could writing the mixin by hand. OmniMixin doesn't add extra guardrails here beyond what vanilla already has.

With that out of the way, pick a subsystem page and start building.

Clone this wiki locally