Hytale Asset Store: Data-Driven Content and Asset Resolution

How Hytale stores, references, and resolves data-driven content. A source-grounded tour of the AssetStore, AssetMap, codecs, contained assets, and the JSON-to-runtime pipeline for blocks, items, and entities.

Player Games12 min read

Almost everything that defines a Hytale world — every block, item, projectile, entity effect, game mode — is data. It lives on disk as JSON, gets decoded into a typed Java object, and is filed into an in-memory store keyed by a stable identifier. The package that owns this lifecycle is com.hypixel.hytale.assetstore, and understanding it is the single highest-leverage thing a server modder can do: it is the seam where your content enters the running server. This reference is based on Hytale server version 2026.03.26-89796e57b.

This article walks the real classes in assetstore and traces a definition from a file on disk all the way to a resolvable runtime object. The central abstraction is AssetStore<K, T, M> — a generic, per-asset-type loader and registry. Concrete content types like BlockType plug into it by implementing JsonAssetWithMap. Every store is reachable from a static AssetRegistry, dependencies between stores are honored through a topological load order, and assets can reference other assets either by key (a lightweight string handle) or by embedding (a fully inlined child definition). We will cover all of it.

#The Core Data Model: JsonAsset, AssetMap, and AssetStore

Three generic types form the spine of the system. They are deliberately tiny interfaces with a lot of machinery hung off them.

Java
// The minimal contract for a data-driven asset: it has a key.
public interface JsonAsset<K> {
   K getId();
}

// An asset that declares which AssetMap implementation stores it.
public interface JsonAssetWithMap<K, M extends AssetMap<K, ?>> extends JsonAsset<K> {
}

K is the key type (almost always String), T is the asset class, and M is the storage map. A concrete content type wires all three together. BlockType, for example, declares:

Java
public class BlockType
      implements JsonAssetWithMap<String, BlockTypeAssetMap<String, BlockType>> {
   public String getId() { return this.id; } // its stable key
}

The store itself is AssetStore<K, T extends JsonAssetWithMap<K, M>, M extends AssetMap<K, T>>. It is abstract: it knows how to load, decode, validate, and index assets, but defers a few hooks (the event bus, file monitoring, and the post-update callback) to a concrete subclass. On the server that subclass is HytaleAssetStore, which supplies getEventBus() from HytaleServer and registers a filesystem watcher in addFileMonitor.

The store holds an AssetMap<K, T> — the actual indexed storage. AssetMap is an abstract class defining the read and resolution surface:

MethodReturnsPurpose
getAsset(K key)TResolve an asset by its key (the hot path)
getAsset(String packKey, K key)TResolve the value beneath a given pack's override
getPath(K key)PathThe source file an asset was loaded from
getAssetPack(K key)StringWhich pack contributed the live value
getChildren(K key)Set<K>Keys of assets contained by this one
getKeysForTag(int tagIndex)Set<K>Reverse lookup: every asset carrying a tag
getKeysForPack(String name)Set<K>Every key a pack contributed
getAssetCount()intLive asset count

The default implementation, DefaultAssetMap, stores assets in an Object2ObjectOpenCustomHashMap keyed with CaseInsensitiveHashStrategy — so Stone and stone resolve to the same BlockType. Reads use a StampedLock optimistic read for lock-free lookups on the steady-state hot path, falling back to a real read lock only if a concurrent write invalidated the stamp.

Keys are case-insensitive

Because DefaultAssetMap uses CaseInsensitiveHashStrategy, asset keys collide across case. Two packs that define MyMod:Sword and MyMod:sword are addressing the same slot. Pick one canonical casing for your IDs and stick to it.

#Registration and Lookup: The AssetRegistry

Every store is global and discoverable through the static AssetRegistry. Stores register themselves once, keyed by their asset Class:

Java
// Register a store (done once, at startup, per asset type).
AssetRegistry.register(myAssetStore);

// Resolve the store for a content type, anywhere in the codebase.
AssetStore<String, BlockType, BlockTypeAssetMap<String, BlockType>> store =
      AssetRegistry.getAssetStore(BlockType.class);

BlockType stone = store.getAssetMap().getAsset("Hytale:Stone");

getAssetStore(Class<T>) is the entry point you will use most: hand it an asset class, get back its fully-typed store. Registration is guarded by a process-wide AssetRegistry.ASSET_LOCK (a ReentrantReadWriteLock) that also serializes every load and removal, so the asset graph is never observed half-mutated.

AssetRegistry also owns the tag interning table. Tags are strings, but storing them as strings everywhere would be wasteful, so each distinct tag is mapped to a stable int index via getOrCreateTagIndex(String). The maps use a default return value of Integer.MIN_VALUE (exposed as AssetRegistry.TAG_NOT_FOUND) for "unknown tag". This is why AssetMap.getKeysForTag takes an int, not a String — tag membership is stored as packed integer sets for cheap reverse lookups.

#From Disk to Runtime: The Load Pipeline

The public load surface on AssetStore is broad, but it funnels into one internal method, loadAssets0. The most common entry point reads a directory tree:

Java
// Walk a pack directory and load every file matching the store's extension.
AssetLoadResult<String, BlockType> result =
      store.loadAssetsFromDirectory("MyMod:Blocks", blocksDirectory);

loadAssetsFromDirectory uses Files.walkFileTree to collect every file ending in the store's extension (default .json), then hands the paths to loadAssetsFromPaths. From there the pipeline is:

  1. Path to key. decodeFilePathKey(Path) strips the extension off the filename and runs it through the key codec to produce the asset's K.
  2. Raw collection. Each file becomes a RawAsset<K> — a holder pairing a key with either a source Path or an in-memory char[] buffer.
  3. Parallel decode. decodeAssets fans the raw assets out across CompletableFutures. Each future opens a RawJsonReader and decodes the JSON through the store's AssetCodec.
  4. Parent resolution. Assets may inherit from a parent (more below). Any asset whose parent has not decoded yet is parked in a waitingForParent map and retried in waves until it resolves or is provably unresolvable.
  5. Commit. Successfully decoded assets are written into the AssetMap under the write lock, failures trigger removal of stale keys, and events fire.

The result of any load is an AssetLoadResult<K, T>, which is honest about partial failure:

Java
AssetLoadResult<String, BlockType> result = store.loadAssetsFromDirectory(pack, dir);

result.getLoadedAssets();      // Map<K, T> that decoded successfully
result.getFailedToLoadKeys();  // keys that failed
result.getFailedToLoadPaths(); // files that failed to parse
result.hasFailed();            // true if this load OR any child load failed

Note that hasFailed() recurses into childAssetResults — a load is only clean if every contained asset it pulled in also loaded. We will see where those children come from shortly.

Asset comparison is off by default

AssetStore.DISABLE_ASSET_COMPARE is true, so the loader does not skip re-decoding an unchanged asset by default. An AssetUpdateQuery lets a caller opt into comparison and control which client-side caches (block textures, models, item icons, map geometry) get rebuilt after a load via its nested RebuildCache.

#Load Order: Dependencies and the Topological Iterator

Content types are not independent. A BlockType references a block sound set; a recipe references items. If items load after the recipes that reference them, validation explodes. The store models this with two declared sets, configured on the builder:

Java
// Sketch: a store that must load after BlockType and ItemType exist.
new HytaleAssetStore.Builder<>(String.class, Recipe.class, new DefaultAssetMap<>())
      .setPath("Recipes")
      .setCodec(recipeCodec)
      .setKeyFunction(Recipe::getId)
      .loadsAfter(BlockType.class, ItemType.class) // dependency edges
      .build();

loadsAfter(...) and loadsBefore(...) declare edges in a dependency graph. At startup, AssetStoreIterator walks the registered stores and yields them in dependency-respecting order: its next() only returns a store once every class in its getLoadsAfter() has already been drained from the work list. If no store can make progress, the remaining set is a cycle and a CircularDependencyException is thrown — its message names every store still waiting and exactly which dependency is blocking it.

A nice ergonomic touch: simplifyLoadBeforeDependencies() rewrites every loadsBefore edge into the equivalent loadsAfter edge on the other store, so the iterator only ever has to reason about one direction.

logDependencies() is a self-auditing tool worth knowing about. It introspects the store's codec, finds every AssetKeyValidator (the validator attached to fields that reference other assets) and every ContainedAssetCodec, derives the set of asset types this store actually references, and warns about missing dependencies (referenced but not declared in loadsAfter) and unused ones (declared but never referenced).

#Asset References: By Key vs. Contained

This is the most important concept for content authors. An asset can point at another asset two ways, and the codec layer decides which based on the JSON shape.

By key. A plain string is a reference. When a BlockType's JSON says "blockSoundSet": "Hytale:Stone", the field decodes to the key of another asset. Resolution is deferred: the referenced asset is looked up lazily via AssetMap.getAsset(key) when needed. Validation of these references runs through AssetKeyValidator, which calls AssetStore.validate(key, results, extraInfo) and fails if the key resolves to nothing — surfacing a MissingAssetException with the offending field, type, and id.

Contained (embedded). Instead of a string, a field can hold a full nested object. ContainedAssetCodec handles this: when it sees an object rather than a string, it carves the nested JSON out, synthesizes a key for it, and registers it as a contained asset of the parent. The behavior is governed by ContainedAssetCodec.Mode:

ModeKey behavior
GENERATE_IDMint a fresh synthetic key for the embedded asset
INHERIT_IDReuse the parent's key; also inherit the parent's tags
INHERIT_ID_AND_PARENTInherit the key, and inherit the parent's parent-key for further inheritance
INJECT_PARENTGenerate a key, but inject the container as the asset's parent
NONESentinel only — constructing a codec with NONE throws

Synthetic keys are minted by AssetExtraInfo.generateKey(), which produces an id prefixed with * (for example *Hytale:Door_door). That leading * is a marker: ContainedAssetCodec.encode checks for it to decide whether to re-inline the full child definition or just write the key back out.

The plumbing that carries embedded children through a load is AssetExtraInfo.Data. As the parent decodes, each contained child is stashed via addContainedAsset(...); after the parent commits, AssetStore.loadContainedAssets drains those into the correct child store (resolved through AssetRegistry.getAssetStore) and records the parent-to-child link in childAssetsMap. That link is what makes getChildren(key) work, and what makes removal cascade: deleting a parent (removeAssets) collects and removes its children too.

Embedding couples lifecycles

A contained asset is owned by its container. When you reload or remove the parent file, its embedded children are reloaded or removed with it. Use a key reference when two definitions have independent lifecycles; use embedding only when the child genuinely has no meaning outside its parent.

#Inheritance, Packs, and Layered Overrides

Assets support single-parent inheritance. An asset's JSON can name a parent key, and decodeAndInheritJsonAsset decodes the child on top of the resolved parent — the child only needs to specify what it changes. The special parent key "super" means "inherit from whatever lower-priority pack already defined this same key," which is how layered overrides work.

That layering is the job of AssetPack plus DefaultAssetMap's chain logic. An AssetPack is an immutable descriptor: a name, a root path, an optional in-memory FileSystem (for zipped packs), an isImmutable flag, and the PluginManifest that shipped it. Multiple packs can define the same key; DefaultAssetMap keeps an AssetRef chain per key recording which pack contributed which value. getAsset(packKey, key) walks that chain to return the value underneath a given pack — precisely what "super" inheritance needs to resolve "the version before mine." When a pack is removed via removeAssetPack(name), only that pack's contributions are peeled off; lower layers reappear automatically.

writeAssetToDisk closes the loop in the other direction: it encodes a live asset back to JSON under a pack's Server/<path> directory and reloads it — but only if the target AssetPack is not immutable, otherwise it throws.

#Specialized Maps: Block Types Get Numeric IDs

Not every asset is happy being addressed only by string. Blocks need a dense numeric id for the world format and network protocol. BlockTypeAssetMap (via AssetMapWithIndexes) assigns each BlockType a stable integer index on first load and maintains a parallel array for O(1) getAsset(int index) lookups, plus getIndex(key) to go the other way. Because the array is dense, this map sets requireReplaceOnRemove() = true: you cannot simply delete a block id and leave a hole, so the store must be configured with setReplaceOnRemove(...) to supply a stand-in (typically BlockType.UNKNOWN). The AssetStore constructor enforces this and throws if you forget.

AssetMapWithIndexes also maintains indexedTagStorage — tag-to-index sets — so systems iterating "all blocks tagged X" never touch strings on the hot path.

#Hot Reload: File Monitors and Events

Because HytaleAssetStore.addFileMonitor registers a filesystem watcher, edits to asset files are picked up live. A change produces an AssetMonitorEvent exposing getCreatedOrModifiedFilesToLoad() and getRemovedFilesToUnload(); the store reloads or unloads exactly those paths. Every mutation also fires typed events on the server EventBus, which is how plugins react to content changes:

EventFires when
RegisterAssetStoreEventA store is registered with the registry
GenerateAssetsEventAfter decode, before commit — a chance to inject/modify assets
LoadedAssetsEventAssets were committed to the map
RemovedAssetsEventAssets were removed (carries whether they were replaced)
RemoveAssetStoreEventA store is unregistered

GenerateAssetsEvent is the interesting one for modders: it fires inside the write lock with the freshly decoded batch in hand, before it is committed, so a listener can add procedurally generated assets that get filed alongside the disk-loaded ones.

#Putting It Together

To add a new data-driven content type, you define an asset class, give it a codec, build a store, register it, and point it at a directory. The store handles decode, dependency ordering, reference resolution, indexing, and hot reload for you.

Java
// Conceptual end-to-end: defining and registering a custom asset type.
AssetStore<String, MyGadget, DefaultAssetMap<String, MyGadget>> store =
      AssetRegistry.register(
         new HytaleAssetStore.Builder<>(String.class, MyGadget.class, new DefaultAssetMap<>())
            .setPath("Gadgets")          // <pack>/Server/Gadgets/*.json
            .setExtension(".json")
            .setCodec(myGadgetCodec)     // drives decode + reference validation
            .setKeyFunction(MyGadget::getId)
            .loadsAfter(ItemType.class)  // resolve item references before us
            .build());

// Later, anywhere on the server:
MyGadget g = AssetRegistry.getAssetStore(MyGadget.class)
      .getAssetMap().getAsset("MyMod:LaserDrill");

Modder checklist: adding a data-driven asset type

  • Implement JsonAssetWithMap<K, M> with a stable getId() key.
  • Write an AssetCodec so JSON fields decode — use a key-reference field for independent assets, a ContainedAssetCodec for embedded children.
  • Pick an AssetMap: DefaultAssetMap for string keys, an indexed map if you need dense numeric ids (and then supply setReplaceOnRemove).
  • Build with HytaleAssetStore.Builder, set path, codec, and keyFunction.
  • Declare loadsAfter(...) for every asset type you reference; run logDependencies() to catch missing or unused edges.
  • Register with AssetRegistry.register(...) and ship JSON under <pack>/Server/<path>/.
  • Inspect AssetLoadResult.hasFailed() and the failed-key/path sets to verify a clean load.

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