Back to Knowledge Base

Hytale Entity Effects: Buffs, Debuffs, and Damage-Over-Time

A deep dive into Hytale's entity effect system covering data-driven effect definitions, overlap and removal behaviors, periodic damage with cooldowns, stat modifiers, model changes, invulnerability, and the ECS EffectController pipeline.

Player Games··hytale

Hytale's entity effect system handles everything from poison ticks to speed boosts to temporary invulnerability. Effects are fully data-driven through JSON asset definitions, support configurable overlap and removal behaviors, and integrate deeply with the stat modifier and damage systems. Rather than hardcoding individual effects, Hytale uses a generic EntityEffect asset combined with an EffectControllerComponent that manages active effects per entity. This reference is based on Hytale server version 2026.03.26-89796e57b.

EntityEffect: The Asset Definition

Every effect type is defined as a JSON asset loaded from the asset registry. The EntityEffect class holds all configuration for how an effect behaves:

public class EntityEffect {
    protected String id;                          // Unique effect ID (e.g. "Poison", "SpeedBoost")
    protected String name;                        // Localization key for UI display
    protected float duration;                     // Default duration in seconds
    protected boolean infinite;                   // Never expires if true
    protected boolean debuff;                     // Shown as negative in UI
    protected boolean invulnerable;               // Grants invulnerability while active
    protected String statusEffectIcon;            // UI icon identifier
    protected OverlapBehavior overlapBehavior;     // What happens when reapplied
    protected RemovalBehavior removalBehavior;     // What happens when removed
    protected ValueType valueType;                // Absolute or Percent for stat changes
    protected DamageCalculator damageCalculator;   // Periodic damage config
    protected float damageCalculatorCooldown;      // Seconds between damage ticks
    protected Int2FloatMap entityStats;            // Stat modifiers to apply
    protected String modelChange;                 // Temporary model swap
    protected Condition[] applyConditions;         // Conditions for effect to stay active
    protected Map<DamageCause, StaticModifier[]> damageResistanceValues; // Damage resistance
}

This single class covers buffs, debuffs, DoTs, shields, transformations, and more. The key is composition—each field is optional, so a speed buff might only set entityStats, while a poison effect sets damageCalculator and damageCalculatorCooldown.

Overlap Behavior: Stacking Rules

When an effect is applied to an entity that already has it, the OverlapBehavior enum determines what happens:

BehaviorEffect
IGNOREDo nothing—the existing effect continues unchanged (default)
OVERWRITEReset the duration to the new value
EXTENDAdd the new duration to the remaining duration
// Inside EffectControllerComponent.addEffect()
if (currentActiveEffect != null) {
    if (currentActiveEffect.isInfinite()) return true;  // Can't modify infinite effects
    if (overlapBehavior == OverlapBehavior.EXTEND) {
        currentActiveEffect.remainingDuration += duration;
    } else if (overlapBehavior == OverlapBehavior.OVERWRITE) {
        currentActiveEffect.remainingDuration = duration;
    }
    // IGNORE: do nothing
}

This prevents the common problem of stacking poison infinitely—designers choose per-effect whether reapplication extends, overwrites, or is ignored entirely.

Removal Behavior: Graceful Cleanup

When an effect is removed (by expiry, command, or another effect), RemovalBehavior controls the transition:

BehaviorEffect
COMPLETEImmediately remove the effect and all its modifiers
INFINITEStop the infinite flag but let the remaining duration play out
DURATIONSet remaining duration to zero (effect expires on next tick)
public void removeEffect(Ref<EntityStore> ownerRef, int entityEffectIndex,
                          RemovalBehavior removalBehavior, ComponentAccessor<EntityStore> accessor) {
    ActiveEntityEffect active = this.activeEffects.get(entityEffectIndex);
    if (active != null) {
        this.tryResetModelChange(ownerRef, active.getEntityEffectIndex(), accessor);
        switch (removalBehavior) {
            case COMPLETE:
                this.activeEffects.remove(entityEffectIndex);
                entityStatMap.getStatModifiersManager().scheduleRecalculate();
                break;
            case INFINITE:
                active.infinite = false;  // Will expire naturally
                break;
            case DURATION:
                active.remainingDuration = 0.0F;  // Expires next tick
                break;
        }
    }
}

The INFINITE behavior is particularly useful for toggleable effects—making a permanent buff time out naturally rather than vanishing instantly.

Active Effects and the Tick Cycle

When an effect is applied, it becomes an ActiveEntityEffect—a runtime instance that tracks remaining duration, damage cooldowns, and sequential hit state:

public class ActiveEntityEffect implements Damage.Source {
    protected String entityEffectId;
    protected float initialDuration;
    protected float remainingDuration;
    protected boolean infinite;
    protected boolean debuff;
    protected boolean invulnerable;
    protected String statusEffectIcon;
    private float sinceLastDamage;          // Accumulator for damage cooldown
    private boolean hasBeenDamaged;         // For zero-cooldown single-hit effects
    private DamageCalculatorSystems.Sequence sequentialHits;
}

Each tick, the effect controller calls tick() on every active effect, which runs two pipelines:

Periodic Damage

The damage system uses a cooldown accumulator to fire at configurable intervals:

public void tick(CommandBuffer<EntityStore> commandBuffer, Ref<EntityStore> ref,
                 EntityEffect entityEffect, EntityStatMap entityStatMap, float dt) {
    int cyclesToRun = this.calculateCyclesToRun(entityEffect, dt);
    this.tickDamage(commandBuffer, ref, entityEffect, cyclesToRun);
    tickStatChanges(commandBuffer, ref, entityEffect, entityStatMap, cyclesToRun);
    if (!this.infinite) {
        this.remainingDuration -= dt;
    }
}

private int calculateCyclesToRun(EntityEffect entityEffect, float dt) {
    float cooldown = entityEffect.getDamageCalculatorCooldown();
    if (cooldown > 0.0F) {
        this.sinceLastDamage += dt;
        int cycles = MathUtil.fastFloor(this.sinceLastDamage / cooldown);
        this.sinceLastDamage %= cooldown;  // Keep the remainder for smooth timing
        return cycles;
    } else if (!this.hasBeenDamaged) {
        this.hasBeenDamaged = true;
        return 1;  // Single-hit effect (zero cooldown = apply once)
    }
    return 0;
}

When damageCalculatorCooldown is 0, the effect applies damage exactly once. When positive, damage ticks at that interval using a modular accumulator that prevents drift across frames.

Stat Modification

Effects can modify entity stats (speed, health regen, damage, etc.) each cycle:

private static void tickStatChanges(CommandBuffer<EntityStore> commandBuffer, Ref<EntityStore> ref,
                                     EntityEffect entityEffect, EntityStatMap entityStatMap, int cyclesToRun) {
    Int2FloatMap entityStats = entityEffect.getEntityStats();
    if (entityStats != null && cyclesToRun > 0) {
        DamageEffects statModifierEffects = entityEffect.getStatModifierEffects();
        if (statModifierEffects != null) {
            statModifierEffects.spawnAtEntity(commandBuffer, ref);  // Visual feedback
        }
        entityStatMap.processStatChanges(EntityStatMap.Predictable.ALL,
            entityStats, entityEffect.getValueType(), ChangeStatBehaviour.Add);
    }
}

The ValueType determines whether stat values are absolute amounts or percentages. Setting ValueType.Absolute with a value of 100 matches the stat's maximum value, while ValueType.Percent scales relative to the current value.

The EffectController Component

EffectControllerComponent is an ECS component attached to any entity that can have effects. It manages the full lifecycle:

public class EffectControllerComponent implements Component<EntityStore> {
    protected final Int2ObjectMap<ActiveEntityEffect> activeEffects;
    protected int[] cachedActiveEffectIndexes;      // Perf cache, invalidated on change
    protected ObjectList<EntityEffectUpdate> changes; // Pending network updates
    protected boolean isNetworkOutdated;
    protected Model originalModel;                   // Saved for model restoration
    protected boolean isInvulnerable;
}

Key operations:

  • addEffect() — Applies an effect, respecting overlap behavior and apply conditions. Triggers stat recalculation and network sync.
  • removeEffect() — Removes an effect per its removal behavior. Restores the original model if a model change was active.
  • clearEffects() — Removes all active effects and restores the entity's original model.
  • hasEffect() — Checks whether a specific effect is currently active (O(1) lookup by index).

The controller also handles apply conditions—an array of Condition objects that must all be true for the effect to remain active. If any condition fails, the effect is automatically removed. This enables context-sensitive effects like “speed boost only while on grass” without custom code per effect.

Model Changes: Visual Transformations

Effects can temporarily swap an entity's 3D model:

public void setModelChange(Ref<EntityStore> ownerRef, EntityEffect entityEffect,
                            int entityEffectIndex, ComponentAccessor<EntityStore> accessor) {
    if (this.originalModel == null && entityEffect.getModelChange() != null) {
        // Save original model for restoration
        ModelComponent modelComponent = accessor.getComponent(ownerRef, ModelComponent.getComponentType());
        this.originalModel = modelComponent.getModel();
        this.activeModelChangeEntityEffectIndex = entityEffectIndex;

        // Apply new model with random scale variation
        ModelAsset modelAsset = ModelAsset.getAssetMap().getAsset(entityEffect.getModelChange());
        Model scaledModel = Model.createRandomScaleModel(modelAsset);
        accessor.putComponent(ownerRef, ModelComponent.getComponentType(), new ModelComponent(scaledModel));
    }
}

Only one model change can be active at a time (first effect wins). When the effect ends, tryResetModelChange() restores the original model and invalidates the player skin network state so clients see the change. This enables transformation effects like turning a player into a creature or shrinking them.

Death Messages

ActiveEntityEffect implements Damage.Source, which means effects that kill an entity provide custom death messages:

public Message getDeathMessage(Damage info, Ref<EntityStore> targetRef, ComponentAccessor<EntityStore> accessor) {
    EntityEffect entityEffect = EntityEffect.getAssetMap().getAsset(this.entityEffectIndex);
    String deathMessageKey = entityEffect.getDeathMessageKey();
    if (deathMessageKey != null) {
        return Message.translation(deathMessageKey);  // Custom: "Burned to a crisp"
    }
    // Fallback: "Killed by <effect name>"
    String reason = entityEffect.getLocale() != null
        ? entityEffect.getLocale()
        : entityEffect.getId().toLowerCase();
    return Message.translation("server.general.killedBy")
        .param("damageSource", Message.translation("server.general.damageCauses." + reason));
}

Damage Resistance

Effects can grant resistance to specific damage types through the DamageResistance field, which maps DamageCause keys to StaticModifier arrays. This enables fire resistance potions, explosion shields, or elemental immunity effects—all configured in JSON without code changes.

Network Synchronization

The controller maintains a change list of EntityEffectUpdate packets (add/remove operations) and a dirty flag. Each frame, the ECS system drains the change list and sends updates to clients. Initial sync sends all active effects when an entity enters a player's view, ensuring effects display correctly even when joining mid-fight.

Key Takeaways for Modders

  1. Effects are pure data. Define them in JSON with DamageCalculator for DoTs, StatModifiers for buffs, and ModelChange for transformations—no Java required.
  2. Overlap and removal behaviors are per-effect. Choose EXTEND for stackable durations, IGNORE for non-stacking, OVERWRITE to refresh.
  3. Zero-cooldown damage applies exactly once; positive cooldowns tick periodically with drift-free timing.
  4. Apply conditions let effects self-remove when context changes (leaving a zone, unequipping an item).
  5. Model changes are first-come-first-served with automatic restoration. The original model is always preserved.
  6. Invulnerability is a first-class effect property—no need for hacky health manipulation.