Skip to content

World Storage

Worlds are stored under the universe directory:

<universe>/
├── blockIdMap.json
├── worlds/
│ └── <world-name>/
│ ├── config.json # WorldConfig
│ ├── config.json.bak # backup
│ ├── paths.json # path configuration
│ ├── chunks/ # region files
│ │ └── <x>.<z>.region.bin
│ └── resources/ # persistent ECS resources
└── players/
└── <uuid>.json # per-player data

Access a world’s save path programmatically via world.getSavePath(). Mod data directories (mods/<group>_<name>/) are at the server root, not inside the universe — see Configuration - Data Directory.

config.json contains WorldConfig settings serialized as BSON-to-JSON:

{
"seed": 12345,
"worldType": "hytale:overworld",
"spawnPosition": { "x": 0, "y": 64, "z": 0 },
"chunkConfig": {
"keepLoadedRegion": { ... },
"pregenerateRegion": { ... }
}
}

Chunks are grouped into region files, each containing a 32×32 grid (1024 chunks):

chunks/<regionX>.<regionZ>.region.bin

Region files use the IndexedStorageFile format. Each chunk is stored as a Zstd-compressed BSON blob.

Each chunk is stored as a Zstd-compressed BSON document (see Binary Format for the file layout). Rather than one monolithic data structure, chunk data is split into independent components (BlockChunk, EntityChunk, etc.) that can be added, removed, or modified separately. This follows the Entity Component System (ECS) pattern used throughout Hytale - see Entity Overview for more on ECS.

Each component is a keyed field in the BSON document. Components like BlockChunk and BlockSection store their data in a binary Data field containing packed palettes, heightmaps, and other structures described below.

A chunk is 32×32 blocks horizontally and 320 blocks vertically, divided into 10 sections of 32 blocks height each (section 0 = Y 0-31, section 1 = Y 32-63, etc).

WorldChunk # chunk controller with state flags
└── BlockChunk # chunk-wide data + sections array
└── BlockSection (×10) # 32×32×32 block data per section

WorldChunk manages chunk lifecycle and state flags. BlockChunk contains all the actual data: heightmap, biome tints, environment info, and the array of 10 BlockSections.

Note: Older saves (v1-2) used a ChunkColumn component that stored sections as separate entities. This is deprecated - the migration system loads ChunkColumn data into BlockChunk’s section array on world load.

Current version: v3

FieldDescription
heightHeightmap - surface Y level per column (32×32)
tintBiome tint color per column (32×32), packed ARGB int
environmentsEnvironment/biome IDs with vertical ranges per column
chunkSectionsArray of 10 BlockSection

Tint colors are packed as 0xAARRGGBB (alpha always 0xFF). Multiplied with block textures that have biome tinting enabled (grass, leaves, etc.). One value per X,Z column. Tint is pre-computed during world generation because biomes can use noise-based TintProviders that vary smoothly across terrain - storing the result avoids recomputing noise at runtime and allows clients to render without world gen functions.

Environments support vertical biomes - each column stores environment IDs with Y-range boundaries using run-length encoding. For example, a column might have “Surface” from Y 64-319 and “Cave” from Y 0-63.

Current version: v6

FieldTypeDescription
chunkSectionISectionPaletteBlock type IDs (32×32×32 = 32,768 blocks)
rotationSectionISectionPaletteBlock orientation index per block (added in v5)
fillerSectionISectionPaletteMulti-block offset per block (see below)
localLightChunkLightDataLocal (block-emitted) lighting
globalLightChunkLightDataSky lighting
tickingBlocksBitSet32,768 bits marking blocks that need tick updates

Rotation stores an orientation index per block. Blocks like stairs, slabs, and logs use this to determine which way they face.

Filler handles blocks with bounding boxes larger than 1×1×1 (doors, beds, large decorations). One position is the “base” block (filler = 0), and all other positions the object occupies store a packed XYZ offset back to that base. For example, a 2-tall door has filler = 0 at the bottom and filler = pack(0, 1, 0) at the top, pointing back to the base. This lets the engine find all blocks belonging to the same multi-block object.

All three data fields (chunkSection, rotationSection, fillerSection) use the same palette system. Instead of storing "hytale:stone" for every stone block, each field stores a list of unique values plus an array of indices into that list:

Each palette stores 32×32×32 = 32,768 entries. The palette type determines how many bits each index uses:

PaletteMax TypesBits/IndexSection Size
Empty1 (air)00 bytes
HalfByte16416 KB
Byte256832 KB
Short655361664 KB

Each of the three fields has its own palette that promotes/demotes independently. New sections start as Empty and promote when variety exceeds capacity, or demote when variety decreases (e.g., clearing an area back to air).

Version history:

  • v1-4: Block properties (fluid, rotation, filler) encoded in block key strings
  • v5: Added rotation as separate palette
  • v6: Added block migration version field

When block types are renamed, BlockMigration assets ensure old worlds update automatically. Each migration is a JSON asset keyed by a version number:

{
"DirectMigrations": {
"hytale:old_stone": "hytale:stone",
"hytale:old_dirt": "hytale:dirt"
},
"NameMigrations": {
"hytale:renamed_block": "hytale:new_name"
}
}
  • DirectMigrations - Exact key renames (checked first)
  • NameMigrations - Name-based renames (fallback)

Each BlockSection stores the migration version it was last saved with. On load, the engine chains all migrations from the section’s version to the current version, transforming each block key string incrementally. This means renaming a block type only requires adding a new migration asset - all existing world data updates on load without manual conversion.

Entities are stored in the EntityChunk component:

List<Holder<EntityStore>> entityHolders; // saved entities
Set<Ref<EntityStore>> entityReferences; // active (runtime only)
  1. Chunk loads - Entity holders deserialized from BSON
  2. Chunk starts ticking - Holders loaded into world’s EntityStore
  3. Chunk stops ticking - Active entities converted back to holders
  4. Chunk saves - Holders serialized to BSON

Entities are serialized via the component registry’s entity codec. Key data includes UUIDComponent (survives save/load), TransformComponent, and all other attached components.

From ChunkUtil:

ConstantValueDescription
SIZE32Chunk width/depth in blocks
HEIGHT320World height in blocks
HEIGHT_SECTIONS10Vertical sections per chunk
// pack chunk coordinates to long
long index = ChunkUtil.indexChunk(chunkX, chunkZ);
// unpack
int chunkX = ChunkUtil.xOfChunkIndex(index);
int chunkZ = ChunkUtil.zOfChunkIndex(index);
// block position to chunk
int chunkX = blockX >> 5;
int chunkZ = blockZ >> 5;
// Y to section index (0-9)
int section = ChunkUtil.chunkCoordinate(blockY);
// chunk to region (32 chunks per region)
int regionX = chunkX >> 5;
int regionZ = chunkZ >> 5;
// chunk position within region (0-31)
int localX = chunkX & 31;
int localZ = chunkZ & 31;
// blob index within region file (0-1023)
int blobIndex = ChunkUtil.indexColumn(localX, localZ); // localZ * 32 + localX
// block position within a 32×32×32 section (0-32767)
int index = (y & 31) << 10 | (z & 31) << 5 | (x & 31);

For tools that need to parse region files directly.

OffsetSizeField
020Magic ("HytaleIndexedStorage")
204Version (1)
244Blob count (1024)
284Segment size (4096 bytes)
324096Blob index table (1024 × 4-byte segment indices)

Data is stored in fixed-size segments (4096 bytes by default). Segments are purely a file allocation unit (unrelated to chunk sections or any in-game concept) that enable fast random access - the fixed size means any chunk’s file position can be computed with simple math, without scanning through variable-length data. The blob index table maps each blob (chunk) to a 1-based segment index. Value 0 means the blob is empty/unassigned.

Segments start immediately after the header and index table:

segments_base = 32 (header) + 4096 (index table) = 4128
segment_position = segments_base + (segment_index - 1) * segment_size

A blob can span multiple contiguous segments if the compressed data exceeds one segment.

Each blob starts at its segment position:

OffsetSizeField
04Uncompressed length
44Compressed length
8NZstd-compressed BSON (level 3)

To read a chunk from a region file:

  1. Compute the blob index from local chunk coordinates (see Region Coordinates)
  2. Read the segment index from the blob index table at offset 32 + blob_index * 4
  3. If segment index is 0, the chunk is empty
  4. Compute file position: 4128 + (segment_index - 1) * 4096
  5. Read blob header (8 bytes) to get uncompressed and compressed lengths
  6. Read compressed_length bytes (contiguous, even across segment boundaries) and decompress with Zstd
  7. Parse the resulting BSON document - fields map to chunk components
  • The server uses StampedLock per blob for concurrent access - files should not be modified while the server is running
  • Block colors for map rendering come from BlockType.getParticleColor() combined with biome tint