Hytale's Entity Component System: Archetypes, Chunks, and Queries
A source-grounded tour of Hytale's archetype-based ECS — Component, ComponentType, Archetype, ArchetypeChunk, ReadWriteQuery, CommandBuffer, and the system pipeline modders write against.
Hytale's server (version 2026.03.26) is built on a data-oriented Entity Component System living under the com.hypixel.hytale.component package. An entity is not an object with methods — it is a Ref<ECS_TYPE> (an opaque, validated handle) that points at a row of plain-data Component instances stored in column arrays. There are no inheritance hierarchies of Mob extends Entity; there is only the set of components an entity currently holds, and that set is its archetype.
This design exists for one reason: locality and batch iteration. Entities with identical component sets are packed together into an ArchetypeChunk, components are stored as parallel arrays rather than per-entity objects, and systems iterate those chunks in bulk. If you understand Component, ComponentType, Archetype, ArchetypeChunk, Store, Query, and CommandBuffer, you understand how every piece of server-side gameplay logic — movement, AI, spawning, effects — is actually wired. Every symbol below is read directly from the decompiled server. Illustrative mod code is marked as a sketch; everything else is the real API surface.
#Components, Types, and Archetypes
A component is just data. The Component interface is deliberately minimal — it extends Cloneable and exists so the ECS can copy entity data when entities move between chunks:
public interface Component<ECS_TYPE> extends Cloneable {
@Nullable Component<ECS_TYPE> clone();
default Component<ECS_TYPE> cloneSerializable() { return this.clone(); }
}A component class (e.g. your HealthComponent implements Component<...>) is registered once to obtain a ComponentType — the stable identity the ECS uses everywhere. ComponentType is interesting: it carries an integer index (its column position) and is itself a Query. Its test(Archetype) simply asks "does this archetype contain me?":
public class ComponentType<ECS_TYPE, T extends Component<ECS_TYPE>>
implements Comparable<ComponentType<ECS_TYPE, ?>>, Query<ECS_TYPE> {
public int getIndex() { return this.index; }
@Override public boolean test(Archetype<ECS_TYPE> archetype) {
return archetype.contains(this); // archetype membership == query match
}
}An Archetype is an immutable set of ComponentTypes. It is stored sparsely — indexed by each type's getIndex() — so contains() is an O(1) array lookup, and add/remove return new archetypes rather than mutating:
Archetype<ECS_TYPE> a = Archetype.of(positionType, velocityType);
Archetype<ECS_TYPE> b = Archetype.add(a, healthType); // a is unchanged
boolean has = b.contains(positionType); // true
boolean superset = b.contains(a); // true: b ⊇ aArchetype.contains(Archetype) is the workhorse of the whole query layer: a chunk's archetype matches a required archetype when it is a superset of it.
| Symbol | Role |
|---|---|
Component<ECS_TYPE> | Marker interface for cloneable data blobs |
ComponentType<ECS_TYPE, T> | Stable identity + column index; also a Query |
Archetype<ECS_TYPE> | Immutable, sparse set of component types |
Archetype.of(...) / add / remove | Build archetypes (return new instances) |
Ref<ECS_TYPE> | Validated handle to one entity inside a Store |
#ArchetypeChunk: the Column Store
Entities are not scattered on the heap. Every entity with a given archetype lives in an ArchetypeChunk, and that chunk holds a 2D array Component<ECS_TYPE>[componentIndex][entityIndex] — structure of arrays. The i-th entity's HealthComponent sits at components[healthType.getIndex()][i], right next to every other entity's health. That is what makes a system loop cache-friendly.
public class ArchetypeChunk<ECS_TYPE> {
protected Ref<ECS_TYPE>[] refs;
protected Component<ECS_TYPE>[][] components; // [componentType.getIndex()][entityIndex]
public int size() { return this.entitiesSize; }
public Ref<ECS_TYPE> getReferenceTo(int index) { ... }
public <T extends Component<ECS_TYPE>> T getComponent(
int index, ComponentType<ECS_TYPE, T> componentType) { ... }
public <T extends Component<ECS_TYPE>> void setComponent(
int index, ComponentType<ECS_TYPE, T> componentType, T component) { ... }
}Two consequences matter for modders. First, you read and write components by entity index within a chunk, not by Ref, inside a hot loop — chunk.getComponent(i, healthType). Second, adding or removing a component changes an entity's archetype, so the ECS must physically move the entity to a different chunk. ArchetypeChunk exposes addEntity, removeEntity, and transferTo for exactly this — and that relocation is why you almost never mutate structure mid-iteration directly (see CommandBuffer below).
Component index is column position
ComponentType.getIndex() is both the type's identity and its slot in the chunk's column array. Archetype uses the same index for O(1) contains. Never assume the index is stable across server versions or different registries — always resolve types through registration, never hard-code an integer.
#Queries: Describing What to Iterate
A system declares the entities it cares about with a Query. The interface is tiny and composable:
public interface Query<ECS_TYPE> {
boolean test(Archetype<ECS_TYPE> archetype);
boolean requiresComponentType(ComponentType<ECS_TYPE, ?> type);
static <E> AnyQuery<E> any();
static <E> NotQuery<E> not(Query<E> q);
static <E> AndQuery<E> and(Query<E>... qs);
static <E> OrQuery<E> or(Query<E>... qs);
}Because ComponentType and Archetype both implement Query, you compose queries directly out of them:
// conceptual: "has Position AND Velocity, but NOT Frozen"
Query<ECS_TYPE> moving = Query.and(
Archetype.of(positionType, velocityType),
Query.not(frozenType)
);| Query | Matches when… |
|---|---|
ComponentType | archetype contains that one type |
Archetype | archetype is a superset of it |
AndQuery | every sub-query matches |
OrQuery | at least one sub-query matches |
NotQuery | the wrapped query does not match |
AnyQuery (Query.any()) | always (every archetype) |
For systems that distinguish reads from writes, ReadWriteQuery carries two archetypes. Its default test requires the chunk to contain both the read set and the write set — this is the metadata the scheduler uses to know which systems touch which columns:
public class ReadWriteQuery<ECS_TYPE> implements ReadWriteArchetypeQuery<ECS_TYPE> {
public ReadWriteQuery(Archetype<ECS_TYPE> read, Archetype<ECS_TYPE> write) { ... }
public Archetype<ECS_TYPE> getReadArchetype();
public Archetype<ECS_TYPE> getWriteArchetype();
}The Store resolves a query into the matching chunks. The public iteration entry points are forEachChunk(Query, BiConsumer<ArchetypeChunk, CommandBuffer>), the predicate-returning overloads for early exit, forEachEntityParallel(Query, ...) for fan-out, and getEntityCountFor(Query) for a cheap count.
#Systems: Where Mod Logic Lives
A system is a unit of behavior. The base contract is ISystem, which can declare a SystemGroup and a set of Dependency objects for ordering. The abstract System base class adds the registration helpers — this is where a system declares the components and resources it owns:
public abstract class System<ECS_TYPE> implements ISystem<ECS_TYPE> {
protected <T extends Component<ECS_TYPE>> ComponentType<ECS_TYPE, T>
registerComponent(Class<? super T> tClass, Supplier<T> supplier);
public <T extends Resource<ECS_TYPE>> ResourceType<ECS_TYPE, T>
registerResource(Class<? super T> tClass, Supplier<T> supplier);
}For per-tick logic over entities, you extend EntityTickingSystem, which is a QuerySystem — it supplies a getQuery() and the scheduler only feeds it chunks whose archetype passes that query (and that aren't tagged NonTicking). The per-entity callback gives you the chunk, the entity's index, the Store, and a CommandBuffer:
public abstract class EntityTickingSystem<ECS_TYPE> extends ArchetypeTickingSystem<ECS_TYPE> {
public abstract void tick(float dt, int index,
ArchetypeChunk<ECS_TYPE> chunk, Store<ECS_TYPE> store, CommandBuffer<ECS_TYPE> buffer);
}A sketch of a regeneration system reads and writes components by index, and queues structural change through the buffer:
// conceptual mod system — symbols (EntityTickingSystem, getQuery, tick,
// chunk.getComponent, CommandBuffer.removeComponent) are real
public final class RegenSystem extends EntityTickingSystem<World> {
private final ComponentType<World, HealthComponent> healthType;
private final ComponentType<World, RegenComponent> regenType;
@Override public Query<World> getQuery() {
return Archetype.of(healthType, regenType); // only matching chunks are ticked
}
@Override public void tick(float dt, int i, ArchetypeChunk<World> chunk,
Store<World> store, CommandBuffer<World> buffer) {
HealthComponent h = chunk.getComponent(i, healthType);
RegenComponent r = chunk.getComponent(i, regenType);
h.current = Math.min(h.max, h.current + r.perSecond * dt); // write into the column
if (h.current >= h.max) {
Ref<World> ref = chunk.getReferenceTo(i);
buffer.removeComponent(ref, regenType); // queued — does NOT relocate now
}
}
}Beyond ticking, the family includes EntityEventSystem (handles an EcsEvent for matching entities), ArchetypeChunkSystem, SpatialSystem (rebuilds a spatial index — see below), and the data/fetch systems. Each is registered through the registry's registerSystem(ISystem).
| Base class | Use it for |
|---|---|
EntityTickingSystem | Per-entity logic every tick |
ArchetypeTickingSystem | Per-chunk logic every tick |
EntityEventSystem | Reacting to an EcsEvent on matching entities |
SpatialSystem | Maintaining a spatial query structure |
ArchetypeChunkSystem | Hooks when systems attach/detach from chunks |
#CommandBuffer: Deferred, Thread-Safe Mutation
Iterating a chunk while simultaneously adding or removing components from it would relocate entities out from under the loop. The ECS solves this with CommandBuffer — a queue of Consumer<Store> operations that the engine drains after the current pass. CommandBuffer implements the full ComponentAccessor interface, so it is the single object you mutate the world through from inside a system:
public class CommandBuffer<ECS_TYPE> implements ComponentAccessor<ECS_TYPE> {
public Ref<ECS_TYPE> addEntity(Holder<ECS_TYPE> holder, AddReason reason);
public void removeEntity(Ref<ECS_TYPE> ref, RemoveReason reason);
public <T extends Component<ECS_TYPE>> T addComponent(Ref<ECS_TYPE> ref, ComponentType<ECS_TYPE, T> type);
public <T extends Component<ECS_TYPE>> void removeComponent(Ref<ECS_TYPE> ref, ComponentType<ECS_TYPE, T> type);
public <T extends Component<ECS_TYPE>> void putComponent(Ref<ECS_TYPE> ref, ComponentType<ECS_TYPE, T> type, T value);
public <Event extends EcsEvent> void invoke(Ref<ECS_TYPE> ref, Event param);
}Reads are immediate (getComponent, getArchetype, getResource); structural writes are deferred until consume() runs. Note the entity-lifecycle enums on the API: addEntity takes an AddReason (SPAWN or LOAD) and removals take a RemoveReason (REMOVE or UNLOAD) — so listeners can distinguish a fresh spawn from a chunk reload, and a real death from a chunk unload.
The buffer is also the unit of parallelism. fork() produces a child buffer for a worker thread, and mergeParallel(parent) folds its queued operations back into the parent so they apply deterministically on the main thread. EntityTickingSystem uses exactly this when isParallel(...) is true, forking a buffer per parallel range and merging afterward.
Never relocate during iteration
Always route addComponent / removeComponent / addEntity / removeEntity through the CommandBuffer you're handed — never call structural mutators on the Store mid-tick. The buffer guarantees those changes are applied between passes, after the chunk you're iterating is no longer live, and merges correctly across parallel workers.
#Resources, Refs, and the Spatial Layer
Not all state belongs to entities. A Resource<ECS_TYPE> is a singleton blob owned by the Store, registered with registerResource(...) to get a ResourceType, and read via store.getResource(type). World-global state (time of day, a navigation graph, a spatial index) lives here rather than being duplicated on every entity.
The spatial subpackage is a concrete, important resource. SpatialSystem ticks every frame, clears its SpatialResource, walks every matching chunk via store.forEachChunk(systemIndex, ...), reads each entity's Vector3d position through your getPosition(chunk, index) override, and rebuilds the SpatialStructure (a KDTree, with MortonCode support) so other systems can answer nearest-neighbour and radius queries cheaply. This is how proximity-driven gameplay avoids O(n²) scans.
Finally, Ref<ECS_TYPE> deserves respect. It is a validated handle, not a pointer: it carries the owning Store and a mutable internal index, and it is invalidated when its entity is removed. Calling validate() on a stale ref throws IllegalStateException — and the decompiled code even captures the invalidating stack trace as the cause, so a use-after-free in mod logic points you straight at the removal that killed it.
#Putting It Together
A mod that adds new behavior follows the same path the engine's own systems do: define plain-data components, register them to obtain ComponentTypes, build an Archetype/Query describing the entities you operate on, implement a system that reads and writes component columns by index, and route every structural change through the CommandBuffer. The ECS handles chunk packing, query resolution, ordering, and deferred mutation for you — your job is to keep the hot path doing arithmetic on arrays.
ECS modder checklist
- Define each piece of state as a class implementing
Component<ECS_TYPE>with a workingclone(). - Register it (e.g. via
System.registerComponentor the registry) to obtain a stableComponentType— never hard-code a column index. - Describe target entities with an
Archetype.of(...), composingQuery.and/or/notas needed. - Extend
EntityTickingSystem(orEntityEventSystem), return your query fromgetQuery(). - In
tick, read/write viachunk.getComponent(i, type); get the handle withchunk.getReferenceTo(i). - Make every add/remove/spawn go through the
CommandBuffer, with the rightAddReason/RemoveReason. - Put world-global state in a
Resource; use aSpatialSystemfor proximity queries instead of scanning all entities. - Register the system with
registerSystem(...)and treat staleRefs as fatal — validate before use.
Build it yourself
Turn an idea into a working Hytale mod
Describe what you want in plain English and get a real, installable build. No boilerplate, no setup.
Start building