coherence-authority-handoff
Use for shared or contested networked objects in coherence — pickups, doors, seats, vehicles, mounts — where one client at a time owns state. Covers authority requests, orphan adoption, occupancy flag
SKILL.md.claude/skills/coherence-authority-handoff/SKILL.md
GitHubAuthority handoff for shared objects
When to use this skill
- "Two players grabbed the same item and now it's stuck/duplicated."
- Player picks up a tool, another player wants to take it from them.
- Sitting in a chair, mounting a vehicle, claiming a turret.
- An interactable object's previous owner disconnected and now nobody can touch it.
- The developer mentions
RequestAuthority,IsOrphaned, orAdopt().
The core idea
A networked entity has one state authority at a time. To mutate its synced fields, you must own state authority. Acquiring it is a request the current owner can deny — they may have legitimate reasons (already in use, mid-animation, owner-locked).
Two failure modes dominate real games:
- Races. Two clients request simultaneously; both think they won.
- Orphans. The owner disconnected; the object sits there with no one to ask.
Solve both with three coordinated pieces: a request flow, a synced "in use" flag, and an orphan adoption path.
Pattern 1: Request → callback → state mutation
The base flow. The requester asks; the current authority's OnAuthorityRequest callback decides; on grant, the new authority sets the synced flag atomically with using the object.
public class Seat : MonoBehaviour
{
private CoherenceSync _sync;
[Sync, OnValueSynced(nameof(OnOccupiedChanged))]
public bool IsOccupied { get; set; }
[Sync] public uint OccupantClientId { get; set; } // reading this inside OnOccupiedChanged is safe — callbacks fire after all bindings apply
private void Awake()
{
_sync = GetComponent<CoherenceSync>();
_sync.OnAuthorityRequest.AddListener(OnAuthorityRequest);
}
public void TryOccupy()
{
if (_sync.HasStateAuthority)
{
ClaimSeat(); // already own it — just claim
return;
}
if (_sync.IsOrphaned)
{
_sync.Adopt(); // owner left — take it (returns bool: whether the request was queued)
return;
}
_ = _sync.RequestAuthorityAsync(AuthorityType.Full)
.ContinueWith(t => { if (t.Result) ClaimSeat(); }); // implicit bool: true iff granted
}
private void OnAuthorityRequest(AuthorityRequest req, CoherenceSync _)
{
if (IsOccupied) req.Reject(); // someone already sitting
else req.Accept();
}
private void ClaimSeat()
{
var wasOccupied = IsOccupied;
IsOccupied = true;
OccupantClientId = _sync.CoherenceBridge.ClientID;
ApplyOccupiedChanged(wasOccupied, IsOccupied); // authority-side local reaction
}
private void OnOccupiedChanged(bool wasOccupied, bool nowOccupied)
{
ApplyOccupiedChanged(wasOccupied, nowOccupied); // remote receive path
}
private void ApplyOccupiedChanged(bool wasOccupied, bool nowOccupied) { /* update visuals */ }
}
The key invariant: IsOccupied is set on the same frame as authority is gained, by the new authority. That eliminates the race window where two clients both think they won.
Pattern 2: Captive/occupancy flag
For pickupable objects, "is this currently held by a player" is a synced bool that gates both the request callback and any subsequent steal attempts.
[Sync] public bool IsBeingCarried { get; set; }
private bool ShouldAcceptAuthority(ClientID requester)
{
if (IsBeingCarried) return false; // hands off — owner is using it
if (Time.time < _lastHandoffTime + 1f) return false; // cooldown prevents thrash
return true;
}
The cooldown (coyote-time) prevents two players standing close to an object from rapidly ping-ponging authority — once it changes hands, the previous owner can't immediately steal it back via collision triggers.
Pattern 3: Orphan adoption for disconnected owners
If a player disconnects while holding an item, the entity is orphaned — has no authority. Any client can Adopt() it. This needs explicit code; nothing automatic happens.
private void Awake()
{
_sync.CoherenceBridge.onLiveQuerySynced.AddListener(OnLiveQuerySynced);
}
private void OnLiveQuerySynced(CoherenceBridge _)
{
if (_sync.IsOrphaned && IsAdoptionEligible())
_sync.Adopt();
}
private bool IsAdoptionEligible()
{
// Examples:
// - I'm the simulator (always adopt orphans)
// - I'm physically closest
// - The item is in my room/zone
return SimulatorUtility.IsSimulator;
}
For persistent worlds, prefer one designated adopter (the simulator). For ad-hoc sessions without a simulator, "first client to touch it" works.
Carried / parented objects on carrier disconnect
A held item is usually parented to its carrier (so it follows the hands), which makes it a child network entity. That introduces a second, independent failure mode beyond orphaning, and it bites silently. Four settings must line up:
- Parent (carrier):
Preserve ChildrenON (CoherenceSync → Advanced Settings). By default coherence destroys child entities when the parent is destroyed. So when the carrier disconnects, the item is destroyed with it — even though the item is persistent. Preserve Children unparents the child instead, leaving it in the world. - Child (item):
Lifetime = Persistent. A session-based item is destroyed on its own authority-holder's disconnect regardless of the parent setting. Only a persistent entity survives to become an adoptable orphan. - Child (item):
Auto-adopt OrphanON (or a simulator runningAuthRequestable.AutoAdoptOnSimulator). An orphan has no authority, so nobody simulates it — it freezes in mid-air and will not fall until someone takes ownership. Auto-adopt makes the RS hand it to a client immediately so simulation resumes. - A resume callback on the item. When a client gains authority over the recovered orphan (
OnStateAuthority), reset the held/occupancy flags and decide how the item resumes. A gravity-driven rigidbody just needs to be left dynamic and it falls. Kinematic items whose motion is scripted need an explicit hook to start their own fall/settle routine — leaving them dynamic is wrong for them.
private void OnStateAuthorityGained() // CoherenceSync.OnStateAuthority UnityEvent
{
if (_wasOccupied && _localCarrier == null) // adopted an orphan whose carrier had vanished
{
ClearHeldState(); // occupancy flag, parent ref, etc.
ResumeAfterCarrierLost(); // gravity: leave dynamic. kinematic: drive the fall.
}
}
Miss #1 and the item silently disappears on disconnect. Miss #3 and it survives but hangs frozen. Miss #4 on a kinematic game and it falls through the floor or never moves. Behaviours/Pickupable.md + PickupCarrier.md implement all four (the hook is Pickupable.OnCarrierLost).
Pattern 4: Collision-driven opportunistic requests
For grab-by-touching games, the request fires on collision, not on a button press. To prevent constant thrashing as bodies overlap, gate by a recency check.
private float _lastAuthorityChangeTime;
private void Awake()
{
_sync.OnStateAuthority.AddListener(OnStateAuthorityGained); // UnityEvent — fires on gain
}
private void OnCollisionEnter(Collision c)
{
if (_sync.HasStateAuthority) return;
if (Time.time < _lastAuthorityChangeTime + 1f) return;
if (IsBeingCarried) return;
_ = _sync.RequestAuthorityAsync(AuthorityType.Full);
}
private void OnStateAuthorityGained()
{
_lastAuthorityChangeTime = Time.time;
}
When it works / when it doesn't
These patterns work when:
- The object's transfer mode is
Request(so callbacks fire).Stealmode skips the callback entirely. - There's at least one consistent authority (an active player or a simulator).
- The synced "occupancy" flag is the only writer of "who owns me right now" state.
They don't work when:
- All clients in a room disconnect simultaneously — the entity becomes orphaned and stays that way until a new client joins and chooses to adopt.
- You need partial ownership (multiple players collaboratively holding an object). For that, see the cooperative-hold pattern in
Behaviours/SharedHoldInteractable.md. - The entity must change rapidly between owners (e.g., a ball in a sports game with constant passing). Authority transfer has latency; consider a single permanent owner (the simulator) and replicate input from each player instead.
Gotchas
- Setting
IsOccupied = truebefore authority is granted is a bug. Without authority you can't mutate synced fields; the write is dropped silently. OnValueSyncedcallbacks do not fire on the authority that wrote the synced value. They fire on remote instances receiving replication. Most of the time this is what you want — the authority already updated visuals/state inline before writing the synced field, so callbacks should be remote-only. The dual-apply pattern (ApplyOccupiedChangedcalled from both write-path and callback) is the right shape only when the same reaction must run identically on both sides and the authority hasn't already produced it inline. Reach for it deliberately; don't reflex-add it.OnAuthorityRequestfires on the current authority, not the requester. If you're the requester, listen to theRequestAuthorityAsynctask result instead.- The legacy
OnAuthorityRequestedevent is deprecated (SDK 1.7, 05/2025) — use the UnityEventOnAuthorityRequest(UnityEventCountable<AuthorityRequest, CoherenceSync>). The two coexist for back-compat but the deprecated one will go away. RequestAuthorityResulthas noGrantedproperty. Use the implicitboolconversion (if (t.Result)istrueiffType == Success) or checkt.Result.Type == RequestAuthorityResultType.Success.- Adopting an orphan doesn't reset its state. A half-eaten apple stays half-eaten; the new authority inherits whatever the previous one last wrote.
- Transfer mode
Not Transferableblocks every request silently. Check the prefab's CoherenceSync config first if requests never seem to land. - A request can be both
Accepted andRejected if your code paths overlap. MakeOnAuthorityRequestearly-return after one of the two.
Anti-patterns
- Mutating the "occupied" flag in the requester before getting authority. The write doesn't replicate.
- Polling
HasStateAuthorityinUpdateevery frame to decide whether to grab. Trigger by event (collision, button press) and request once. - Multiple simultaneous
RequestAuthorityAsynccalls from the same client on the same entity. Coalesce. - Using
Adopt()on a non-orphan. It's a no-op; checkIsOrphanedfirst.
See also
Behaviours/AuthRequestable.md— drop-in component wrapping this whole flow with occupancy, timeouts, and event hooks.Behaviours/AutoAuthRequester.md— proactively requests authority over nearby objects before the player presses the interact button (reduces perceived latency).Behaviours/Pickupable.md+Behaviours/PickupCarrier.md— full pickup/carry pattern using the above.