askill
hytale-ecs

hytale-ecsSafety --Repository

Core Hytale ECS (Entity Component System) architecture and patterns for plugin development. Covers Store, EntityStore, ChunkStore, Holder, Ref, Components, Systems (EntityTickingSystem, TickingSystem, DelayedEntitySystem, RefChangeSystem), Queries, SystemGroups, CommandBuffer, block components, and plugin registration. Use when creating components, systems, queries, or working with entity/block data. Triggers - ECS, entity component system, Store, EntityStore, ChunkStore, Holder, Ref, Component, System, Query, CommandBuffer, SystemGroup, ArchetypeChunk, ComponentType, registerComponent, registerSystem, block component, block tick, RefChangeSystem, EntityTickingSystem, TickingSystem, DelayedEntitySystem.

6 stars
1.2k downloads
Updated 2/16/2026

Package Files

Loading files...
SKILL.md

Hytale ECS (Entity Component System)

Comprehensive reference for Hytale's ECS architecture. This is the foundation of all plugin development.

Related skills: For Codec/BuilderCodec serialization details, see hytale-persistent-data. For entity effects using ECS, see hytale-entity-effects.

Quick Reference

TaskApproach
Access entity datastore.getComponent(ref, ComponentType)
Queue component changecommandBuffer.addComponent(ref, componentType, instance)
Build new entityCreate Holder<EntityStore>, add components, call store.addEntity(holder, reason)
Get entity handlearchetypeChunk.getReferenceTo(index) returns Ref<EntityStore>
Per-entity tick logicExtend EntityTickingSystem<EntityStore>
Global tick logicExtend TickingSystem<EntityStore>
Interval-based logicExtend DelayedEntitySystem<EntityStore>
React to component changesExtend RefChangeSystem<EntityStore, T>
Filter entitiesQuery.and(componentTypes...), Query.not(componentType)
Register componentgetEntityStoreRegistry().registerComponent(Class, factory) in setup()
Register systemgetEntityStoreRegistry().registerSystem(system) in start()
Register block componentgetChunkStoreRegistry().registerComponent(Class, name, CODEC) in setup()
Register block systemgetChunkStoreRegistry().registerSystem(system) in start()

Core Architecture

ECS follows composition over inheritance: Entities are identifiers, Components are pure data, Systems contain logic.

Store

The Store class is the core of Hytale's ECS. It stores entities using archetypes — entities with the same set of components are chunked together for fast retrieval.

Store
├── EntityStore   — entities in a World (players, mobs, NPCs, projectiles)
└── ChunkStore    — block data in a World (chunks, block sections, block components)

EntityStore

EntityStore extends Store and implements WorldProvider, giving access to a specific Hytale World. It maintains internal lookups:

  • entitiesByUuid — find entity by persistent UUID
  • networkIdToRef — find entity by networking ID

Every entity has a UUIDComponent and NetworkId for these lookups.

ChunkStore

ChunkStore manages block/chunk components. Contains WorldChunk components (which hold EntityChunk for entities in the chunk and BlockChunk with BlockSections). Use for block systems and ticking blocks.

Holder (Entity Blueprint)

A Holder is a staging cart / blueprint for an entity. Collect all components, then "check out" at the Store:

// Conceptual flow (see Universe.addPlayer for real example):
// 1. Create Holder and add components
// 2. store.addEntity(holder, AddReason.LOAD) → returns Ref

PlayerStorage#load returns CompletableFuture<Holder<EntityStore>> — async loading that eventually adds to the store.

Ref (Reference Handle)

A safe handle/pointer to an entity. Never store direct references to entity objects — use Ref instead.

Ref<EntityStore> ref = archetypeChunk.getReferenceTo(index);

// Validate before use (throws if entity deleted)
ref.validate();

Components

Components are pure data containers — no logic. They must implement Component<StoreType> and provide:

  1. Default constructor — required for registration factory
  2. Copy constructor — used by clone()
  3. clone() method — ECS calls this internally to duplicate data

Entity Component Template

public class PoisonComponent implements Component<EntityStore> {
    private float damagePerTick;
    private float tickInterval;
    private int remainingTicks;
    private float elapsedTime;

    // Static ComponentType holder for convenient access
    private static ComponentType<EntityStore, PoisonComponent> type;

    public static ComponentType<EntityStore, PoisonComponent> getComponentType() {
        return type;
    }

    public static void setComponentType(ComponentType<EntityStore, PoisonComponent> type) {
        PoisonComponent.type = type;
    }

    // BuilderCodec for serialization (see hytale-persistent-data skill for full Codec reference)
    public static final BuilderCodec<PoisonComponent> CODEC = BuilderCodec
        .builder(PoisonComponent.class, PoisonComponent::new)
        .append(
            new KeyedCodec<>("DamagePerTick", Codec.FLOAT),
            (data, value) -> data.damagePerTick = value,
            data -> data.damagePerTick
        ).add()
        .append(
            new KeyedCodec<>("TickInterval", Codec.FLOAT),
            (data, value) -> data.tickInterval = value,
            data -> data.tickInterval
        ).add()
        .append(
            new KeyedCodec<>("RemainingTicks", Codec.INTEGER),
            (data, value) -> data.remainingTicks = value,
            data -> data.remainingTicks
        ).add()
        .append(
            new KeyedCodec<>("ElapsedTime", Codec.FLOAT),
            (data, value) -> data.elapsedTime = value,
            data -> data.elapsedTime
        ).add()
        .build();

    // Default constructor (required for factory)
    public PoisonComponent() {
        this(5f, 1.0f, 10);
    }

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

    // Copy constructor (required for clone)
    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);
    }

    // Getters, setters, utility methods...
}

Important: KeyedCodec identifier strings must be Uppercase and globally unique across your entire mod. See hytale-persistent-data skill for full Codec reference including validators, MapCodec, and complex types.

Block Component Template

Block components use Component<ChunkStore> instead of Component<EntityStore>:

public class ExampleBlock implements Component<ChunkStore> {
    public static final BuilderCodec CODEC = BuilderCodec
        .builder(ExampleBlock.class, ExampleBlock::new)
        .build();

    public ExampleBlock() { }

    public static ComponentType getComponentType() {
        return ExamplePlugin.get().getExampleBlockComponentType();
    }

    @Nullable
    public Component<ChunkStore> clone() {
        return new ExampleBlock();
    }
}

Accessing Components

Always use Store to access component data — never call methods directly on entity objects:

// In a command, system, or event handler with store + ref:
Player player = store.getComponent(ref, Player.getComponentType());
UUIDComponent uuid = store.getComponent(ref, UUIDComponent.getComponentType());
TransformComponent transform = store.getComponent(ref, TransformComponent.getComponentType());

player.sendMessage(Message.raw("Position: " + transform.getPosition()));

Player Components

Players are composed of two key components:

ComponentLifetimePurpose
PlayerRefWhile connected to server (survives world switches)Connection identity: username, UUID, language, packet handler
PlayerWhile spawned in a world (per-world)Physical presence, gameplay-specific data

Systems

Systems contain all logic. They operate on entities matching component queries. The ECS scheduler runs systems each tick.

EntityTickingSystem

Most common type. Runs every tick, processes each matching entity 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);
    }
}

Key parameters:

  • dt — delta time since last tick (use for time accumulation, not tick counting)
  • index — position in the archetype chunk
  • archetypeChunk — access entity components via index
  • commandBuffer — queue changes (thread-safe)

TickingSystem

Runs once per tick globally, not per-entity. Use for world-wide logic.

public class GlobalUpdateSystem extends TickingSystem<EntityStore> {
    @Override
    public void tick(float dt, int index, Store<EntityStore> store) {
        World world = store.getExternalData().getWorld();
        // Global logic here
    }
}

DelayedEntitySystem

Like EntityTickingSystem but with a built-in interval. Constructor takes seconds between executions.

public class HealthRegenSystem extends DelayedEntitySystem<EntityStore> {
    public HealthRegenSystem() {
        super(1.0f); // Runs every 1 second
    }

    @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());
    }
}

RefChangeSystem (RefSystem)

Reacts to component add/set/remove events. Use for caching, side effects, and initialization logic.

public class MyRefSystem extends RefChangeSystem<EntityStore, MyComponent> {
    @Nonnull
    @Override
    public ComponentType<EntityStore, MyComponent> componentType() {
        return MyComponent.getComponentType();
    }

    @Override
    public void onComponentAdded(@Nonnull Ref<EntityStore> ref,
                                  @Nonnull MyComponent component,
                                  @Nonnull Store<EntityStore> store,
                                  @Nonnull CommandBuffer<EntityStore> commandBuffer) {
        // Component was added to entity
    }

    @Override
    public void onComponentSet(@Nonnull Ref<EntityStore> ref,
                                @Nullable MyComponent oldComponent,
                                @Nonnull MyComponent newComponent,
                                @Nonnull Store<EntityStore> store,
                                @Nonnull CommandBuffer<EntityStore> commandBuffer) {
        // Component was updated via replaceComponent or putComponent
    }

    @Override
    public void onComponentRemoved(@Nonnull Ref<EntityStore> ref,
                                    @Nonnull MyComponent component,
                                    @Nonnull Store<EntityStore> store,
                                    @Nonnull CommandBuffer<EntityStore> commandBuffer) {
        // Component was removed from entity
    }

    @Nullable
    @Override
    public Query<EntityStore> getQuery() {
        return MyComponent.getComponentType();
    }
}

Queries

Queries filter which entities a system processes. Only matching entities reach tick().

// 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

Queues changes instead of mutating the Store directly. Always use CommandBuffer for thread safety and proper ordering.

// Add a component
commandBuffer.addComponent(ref, componentType, new MyComponent());

// Remove a component
commandBuffer.removeComponent(ref, componentType);

// Read a component (safe within system tick)
MyComponent comp = commandBuffer.getComponent(ref, componentType);

SystemGroups and Dependencies

Controls execution order. Critical for systems that interact (e.g., damage pipeline).

Declaring a Group

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

Declaring Dependencies

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

Damage Pipeline Stages (Example)

Hytale's damage system demonstrates why ordering matters:

  1. GatherDamageGroup — Collects damage sources
  2. FilterDamageGroup — Applies reductions, cancellations (armor, invulnerability)
  3. Apply — Damage applied to health
  4. InspectDamageGroup — Side effects (particles, sounds, death animations)

Wrong order = death animations before entity dies, or armor applied after health subtracted.


Block Components (ChunkStore)

Block components use ChunkStore instead of EntityStore. They require a different registration path and additional setup for ticking.

Block RefSystem (Initializer)

Reacts when block entities with your component are added. Use to mark blocks as ticking:

public class ExampleInitializer extends RefSystem {
    @Override
    public void onEntityAdded(@Nonnull Ref ref, @Nonnull AddReason reason,
                               @Nonnull Store store, @Nonnull CommandBuffer commandBuffer) {
        BlockModule.BlockStateInfo info = (BlockModule.BlockStateInfo) commandBuffer
            .getComponent(ref, BlockModule.BlockStateInfo.getComponentType());
        if (info == null) return;

        ExampleBlock generator = (ExampleBlock) commandBuffer
            .getComponent(ref, ExamplePlugin.get().getExampleBlockComponentType());
        if (generator != null) {
            int x = ChunkUtil.xFromBlockInColumn(info.getIndex());
            int y = ChunkUtil.yFromBlockInColumn(info.getIndex());
            int z = ChunkUtil.zFromBlockInColumn(info.getIndex());

            WorldChunk worldChunk = (WorldChunk) commandBuffer
                .getComponent(info.getChunkRef(), WorldChunk.getComponentType());
            if (worldChunk != null) {
                worldChunk.setTicking(x, y, z, true);
            }
        }
    }

    @Override
    public void onEntityRemove(@Nonnull Ref ref, @Nonnull RemoveReason reason,
                                @Nonnull Store store, @Nonnull CommandBuffer commandBuffer) { }

    @Override
    public Query getQuery() {
        return Query.and(BlockModule.BlockStateInfo.getComponentType(),
                         ExamplePlugin.get().getExampleBlockComponentType());
    }
}

Block Ticking System

Processes ticking blocks each tick:

public class ExampleSystem extends EntityTickingSystem {
    public void tick(float dt, int index,
                     @Nonnull ArchetypeChunk archetypeChunk,
                     @Nonnull Store store,
                     @Nonnull CommandBuffer commandBuffer) {
        BlockSection blocks = (BlockSection) archetypeChunk
            .getComponent(index, BlockSection.getComponentType());
        if (blocks.getTickingBlocksCountCopy() != 0) {
            ChunkSection section = (ChunkSection) archetypeChunk
                .getComponent(index, ChunkSection.getComponentType());
            BlockComponentChunk blockComponentChunk = (BlockComponentChunk) commandBuffer
                .getComponent(section.getChunkColumnReference(), BlockComponentChunk.getComponentType());

            blocks.forEachTicking(blockComponentChunk, commandBuffer, section.getY(),
                (bcc, cb, localX, localY, localZ, blockId) -> {
                    Ref<ChunkStore> blockRef = bcc
                        .getEntityReference(ChunkUtil.indexBlockInColumn(localX, localY, localZ));
                    if (blockRef == null) return BlockTickStrategy.IGNORED;

                    ExampleBlock exampleBlock = (ExampleBlock) cb
                        .getComponent(blockRef, ExampleBlock.getComponentType());
                    if (exampleBlock != null) {
                        WorldChunk worldChunk = (WorldChunk) commandBuffer
                            .getComponent(section.getChunkColumnReference(), WorldChunk.getComponentType());
                        World world = worldChunk.getWorld();
                        int globalX = localX + (worldChunk.getX() * 32);
                        int globalZ = localZ + (worldChunk.getZ() * 32);

                        // Must execute setBlock on world thread
                        world.execute(() -> {
                            world.setBlock(globalX + 1, localY, globalZ, "Rock_Ice");
                        });
                        return BlockTickStrategy.CONTINUE;
                    }
                    return BlockTickStrategy.IGNORED;
                });
        }
    }

    @Nullable
    public Query getQuery() {
        return Query.and(BlockSection.getComponentType(), ChunkSection.getComponentType());
    }
}

Key points:

  • worldChunk.setTicking(x, y, z, true) marks a block for ticking
  • BlockTickStrategy.CONTINUE keeps it ticking next tick; IGNORED skips
  • world.execute(() -> ...) schedules work on the world thread (cannot call store functions from a system directly)
  • Coordinate conversion: globalX = localX + (worldChunk.getX() * 32)

Plugin Registration

Components and systems must be registered during the plugin lifecycle.

EntityStore Registration (Entities)

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() {
        // Register components in setup() — returns ComponentType handle
        this.poisonComponent = this.getEntityStoreRegistry()
            .registerComponent(PoisonComponent.class, PoisonComponent::new);
        PoisonComponent.setComponentType(this.poisonComponent);

        // Register commands, events, etc.
        this.getCommandRegistry().registerCommand(new ExampleCommand());
        this.getEventRegistry().registerGlobal(PlayerReadyEvent.class, ExampleEvent::onPlayerReady);
    }

    @Override
    protected void start() {
        // Register systems in start()
        this.getEntityStoreRegistry().registerSystem(new PoisonSystem(PoisonComponent.getComponentType()));
    }

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

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

ChunkStore Registration (Blocks)

@Override
protected void setup() {
    this.exampleBlockComponentType = this.getChunkStoreRegistry()
        .registerComponent(ExampleBlock.class, "ExampleBlock", ExampleBlock.CODEC);
}

@Override
protected void start() {
    this.getChunkStoreRegistry().registerSystem(new ExampleSystem());
    this.getChunkStoreRegistry().registerSystem(new ExampleInitializer());
}

Block Module Dependencies

If working with block components, add dependencies in manifest.json to ensure proper load order:

{
  "Dependencies": {
    "Hytale:EntityModule": "*",
    "Hytale:BlockModule": "*"
  }
}

Without these, you'll get NullPointerException: Cannot invoke "Query.validateRegistry" on startup.


Best Practices

  1. Never store direct entity references — always use Ref<EntityStore> handles
  2. Use CommandBuffer for all entity/component mutations (thread safety)
  3. Keep components as pure data — no logic in components
  4. Store ComponentType as a static field on the component class for easy access
  5. Use queries to filter — don't check component existence inside tick()
  6. Use SystemGroups/Dependencies to control execution order
  7. Use dt (delta time) for time-based logic — don't count ticks
  8. Register components in setup() and systems in start()
  9. Use world.execute(() -> ...) when calling world/store functions from block systems
  10. Reference FarmingSystems.Ticking in Hytale source for block ticking patterns

External References

Install

Download ZIP
Requires askill CLI v1.0+

AI Quality Score

AI review pending.

Metadata

Licenseunknown
Version-
Updated2/16/2026
PublisherJBurlison

Tags

ci-cd