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.

Player Games11 min read

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:

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

ConstantTypeBacking class
Codec.STRINGStringStringCodec
Codec.BOOLEANBooleanBooleanCodec
Codec.INTEGERIntegerIntegerCodec
Codec.LONG / Codec.SHORT / Codec.BYTEintegralLongCodec, ShortCodec, ByteCodec
Codec.DOUBLE / Codec.FLOATfloatingDoubleCodec, FloatCodec
Codec.STRING_ARRAYString[]ArrayCodec<String>
Codec.INT_ARRAY / Codec.DOUBLE_ARRAYprimitive arraysIntArrayCodec, DoubleArrayCodec
Codec.UUID_STRING / Codec.INSTANT / Codec.DURATIONderivedFunctionCodec<…>

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.

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

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

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

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

  1. Keys cannot be empty.
  2. 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 a bypassCaseCheck flag exists for legacy lowercase keys like type in 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 callConstraint
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.

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

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

Java
// 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 via BuilderCodec.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 required flag to KeyedCodec; remember primitive fields are implicitly non-null.
  • Use .appendInherited(...) for fields that should merge from a Parent asset.
  • 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

Related guides