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

Combat

Package: dev.goshi.omnimixin.api.event.combat

Everything to do with health going down (PlayDamage, PlayKnockback, PlayArmor) and health going up or being lost permanently (PlayHealth), plus status effects (PlayStatusEffect).

PlayDamage — the full damage pipeline

Three phases sharing one [DamageContext](https://claude.ai/chat/a3c01413-be46-46f0-a149-92ab00193506#damagecontext):

PRE_CALCULATE  →  MODIFY_AMOUNT  →  (vanilla applies the damage)  →  POST_APPLY
// Immunity to fire damage for anything wearing my custom fire cloak.
PlayDamage.PRE_CALCULATE.register(ctx -> {
    if (ctx.source().isIn(DamageTypeTags.IS_FIRE) && isWearingFireCloak(ctx.victim())) {
        ctx.cancel().cancel();
    }
});

// A "last stand" perk: never let a hit drop a player below 1 HP.
PlayDamage.MODIFY_AMOUNT.register(ctx -> {
    if (ctx.victim() instanceof PlayerEntity player && hasLastStandPerk(player)) {
        float wouldSurvive = player.getHealth() - ctx.finalDamage().get();
        if (wouldSurvive < 1.0f) {
            ctx.finalDamage().set(player.getHealth() - 1.0f);
        }
    }
});

// Log every hit that actually landed, for a combat log feature.
PlayDamage.POST_APPLY.register((ctx, applied) -> {
    if (applied) {
        combatLog.record(ctx.victim(), ctx.source(), ctx.finalDamage().get());
    }
});

DamageContext(LivingEntity victim, DamageSource source, float originalDamage, MutableFloat finalDamage, Cancellable cancel)

originalDamage never changes across all three phases — it's always the true starting number, so you can always compare "what was it originally" against "what is it now" (finalDamage.get()). Cancelling from either PRE_CALCULATE or MODIFY_AMOUNT blocks the damage entirely and skips the remaining phases (including POST_APPLY).

PlayKnockback

PRE_APPLY and MODIFY_FORCE, sharing [KnockbackContext](https://claude.ai/chat/a3c01413-be46-46f0-a149-92ab00193506#knockbackcontext).

// Grant knockback resistance to anything standing in molasses.
PlayKnockback.MODIFY_FORCE.register(ctx -> {
    if (isInMolasses(ctx.entity())) {
        ctx.forceMultiplier().multiply(0.2f);
    }
});

// Fully immune to knockback while channeling a spell.
PlayKnockback.PRE_APPLY.register(ctx -> {
    if (isChanneling(ctx.entity())) {
        ctx.cancel().cancel();
    }
});

KnockbackContext(LivingEntity entity, double originalStrength, double x, double z, MutableFloat forceMultiplier, Cancellable cancel)

x/z describe the horizontal direction the knockback is being applied in — useful if you want to redirect rather than just scale it (you'd need to apply your own velocity change separately; this event only exposes a strength multiplier, not a direction override).

PlayArmor

MODIFY_PROTECTION adjusts how much damage armor blocks; ON_DURABILITY_DAMAGE lets you veto armor losing durability from a specific hit.

// A "vanity armor" toggle: skip durability loss, but keep the protection.
PlayArmor.ON_DURABILITY_DAMAGE.register(ctx -> {
    if (isVanityModeEnabled(ctx.victim())) {
        ctx.cancel().cancel();
    }
});

ArmorContext(LivingEntity victim, DamageSource source, float incomingDamage, float armorAdjustedDamage, MutableFloat modifier)

DurabilityContext(LivingEntity victim, DamageSource source, float amount, Cancellable cancel)

PlayStatusEffect

APPLY fires when a potion/beacon/other effect is about to be added to an entity; EXPIRE fires when one is about to be removed (naturally running out, milk bucket, etc.). Both are cancellable.

// Immunity to a specific custom "cursed" effect for anything holy-blessed.
PlayStatusEffect.APPLY.register(ctx -> {
    if (ctx.effect().getEffectType() == MyEffects.CURSED && isBlessed(ctx.entity())) {
        ctx.cancel().cancel();
    }
});

StatusEffectApplyContext(LivingEntity entity, StatusEffectInstance effect, Entity source, Cancellable cancel)

StatusEffectExpireContext(LivingEntity entity, RegistryEntry<StatusEffect> effect, Cancellable cancel)

Note: only application and expiry are covered — a per-tick TICK hook is not, see Known Limitations.

PlayHealth

Covers healing, death, and absorption (yellow hearts) — the "gaining or holding onto health" side of combat, as opposed to PlayDamage's "losing it" side.

// Double the effectiveness of all healing.
PlayHealth.HEAL.register(ctx -> ctx.modifier().multiply(2.0f));

// Custom last-words message on death.
PlayHealth.DEATH.register(ctx -> {
    if (ctx.entity() instanceof PlayerEntity player) {
        broadcastLastWords(player, ctx.source());
    }
});

// Absorption hearts decay 50% slower for anything under a "ward" buff.
PlayHealth.ABSORPTION_CHANGE.register(ctx -> {
    if (ctx.newAmount() < ctx.oldAmount() && hasWardBuff(ctx.entity())) {
        float decayed = ctx.oldAmount() - ctx.newAmount();
        ctx.modifier().set(ctx.oldAmount() - decayed * 0.5f);
    }
});

HealContext(LivingEntity entity, float originalAmount, MutableFloat modifier, Cancellable cancel)

DeathContext(LivingEntity entity, DamageSource source, Cancellable cancel)

Important: by the time DEATH fires, the entity's health has already hit zero and vanilla has already committed to the kill. Cancelling here skips onDeath's own side effects — loot drops, the death message, advancement and stat updates, the death animation and sound — but the entity does not come back to life. It stays at zero health without ever finishing its death sequence. If you actually want to prevent a kill, intervene earlier in PlayDamage.MODIFY_AMOUNT and cap the damage short of lethal instead.

AbsorptionChangeContext(LivingEntity entity, float oldAmount, float newAmount, MutableFloat modifier, Cancellable cancel)

Note: there's deliberately no raw hook on LivingEntity.setHealth(float) itself — see Known Limitations for why.

Clone this wiki locally