Limb Health

This page builds per-limb health on top of Bone Hitboxes: each limb takes damage separately, and once a limb runs out of health it is severed. A severed limb stops rendering, and its hitbox goes away with it, so attacks and projectiles pass through the space where it used to be.

Everything here uses the public hitbox and animator APIs. You need a working AzHitboxHolder entity and AzureLib 3.2.2 or newer, for onHitboxDamaged.

How it fits together

Limb health is split across both sides:

  1. Server: onHitboxDamaged tells you which limb took damage. The entity tracks each limb's health and records severed limbs in a synced bitmask.
  2. Client: the animator reads the bitmask and hides the bone of every severed limb.
  3. Hitboxes: AzureLib already disables a hitbox while its bone is hidden. The client stops targeting it straight away, and the server stops using it on the next pose report.

The server decides when a limb is severed and the client only draws the result, so a modified client can't sever limbs on its own.

Defining limbs

Keep the limb definitions in one place, with each limb's hitbox name, bone name and maximum health:

public enum ExampleLimb {
LEFT_ARM("left_arm", "leftArm", 12f),
RIGHT_ARM("right_arm", "rightArm", 12f),
LEFT_LEG("left_leg", "leftLeg", 16f),
RIGHT_LEG("right_leg", "rightLeg", 16f),
TAIL("tail", "tail1", 20f);
private static final ExampleLimb[] VALUES = values();
public final String hitbox;
public final String bone;
public final float maxHealth;
ExampleLimb(String hitbox, String bone, float maxHealth) {
this.hitbox = hitbox;
this.bone = bone;
this.maxHealth = maxHealth;
}
public int bit() {
return 1 << ordinal();
}
public static @Nullable ExampleLimb byHitbox(String name) {
for (var limb : VALUES) {
if (limb.hitbox.equals(name)) {
return limb;
}
}
return null;
}
public static @Nullable ExampleLimb byBone(String name) {
for (var limb : VALUES) {
if (limb.bone.equals(name)) {
return limb;
}
}
return null;
}
}

The severed bitmask is an int, so it supports up to 32 limbs. Use EntityDataSerializers.LONG if you need more. byBone is only needed if you show limb health on the client.

Then build the hitbox set from the enum:

private static final AzHitboxSet HITBOXES = buildHitboxes();
private static AzHitboxSet buildHitboxes() {
var builder = AzHitboxSet.builder()
.add("head", h -> h.damageMultiplier(2f))
.add("body");
for (var limb : ExampleLimb.values()) {
builder.add(limb.hitbox, limb.bone, h -> h.damageMultiplier(0.5f));
}
return builder.build();
}
@Override
public AzHitboxSet getHitboxSet() {
return HITBOXES;
}

Tracking limb health on the entity

The severed bitmask is synced to clients so they can hide bones. The exact limb health values only live on the server; see Showing limb health on the client to send clients an approximate value as well.

private static final EntityDataAccessor<Integer> SEVERED_LIMBS = SynchedEntityData.defineId(
ExampleEntity.class,
EntityDataSerializers.INT
);
private final float[] limbHealth = new float[ExampleLimb.values().length];
public ExampleEntity(EntityType<? extends Monster> type, Level level) {
super(type, level);
for (var limb : ExampleLimb.values()) {
limbHealth[limb.ordinal()] = limb.maxHealth;
}
}
@Override
protected void defineSynchedData(SynchedEntityData.Builder builder) {
super.defineSynchedData(builder);
builder.define(SEVERED_LIMBS, 0);
}
public boolean isSevered(ExampleLimb limb) {
return (entityData.get(SEVERED_LIMBS) & limb.bit()) != 0;
}

Damaging limbs

Hitbox holders have two damage hooks, and limb health uses both:

  • onHitboxHit runs at the start of LivingEntity#hurt, before vanilla's invulnerability checks. It decides how much damage the hit deals, but the hit may still be rejected afterwards.
  • onHitboxDamaged runs once vanilla has accepted and applied the damage, at most once per hit. Hits blocked by invulnerability frames never reach it, so it's the place to change limb health.
@Override
public float onHitboxHit(AzHitboxHit hit, DamageSource source, float amount) {
var limb = ExampleLimb.byHitbox(hit.name());
if (limb != null && isSevered(limb)) {
// A stale pose can still place a severed limb for a tick or two. Count it as a body hit.
return amount;
}
return AzHitboxHolder.super.onHitboxHit(hit, source, amount);
}
@Override
public void onHitboxDamaged(AzHitboxHit hit, DamageSource source, float amount) {
var limb = ExampleLimb.byHitbox(hit.name());
if (limb != null && !isSevered(limb)) {
damageLimb(limb, amount);
}
}

The severed check in onHitboxHit is there because the server's copy of the pose trails the client by a few ticks. Right after a limb is severed, a projectile can still land on where it used to be.

The limb takes the same amount the entity does: the value onHitboxHit returned, before armor and absorption. If limbs should take full damage while the body takes less, divide amount by hit.hitbox().damageMultiplier() before passing it to damageLimb (for any multiplier above zero).

Severing the limb is a matter of setting its bit:

private void damageLimb(ExampleLimb limb, float amount) {
var index = limb.ordinal();
limbHealth[index] = Math.max(0, limbHealth[index] - amount);
if (limbHealth[index] <= 0) {
severLimb(limb);
}
}
public void severLimb(ExampleLimb limb) {
if (isSevered(limb)) {
return;
}
limbHealth[limb.ordinal()] = 0;
entityData.set(SEVERED_LIMBS, entityData.get(SEVERED_LIMBS) | limb.bit());
playSound(SoundEvents.ZOMBIE_BREAK_WOODEN_DOOR, 1f, 1.4f);
updateLimbEffects();
}
public void restoreLimb(ExampleLimb limb) {
limbHealth[limb.ordinal()] = limb.maxHealth;
entityData.set(SEVERED_LIMBS, entityData.get(SEVERED_LIMBS) & ~limb.bit());
updateLimbEffects();
}

severLimb is public, so other code (a special attack, a command, a regeneration timer calling restoreLimb) can use it too.

Saving

Save both the bitmask and the remaining health, so a damaged limb stays damaged across reloads:

@Override
public void addAdditionalSaveData(CompoundTag tag) {
super.addAdditionalSaveData(tag);
tag.putInt("SeveredLimbs", entityData.get(SEVERED_LIMBS));
var health = new CompoundTag();
for (var limb : ExampleLimb.values()) {
health.putFloat(limb.hitbox, limbHealth[limb.ordinal()]);
}
tag.put("LimbHealth", health);
}
@Override
public void readAdditionalSaveData(CompoundTag tag) {
super.readAdditionalSaveData(tag);
entityData.set(SEVERED_LIMBS, tag.getInt("SeveredLimbs"));
var health = tag.getCompound("LimbHealth");
for (var limb : ExampleLimb.values()) {
if (health.contains(limb.hitbox)) {
limbHealth[limb.ordinal()] = health.getFloat(limb.hitbox);
}
}
updateLimbEffects();
}

Hiding severed limbs

Hide the bones in your animator's setCustomAnimations. See Procedural Animation for how that method works.

@Override
public void setCustomAnimations(ExampleEntity entity, float partialTicks) {
super.setCustomAnimations(entity, partialTicks);
var model = context().boneCache().getBakedModel();
for (var limb : ExampleLimb.values()) {
var bone = model.getBoneOrNull(limb.bone);
// Skip bones that are missing or currently hidden by the LOD system; LOD restores them itself.
if (bone == null || bone.getLodHidden()) {
continue;
}
var severed = entity.isSevered(limb);
if (bone.isHidden() != severed) {
bone.setHidden(severed);
}
}
}

Use the animator rather than a setPrerenderEntry hook. AzureLib also poses hitbox entities that are off screen, so their hitboxes keep updating for the server, and that path runs your animator but not your renderer hooks. Hiding the bone in the animator keeps both paths in agreement, so an off-screen mob doesn't report a severed limb's hitbox back to the server.

Each entity has its own copy of the model's bones, so hiding a limb on one mob doesn't affect others using the same model.

What hiding does

setHidden(true) hides the bone and all of its children, and their hitboxes are disabled with them. Severing an upper arm removes the forearm, the hand and their hitboxes too.

To hide a bone's own cubes but keep its children (for example, to keep a stump that is modelled as a child bone), call bone.setChildrenHidden(false) after setHidden(true).

With keepEntityBox(false) (the default), a shot through the space where the limb was misses the entity unless it hits another hitbox. With keepEntityBox(true), it hits the vanilla box as a plain body hit.

Showing limb health on the client

Clients only know which limbs are severed. To draw a health bar per limb, or switch a limb to a damaged texture as it weakens, sync an approximate health value as well.

There's no serializer for float arrays, so pack each limb's health into one byte, as a fraction of its maximum from 0 to 255. One long holds 8 limbs:

private static final EntityDataAccessor<Long> LIMB_HEALTH = SynchedEntityData.defineId(
ExampleEntity.class,
EntityDataSerializers.LONG
);
@Override
protected void defineSynchedData(SynchedEntityData.Builder builder) {
super.defineSynchedData(builder);
builder.define(SEVERED_LIMBS, 0);
builder.define(LIMB_HEALTH, -1L); // Every byte 0xFF: all limbs at full health.
}
private void syncLimbHealth(ExampleLimb limb) {
var fraction = limbHealth[limb.ordinal()] / limb.maxHealth;
// Round up so a limb that's still attached never shows as empty.
var value = (long) Mth.clamp(Mth.ceil(fraction * 255), 0, 255);
var shift = limb.ordinal() * 8;
var packed = entityData.get(LIMB_HEALTH);
entityData.set(LIMB_HEALTH, (packed & ~(0xFFL << shift)) | (value << shift));
}
/** Works on both sides. Returns 0 to 1. */
public float getLimbHealthFraction(ExampleLimb limb) {
return ((entityData.get(LIMB_HEALTH) >>> (limb.ordinal() * 8)) & 0xFF) / 255f;
}

Call syncLimbHealth(limb) wherever limb health changes: at the end of damageLimb, severLimb and restoreLimb, and for every limb at the end of readAdditionalSaveData. For example:

private void damageLimb(ExampleLimb limb, float amount) {
var index = limb.ordinal();
limbHealth[index] = Math.max(0, limbHealth[index] - amount);
syncLimbHealth(limb);
if (limbHealth[index] <= 0) {
severLimb(limb);
}
}

Synced data is only sent when it changes, so this costs nothing while the entity isn't being hit. With more than 8 limbs, add a second LONG accessor. If you need exact values on the client (rather than steps of about 0.4%), use one EntityDataSerializers.FLOAT accessor per limb instead.

Damaged textures

setBoneTextureOverrideProvider can switch a limb to a different texture once it drops below a threshold. The override applies to each bone separately, so walk up the parents to find which limb a bone belongs to; that way a forearm and hand change along with the upper arm:

private static final ResourceLocation DAMAGED_TEXTURE = ResourceLocation.fromNamespaceAndPath(
MOD_ID,
"textures/entity/example_damaged.png"
);
// In your renderer's config builder:
.setBoneTextureOverrideProvider((entity, bone) -> {
if (entity == null) {
return null;
}
for (var current = bone; current != null; current = current.getParent()) {
var limb = ExampleLimb.byBone(current.getName());
if (limb != null) {
return entity.getLimbHealthFraction(limb) < 0.5f ? DAMAGED_TEXTURE : null;
}
}
return null; // Not part of a limb: use the normal texture.
})

The damaged texture must use the same UV layout as the normal one. Returning null keeps the normal texture.

Gameplay effects

Put anything that depends on severed limbs in updateLimbEffects. It runs whenever a limb is severed or restored and after loading, so effects that aren't saved (such as transient attribute modifiers) come back after a reload. For example, slowing the entity down for each missing leg:

private static final ResourceLocation LEG_LOSS = ResourceLocation.fromNamespaceAndPath(MOD_ID, "leg_loss");
private void updateLimbEffects() {
var speed = getAttribute(Attributes.MOVEMENT_SPEED);
if (speed == null) {
return;
}
var legsLost = (isSevered(ExampleLimb.LEFT_LEG) ? 1 : 0) + (isSevered(ExampleLimb.RIGHT_LEG) ? 1 : 0);
speed.removeModifier(LEG_LOSS);
if (legsLost > 0) {
speed.addTransientModifier(
new AttributeModifier(LEG_LOSS, -0.35 * legsLost, AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL)
);
}
}

Your AI goals can also check isSevered directly, for example to stop a melee goal from using an arm that's gone.

Damage that isn't attributed to a limb

onHitboxHit and onHitboxDamaged only run for player melee and for projectiles that use vanilla's collision. Other mobs' melee attacks, explosions, fire and so on only damage the entity's health.

To let another mob's melee attack hit a limb too, pick one yourself in actuallyHurt. This casts a line from the attacker's eyes to the entity's center and takes the first limb it crosses:

@Override
protected void actuallyHurt(DamageSource source, float amount) {
super.actuallyHurt(source, amount);
if (
source.getDirectEntity() instanceof LivingEntity attacker
&& !(attacker instanceof Player)
&& !isInvulnerableTo(source)
) {
AzHitboxes.raycast(
this,
attacker.getEyePosition(),
getBoundingBox().getCenter(),
hitbox -> ExampleLimb.byHitbox(hitbox.name()) != null
).ifPresent(hit -> damageLimb(ExampleLimb.byHitbox(hit.name()), amount));
}
}

Mob melee never carries a hitbox hit, so this doesn't double up with onHitboxDamaged. Severed limbs have no active hitbox, so the raycast never returns one. For area damage such as explosions, AzHitboxes.getIntersecting returns every hitbox inside an area. On the server, both only work while a nearby client is reporting the entity's pose (see How syncing works), so treat them as optional and fall back to plain damage when they return nothing.

Checking it

Press F3 + B while looking at the entity. When a limb is severed, its bone stops rendering and its hitbox outline disappears with it.