Hytale Modifikavimas

Hytale ECS Basics

In this guide you will learn about the basics of Hytale's powerful ECS system as well as create your own component, a system, and work together with other systems to create gameplay logic.

Store

The Store class is the core of Hytale's ECS system, it's responsible for storing entities, if you ever need to access an entity, you need access to the store. It utilises a concept called Archetypes where data is grouped together in chunks. For example if we have 100 Trorks, they will be chunked together along with their components so that they're closely packed together and faster to retrieve.

EntityStore

When looking through Hytale's server code you will find that Store will be of type EntityStore. This name can be misleading as it might suggest that it's a Store for entities. But didn't we just say that the base Store already stores entities? The EntityStore class implements WorldProvider meaning that EntityStore is responsible for accessing entities in a specific Hytale World. It maintains internal maps entitiesByUuid and networkIdToRef, allowing you to find a specific entity by its persistent ID or its networking ID.

Every Entity has a UUIDComponent as well as a NetworkId which are used by the EntityStore to lookup entities inside of the Store.

Ref (Reference)

For those familiar with languages like C++, you probably already can guess what this class is purely by the name of it. However, a Ref is a safe "handle" or pointer to an entity. You should NEVER store a direct reference to an entity object, you use a Ref instead. It tracks whether an entity is still alive. If you call validate() on a Ref for an entity that has been deleted, it throws an exception.

Player Components

In Hytale, a "Player" is not just one object. It is a single entity composed of multiple specialized components. Understanding the difference between Player and PlayerRef is crucial for modding.

PlayerRef

Despite its name, PlayerRef is a Component, not a handle. It represents the player's connection and identity. It's a special component which stays active as long as the player is connected to the server, even if the player switches worlds. The key data that it stores are the player's username, UUID, language as well as the packet handler.

Player

The Player component represents the player's physical presence. It only exists when the player is actually spawned in a world. Providing access to gameplay specific data, this component differs per world.

To interact with an entity, you use the Store to retrieve its components via their ComponentType. Because Hytale uses a decoupled system, you don't call entity.getHealth(). Instead, you ask the Store for the health data associated with that entity's Ref.

@Override
protected void execute(@Nonnull CommandContext commandContext, @Nonnull Store<EntityStore> store, 
    @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
  Player player = store.getComponent(ref, Player.getComponentType());
  UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
  TransformComponent transform = store.getComponent(ref, TransformComponent.getComponentType());
  player.sendMessage(Message.raw("UUIDComponent : " + component.getUuid()));
  player.sendMessage(Message.raw("Transform : " + transform.getPosition()));
}

In here we use the Store<EntityStore> to access the Player component using the Ref<EntityStore>. We can do the same for other components like the UUIDComponent or the TransformComponent to retrieve the entity Transform containing the position and rotation.

Components

Components are pure data containers. They hold state but contain no logic. In Hytale, components must implement Component<EntityStore> and provide a clone method for the ECS to copy them when needed.

public class PoisonComponent implements Component<EntityStore> {

  private float damagePerTick;
  private float tickInterval;
  private int remainingTicks;
  private float elapsedTime;

  public PoisonComponent() {
    this(5f, 1.0f, 10);
  }

  public PoisonComponent(float damagePerTick, float tickInterval, int totalTicks) {
    this.damagePerTick = damagePerTick;
    this.tickInterval = tickInterval;
    this.remainingTicks = totalTicks;
    this.elapsedTime = 0f;
  }

  public PoisonComponent(PoisonComponent other) {
    this.damagePerTick = other.damagePerTick;
    this.tickInterval = other.tickInterval;
    this.remainingTicks = other.remainingTicks;
    this.elapsedTime = other.elapsedTime;
  }

  @Nullable
  @Override
  public Component<EntityStore> clone() {
    return new PoisonComponent(this);
  }

  public float getDamagePerTick() {
    return damagePerTick;
  }

  public float getTickInterval() {
    return tickInterval;
  }

  public int getRemainingTicks() {
    return remainingTicks;
  }

  public float getElapsedTime() {
    return elapsedTime;
  }

  public void addElapsedTime(float dt) {
    this.elapsedTime += dt;
  }

  public void resetElapsedTime() {
    this.elapsedTime = 0f;
  }

  public void decrementRemainingTicks() {
    this.remainingTicks--;
  }

  public boolean isExpired() {
    return this.remainingTicks <= 0;
  }
}

The default constructor is required for the registration factory. The copy constructor is used by clone() which the ECS calls internally when it needs to duplicate component data.

Systems

Systems are where the logic lives. While components are pure data containers, systems operate on entities that match specific component queries. The ECS scheduler runs systems each tick, passing in only the entities that have the components the system cares about.

EntityTickingSystem

The most common system type. It runs every tick and processes each entity matching its query individually.

public class PoisonSystem extends EntityTickingSystem<EntityStore> {

  private final ComponentType<EntityStore, PoisonComponent> poisonComponentType;

  public PoisonSystem(ComponentType<EntityStore, PoisonComponent> poisonComponentType) {
    this.poisonComponentType = poisonComponentType;
  }

  @Override
  public void tick(float dt, int index, @Nonnull ArchetypeChunk<EntityStore> archetypeChunk,
      @Nonnull Store<EntityStore> store, @Nonnull CommandBuffer<EntityStore> commandBuffer) {

    PoisonComponent poison = archetypeChunk.getComponent(index, poisonComponentType);
    Ref<EntityStore> ref = archetypeChunk.getReferenceTo(index);

    poison.addElapsedTime(dt);

    if (poison.getElapsedTime() >= poison.getTickInterval()) {
      poison.resetElapsedTime();

      Damage damage = new Damage(Damage.NULL_SOURCE, DamageCause.OUT_OF_WORLD, poison.getDamagePerTick());
      DamageSystems.executeDamage(ref, commandBuffer, damage);

      poison.decrementRemainingTicks();
    }

    if (poison.isExpired()) {
      commandBuffer.removeComponent(ref, poisonComponentType);
    }
  }

  @Nullable
  @Override
  public SystemGroup<EntityStore> getGroup() {
    return DamageModule.get().getGatherDamageGroup();
  }

  @Nonnull
  @Override
  public Query<EntityStore> getQuery() {
    return Query.and(this.poisonComponentType);
  }
}

The tick method receives dt which is delta time since the last tick. This lets you accumulate time for interval-based logic rather than counting ticks. The ArchetypeChunk gives you access to the entity's components via the index, and getReferenceTo returns the Ref you need for issuing commands.

TickingSystem

Runs once per tick globally, not per-entity. Use this for world-wide updates or logic that doesn't target specific entities.

public class GlobalUpdateSystem extends TickingSystem<EntityStore> {

  @Override
  public void tick(float dt, int index, Store<EntityStore> store) {
    World world = store.getExternalData().getWorld();
  }
}

DelayedEntitySystem

Like EntityTickingSystem but with a built-in delay. The constructor takes a float representing seconds between executions.

public class HealthRegenSystem extends DelayedEntitySystem<EntityStore> {

  public HealthRegenSystem() {
    super(1.0f);
  }

  @Override
  public void tick(float dt, int index, @Nonnull ArchetypeChunk<EntityStore> archetypeChunk,
      @Nonnull Store<EntityStore> store, @Nonnull CommandBuffer<EntityStore> commandBuffer) {
    // Runs every 1 second per matching entity
  }

  @Nonnull
  @Override
  public Query<EntityStore> getQuery() {
    return Query.and(Player.getComponentType());
  }
}

Queries

Queries filter which entities a system processes. The ECS only passes entities to your system's tick method if they have all the components specified in the query.

// Single component - any entity with PoisonComponent
Query.and(poisonComponentType)

// Multiple components - entities with both
Query.and(poisonComponentType, Player.getComponentType())

// Exclusion - players that aren't dead
Query.and(Player.getComponentType(), Query.not(DeathComponent.getComponentType()))

CommandBuffer

The CommandBuffer queues changes to entities. Use it instead of modifying the store directly to ensure thread safety and proper ordering. You'll use it to add components, remove components, and execute damage.

commandBuffer.addComponent(ref, componentType, new MyComponent());

commandBuffer.removeComponent(ref, componentType);

MyComponent comp = commandBuffer.getComponent(ref, componentType);

SystemGroups and Dependencies

Systems can declare which group they belong to and what dependencies they have. This controls execution order which is critical for systems that interact.

@Nullable
@Override
public SystemGroup<EntityStore> getGroup() {
  return DamageModule.get().getGatherDamageGroup();
}

For more complex ordering you can override getDependencies:

@Nonnull
public Set<Dependency<EntityStore>> getDependencies() {
  return Set.of(
    new SystemGroupDependency(Order.AFTER, DamageModule.get().getFilterDamageGroup()),
    new SystemDependency(Order.BEFORE, PlayerSystems.ProcessPlayerInput.class)
  );
}

The damage system is a good example of why ordering matters. Hytale's damage pipeline has four stages: GatherDamageGroup collects damage sources, FilterDamageGroup applies reductions and cancellations, then damage is applied to health, and finally InspectDamageGroup handles side effects like particles and sounds. If these ran in the wrong order you'd play death animations before the entity dies or apply armor reduction after health is already subtracted.

Registering Components and Systems

Components and systems must be registered during plugin setup. The EntityStoreRegistry handles this.

public final class ExamplePlugin extends JavaPlugin {

  private static ExamplePlugin instance;
  private ComponentType<EntityStore, PoisonComponent> poisonComponent;

  public ExamplePlugin(@Nonnull JavaPluginInit init) {
    super(init);
    instance = this;
  }

  @Override
  protected void setup() {
    this.getCommandRegistry().registerCommand(new ExampleCommand());
    this.getEventRegistry().registerGlobal(PlayerReadyEvent.class, ExampleEvent::onPlayerReady);
    this.getEventRegistry().registerGlobal(PlayerChatEvent.class, ChatFormatter::onPlayerChat);

    this.poisonComponent = this.getEntityStoreRegistry()
        .registerComponent(PoisonComponent.class, PoisonComponent::new);
    this.getEntityStoreRegistry().registerSystem(new PoisonSystem(this.poisonComponent));
  }

  public ComponentType<EntityStore, PoisonComponent> getPoisonComponentType() {
    return poisonComponent;
  }

  public static ExamplePlugin get() {
    return instance;
  }
}

The registerComponent method returns a ComponentType which acts as a key for accessing that component type throughout your plugin. Store it as a field and pass it to any systems that need it. The second argument is a factory for creating default instances.

Practical Example: Poison System

Putting it all together, here's a complete poison effect. When applied to any entity, it deals damage at a set interval until it expires and removes itself.

package scot.oskar.hytaletemplate.components;

import com.hypixel.hytale.component.Component;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nullable;

public class PoisonComponent implements Component<EntityStore> {

  private float damagePerTick;
  private float tickInterval;
  private int remainingTicks;
  private float elapsedTime;

  public PoisonComponent() {
    this(5f, 1.0f, 10);
  }

  public PoisonComponent(float damagePerTick, float tickInterval, int totalTicks) {
    this.damagePerTick = damagePerTick;
    this.tickInterval = tickInterval;
    this.remainingTicks = totalTicks;
    this.elapsedTime = 0f;
  }

  public PoisonComponent(PoisonComponent other) {
    this.damagePerTick = other.damagePerTick;
    this.tickInterval = other.tickInterval;
    this.remainingTicks = other.remainingTicks;
    this.elapsedTime = other.elapsedTime;
  }

  @Nullable
  @Override
  public Component<EntityStore> clone() {
    return new PoisonComponent(this);
  }

  public float getDamagePerTick() {
    return damagePerTick;
  }

  public float getTickInterval() {
    return tickInterval;
  }

  public int getRemainingTicks() {
    return remainingTicks;
  }

  public float getElapsedTime() {
    return elapsedTime;
  }

  public void addElapsedTime(float dt) {
    this.elapsedTime += dt;
  }

  public void resetElapsedTime() {
    this.elapsedTime = 0f;
  }

  public void decrementRemainingTicks() {
    this.remainingTicks--;
  }

  public boolean isExpired() {
    return this.remainingTicks <= 0;
  }
}
package scot.oskar.hytaletemplate.systems;

import com.hypixel.hytale.component.ArchetypeChunk;
import com.hypixel.hytale.component.CommandBuffer;
import com.hypixel.hytale.component.ComponentType;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.component.SystemGroup;
import com.hypixel.hytale.component.query.Query;
import com.hypixel.hytale.component.system.tick.EntityTickingSystem;
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageModule;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageSystems;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import scot.oskar.hytaletemplate.components.PoisonComponent;

public class PoisonSystem extends EntityTickingSystem<EntityStore> {

  private final ComponentType<EntityStore, PoisonComponent> poisonComponentType;

  public PoisonSystem(ComponentType<EntityStore, PoisonComponent> poisonComponentType) {
    this.poisonComponentType = poisonComponentType;
  }

  @Override
  public void tick(float dt, int index, @Nonnull ArchetypeChunk<EntityStore> archetypeChunk,
      @Nonnull Store<EntityStore> store, @Nonnull CommandBuffer<EntityStore> commandBuffer) {

    PoisonComponent poison = archetypeChunk.getComponent(index, poisonComponentType);
    Ref<EntityStore> ref = archetypeChunk.getReferenceTo(index);

    poison.addElapsedTime(dt);

    if (poison.getElapsedTime() >= poison.getTickInterval()) {
      poison.resetElapsedTime();

      Damage damage = new Damage(Damage.NULL_SOURCE, DamageCause.OUT_OF_WORLD, poison.getDamagePerTick());
      DamageSystems.executeDamage(ref, commandBuffer, damage);

      poison.decrementRemainingTicks();
    }

    if (poison.isExpired()) {
      commandBuffer.removeComponent(ref, poisonComponentType);
    }
  }

  @Nullable
  @Override
  public SystemGroup<EntityStore> getGroup() {
    return DamageModule.get().getGatherDamageGroup();
  }

  @Nonnull
  @Override
  public Query<EntityStore> getQuery() {
    return Query.and(this.poisonComponentType);
  }
}
package scot.oskar.hytaletemplate.commands;

public class ExampleCommand extends AbstractPlayerCommand {

  public ExampleCommand() {
    super("test", "Super test command!");
  }

  @Override
  protected void execute(@Nonnull CommandContext commandContext, @Nonnull Store<EntityStore> store, 
      @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
    Player player = store.getComponent(ref, Player.getComponentType());
    PoisonComponent poison = new PoisonComponent(3f, 0.5f, 8);
    store.addComponent(ref, ExamplePlugin.get().getPoisonComponentType(), poison);
    player.sendMessage(Message.raw("You have been poisoned!").color(Color.GREEN).bold(true));
  }
}
package scot.oskar.hytaletemplate;

public final class ExamplePlugin extends JavaPlugin {

  private static ExamplePlugin instance;
  private ComponentType<EntityStore, PoisonComponent> poisonComponent;

  public ExamplePlugin(@Nonnull JavaPluginInit init) {
    super(init);
    instance = this;
  }

  @Override
  protected void setup() {
    this.getCommandRegistry().registerCommand(new ExampleCommand());
    this.getEventRegistry().registerGlobal(PlayerReadyEvent.class, ExampleEvent::onPlayerReady);
    this.getEventRegistry().registerGlobal(PlayerChatEvent.class, ChatFormatter::onPlayerChat);

    this.poisonComponent = this.getEntityStoreRegistry()
        .registerComponent(PoisonComponent.class, PoisonComponent::new);
    this.getEntityStoreRegistry().registerSystem(new PoisonSystem(this.poisonComponent));
  }

  public ComponentType<EntityStore, PoisonComponent> getPoisonComponentType() {
    return poisonComponent;
  }

  public static ExamplePlugin get() {
    return instance;
  }
}

The query uses only poisonComponentType which means the system will process any entity with a PoisonComponent, not just players. This makes it flexible for poisoning NPCs, mobs, or any living entity. The system places itself in the GatherDamageGroup so the damage it creates flows through the full damage pipeline including armor reduction and invulnerability checks.

To be continued.

Written by oskarscot