-
Notifications
You must be signed in to change notification settings - Fork 0
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.
Every feature in OmniMixin is really three small pieces working together:
-
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. -
An event class (
PlayDamage,PlayBlock,PlaySpawn, ...) — apublic final classwith a private constructor, holding one or morepublic static final Event<T>fields. This is the only part you, as a consuming mod, ever touch directly. -
A mixin — lives under
dev.goshi.omnimixin.internal.mixinand 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.
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.DEATHdoesn'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, checkingctx.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.
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.
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.
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());
}
});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.
-
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
PlayDamagelistener that callsentity.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.