Skip to content

Synchronization

GMalvestiti edited this page Aug 31, 2026 · 5 revisions

Sync sends selected server config values to connected clients.

@Config(name = "rules", sync = true)
public final class RulesConfig {
    public int maxTeamSize = 4;
    public boolean friendlyFire = false;
}

The example syncs every persisted leaf in RulesConfig from server to client.

Choosing values to sync

Use @Config(sync = true) when the whole config is server-owned. For a mixed config, opt in only the properties clients need:

@Config(name = "rules")
public final class RulesConfig {
    @Entry(sync = true)
    public int maxTeamSize = 4;

    public String localLogPath = "logs/audit";
}

Nested objects follow the same rule. Marking a nested object or a containing config syncs its persisted leaves; marking a leaf syncs only that leaf.

holder.metadata().synced() exposes the flattened synced properties, which is useful when building a config screen.

Lifecycle

Sync is one-way and server-authoritative. Creating a holder loads that config and registers its synced values; configs without a holder are never loaded or added to the network registry.

When a client joins, the server sends the cached SHA-256 hash of each synced config it has loaded. The client compares those hashes locally and requests only mismatched config IDs. Requests are stateless, and the server answers with its current cached values. A holder created after login performs the same comparison; a holder created late on the server publishes a refreshed manifest. Accepted server updates and loads are broadcast later, but an unchanged hash produces no packet.

Received values replace the synced portion of holder.data() and are written to the client file on the ordered config worker rather than the client game thread. The receive completion finishes only after persistence, state publication, callbacks, and update listeners have completed. Values that are not synced stay local. Disconnecting never restores an older client value.

Packet limits and batching

Limit Maximum
Entries per packet 64

Lite Config batches by entry count and leaves encoded packet-size enforcement to Minecraft's network layer. Large manifests and responses are split into ordered chunks. Response chunks are applied in arrival order on the config worker; the final chunk completes restart handling for the response. Packet bytes are immutable after construction.

Nested values and codecs

Built-in leaf types include primitive values, strings, characters, enums, List<T>, and Map<K, V>. Collection element, key, and value types may themselves be built-in collections or types with registered stream codecs. Nested config objects are flattened into their synced leaves. A custom leaf needs a codec registered during common initialization before any holder using that type is created.

Prefer Minecraft's Codec<T> and StreamCodec<ByteBuf, T> when the value already has them. Codec<T> handles JSON5, TOML, and copying. Adding StreamCodec<ByteBuf, T> enables sync.

This generic range value is saved as an object and synced with two variable-length integers:

public record IntRange(int minimum, int maximum) {

    public static final Codec<IntRange> CODEC = RecordCodecBuilder.create(instance -> instance.group(
        Codec.INT.fieldOf("minimum").forGetter(IntRange::minimum),
        Codec.INT.fieldOf("maximum").forGetter(IntRange::maximum)
    ).apply(instance, IntRange::new));

    public static final StreamCodec<ByteBuf, IntRange> STREAM_CODEC = StreamCodec.composite(
        ByteBufCodecs.VAR_INT, IntRange::minimum,
        ByteBufCodecs.VAR_INT, IntRange::maximum,
        IntRange::new
    );
}

LiteConfig.codecs()
    .registerCodec(IntRange.class, IntRange.CODEC)
    .registerStreamCodec(IntRange.class, IntRange.STREAM_CODEC);

@Config(name = "limits", sync = true)
public final class LimitsConfig {
    public IntRange playerCount = new IntRange(1, 12);
}

The generated JSON tree is equivalent to:

{
  "minimum": 1,
  "maximum": 12
}

File and network codecs are independent. Register only IntRange.CODEC with registerCodec(IntRange.class, IntRange.CODEC) when Gson cannot persist or copy the value correctly. Register only IntRange.STREAM_CODEC with registerStreamCodec(IntRange.class, IntRange.STREAM_CODEC) when Gson already handles the value and only synchronization needs custom encoding. Both methods return the registry, so they can be chained when both representations are needed. The stream codec must be context-free; registry-aware stream codecs cannot be used because synchronized snapshots are encoded once and cached without a player or registry context.

A registration may also target an exact parameterized type, such as List<IntRange>, to replace the built-in collection representation. Pass an explicit schema version as the second registerStreamCodec argument when maintaining a custom wire protocol, and change it whenever STREAM_CODEC changes incompatibly. The version participates in the handshake schema, including when the registered type is nested inside a built-in list or map. A synced type without a built-in or registered stream codec is rejected while the holder is registered.

LiteConfig.codecs().registerStreamCodec(IntRange.class, "2", IntRange.STREAM_CODEC);

Compatibility and errors

Each payload includes a schema fingerprint describing its synced paths, order, and wire types. The fingerprint participates in handshake hashes so clients can request values when their wire shape differs. Values rejected by config validation leave the current config unchanged.

Callbacks from holder operations and network persistence share a per-registration FIFO. Accepted transitions invoke callbacks in transition order, and callbacks for one registration never overlap.

When a changed synced entry is restart-only, Lite Config writes the server value to the client file but keeps the startup value in memory. The client is then disconnected with a message asking for a game restart. Reconnecting without restarting repeats the mismatch and disconnect; after restart, the file loads the server value and the hash matches.

Clients that do not support the optional payload are not sent it and can still connect normally.

Local files

Server values are ordinary durable state after they arrive. save() and updateAndSave(...) keep those synced values unless a later server payload replaces them. Non-synced local changes apply and persist normally. See Lifecycle Listeners for update notifications and Names and Paths for file locations.

Clone this wiki locally