Skip to content

Networking and Data Sync

goshi-hider edited this page Jul 24, 2026 · 1 revision

Networking and Data Sync

Package: dev.goshi.omnimixin.api.sync

This subsystem is different in shape from every other subsystem in OmniMixin. Everywhere else, you're reacting to something vanilla is already doing. Here, you're declaring a brand new piece of state — a SyncedValue — that OmniMixin manages entirely on your behalf: storing it on the entity, saving it to NBT so it survives a chunk unload/reload or a server restart, and pushing it to nearby clients automatically whenever it changes.

If you've ever hand-rolled a TrackedData<T>/DataTracker field, or written your own S2C packet just to keep one number synced between server and client, this is meant to replace that boilerplate entirely.

Declaring a value

A SyncedValue<T> is a public static final field, built once via its builder:

package com.example.mymod;

import dev.goshi.omnimixin.api.sync.SyncedType;
import dev.goshi.omnimixin.api.sync.SyncedValue;
import net.minecraft.util.Identifier;

public final class MyValues {

    public static final SyncedValue<Float> MANA = SyncedValue
            .<Float>builder(Identifier.of("mymod", "mana"))
            .type(SyncedType.FLOAT)
            .defaultValue(100.0f)
            .syncRange(64.0)
            .build();

    private MyValues() {
    }
}

Build it once, somewhere that's guaranteed to run during mod init (a static field on a class you reference from onInitialize() is the usual pattern — the Identifier doubles as the value's unique registration key, so it must be built exactly once).

Supported types

SyncedType currently supports FLOAT, DOUBLE, INT, BOOLEAN, and STRING. There's no support yet for arbitrary objects, NBT compounds, or collections — if you need something more complex than these five primitive types, you'll need your own packet for now.

syncRange

syncRange is the radius, in blocks, within which a client needs to be tracking the entity to receive updates for this value. It defaults to 64.0 if you don't set it. A smaller range means less network traffic for values that only matter up close (a "charge-up" visual effect, say); Double.MAX_VALUE effectively means "always sync to anyone tracking this entity, however far the tracking range itself allows."

Reading and writing

Once declared, a SyncedValue<T> reads and writes against any Entity:

float currentMana = MyValues.MANA.get(entity);

MyValues.MANA.set(entity, currentMana - 10.0f);

get(entity) returns the stored value, or the declared defaultValue() if nothing's been set yet (including for an entity that just spawned and has never had this value written). set(entity, value) does three things in one call:

  1. Stores the new value on the entity (so future get() calls see it).
  2. Marks it to be written to NBT the next time this entity saves, so it survives a chunk unload, world save, or server restart.
  3. Immediately pushes the new value out over the network to every client currently tracking that entity within syncRange().

You don't need to call anything else, register any packet handler, or write any NBT code yourself — all of that is wired up automatically the moment you declare the SyncedValue.

A complete example: a simple mana bar

package com.example.mymod;

import dev.goshi.omnimixin.api.sync.SyncedType;
import dev.goshi.omnimixin.api.sync.SyncedValue;
import net.fabricmc.api.ModInitializer;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.util.Identifier;

public final class ManaMod implements ModInitializer {

    public static final SyncedValue<Float> MANA = SyncedValue
            .<Float>builder(Identifier.of("manamod", "mana"))
            .type(SyncedType.FLOAT)
            .defaultValue(50.0f)
            .syncRange(32.0)
            .build();

    @Override
    public void onInitialize() {
        // Regenerate 1 mana/second for every player, server-side.
        ServerTickEvents.END_SERVER_TICK.register(server -> {
            if (server.getTicks() % 20 != 0) {
                return;
            }
            for (PlayerEntity player : server.getPlayerManager().getPlayerList()) {
                float current = MANA.get(player);
                if (current < 100.0f) {
                    MANA.set(player, Math.min(100.0f, current + 1.0f));
                }
            }
        });
    }
}

On the client, any mod (yours or someone else's) can read MANA.get(somePlayerEntity) for a locally-tracked player and get an up-to-date value — no packet handling code required on the client side either, since OmniMixinClient already listens for and applies these updates for every registered SyncedValue automatically.

What's actually happening under the hood

You don't need to know this to use SyncedValue, but it's useful for reasoning about behavior:

  • Values are only written to NBT for the entities that actually have a non-default value set — an entity that's never had set() called for a given SyncedValue doesn't carry any extra NBT weight.
  • The network push in set() is a no-op on the logical client — a client calling set() on its own local copy only updates its own local, non-authoritative value, since only the server is allowed to actually broadcast a synced value's new state. Don't rely on a client-side set() call propagating anywhere.
  • Sync pushes ride Fabric's standard S2C custom payload channel, so they play nicely with everything else on the connection — there's no separate connection or protocol involved.

When not to reach for this

SyncedValue is built for simple, frequently-read, frequently-changing numeric or string state that needs to be visible client-side — health bars, resource meters, cooldown timers, and similar. For anything structural (an inventory, a list of quests, nested data), you're better served by a proper PersistentState/component library and your own packet, since SyncedValue intentionally doesn't support complex types — see [[Known Limitations]].

Clone this wiki locally