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.

Player Games10 min read

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:

Java
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:

Java
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 register after shutdown() immediately tears the registration back down and throws. The precondition is checked separately by subclasses via checkPrecondition(), which throws an IllegalStateException(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.

SymbolKindRole
RegistrationclassReversible handle; unregister(), isRegistered()
Registry<T extends Registration>abstract classTracks registrations; register, shutdown, shutdownAndCleanup, checkPrecondition
Registry.RegistrationWrapFunction<T>interfaceFactory that wraps a registration into its typed handle
IRegistryinterfaceThe 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:

Java
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:

Java
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:

Java
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:

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

Java
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.

WrapperBacking codecregister(...) 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:

Java
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:

Java
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:

Java
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:

Java
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:

Java
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 String key (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 returned EntityRegistration if you need to introspect or unregister early.
  • For polymorphic JSON: get the right wrapper via getCodecRegistry(mapCodec) and call register(id, aClass, codec); pass a Priority only when ordering matters (Priority.NORMAL is the default, Priority.DEFAULT for fallbacks).
  • For a JSON asset type: build your AssetStore and register it through getAssetRegistry().register(store) — it enters the global store under ASSET_LOCK and emits a RegisterAssetStoreEvent.
  • Resolve content with AssetRegistry.getAssetStore(tClass) then getAssetMap().getAsset(key); treat null as "not registered".
  • Do not store hard references that outlive your plugin — let the shared shutdownTasks reverse 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

Related guides