Hytale Codec System: Type-Safe Data Schemas and Validation
A source-grounded guide to Hytale's codec system: how Codec, BuilderCodec, KeyedCodec, and validators (de)serialize and validate every JSON asset a modder writes.
Every data-driven definition a Hytale modder writes — a block type, a sound set, a server config — is read, validated, and turned into a live Java object by one subsystem: the codec layer in com.hypixel.hytale.codec. This article is based on the decompiled Hytale server, version 2026.03.26. The codec package is the spine that connects your JSON files on disk to the strongly-typed objects the server actually runs, and understanding it is the difference between guessing at a schema and knowing exactly which keys, types, and constraints the engine will accept.
The design is deliberately bidirectional and format-agnostic. A single Codec<T> can decode from BSON (org.bson.BsonValue), decode directly from raw JSON via a streaming RawJsonReader, encode back out to BSON, and emit a JSON Schema describing itself. That last capability is why Hytale's editor tooling can offer autocomplete and inline validation: the same object that parses your asset also describes it. Below we walk the real type hierarchy, the builder that assembles object schemas, the keyed-field plumbing, the validation pipeline, and the lookup tables that power polymorphic asset types.
#The Codec Interface: One Object, Four Jobs
Codec<T> is the root abstraction. It extends RawJsonCodec<T> (streaming JSON) and SchemaConvertable<T> (self-description), so any codec can do all four jobs:
public interface Codec<T> extends RawJsonCodec<T>, SchemaConvertable<T> {
@Nullable T decode(BsonValue bsonValue, ExtraInfo extraInfo);
BsonValue encode(T value, ExtraInfo extraInfo);
// from RawJsonCodec:
@Nullable T decodeJson(RawJsonReader reader, ExtraInfo extraInfo) throws IOException;
// from SchemaConvertable: Schema toSchema(SchemaContext context);
}The interface ships with a catalog of ready-made primitive codecs as constants. These are the leaves of every schema tree you will ever build:
| Constant | Type | Backing class |
|---|---|---|
Codec.STRING | String | StringCodec |
Codec.BOOLEAN | Boolean | BooleanCodec |
Codec.INTEGER | Integer | IntegerCodec |
Codec.LONG / Codec.SHORT / Codec.BYTE | integral | LongCodec, ShortCodec, ByteCodec |
Codec.DOUBLE / Codec.FLOAT | floating | DoubleCodec, FloatCodec |
Codec.STRING_ARRAY | String[] | ArrayCodec<String> |
Codec.INT_ARRAY / Codec.DOUBLE_ARRAY | primitive arrays | IntArrayCodec, DoubleArrayCodec |
Codec.UUID_STRING / Codec.INSTANT / Codec.DURATION | derived | FunctionCodec<…> |
The simple codecs implement a marker interface, PrimitiveCodec. IntegerCodec, for example, is declared implements Codec<Integer>, RawJsonCodec<Integer>, PrimitiveCodec. That marker matters later: the builder treats primitive-backed fields as non-nullable by default, because a JSON number genuinely cannot be absent and still produce a valid int.
Note also that IntegerCodec.decode rejects decimals — it compares bsonValue.asNumber().intValue() against .doubleValue() and throws "Expected an int but got a decimal!" if they differ. Codecs are strict by construction.
#ExtraInfo: The Decode Context That Travels With You
Every decode/encode call takes an ExtraInfo. It is the per-operation context object, and it is doing far more than it looks. It maintains a key path stack so errors can report exactly where in the document a failure occurred, it tracks the schema version being decoded, it collects unknown keys, and it owns the ValidationResults and CodecStore for the operation.
public class ExtraInfo {
public static final ThreadLocal<ExtraInfo> THREAD_LOCAL = ThreadLocal.withInitial(ExtraInfo::new);
public void pushKey(String key); // entering a nested field
public void popKey(); // leaving it
public String peekKey(); // dotted path, e.g. "Defaults.MaxPlayers"
public void addUnknownKey(String key);
public ValidationResults getValidationResults();
public CodecStore getCodecStore();
public int getVersion();
}The push/pop discipline is why a CodecException can say Failed to decode 'Defaults.ConnectionTimeouts.Idle' instead of a generic stack trace. KeyedCodec.get, BuilderField.readField, and the array codecs all wrap their work in pushKey / popKey (in finally blocks), so the path is always accurate even when an exception unwinds the stack.
Unknown keys are silently dropped, not rejected
When BuilderCodec hits a JSON key it does not recognize, it calls extraInfo.addUnknownKey(key) and moves on — it does not fail. Editor metadata keys like $Title, $Comment, $Author, and $TODO are hard-coded to be ignored entirely. If a field of yours is silently doing nothing, suspect a typo'd or mis-cased key before suspecting the engine.
VersionedExtraInfo is a thin decorator: it wraps an existing ExtraInfo, overrides getVersion() to return a fixed schema version, and delegates everything else. The builder constructs one whenever it decodes a versioned object, so downstream fields can ask "which version am I being read as?" and select the correct field definition.
#BuilderCodec: Declaring an Object Schema
BuilderCodec<T> is where modders spend most of their time. It implements Codec<T>, InheritCodec<T>, and ValidatableCodec<T>, and it assembles an object schema field-by-field through a fluent builder. Here is the real definition of the server's top-level config, lightly trimmed:
// com.hypixel.hytale.server.core.HytaleServerConfig — real, abbreviated
public static final BuilderCodec<HytaleServerConfig> CODEC =
BuilderCodec.builder(HytaleServerConfig.class, HytaleServerConfig::new)
.versioned()
.append(new KeyedCodec<>("ServerName", Codec.STRING),
(o, s) -> o.serverName = s, o -> o.serverName)
.add()
.append(new KeyedCodec<>("MaxPlayers", Codec.INTEGER),
(o, i) -> o.maxPlayers = i, o -> o.maxPlayers)
.add()
.append(new KeyedCodec<>("Defaults", HytaleServerConfig.Defaults.CODEC),
(o, obj) -> o.defaults = obj, o -> o.defaults)
.add()
.build();builder(Class<T>, Supplier<T>) opens a builder; each append(keyedCodec, setter, getter) returns a BuilderField.FieldBuilder whose .add() commits the field and returns you to the parent builder; .build() produces the immutable codec. The Supplier<T> is the no-arg constructor the codec calls before populating fields — decoding always mutates a fresh instance, never constructs via a giant argument list.
#Fields, getters, and setters
Each field is a BuilderField<Type, FieldType> pairing a KeyedCodec<FieldType> with a setter and getter. The setter runs on decode; the getter runs on encode and when generating the schema's default value. The fluent FieldBuilder exposes the knobs that make a field expressive:
FieldBuilder method | Effect |
|---|---|
.addValidator(Validator) | attach a constraint (range, non-null, non-empty…) |
.setVersionRange(min, max) | field only applies within a schema-version window |
.documentation(String) | markdown description surfaced in the generated schema |
.metadata(Metadata) | editor/UI hints (display mode, buttons, previews) |
.add() | commit the field, return to the builder |
A real-world field with validators, from the BlockSoundSet asset codec (built via an AssetBuilderCodec, a BuilderCodec subclass), attaches several at once:
// abbreviated from the BlockSoundSet asset codec
.appendInherited(/* KeyedCodec */, /* setter */, /* getter */, /* inherit */)
.addValidator(Validators.nonNull())
.addValidator(SoundEvent.VALIDATOR_CACHE.getMapValueValidator())
.add()#Builder-level options
On the builder itself (BuilderCodec.BuilderBase) you also get .versioned() to enable a Version key, .documentation(String) for the type description, .afterDecode(Consumer<T>) to run a post-processing hook once decoding finishes, and .codecVersion(min, max) to pin the supported version range explicitly. There is also abstractBuilder(Class<T>) — a builder with a null supplier, used for base types that are never decoded directly but are extended by subtype codecs.
#KeyedCodec: A Field's Name, Type, and Required Flag
A BuilderField always wraps a KeyedCodec<T> — the binding of a JSON key to a codec plus a required flag.
public class KeyedCodec<T> {
public KeyedCodec(String key, Codec<T> codec); // optional
public KeyedCodec(String key, Codec<T> codec, boolean required); // required if true
public Optional<T> get(BsonDocument document, ExtraInfo extraInfo);
public T getNow(BsonDocument document, ExtraInfo extraInfo); // throws if absent
public void put(BsonDocument document, T value, ExtraInfo extraInfo);
}Two construction-time rules are worth committing to memory, because they are enforced in the constructor and will throw immediately:
- Keys cannot be empty.
- A key's first character, if it is a letter, must be uppercase. The constructor rejects a lowercase-letter first character (
Character.isLetter(c) && !Character.isUpperCase(c)), throwing"Key must start with an upper case character!". This is why every Hytale asset key you have ever seen is PascalCase:ServerName,MaxPlayers,Defaults. (A deprecated four-arg constructor with abypassCaseCheckflag exists for legacy lowercase keys liketypein the schema codec — do not reach for it.)
get returns Optional.empty() for an absent optional field; getNow throws a BsonSerializationException if the key is missing. On encode, put simply skips null values — an unset optional field never appears in the output document.
#Validation: Constraints That Travel With the Schema
A Validator<T> is a BiConsumer<T, ValidationResults> that also knows how to updateSchema. That dual role is the elegant part: the same validator that rejects bad data at decode time also writes the corresponding constraint into the generated JSON Schema, so the editor enforces it before the file ever reaches the server.
The Validators factory is the catalog you build from:
| Factory call | Constraint |
|---|---|
Validators.nonNull() | value must be present |
Validators.nonEmptyString() | string must be non-empty |
Validators.min(v) / max(v) / range(min, max) | inclusive numeric bounds |
Validators.greaterThan(v) / lessThan(v) | exclusive bounds |
Validators.equal(v) / notEqual(v) | exact match constraints |
Validators.arraySize(n) / arraySizeRange(min, max) | array length |
Validators.nonEmptyArray() / nonEmptyMap() | non-empty collections |
Validators.uniqueInArray() | no duplicate elements |
Validators.requiredMapKeysValidator(keys) | map must contain keys |
Validators.or(v1, v2, …) | pass if any validator passes |
Internally each maps to a class under codec.validation.validator. Validators.range(2, 10), for instance, builds a RangeValidator whose accept calls results.fail("Must be less than or equal to 10") when violated, and whose updateSchema writes minimum: 2 / maximum: 10 onto an IntegerSchema or NumberSchema.
Results flow into a ValidationResults object held by ExtraInfo. A Validator calls results.fail(reason) or results.warn(reason); the builder then calls results._processValidationResults(). The default implementation in production is ThrowingValidationResults — a fail immediately raises a CodecValidationException carrying the dotted key path, while a warn is logged at WARNING and decoding continues.
// conceptual: how a field constraint is wired
.append(new KeyedCodec<>("MaxPlayers", Codec.INTEGER),
(o, i) -> o.maxPlayers = i, o -> o.maxPlayers)
.addValidator(Validators.range(1, 200)) // rejects <1 or >200, and
.add() // emits minimum/maximum in the schemaPrimitive fields get an implicit non-null check
BuilderField.setValue checks isPrimitive — true when the field's codec implements PrimitiveCodec. If a primitive field decodes to null, the builder runs Validators.nonNull() automatically and fails. You do not need to add a non-null validator to an int, boolean, or double field; the engine already guarantees it.
#Inheritance: InheritCodec and the Parent Key
Hytale assets support inheritance — a child asset can declare a Parent and inherit unspecified fields. InheritCodec<T> extends Codec<T> with decodeAndInherit(BsonDocument, T parent, ExtraInfo), and BuilderCodec implements it. When you use .appendInherited(codec, setter, getter, inherit) instead of .append(...), the field participates in this merge: the builder first copies the parent's value, then overlays whatever the child document specifies.
The Parent key is special-cased in the builder's key table as IGNORE_IN_BASE_OBJECT — it is consumed during inheritance resolution rather than treated as a normal field or flagged as unknown. Nested inherited objects recurse: if a child field's codec is itself a BuilderCodec, the merge descends into it field-by-field.
#Lookup: Polymorphic and Registry-Backed Codecs
For asset types with multiple shapes — a TagPattern that might be an "equals" op or a "range" op — Hytale uses ACodecMapCodec and its concrete subclass CodecMapCodec<T>. These dispatch on a discriminator key inside the document and route to the registered subtype codec:
// conceptual sketch of registering polymorphic subtypes
CodecMapCodec<TagOp> codec = new CodecMapCodec<>("Type", /* allowDefault */ false);
codec.register("Equals", EqualsTagOp.class, EqualsTagOp.CODEC);
codec.register(Priority.NORMAL, "Range", RangeTagOp.class, RangeTagOp.CODEC);register takes an id, the concrete class, and the codec; an overload accepts a Priority (Priority.DEFAULT or Priority.NORMAL) to order resolution. On decode, StringCodecMapCodec.decodeJson seeks the discriminator key, looks the id up in a StringTreeMap, and delegates to the matching codec — throwing UnknownIdException if no codec is registered and there is no default.
Separately, CodecStore is a thread-safe registry mapping a CodecKey<T> (an id-wrapping handle) to a Codec<T>, with a static root (CodecStore.STATIC) and child-store fallback. ExtraInfo exposes the active store via getCodecStore(), letting codecs resolve other codecs by key at runtime rather than holding hard references.
#Putting It Together
The pieces compose top-down. A BuilderCodec describes an object; each BuilderField binds a PascalCase key (via KeyedCodec) to a leaf or nested codec and a list of Validators; an ExtraInfo carries the key path, version, and ValidationResults through the whole decode; failures throw a CodecValidationException pinpointing the exact path. Encode runs the same map in reverse, and toSchema walks it once more to emit the JSON Schema that drives editor autocomplete. One declaration, four behaviors, zero drift between parser and schema.
Modder checklist: defining a Hytale asset codec
- Declare a class with a no-arg constructor and a public
BuilderCodec<T>built viaBuilderCodec.builder(MyType.class, MyType::new). - Add each field with
.append(new KeyedCodec<>("PascalKey", Codec.<LEAF>), setter, getter).add()— keys must start uppercase. - Attach constraints with
.addValidator(Validators.range(...)),nonEmptyString(),nonNull(), etc., before.add(). - For optional vs. required keys, pass the
requiredflag toKeyedCodec; remember primitive fields are implicitly non-null. - Use
.appendInherited(...)for fields that should merge from aParentasset. - Call
.versioned()and.setVersionRange(min, max)on fields when your schema evolves over releases. - For multi-shape assets, register subtype codecs on a
CodecMapCodec<T>keyed by a discriminator. - Add
.documentation("...")so your generated schema gives editor users inline help.
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