Hytale Registry System: Registering and Resolving Game Content
A source-grounded tour of Hytale's server registry system — how plugins register entities, codecs, and assets under string keys, and how those registrations are tracked, resolved, and torn down.
Every piece of content a Hytale plugin adds — a new entity type, a new codec for a JSON asset, a new command — has to be announced to the server and given a key the rest of the engine can resolve it by. In the decompiled Hytale server (version 2026.03.26) this is done by a small family of registry classes that share one consistent contract: a plugin calls register(...) with a string id and an implementation, the engine returns a handle, and the registration is automatically reversed when the plugin shuts down.
There is no single monolithic "Registry" god-object. Instead Hytale splits the job across two complementary layers. The base layer — com.hypixel.hytale.registry.Registry and Registration — is a generic lifecycle tracker: it remembers what you registered so it can be unregistered cleanly. The content layer — EntityRegistry, CodecMapRegistry, MapKeyMapRegistry, and AssetRegistry under server.core.plugin.registry — does the actual keyed bookkeeping, writing entries into codecs and asset stores under a global lock. This article walks both layers from the real code.
#The lifecycle base: Registry and Registration
The generic base lives in com.hypixel.hytale.registry. Registration is the handle you get back from a registry; Registry<T extends Registration> is the tracker that owns a list of them.
A Registration is deliberately tiny. It holds two callbacks — an isEnabled supplier and an unregister runnable — plus a registered flag, and guarantees the teardown runs at most once:
public class Registration {
protected final BooleanSupplier isEnabled;
protected final Runnable unregister;
private boolean registered = true;
public void unregister() {
if (this.registered && this.isEnabled.getAsBoolean()) {
this.unregister.run(); // reverse the registration exactly once
}
this.registered = false;
}
public boolean isRegistered() {
return this.registered && this.isEnabled.getAsBoolean();
}
}Registry<T> wraps a List<BooleanConsumer> of teardown callbacks and gates everything behind a precondition (a BooleanSupplier) with a preconditionMessage. The contract is enforced in register:
public abstract class Registry<T extends Registration> {
private boolean enabled = true;
public T register(@Nonnull T registration) {
if (!this.enabled) {
registration.unregister();
throw new IllegalStateException("Registry is not enabled!");
}
BooleanConsumer reg = v -> registration.unregister();
this.registrations.add(reg);
return this.wrappingFunction.wrap(registration,
() -> this.enabled || registration.isRegistered(),
() -> { this.registrations.remove(reg); registration.unregister(); });
}
}Two design points worth internalizing as a modder:
- Registration is reversible by construction. Every call appends a teardown closure.
shutdownAndCleanup(boolean)walks the list in reverse and invokes each one — last-registered, first-removed — then clears the list. - A disabled registry rejects new content. Calling
registeraftershutdown()immediately tears the registration back down and throws. Thepreconditionis checked separately by subclasses viacheckPrecondition(), which throws anIllegalStateException(preconditionMessage)when the gate is closed.
The RegistrationWrapFunction<T> interface is how each concrete registry produces its specialized handle (e.g. EntityRegistration::new) while reusing the base bookkeeping.
| Symbol | Kind | Role |
|---|---|---|
Registration | class | Reversible handle; unregister(), isRegistered() |
Registry<T extends Registration> | abstract class | Tracks registrations; register, shutdown, shutdownAndCleanup, checkPrecondition |
Registry.RegistrationWrapFunction<T> | interface | Factory that wraps a registration into its typed handle |
IRegistry | interface | The content-layer marker; single method shutdown() |
#Registering an entity type: EntityRegistry
EntityRegistry is the clearest example of a keyed content registry built on the base. It extends Registry<EntityRegistration> and exposes one purpose-built method:
public class EntityRegistry extends Registry<EntityRegistration> {
@Nullable
public <T extends Entity> EntityRegistration registerEntity(
@Nonnull String key, @Nonnull Class<T> clazz,
Function<World, T> constructor, DirectDecodeCodec<T> codec) {
this.checkPrecondition();
return this.register(EntityModule.get().registerEntity(key, clazz, constructor, codec));
}
}This is the canonical registration shape in Hytale: a string key, the Class that implements the content, a constructor the engine calls to instantiate it, and a Codec to (de)serialize it. EntityModule.get().registerEntity(...) does the real insertion and returns an EntityRegistration; the EntityRegistry then wraps it for lifecycle tracking and checkPrecondition() ensures the owning plugin is enabled first.
EntityRegistration simply carries the entity Class alongside the base handle so callers can introspect what was registered:
public class EntityRegistration extends Registration {
private final Class<? extends Entity> entityClass;
public Class<? extends Entity> getEntityClass() { return this.entityClass; }
}The key is the public identity
The String key you pass to registerEntity is the stable identifier the engine and other content resolve your entity by. Treat it like a namespaced id — prefix it with your plugin's name so it cannot collide with another mod's entity. The Class is an implementation detail; the key is the contract.
#Codec-keyed registries: CodecMapRegistry and MapKeyMapRegistry
Not all content is a top-level entity. A lot of Hytale's data is polymorphic JSON — a field whose concrete type depends on a discriminator string in the document. These are registered into codec maps, and server.core.plugin.registry provides thin plugin-facing wrappers around them. Both implement IRegistry.
CodecMapRegistry<T, C extends Codec<? extends T>> wraps a StringCodecMapCodec. You register a concrete subtype under a string id:
public class CodecMapRegistry<T, C extends Codec<? extends T>> implements IRegistry {
public CodecMapRegistry<T, C> register(String id, Class<? extends T> aClass, C codec) {
this.mapCodec.register(id, aClass, codec);
this.unregister.add(shutdown -> {
AssetRegistry.ASSET_LOCK.writeLock().lock(); // global write lock
try { this.mapCodec.remove(aClass); }
finally { AssetRegistry.ASSET_LOCK.writeLock().unlock(); }
});
return this;
}
public CodecMapRegistry<T, C> register(@Nonnull Priority priority, @Nonnull String id,
Class<? extends T> aClass, C codec) { /* same, with ordering */ }
}MapKeyMapRegistry<V> is the same idea over a MapKeyMapCodec<V>, registering by Class + id + Codec. Its teardown is conditional — it only unregisters when shutdown == false (i.e. on a plugin reload, not a full server stop, where the whole process is going away anyway).
The underlying StringCodecMapCodec / ACodecMapCodec is where keys actually live. Each registration writes into a classToId map and an id-to-codec map. Resolution at decode time keys off the discriminator string; an unknown id throws:
// inside StringCodecMapCodec.decodeJson — conceptual control flow
throw new ACodecMapCodec.UnknownIdException(
"No codec registered with for '" + this.key + "': " + id);You never construct these codecs yourself in the registry layer — you obtain the wrapper from your plugin and call register.
#Priority: ordering, not precedence numbers
When two registrations could match, Priority decides order. It is not an enum of named levels — it is a small value class wrapping an int level:
public class Priority {
public static Priority DEFAULT = new Priority(-1000);
public static Priority NORMAL = new Priority(0);
public Priority before(int by) { return new Priority(this.level - by); }
public Priority after(int by) { return new Priority(this.level - by); }
}Priority.NORMAL is the implicit default in ACodecMapCodec.register(id, aClass, codec). Use Priority.DEFAULT for fallbacks you want considered last, and before(n) to nudge a registration ahead of others.
| Wrapper | Backing codec | register(...) key shape |
|---|---|---|
CodecMapRegistry<T,C> | StringCodecMapCodec<T,C> | (String id, Class, Codec) ± Priority |
CodecMapRegistry.Assets<T,C> | AssetCodecMapCodec | (String id, Class, BuilderCodec) ± Priority |
MapKeyMapRegistry<V> | MapKeyMapCodec<V> | (Class, String id, Codec) |
#The global asset store: registration, freezing, and resolution
Above the codec wrappers sits the engine-wide store of JSON-backed content: com.hypixel.hytale.assetstore.AssetRegistry. This is the closest thing to a classic "freeze after load, resolve by key" registry.
It is a static singleton holding a Map<Class<? extends JsonAssetWithMap>, AssetStore<?,?,?>> plus the global ASSET_LOCK (a ReentrantReadWriteLock) that every codec-map teardown above acquires. Registration is idempotent-or-fail and event-emitting:
public class AssetRegistry {
public static final ReadWriteLock ASSET_LOCK = new ReentrantReadWriteLock();
public static <K, T extends JsonAssetWithMap<K, M>, M extends AssetMap<K, T>, S extends AssetStore<K, T, M>>
S register(@Nonnull S assetStore) {
ASSET_LOCK.writeLock().lock();
try {
if (storeMap.putIfAbsent(assetStore.getAssetClass(), assetStore) != null) {
throw new IllegalArgumentException("Asset Store already exists for " + assetStore.getAssetClass());
}
} finally {
ASSET_LOCK.writeLock().unlock();
}
// notify listeners that a new store came online
// ... dispatch RegisterAssetStoreEvent ...
return assetStore;
}
}The "freezing" semantics here are pragmatic rather than a one-shot freeze() call. getStoreMap() hands out a Collections.unmodifiableMap view so callers cannot mutate the registry directly, and AssetStore exposes isUnmodifiable() for stores that are sealed after load. Writes only happen through register/unregister under the write lock; readers take the read lock, so content is stable while the world is ticking.
The plugin-facing AssetRegistry wrapper (note: a different class, in server.core.plugin.registry) is the lifecycle-tracked door into that static store:
public class AssetRegistry { // plugin-facing wrapper
public <K, T extends JsonAssetWithMap<K, M>, M extends AssetMap<K, T>, S extends AssetStore<K, T, M>>
AssetRegistry register(@Nonnull S assetStore) {
com.hypixel.hytale.assetstore.AssetRegistry.register(assetStore); // into global store
this.unregister.add(shutdown ->
com.hypixel.hytale.assetstore.AssetRegistry.unregister(assetStore)); // auto-cleanup
return this;
}
}#Resolving content by key
Resolution lives on the store, not the registry. AssetRegistry.getAssetStore(tClass) hands you the AssetStore<K,T,M> for a content type; its AssetMap does the key lookup:
public abstract class AssetMap<K, T extends JsonAsset<K>> {
public abstract T getAsset(K key);
public abstract T getAsset(@Nonnull String pack, K key);
public abstract Set<K> getKeysForTag(int tagIndex);
public abstract int getAssetCount();
}Tags are interned to dense int indices via AssetRegistry.getOrCreateTagIndex(String) (backed by a StampedLock), so tag membership tests stay allocation-free on the hot path. getAsset returning null for an unknown key — and getTagIndex returning TAG_NOT_FOUND (Integer.MIN_VALUE) for an unknown tag — is the resolution contract: absence is a sentinel, not an exception.
#How a plugin reaches the registries
Modders don't instantiate any of these classes. PluginBase constructs them once, wired to a shared shutdownTasks list (a CopyOnWriteArrayList<BooleanConsumer>) and a precondition that the plugin's PluginState is neither NONE nor DISABLED:
private final EntityRegistry entityRegistry = new EntityRegistry(this.shutdownTasks,
() -> this.state != PluginState.NONE && this.state != PluginState.DISABLED, this.notEnabledString);
private final AssetRegistry assetRegistry = new AssetRegistry(this.shutdownTasks);You reach them through accessors. Codec registries are memoized per backing codec so repeated lookups return the same wrapper:
public EntityRegistry getEntityRegistry() { return this.entityRegistry; }
public AssetRegistry getAssetRegistry() { return this.assetRegistry; }
public <T, C extends Codec<? extends T>> CodecMapRegistry<T, C>
getCodecRegistry(@Nonnull StringCodecMapCodec<T, C> mapCodec) {
IRegistry r = this.codecMapRegistries.computeIfAbsent(mapCodec,
v -> new CodecMapRegistry<>(this.shutdownTasks, mapCodec));
return (CodecMapRegistry<T, C>) r;
}Because every registry shares the plugin's shutdownTasks, disabling the plugin replays all of those teardown closures in one pass — entities, codecs, and asset stores all deregister together, and the notEnabledString precondition guarantees nothing slips in afterward.
#Putting it together
A new content type follows the same arc every time: pick a stable key, hand the engine your Class + Codec (+ constructor for entities), register through the plugin's registry, and let the shared shutdown list reverse it for you. Resolution is then a key lookup on a store or codec map — null/sentinel on miss, your object on hit.
Registering new content in a Hytale plugin
- Choose a namespaced
Stringkey (prefix with your plugin name) — it is the public identity callers resolve by. - For an entity: call
getEntityRegistry().registerEntity(key, clazz, constructor, codec)and keep the returnedEntityRegistrationif you need to introspect or unregister early. - For polymorphic JSON: get the right wrapper via
getCodecRegistry(mapCodec)and callregister(id, aClass, codec); pass aPriorityonly when ordering matters (Priority.NORMALis the default,Priority.DEFAULTfor fallbacks). - For a JSON asset type: build your
AssetStoreand register it throughgetAssetRegistry().register(store)— it enters the global store underASSET_LOCKand emits aRegisterAssetStoreEvent. - Resolve content with
AssetRegistry.getAssetStore(tClass)thengetAssetMap().getAsset(key); treatnullas "not registered". - Do not store hard references that outlive your plugin — let the shared
shutdownTasksreverse every registration on disable.
Don't register after disable
Registry.register throws IllegalStateException("Registry is not enabled!") if the registry has been shut down, and subclasses call checkPrecondition() which throws with the plugin's notEnabledString. Always register during your plugin's enable phase — never lazily from a tick handler or an async callback that might fire after teardown.
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