Skip to content

Rubric Sync

Oliver Yasuna edited this page Jul 3, 2026 · 2 revisions

Rubric — Sync

Rubric replicates server-authoritative config values to connected clients over the loader's custom-payload channel — Fabric's PayloadTypeRegistry or NeoForge's PayloadRegistrar. This page covers the model, the annotation, and the on-the-wire lifecycle.

Model

Every entry has a sync scope, set via @Sync (see Annotations):

  • CLIENT — client-owned, local only, never leaves the client's disk. The default.
  • SERVER — server-authoritative. Pushed to clients for the connected session. The client cannot override it, and it is not written to the client's <config>.toml.
  • COMMON — server-authoritative when connected; falls back to the client's local defaults in singleplayer.

The scope is set at the entry level (or the type level as the default for every entry in that config). Configs are never wholesale-synced; only the entries whose scope demands it cross the wire.

Why the scope matters

  • A CLIENT-scoped entry — say, a UI opacity — should never be overwritten by a server. The player configured it once and expects it to stick.
  • A SERVER-scoped entry — say, a game rule expressed as a config value, or a tick interval — has to match on server and client. Rubric makes the server the source of truth and pushes the value on connect.
  • A COMMON entry behaves like SERVER when the world is multiplayer and like CLIENT when it's not. This is what you want for values that are game-rule-like but also fine to just set locally in singleplayer.

Lifecycle

  1. Server startup. The loader entry class (RubricFabricMod.onInitialize on Fabric, RubricNeoForgeMod's constructor on NG) registers the packet channel and constructs a SyncService(Role.SERVER, transport, codecs, gate).
  2. Client connects. On Fabric, ServerPlayConnectionEvents.JOIN fires; on NG, PlayerEvent.PlayerLoggedInEvent. Both call syncService.onClientConnected(player), which sends the client:
    • A Handshake with the server's protocol version and known config IDs.
    • A Snapshot per registered config, containing every SERVER/COMMON entry (nothing CLIENT-scoped ever leaves the server).
  3. Client applies. SyncService(Role.CLIENT) receives via Fabric's ClientPlayNetworking.registerGlobalReceiver or NG's PayloadRegistrar.playToClient handler. Each payload runs through InboundValidator (size caps, per-entry validators, codec decode) then ScopeEnforcer.applyAuthoritative(...) writes the in-scope entries onto the local POJO. Every applied path is marked Origin.FROM_REMOTE, so a later manager.save() doesn't persist it to the client's file.
  4. Delta broadcast. If a SERVER/COMMON value changes on the server after clients are already connected (a manager.set(...) call, or an admin edit through the GUI), the server broadcasts a Delta to every connected client. Applied identically to the initial snapshot.
  5. Client edits. If the client is allowed to edit a SERVER/COMMON entry (see permission gate below), the change is sent up as a ClientEdit. Server validates, applies, and broadcasts the resulting Delta back to all clients. On acceptance the server marks the path as LOCAL_EDIT and save() persists it.

Permission gate

Client edits pass through PermissionGate.canEdit(actor, requiredLevel) on the server. The default gate is DENY_ALL; each loader integration ships its own implementation:

  • FabricFabricPermissionGate honors Fabric's PermissionCheckEvent (or a fallback op-level check).
  • NeoForgeNeoForgePermissionGate calls ServerPlayer.hasPermissions(level).

If your mod wants to expose an admin panel that edits server-authoritative values, either supply your own gate to installServer(...) in the bootstrap, or (on Fabric) override the ambient gate:

FabricPermissionGate.override((actor, level) -> actor.hasPermissionLevel(level));

Rubric currently gates on op-level 2 (CLIENT_EDIT_REQUIRED_LEVEL) — enough for /gamerule-style privileges.

Protocol version

Every payload carries a ProtocolVersion. Servers and clients on mismatched versions:

  • With sync.requireProtocolMatch = true (the default) — fail-closed: the payload is rejected and the session logs a warning. Recommended: mismatches usually mean the server is on a newer/older Rubric that changed the wire.
  • With sync.requireProtocolMatch = false — mismatched payloads are skipped with a log warning; other payloads continue. Handy for gradual rollouts but risky.

See Self-config for sync.* knobs.

Payload cap

sync.payloadMaxBytes caps any single sync payload. Snapshots larger than this are rejected on the receiving side. Default 1 MiB; hard cap 16 MiB. Purpose: defend against a hostile or misconfigured server sending an unbounded payload.

Singleplayer

The integrated server + client run in one JVM. Both transports short-circuit on the loopback path — FabricNetworkTransport.isIntegratedHost on Fabric, the identical check inside NeoForgeNetworkTransport on NG — so no packets are actually pushed. SERVER-scoped values behave as if the client set them locally.

Client-side observability

Two useful hooks:

  • manager.getEvents().subscribe(event -> ...) — fires per changed path on both local and remote sources. Check manager.originOf(path) if you need to distinguish.
  • manager.addReloadListener((previous, current) -> ...) — fires after every non-initial load() on the main thread, with the previous and current POJO. Doesn't fire per remote-delta application (which touches individual paths, not the whole state).

Failure modes worth knowing

  • Server has no config Rubric-registered but client does — no snapshot arrives for that config. Client's SERVER/COMMON values stay at whatever the local defaults are. Not synced.
  • Client rejects a value in InboundValidator — that path is skipped; other accepted paths still apply. Rejection is logged, no exception raised.
  • ScreenProvider writes a SERVER/COMMON value locally without a permission — server denies the ClientEdit. No local error until the next Delta reverts it. A future release will .available(false) such widgets when the client is connected but not permitted — see the frontend gaps documents.

Clone this wiki locally