coherence-persistence
Use for persistent coherence worlds — cross-session saves, rejoinable rooms, growth/decay/cooldown timers, or any state a late-joining client must catch up to. Covers time-anchored state, unique-ID pr
SKILL.md.claude/skills/coherence-persistence/SKILL.md
GitHubPersistence and join-in-progress catch-up
When to use this skill
- "How do I save a multiplayer game and load it next session?"
- "A player joins mid-session and doesn't see the current world state."
- The world has timers — crops growing, fires burning down, cooldowns ticking.
- Pre-placed objects in the scene must survive being destroyed and respawning in the same place across sessions.
- The developer mentions
UniquenessManager,ManualUniqueId, orIsOrphanedin a save/load context.
The core idea
coherence offers two long-lived state stores: Worlds (persistent across sessions until shutdown) and Rooms with persistence (entity lifetime configured per-prefab). Either way, three classes of state need attention:
- Mutable game state the authority owns and freshly-joined clients receive via live query (free, automatic).
- Time-based state that derives from "when did X happen" — sync the timestamp, not the current value.
- Bulk session state (save files, inventories, level seeds, world maps) too large for
[Sync]fields — ship via fragmented command on join.
Pattern 1: Time-anchored state
For anything that progresses on a clock — plants growing, fires burning, cooldowns — sync the start time once, then derive the current state locally from DateTime.UtcNow (or the synced authority time).
public class GrowingPlant : MonoBehaviour
{
[Sync(DefaultSyncMode = SyncMode.CreationOnly)] public uint PlantedAtUnixTime { get; set; }
private static readonly TimeSpan SproutAfter = TimeSpan.FromMinutes(2);
private static readonly TimeSpan BloomAfter = TimeSpan.FromMinutes(10);
private void Update()
{
var planted = DateTimeOffset.FromUnixTimeSeconds(PlantedAtUnixTime).UtcDateTime;
var age = DateTime.UtcNow - planted;
if (age >= BloomAfter) SetStage(Stage.Bloom);
else if (age >= SproutAfter) SetStage(Stage.Sprout);
else SetStage(Stage.Seed);
}
}
Cost: one synced field forever. Late joiners get the right state immediately. No "current stage" enum to keep in sync, no callbacks firing for missed transitions.
Pattern 2: Preserving network identity across save/load
When you save a scene-placed networked object and reload it, coherence assigns a new unique ID by default — references and persistent entity links break. Fix: persist the existing ID and re-register it with the bridge's UniquenessManager before you instantiate the object.
[Serializable]
public class SavedNetworkObject
{
public Vector3 position;
public Quaternion rotation;
public string coherenceUniqueId; // preserved from the original instance
// ... other gameplay state
}
public static class NetworkSaveIdentity
{
public static SavedNetworkObject Capture(GameObject instance, Vector3 pos, Quaternion rot)
{
var sync = instance.GetComponent<CoherenceSync>();
return new SavedNetworkObject
{
position = pos,
rotation = rot,
coherenceUniqueId = (sync != null && sync.IsUnique) ? sync.ManualUniqueId : null,
};
}
public static GameObject Restore(GameObject prefab, SavedNetworkObject saved, CoherenceBridge bridge)
{
if (!string.IsNullOrEmpty(saved.coherenceUniqueId))
bridge.UniquenessManager.RegisterUniqueId(saved.coherenceUniqueId);
var instance = UnityEngine.Object.Instantiate(prefab, saved.position, saved.rotation);
return instance;
}
}
This works because CoherenceSync reads ManualUniqueId during its own Awake/Start, and the registered ID takes precedence. Result: the same logical entity survives a save/load cycle, and persistent-entity adoption finds it again.
Pattern 3: Host state hand-off on join
For bulk catch-up data — a save file, a procedural world seed plus stamped tile overrides, an inventory snapshot — [Sync] fields are wrong (too big, too slow). Have the joiner ask, then the authority answers over a fragmented command. The joiner pulls so the request never fires before the entity exists on both ends.
public class HostStateChannel : MonoBehaviour
{
private CoherenceSync _sync;
private Action<byte[]> _onPayloadReceived;
private bool _requested;
private void Awake() => _sync = GetComponent<CoherenceSync>();
private void Update()
{
// Non-authority side: once this entity is local and the bridge is connected,
// ask the authority for the payload. One-shot per local lifetime.
if (_requested || _sync == null || _sync.CoherenceBridge == null) return;
if (!_sync.CoherenceBridge.IsConnected) return;
if (_sync.HasStateAuthority) return;
_requested = true;
_ = _sync.SendCommand<HostStateChannel>(
nameof(RequestHostPayload), MessageTarget.StateAuthorityOnly, _sync.CoherenceBridge.ClientID);
}
[Command]
public void RequestHostPayload(ClientID requester)
{
if (!_sync.HasStateAuthority) return;
byte[] payload = BuildHostPayload(); // your serialization
_ = _sync.SendCommandOverChannel<HostStateChannel>(
nameof(ReceiveHostPayload),
MessageTarget.Other,
Channel.Fragmented,
requester,
payload);
}
[Command]
public void ReceiveHostPayload(ClientID recipient, byte[] payload)
{
if (recipient != _sync.CoherenceBridge.ClientID) return; // not for me
_onPayloadReceived?.Invoke(payload);
}
public void SetReceiver(Action<byte[]> handler) => _onPayloadReceived = handler;
private byte[] BuildHostPayload()
{
// Recommended structure:
// [version:int][length:int][compressed-body:bytes]
// Compress with DeflateStream for >1 KB payloads.
// ...
return Array.Empty<byte>();
}
}
Why Channel.Fragmented: default channel commands are capped near MTU (~1200 bytes). Fragmented commands are split, sent, and reassembled — designed for kilobyte-to-megabyte payloads. For a single one-shot hand-off the unordered Channel.Fragmented is fine; if the hand-off is multi-chunk and the receiver must apply chunks in send order, use Channel.FragmentedOrdered instead.
Why version prefix: future-proof schema migrations. A v2 client receiving a v1 payload should detect and adapt or refuse cleanly.
If the host can ever have nothing to hand off, never pass a bare null byte[] — and because of coherence's all-or-nothing tuple rule, you must then send every argument of that call in (typeof(T), value) form: (typeof(ClientID), requester) and (typeof(byte[]), payloadOrNull), not a mix. On the receiver, treat a null/byte[0] arrival as "no payload" (a null can deserialize as an empty array). See coherence-rpc-routing Pattern 7.
Pattern 4: Acknowledgement before pool return
If multiple clients need to receive a one-shot event (consumable pickup, gift drop) and you can't afford to lose it on a flaky client, broadcast the event and wait for acks before recycling the entity.
private readonly HashSet<uint> _pendingAcks = new();
private float _ackDeadline;
private void Broadcast()
{
foreach (var conn in _sync.CoherenceBridge.ClientConnections.GetAll())
_pendingAcks.Add(conn.ClientId);
_ackDeadline = Time.time + 1.5f;
_ = _sync.SendOrderedCommand<MyThing>(nameof(Event), MessageTarget.Other, /* args */);
}
[Command]
public void Ack(uint clientId)
{
_pendingAcks.Remove(clientId);
if (_pendingAcks.Count == 0) ReturnToPool();
}
private void Update()
{
if (_pendingAcks.Count > 0 && Time.time > _ackDeadline)
ReturnToPool(); // timeout — accept loss
}
Tradeoff: 1.5 s ceiling on pool reuse vs. guaranteed delivery on the happy path.
When it works / when it doesn't
Time-anchored state works when the rules are pure functions of elapsed time. Branching state (grew → was eaten → respawned) needs the timestamp to update on each transition.
Save/load identity preservation works when you reload into the same coherence room/world. Across worlds, IDs aren't meaningful.
Host state hand-off works when the host is reliable (a simulator, ideally). Peer hosts can disconnect mid-payload; design the receiver to tolerate truncation or use the orphan-adoption path on the receiver to retry from a new host.
Gotchas
DateTime.UtcNowdiffers by milliseconds between clients. Fine for minute-scale timers, bad for sub-second events. For tight timing use a synced[Sync] uint StartSimFrameand a known tick rate.ManualUniqueIdmust be set before the entity registers with the bridge. If you Instantiate first and assign later, you've already lost.- Registering a duplicate unique ID throws. If the entity might already exist (came in via live query), check first or wrap in try/catch.
- Fragmented commands are reliable but not free. A 1 MB payload takes time to ship over a 100 KB/s link. Show a "loading" UI; don't assume instant.
- Payloads larger than a few MB are usually a smell. Consider streaming via a CDN/HTTPS endpoint with the URL passed over coherence, instead of in-band transfer.
OnClientConnectedfires before that client's live query has finished syncing. Don't reference their entities yet; wait for theironLiveQuerySyncedif you need to send entity references.
Anti-patterns
- Polling
DateTime.UtcNowinUpdateto update synced state. The authority computes; clients read the timestamp and derive — they don't sync the derived value. - Saving the runtime CoherenceSync entity ID (the network ID) instead of the prefab's stable
ManualUniqueId. Network IDs are per-session. - Sending bulk state as many small
[Sync]fields. One fragmented command is cheaper and atomic. - Forgetting that a re-joined client's first frame has no networked entities yet. Wait for
onLiveQuerySyncedbefore assuming the world is populated.
See also
Behaviours/NetworkManager.md— connection lifecycle events (OnLiveQuerySynced,OnClientConnected) you'll hook into.Synced/README.md—SyncedList/SyncedDictionaryfor medium-sized collections that change occasionally.- coherence docs: persistent entities, Worlds vs Rooms,
Channel.Fragmented.