Attachments (Riding)
One entity rides another: a rider's pose is derived from its carrier at a fixed offset in the carrier's own frame. Passengers in a vehicle, a patient on a stretcher, cargo in a hold, a player on a moving platform, a turret on a tank — the framework never knows which. The wire carries ONE reliable message per ride (replayed on interest reveal), not a pose per tick: a rider costs nothing while it rides and cannot drift against the thing carrying it. Server-decided and therefore unforgeable — there is no client verb.
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IAttachmentService
One entity rides another: the rider's pose is derived from the carrier's, every tick, at a fixed offset in the carrier's own frame. The framework never knows what a ride MEANS — a passenger in a seat, a patient on a stretcher, cargo in a hold, a player standing on a moving platform, a turret on a tank, a held item — only that one pose follows another. Why this is a piece and not a per-game loop. A game can already move a rider every tick with IServerEntityMovement.Move, and that is what every game did: it costs a full EntityMove per rider per tick, and it looks wrong, because a rider interpolated independently drifts against a carrier interpolated independently — four passengers sliding around inside a car that is itself smoothing. Here the wire carries ONE reliable message when the ride begins, the rider contributes nothing per tick, and a client draws it at the carrier's pose plus a constant, so it cannot drift from the thing it is riding. Server-authoritative and unforgeable. There is no client verb: a ride begins because server game code said so. The rider's authoritative position IS maintained (interest, queries and bounds all see it where it really is) — it simply is not broadcast, because every observer can derive it.
-
bool Attach(long childEntityId, long parentEntityId, float offsetX, float offsetY, float offsetZ, AttachYawMode yawMode = Inherit)Starts (or re-points) a ride: childEntityId now follows parentEntityId at the given offset, expressed in the carrier's own frame. The rider is placed immediately, so it never renders a tick behind the ride starting.
-
event Action<long, long> AttachedRaised after a ride begins or is re-pointed: (rider, carrier).
-
int CollectRiders(long parentEntityId, List<long> into)Appends the entities currently riding parentEntityId to into (not cleared) and returns how many were added — "who is in my ambulance", answered without the game keeping its own book.
-
int Count { get; }Rides currently alive (diagnostics, and the MaxAttachments budget).
-
bool Detach(long childEntityId)Ends a ride, setting the rider down exactly where the ride had carried it to and telling every observer that pose — so nothing pops, and a rider that never moves again is still drawn in the right place. Automatic when either entity leaves the world.
-
event Action<long, long> DetachedRaised after a ride ends, for any reason including either entity leaving the world: (rider, the carrier it was riding).
-
bool TryGetCarrier(long childEntityId, out long parentEntityId)Reads what an entity is riding, if anything.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
AttachmentOptions
Configurable attachment parameters (defaults here; never inlined in logic).
-
int MaxAttachments { get; set; }Most rides alive at once, across every carrier. A budget on the per-tick derive pass (rule 11): every attachment costs one chain walk and one placement each tick, so this is the number that bounds it. Default 1024.
-
int MaxChainDepth { get; set; }Longest chain of rides the piece will resolve: a patient on a stretcher in an ambulance is depth 2, and almost nothing legitimately needs more. It is also the cycle backstop — a chain that somehow closed would otherwise walk forever — so it is a safety bound, not only a budget. Default 4.
-
bool RiderCollidesWithCarrier { get; set; }Composition knob (read by the Hosting layer, never by this piece): whether a rider's contact pair with its OWN carrier is still checked by the Contact piece while it rides. False (the default) registers the Attachments → Contact pair veto, so the one pair that can never be resolved — the rider's position is derived from the carrier, so the two overlap by construction and every correction lands on the carrier, every tick, for the length of the ride — is simply not admitted. True asks for the old behaviour and composes nothing. Only the rider↔carrier pair is affected. The rider's contacts with everything else stay whatever the game decided: a passenger hanging off a truck may be meant to hit a pole; cargo that should be no obstacle to anyone is IContactService.ClearProfile at pickup.
-
bool SuspendRiderInput { get; set; }Whether attaching a PLAYER entity suspends its own movement input for the duration of the ride (the Movement piece's IMotionAuthority, when it is composed). True is what a passenger wants: their walk keys stop fighting the vehicle carrying them, and the server stops resolving a walk against a body that is not standing anywhere. False leaves input alone, which is what a game wants when the ride is cosmetic or when it runs its own control scheme for riders. Server entities are unaffected either way — they have no input to suspend. Authority is only taken when the entity is not ALREADY server-driven by something else (a vehicle sim, a cutscene rig), and only given back if this piece is what took it — so attaching a driver to their own vehicle cannot hand them back control the vehicle still owns.
Wire messages 2
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
EntityAttached Server → observers: ChildEntityId now rides ParentEntityId at a fixed offset in the carrier's own frame. Sent when the ride begins (or changes carrier) and replayed to every observer the moment the rider enters their view, so a late arrival sees a patient already in the ambulance rather than a body standing in the road. This message is the ENTIRE per-tick cost of a ride. The server does not broadcast the rider's position while it is attached, because the receiving client can compute it from the carrier's ordinary movement stream — one reliable message at attach time replaces an EntityMove every tick for every rider, and it cannot jitter relative to the thing it is riding, because it IS the thing it is riding plus a constant. What a ride MEANS is entirely the game's: a passenger in a seat, a patient on a stretcher, cargo in a hold, a player on a moving platform, a turret on a tank, a hat on a head. The framework knows only that one entity's pose is derived from another's.
EntityDetached Server → observers: ChildEntityId stopped riding and stands on its own again, at the pose named here — which is exactly where the ride had carried it to, so a client can hand the view back to ordinary interpolation without a pop. The pose is carried on the message rather than left to the next EntityMove for one reason: there may not be one. A rider that is set down and stays put — a patient laid on the hospital floor, a crate unloaded onto a dock — never moves again, so a client waiting to be told where it ended up would wait forever and draw it at the carrier's last position.
Unity seams you implement 1
The client half's sockets. Bind your implementation in the client context and Crossplay's client calls it — this is where your models, animators, UI and controls plug in.
IAttachmentsClient unity SPI
The Attachments piece, client side: an entity told to ride another has its view driven from the carrier's, every frame, at the offset the server sent. The piece carries no presentation — it moves a transform and raises events; what a ride MEANS (a seat, a stretcher, a cargo hold, a turret) is entirely the game's. Remove this package and rides simply stop being drawn — nothing else breaks.
-
event Action<long, long> AttachedA visible entity started riding another (rider, carrier) — also raised on the replay a client receives when a ride already in progress comes into view.
-
event Action<long> DetachedA rider stopped riding and was set down (rider).
-
bool TryGetCarrier(long childEntityId, out long parentEntityId)What an entity is riding, if anything.