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:
| Behavior | Effect |
|---|---|
IGNORE | Do nothing—the existing effect continues unchanged (default) |
OVERWRITE | Reset the duration to the new value |
EXTEND | Add 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:
| Behavior | Effect |
|---|---|
COMPLETE | Immediately remove the effect and all its modifiers |
INFINITE | Stop the infinite flag but let the remaining duration play out |
DURATION | Set 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
- Effects are pure data. Define them in JSON with
DamageCalculatorfor DoTs,StatModifiersfor buffs, andModelChangefor transformations—no Java required. - Overlap and removal behaviors are per-effect. Choose
EXTENDfor stackable durations,IGNOREfor non-stacking,OVERWRITEto refresh. - Zero-cooldown damage applies exactly once; positive cooldowns tick periodically with drift-free timing.
- Apply conditions let effects self-remove when context changes (leaving a zone, unequipping an item).
- Model changes are first-come-first-served with automatic restoration. The original model is always preserved.
- Invulnerability is a first-class effect property—no need for hacky health manipulation.