Hytale World Storage: Persistence, Chunks, and Serialization

How the Hytale server persists world and entity state — the IndexedStorageFile blob format, BSON chunk serialization, pluggable IChunkStorageProvider backends, and the async save/load pipeline.

Player Games10 min read

When a Hytale world is saved, every modified chunk takes a deterministic trip: its entity components are serialized to a BSON document, the document bytes are zstd-compressed, and the compressed blob is written into a region file keyed by the chunk's local coordinate. This reference walks that path end to end against Hytale server version 2026.03.26, using the real classes in com.hypixel.hytale.storage and com.hypixel.hytale.server.core.universe.world.storage.

The storage layer separates three concerns: a low-level on-disk container (IndexedStorageFile) that knows nothing about chunks, a serialization bridge (BufferChunkLoader / BufferChunkSaver) that turns chunk Holders into byte buffers, and a pluggable provider interface (IChunkStorageProvider) that lets a world pick its backend — flat region files, RocksDB, or a migration chain. These three layers are the key to writing mods that read, transform, or migrate world data safely.

#The IndexedStorageFile Blob Container

IndexedStorageFile (in com.hypixel.hytale.storage) is the foundation. It is a single-file, fixed-slot blob store: a header, an array of blob indexes, then a body of fixed-size segments. Each region file begins with the magic string HytaleIndexedStorage and a version int.

Java
public class IndexedStorageFile implements Closeable {
    public static final String MAGIC_STRING = "HytaleIndexedStorage";
    public static final int VERSION = 1;
    public static final int DEFAULT_BLOB_COUNT = 1024;     // slots per file
    public static final int DEFAULT_SEGMENT_SIZE = 4096;   // bytes per segment
    public static final int DEFAULT_COMPRESSION_LEVEL = 3; // zstd level

    public static IndexedStorageFile open(Path path, OpenOption... options) throws IOException;

    public void writeBlob(int blobIndex, ByteBuffer src) throws IOException;
    public ByteBuffer readBlob(int blobIndex) throws IOException;  // null if empty
    public void removeBlob(int blobIndex) throws IOException;
    public IntList keys() throws IOException;                      // occupied slots
}

A blob is addressed by an integer blobIndex in [0, blobCount). The memory-mapped index region (memoryMapBlobIndexes()) stores, per blob, the first segment index of its data. writeBlob compresses the source with Zstd.compress, allocates a contiguous run of free segments through findFreeSegment, writes the blob header (SRC_LENGTH_OFFSET, COMPRESSED_LENGTH_OFFSET) plus the payload, then atomically points the blob's index at the new segment. readBlob reverses this: read header, pull compressed bytes via readSegments, return Zstd.decompress(src, srcLength).

Concurrency is fine-grained: every blob slot has its own StampedLock (indexLocks[]), free-segment tracking uses a BitSet (usedSegments) guarded by usedSegmentsLock, and segment writes take per-segment locks — so two threads can write different chunks in the same region file without serializing.

Durability vs. throughput

IndexedStorageFile exposes setFlushOnWrite(boolean). When enabled, every putBlobIndex and writeBlob forces the channel (or the mapped buffer) to disk. The provider surfaces this as the FlushOnWrite config key — its own documentation warns it is "Recommended to be enabled to prevent corruption of chunks during unclean shutdowns." Leaving it off is faster but risks losing recently-written chunks on a hard kill.

#Format versioning and migration

The class carries a deprecated predecessor, IndexedStorageFile_v0, which used a linked-list segment layout (each segment stored a NEXT_SEGMENT_OFFSET) and a temp-index crash-recovery scheme (processTempIndexes reclaims orphaned segments on open). When open() reads a version == 0 file, it calls migrateV0 — move the old file aside, create a fresh v1 file, copy every blob across via readBlob/writeBlob. This is why old worlds upgrade transparently.

#From Chunk to Bytes: BSON Serialization

The container stores opaque byte buffers; the chunk-to-bytes translation lives in two abstract classes. BufferChunkSaver.saveHolder serializes a chunk's entity Holder to BSON and hands the bytes down to a saveBuffer implementation:

Java
public abstract class BufferChunkSaver implements IChunkSaver {
    public CompletableFuture<Void> saveHolder(int x, int z, Holder<ChunkStore> holder) {
        BsonDocument document = ChunkStore.REGISTRY.serialize(holder);
        ByteBuffer buffer = ByteBuffer.wrap(BsonUtil.writeToBytes(document));
        return this.saveBuffer(x, z, buffer);
    }
    public abstract CompletableFuture<Void> saveBuffer(int x, int z, ByteBuffer buffer);
}

BufferChunkLoader.loadHolder is the mirror image — BsonUtil.readFromBuffer rebuilds the BsonDocument, ChunkStore.REGISTRY.deserialize reconstructs the Holder<ChunkStore>, and the chunk's WorldChunk component is re-bound to the world via worldChunkComponent.loadFromHolder(world, x, z, holder).

Serialization is driven by Hytale's component registry. ChunkStore.REGISTRY is a ComponentRegistry<ChunkStore>; its serialize(Holder) / deserialize(BsonDocument) methods walk the chunk entity's components against a codec-defined BSON shape. Crucially, non-serialized components are excluded — the save query is

Java
public static final Query<ChunkStore> QUERY = Query.and(
    WorldChunk.getComponentType(),
    Query.not(ChunkStore.REGISTRY.getNonSerializedComponentType()));

So a chunk is only persisted if it carries a WorldChunk component and is not marked non-serialized (NonSerialized). This is the hook modders care about: attach your custom component with a registered codec and it rides along in the chunk blob automatically; mark it transient and it is dropped on save.

#Region Files: Coordinate Math

IndexedStorageChunkStorageProvider is the default backend. It maps the world's chunk grid onto region files of 32×32 chunks. The arithmetic is worth memorizing because it is the same in the loader, saver, and remover:

Java
int regionX = x >> 5;          // 32 chunks per region
int regionZ = z >> 5;
int localX  = x & 31;          // position within the region
int localZ  = z & 31;
int index   = ChunkUtil.indexColumn(localX, localZ);  // blobIndex 0..1023

The region file name is built by toFileName(regionX, regionZ) as "<x>.<z>.region.bin" (e.g. 3.-2.region.bin); fromFileName parses it back. Files are cached per region in an IndexedStorageCache (a Long2ObjectConcurrentHashMap<IndexedStorageFile>), opened lazily via getOrTryOpen (reads) or getOrCreate (writes), all under world.getSavePath().resolve("chunks").

ConceptValue / methodNotes
Region sizex >> 5 / x & 3132×32 chunks per .region.bin file
Blob slotChunkUtil.indexColumn(localX, localZ)0–1023, one per chunk column
File name"<x>.<z>.region.bin"parsed by fromFileName
Storage dirgetSavePath().resolve("chunks")per-world
Save opgetOrCreate(...).writeBlob(index, buffer)creates region on demand
Load opgetOrTryOpen(...).readBlob(index)returns null if region/blob absent
Remove opgetOrTryOpen(...).removeBlob(index)no-op if region missing

getIndexes() reconstructs the full set of stored chunks by listing the chunks directory, opening each region, and expanding every occupied blob slot back into a global chunk index via xFromColumn / zFromColumn and the regionX << 5 | localX reverse mapping. This is what feeds world-wide migration and backup tooling.

#Pluggable Backends: IChunkStorageProvider

Every world chooses a backend through world.getWorldConfig().getChunkStorageProvider(). The interface is small and codec-registered, so providers are selectable from world config by a "Type" discriminator:

Java
public interface IChunkStorageProvider<Data> {
    BuilderCodecMapCodec<IChunkStorageProvider<?>> CODEC =
        new BuilderCodecMapCodec<>("Type", true);

    Data initialize(Store<ChunkStore> store) throws IOException;
    IChunkLoader getLoader(Data data, Store<ChunkStore> store) throws IOException;
    IChunkSaver  getSaver(Data data, Store<ChunkStore> store) throws IOException;
    void close(Data data, Store<ChunkStore> store) throws IOException;

    default <O> Data migrateFrom(Store<ChunkStore> store,
                                 IChunkStorageProvider<O> other) throws IOException;
}

The shipped implementations each register a string ID:

ProviderIDBackend
DefaultChunkStorageProvider"Hytale"Delegates to IndexedStorageChunkStorageProvider
IndexedStorageChunkStorageProvider"IndexedStorage"Flat .region.bin blob files, zstd
RocksDbChunkStorageProvider"RocksDb"Embedded RocksDB key-value store
MigrationChunkStorageProvider"Migration"Reads from a chain of loaders, writes to one saver
EmptyChunkStorageProvider"Empty"Discards everything (read-only / no-save)

DefaultChunkStorageProvider is just a thin delegate whose internal provider is a new IndexedStorageChunkStorageProvider() — so "default" means flat region files today, and its isSame check treats the indexed provider as equivalent to avoid spurious migrations.

The RocksDB backend is the interesting alternative. It opens a db directory with a dedicated chunks column family tuned for spatial data: useFixedLengthPrefixExtractor(8) over an 8-byte big-endian (x, z) key (toKey), IndexType.kHashSearch, a BloomFilter, LZ4 for hot levels with ZSTD at the bottommost level, and blob files with garbage collection enabled. Its getIndexes() iterates the column family with a RocksIterator. Same BSON-over-buffer contract, completely different engine — the point of the abstraction.

The Migration provider is a power tool

MigrationChunkStorageProvider takes a Loaders array and a single Saver. Its MigrationChunkLoader tries each loader in order and returns the first non-null chunk. Pairing Loaders: [IndexedStorage] with Saver: Empty gives you a world that loads existing chunks but never writes changes back — a clean, config-only way to make a read-only world without touching code.

#The Save/Load Pipeline at Runtime

Loading and saving are asynchronous and tick-aware. ChunkStore.getChunkReferenceAsync(index, flags) is the single entry point for getting a chunk in memory; the flags come from GetChunkFlags:

Java
public class GetChunkFlags {
    public static final int NONE = 0;
    public static final int NO_LOAD = 1;        // skip disk load
    public static final int NO_GENERATE = 2;    // skip worldgen fallback
    public static final int SET_TICKING = 4;
    public static final int BYPASS_LOADED = 8;
    public static final int POLL_STILL_NEEDED = 16;
}

The default behavior is load-then-generate: getChunkReferenceAsync first calls loader.loadHolder(x, z), and only if disk returns nothing does it fall back to the generator. Per-chunk ChunkLoadState objects (a StampedLock, in-flight future, and exponential failure backoff capped at MAX_FAILURE_BACKOFF_NANOS = 10s) prevent duplicate loads and stop a corrupt chunk being retried in a hot loop.

Saving is driven by ChunkSavingSystems. A WorldChunk is only written when getNeedsSaving() is true and it is not already isSaving(). The ticking system (ChunkSavingSystems.Ticking) polls dirty chunks on a 0.5s timer, fires a cancellable ChunkSaveEvent, copies a serializable snapshot with store.copySerializableEntity(reference), and dispatches up to ForkJoinPool.commonPool().getParallelism() saves per tick. On success it sets ChunkFlag.ON_DISK and clears the dirty flag via consumeNeedsSaving().

Java
// ChunkSavingSystems.saveChunk — the core of one save (condensed)
Holder<ChunkStore> holder = worldChunkComponent.toHolder();
IChunkSaver saver = chunkStore.getSaver();
saver.saveHolder(worldChunkComponent.getX(), worldChunkComponent.getZ(), holder)
     .whenComplete((v, throwable) -> {
         if (throwable == null) worldChunkComponent.setFlag(ChunkFlag.ON_DISK, true);
     });
worldChunkComponent.consumeNeedsSaving();

On world shutdown, ChunkSavingSystems.WorldRemoved runs a blocking full flush — saveChunksInWorld(store).join() — unless the world config has disabled saving (canSaveChunks()), in which case it logs that the world "will not be saved on shutdown." Newly generated chunks are only queued for saving if worldConfig.shouldSaveNewChunks() is set, which is checked in ChunkStore.preLoadChunkAsync before markNeedsSaving().

Entities are part of the chunk blob

There is no separate entity-region file in this layer. A chunk's Holder is an ECS entity that carries the block data and its serializable child components; EntityStore indexes live entities by UUID and NetworkId at runtime but defers persistence to the same component-registry serialization. To make a custom entity-attached datum survive a save, register it as a serializable component on the chunk store — do not invent a parallel file format.

#Putting It Together

A mod that touches world storage almost always does one of three things: read existing chunks (use getIndexes() + a loader), add persistent data (register a serializable component on ChunkStore.REGISTRY), or migrate formats (configure MigrationChunkStorageProvider). All three ride on the same BSON-over-blob contract, so you rarely touch IndexedStorageFile directly — you let the provider and the component registry do the work, and you mark anything transient as NonSerialized so it never reaches disk.

Java
// Conceptual: enumerate every stored chunk in a world (not literal source)
IChunkStorageProvider<?> provider = world.getWorldConfig().getChunkStorageProvider();
Object data = provider.initialize(store);
try (IChunkLoader loader = provider.getLoader(data, store)) {
    LongSet stored = loader.getIndexes();
    for (long index : stored) {
        int cx = ChunkUtil.xOfChunkIndex(index);
        int cz = ChunkUtil.zOfChunkIndex(index);
        Holder<ChunkStore> holder = loader.loadHolder(cx, cz).join();
        // inspect or transform the chunk's components here
    }
}

Modder checklist: working with world storage

  • Decide your layer: container (IndexedStorageFile), serialization (BufferChunkLoader/Saver), or provider (IChunkStorageProvider) — stay as high as possible.
  • To persist custom chunk data, register a component with a codec on ChunkStore.REGISTRY; it is included by the ChunkSavingSystems.QUERY automatically.
  • Mark anything runtime-only as NonSerialized so it is excluded from serialize(holder).
  • Respect dirtiness: trigger saves through markNeedsSaving() / getNeedsSaving(), never by writing blobs by hand.
  • For read-only worlds, configure MigrationChunkStorageProvider with an Empty saver instead of patching code.
  • Enable FlushOnWrite on the indexed provider for crash safety; accept the throughput cost on busy servers.
  • Use getIndexes() for full-world passes; use getChunkReferenceAsync with the right GetChunkFlags for in-world access.

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