rigidbodies

Rigid Bodies

Optimized networked transform sync for non-player physics bodies (crates/vehicles/debris/doors): quantized position + smallest-three quaternion + velocity, sleep-gated, interest-culled, batched. The game runs its own physics; this only syncs the result.

Category Spatial Seams 21 Services 4 Options 9 Wire ids 4 Unity types 8
Server
services.AddCrossplayRigidBodies();
Unity package
com.crossplay.rigidbodies
Depends on
world

Seams you implement 20

Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayRigidBodies(); and it wins.

IPhysicsBodyFactory game SPI

OPTIONAL creation sibling of IPhysicsBackend: an engine that can build a body from an engine-neutral PhysicsShape. Implement it alongside IPhysicsBackend and a game can populate the physics world without ever naming your engine. The gap this closes.IPhysicsBackend is deliberately a pure READ surface — Step and TryReadBody — which is right for the drive loop and leaves one thing with nowhere to live: creating a body. So every game called a method on the ADAPTER instead (AddBox, AddSphere), which meant the one operation every game must perform was written against a specific engine, and the piece's promise that the engine is swappable was not true of it. It also meant a shape the adapter happened not to expose could not exist at all. Still engine-agnostic. This names only PhysicsShape, Vec3, Quat and entity ids. Crossplay never mentions Bepu, Jolt or PhysX; an adapter maps the descriptor onto whatever its engine calls a sphere. What an adapter may refuse. Returning false is a legitimate answer, not a failure to hide: a solver cannot simulate an arbitrary concave volume as a DYNAMIC body, so an engine may decline a shape it cannot represent (and a game should then use a Compound of convex pieces, or make the thing static). Refusing loudly beats silently simulating a different shape than the one that was asked for, which is the failure mode that produces "the collision does not match the model".

  • bool TryCreateBody(long entityId, in PhysicsBodyDescription description)

    Creates a body for entityId from an engine-neutral description. The id is the one IServerEntities.Spawn returned, so the same id addresses the body in RigidBodies, in the world's interest index, and in the engine.

  • bool TryRemoveBody(long entityId)

    Removes a body. false for an id this engine does not own.

  • bool TrySetBodyKind(long entityId, PhysicsBodyKind kind, float mass, in Vec3 linearVelocity)

    Changes an existing body's kind, keeping its id, shape and pose — the MIXED AUTHORITY operation. The gap this closes. Without it a body's kind is fixed for life, and the sentence "another system owns this position, EXCEPT while physics has taken it over" cannot be expressed at all. That sentence is most of what makes physics visible in a game with characters in it: a player is Kinematic because Movement owns them and two systems must not both write one position — but that same infinite mass means a lorry at 100 km/h cannot move them by definition, and no amount of tuning changes it. The fix is not to pick one kind; it is to hand authority over and take it back. Why this and not an applied impulse. A knockback computed by the game and fed back into its character controller has to invent the answer — the direction, the spin, whether the hit was central or a clip. Retyping asks the SOLVER instead, which already knows both masses, the contact point and the relative velocity, so a glancing blow spins and a square one launches without either case being written down. Who owns the position afterwards is the CALLER'S problem, and the sharp edge here. While a body is dynamic the engine writes its pose; whatever owned it before must stop, and must be given the result when authority returns, or the body will snap back to wherever that system still thinks it is. This seam moves authority; it cannot know who else wanted it.

IPhysicsBodyPlacement game SPI

OPTIONAL placement sibling of IPhysicsBodyFactory: putting an EXISTING body back somewhere, stopped. The gap this closes (PHYS-25). A structure could be built and never rebuilt. The only route to a fresh building was to despawn every piece and build a new instance — new entity ids, a full re-spawn to every client in interest, and a fresh interest recompute for all of it, per retry. Reusing the ids costs a watching client nothing but a pose correction, and the one thing that made it impossible was that no seam could move a body that already exists. Stopped, not just moved, and that is the whole reason the motion is part of the contract. A teleported body that keeps its old velocity is a piece that immediately flies off again — so a rebuild that only set positions would put the house back and watch it explode. Zeroing both velocities is not a convenience here; it is what "back as it was" means. And it must WAKE. A settled body is asleep and out of the solver, so a pose written into a sleeping island stands but nothing else responds to it — the same trap PHYS-13, PHYS-16 and PHYS-26 each paid for once. A separate interface, not a new member on IPhysicsBodyFactory: an engine adapter a game wrote against the existing seam must keep compiling (rule 21).

  • bool TrySetPose(long entityId, in Vec3 position, in Quat orientation)

    Puts a body at a pose with no motion at all, and wakes it.

IPhysicsBodyProfiles game SPI

Decides WHICH physical profile a given entity is — the game's answer to "what kind of thing is this". Why this is a seam and not a setting. A composition that says "players are 80 kg humans" has quietly decided every player is the same kind of object, and that holds right up until the game has a player who is a car, or a cat, or an iron ball, or a human who becomes a bear. The choice is game logic — it depends on what the player picked, what they are riding, what they were turned into — and it cannot live in a config file, so the framework asks instead of assuming. Appearance is the natural key and is deliberately the only hint offered. Crossplay already carries an opaque appearance blob per entity and never interprets it; a game that encodes "sport car" in there can map the same bytes to a 1200 kg profile with the car's baked shape, and the framework still knows nothing about cars. Anything richer than that is a lookup the game does against its own state using the entity id.

  • PhysicsBodyProfile For(long entityId, byte[] appearance)

    The profile for an entity. Never null — an implementation that does not recognise something should return the table's default rather than refuse, so an unmapped entity is a dull object instead of a spawn failure.

IPhysicsBodySleep game SPI

PHYS-28 — putting a settled body's constraint island to sleep, and asking whether it already is. The measured defect this exists for. An engine decides to sleep an island from a VELOCITY heuristic, and a densely welded structure never satisfies one. Measured on a 1,140-piece slab-on-pillars at 240 Hz welds and 8 substeps, standing perfectly still: every body under the linear threshold from the first second (max 0.066 u/s against 0.1), and twenty-odd chunks per second over the ANGULAR threshold (0.15–0.23 rad/s against 0.1) — because a chunk pinned by welds on every face cannot translate but can rock. An island sleeps only when EVERY body in it is a candidate, so one rocking chunk kept 1,140 awake for the full twenty seconds: 14.2 ms a step, for ever, for a building that is not moving. Sixteen substeps did not fix it (26.7 ms and still awake); nor did raising the threshold 5x. Why an explicit sleep is the safe fix and a bigger threshold is not. Raising the threshold makes a body that is genuinely creeping sleep mid-slide, which is exactly the trap docs/DESTRUCTION-PERFORMANCE.md records under "sleep MASKS an under-converged structure". This route sleeps a structure only after the framework has WATCHED every piece of it hold still, so it can never freeze something that is moving — and it changes no simulation parameter at all, which is what keeps a settled building free without changing how anything falls. A separate interface, for the same reason IPhysicsBodyPlacement is one: an engine adapter written against the existing seams must keep compiling (rule 21).

  • bool IsBodyAwake(long entityId)

    Whether this body is currently being simulated. false for a sleeping body AND for an id this engine has no body for — a caller that needs to tell those apart has TryReadBody.

  • bool TrySleepBody(long entityId)

    Puts this body's whole constraint island to sleep — every piece welded to it, transitively. Velocities are preserved, so a body that was creeping resumes creeping when something wakes it. Waking is already covered by every route that disturbs a body (an impulse, a new contact, a joint change, a pose write); nothing needs to opt in to being woken.

IPhysicsForces game SPI

OPTIONAL force sibling of IPhysicsBodyFactory: an engine that can be told to PUSH something. The gap this closes (PHYS-13). A body could be created, removed and retyped, and while it existed the only thing that could touch it was the solver. So a game could DELETE a brick and never knock one: a sledgehammer that shifts masonry, a shove that clears a fallen beam off a teammate, a shoulder-barge through a stuck door, a blast that throws rubble outward — every one of them is "apply an impulse", and none could be written. TrySetBodyKind carries a velocity and is not this: it applies on a TRANSITION, so it can start a body that was kinematic and does nothing for one that is already dynamic. Impulse, not force, and deliberately. A force needs a duration and therefore a tick contract; an impulse is applied once and needs neither. Sustained pushing — a drill leaning on a wall — is an impulse per tick applied by whoever is doing the leaning, which is the game. An adapter owes one thing: WAKE the body. A settled brick is asleep and an impulse applied to a sleeping body is discarded. That is the single most likely way to implement this and have it do nothing, and its symptom is maddening — it works in a test where the wall is still falling, and not in a game where the wall is standing. Still engine-agnostic: ids and Vec3. Crossplay never names Bepu, Jolt or PhysX.

  • bool TryApplyAngularImpulse(long entityId, in Vec3 impulse)

    Applies an angular impulse, kg·units²/s — spin with no push. Distinct from an off-centre impulse because no application point can express it: a pure couple has no line of action.

  • bool TryApplyImpulse(long entityId, in Vec3 impulse, in Vec3 atPoint)

    Applies an impulse AT a world point — the one that matters, because an off-centre blow spins. The same argument TrySetBodyKind makes for retyping rather than computing a knockback: the solver already knows the mass, the inertia tensor and the lever arm, so a blow to the top corner of a brick topples it and one to its face shoves it, without either case being written down. An API that took only a central impulse would force every caller to invent the spin.

  • bool TryApplyImpulse(long entityId, in Vec3 impulse)

    Applies an impulse through the centre of mass: a push with no spin. For a caller that genuinely has no contact point — inventing one is worse than admitting it.

IPhysicsImpactSink game SPI

Where collisions go — the PUSH counterpart to IPhysicsBackend's pull surface, and the difference between physics that is scenery and physics that is gameplay. The gap this closes.IPhysicsBackend is Step and TryReadBody: the drive loop advances the engine and reads poses out of it, and nothing is ever pushed back. That keeps the seam engine-neutral, and it means the framework could never learn that A hit B, how hard, or where. So "a falling beam lands on a player and hurts them" had nowhere to be written — not awkwardly, but at all, short of a game abandoning the seam and talking to its engine directly, which is the thing the seam exists to prevent. What an adapter owes you. Two guarantees, because both are easy to get wrong and impossible for a game to fix afterwards: Thresholded. Every resting body generates contacts every single step. A sink handed all of them receives tens of thousands a second, and the handful that mean something are invisible among them. An adapter filters by closing speed BEFORE calling, from a configured value — the framework has no idea what counts as hard in a game's units.Called on the tick thread, after the step. A physics engine's narrowphase runs on worker threads; every consumer of this (World, Stats, Hostility) is single-threaded by contract. An adapter accumulates during the step and drains here afterwards, so an implementation may touch ordinary server state without a thought. Optional, and absent by default. No sink means impacts are not collected at all — not collected and discarded — so a game that does not care pays nothing.

  • void OnImpacts(ReadOnlySpan<PhysicsImpact> impacts)

    Every impact from the step that just finished, in no particular order. A span rather than one call per impact: they all happened in the same instant, so a game that wants to sort them, keep only the worst per victim, or ignore duplicates can, and one that does not can loop. The memory is the adapter's and is reused — copy anything you intend to keep past the call.

IPhysicsJointBreakSink game SPI

Where breaks go. Deliberately the same shape as IPhysicsImpactSink — a span, drained on the tick thread after the step — because a game should not have to learn two different ways for a physics event to arrive. A break destroys nothing. Both bodies still exist and are still simulated; they have simply stopped being held. What that MEANS — debris, a score, a sound, a structural collapse the game wants to propagate — is the game's business, which is why this reports rather than acts.

  • void OnJointsBroken(ReadOnlySpan<PhysicsJointBreak> broken)

    Every joint that gave way during the step that just finished. The memory is the adapter's and is reused — copy anything you keep.

IPhysicsJointRelease game SPI

OPTIONAL release sibling of IPhysicsJoints: let go of everything holding ONE body. The gap this closes (PHYS-16).IPhysicsJoints removes by JOINT id, and a game does not have joint ids — it has an entity that was just hit. So "cut this stud free" could not be said: destruction by BREAKING was expressible (hit it hard enough and the threshold does the rest) and destruction by CUTTING was not, though a saw, a crowbar, a drill removing fixings and an unbolted anchor are all the same verb. The ordinary case is the one that mattered most: a piece whose hit points reach zero should come loose and DROP, and the only thing a game could do was despawn it, which reads as a brick dissolving in mid-wall. Separate interface, not a new member on IPhysicsJoints, deliberately: an engine adapter a game wrote against the existing seam must keep compiling (rule 21). This is the same optional-sibling shape IPhysicsBodyFactory, IPhysicsJoints and IPhysicsQueryBackend already use for capabilities an engine may or may not have. An adapter owes one thing beyond the removal: WAKE what it let go of, and the neighbours it was holding. A structure that has stood still is asleep, and a brick released from a sleeping wall with nothing to wake it hangs in the air — a far more alarming bug than the one this fixes.

  • int ReleaseJointsOf(long entityId)

    Releases every joint with entityId at either end. The body is NOT removed and nothing is destroyed: it is simply no longer held, becomes ordinary debris, and is subject to the same resting expiry as anything else.

IPhysicsJoints game SPI

OPTIONAL joint sibling of IPhysicsBodyFactory: an engine that can hold two bodies together, and let go when pulled hard enough. The gap this closes. Nothing could express "these are separate bodies, attached, until something pulls hard enough". The nearest thing was Compound, which is a different idea wearing similar clothes — it welds child SHAPES into one rigid body so a concave object can be simulated, permanently, because there was never more than one body to separate. So a wall was either indestructible (one compound) or had never been a wall (loose bricks), and there was no third option. It is not only demolition: a rope bridge, a hinged door, a chain, a tow bar, a crane and a jointed ragdoll are all "two bodies with a defined relationship", and none of them could be said. Still engine-agnostic. This names PhysicsJointDescription, entity ids and Vec3. An adapter maps a weld onto whatever its engine calls one.

  • int JointCount { get; }

    How many joints currently exist. Falls as things break.

  • bool JointExists(long jointId)

    LB-GAP-15 — whether this joint still exists in the engine. The probe a graph/engine consistency sweep asks, and nothing else should need it.

  • bool TryCreateJoint(long jointId, in PhysicsJointDescription description)

    Attaches two bodies.

  • bool TryRemoveJoint(long jointId)

    Removes a joint. false for an id that has none — including one that has already broken, since a break removes it.

IPhysicsJointStrength game SPI

OPTIONAL strength sibling of IPhysicsJoints: changing what an EXISTING joint can carry. The gap this closes (PHYS-26). A joint's break impulse is set once, when the joint is created, and could never change — IPhysicsJoints is create, remove, count, and PhysicsJointDescription.BreakImpulse is read once into the constraint. So a connection was in exactly one of two states for ever: holding at full strength, or gone. The framework had two destruction models and they did not meet — IMPULSE breaks the weld (binary), DAMAGE destroys the piece (a hit-point bar, then a detach) — and neither of them is "wearing something down". What that cost. A rotary hammer worked into a wall for eight seconds should leave the wall visibly and mechanically weaker, and then be sheared by a blow that would have bounced off it a minute earlier. Without this the drill can only deplete a piece until it detaches WHOLE — which is removing a brick, not weakening a wall — or deliver impulse and break the weld outright, which is the hammer's verb. So the tool roster collapses: a driller and a cutter become slower sledges, differing only in how long they take. It also removes the TELL. A weld at 40% of its original strength is something a client can render as cracks and dust; a weld that is simply intact has nothing to show. A separate interface, not a new member on IPhysicsJoints, deliberately: an engine adapter a game wrote against the existing seam must keep compiling (rule 21). The same optional-sibling shape IPhysicsJointRelease already uses. The original strength has to be remembered, by the adapter, at creation: "weaken by 30%" needs a baseline, and after three applications the baseline must still be the ORIGINAL or the decay curve is wrong. An adapter owes one thing beyond the write: WAKE what it weakened. A standing structure is asleep, and weakening a joint in a sleeping island changes a number and nothing else — a wall that only sags once you touch it again is a bug that will look exactly like the weakening not working. The same trap PHYS-13 and PHYS-16 each paid for once.

  • bool TryGetBreakImpulse(long jointId, out float current, out float original)

    What this joint can carry now, and what it could carry when it was created. False for an id this engine has no joint for.

  • bool TrySetBreakImpulse(long jointId, float breakImpulse)

    Sets what this joint can carry from now on.

  • int TryWeakenJointsOf(long entityId, float factor, out float weakestRemaining)

    Weakens every joint holding this body by a factor of its ORIGINAL strength, and reports the weakest survivor as a fraction of its original — the number a client renders as "how cracked". This is the one a game actually calls: a tool acts on a PIECE it is pointed at, not on a joint id, which is the same reasoning that made PHYS-16 key on an entity. The per-joint setter exists underneath it for the precision cases — a cutter severing one named connection.

IPhysicsLevelGeometry game SPI

Gives a physics backend the LEVEL — the static collision a zone is actually made of. The gap this closes. The level already exists as data: Collision3D bakes a zone's collision into a .mesh3d and both tiers use it, the server for ground probes and sweeps, the client for its mirrored prediction. The physics backend never saw any of it. So an engine-backed body was simulating against nothing but what the game had manually created inside the engine — which is why the Bepu sample builds itself a box for a floor and lays out its own ramps. The consequence is that the collision a CHARACTER uses and the collision a physics ENGINE uses are two different worlds that merely resemble each other. Bodies fall through terrain, debris settles at the wrong height, a thrown object passes through a wall, and a demolished building lands on a floor that is not the floor the players are standing on. Why plain arrays and not Collision3D's TriangleMesh. Naming that type would make RigidBodies reference the Collision3D piece, and a game can perfectly well run an engine-backed physics world with no baked meshes at all — a arena of primitives, a procedural world, a zone whose geometry the game builds itself. Vertices and indices are what a mesh IS; they come straight off TriangleMesh.Vertices and .Indices with no copy, and the Hosting bridge is the only thing that has to know both types exist (rule 13).

  • int LoadedZoneCount { get; }

    How many zones currently have collision loaded.

  • bool TryLoadZone(string zone, Vec3[] vertices, int[] indices)

    Puts a zone's collision into the simulation as immovable geometry. Replaces whatever that zone had loaded before.

  • bool TryPrebuildZone(string zone, Vec3[] vertices, int[] indices)

    Does whatever expensive preparation this geometry needs, WITHOUT adding it to the simulation, so that a later TryLoadZone is cheap. The one call in this interface that may be made off the tick thread, and the only reason it exists. An engine typically has to build an acceleration structure over a mesh before it can collide against it, and that is not cheap: for the Bepu adapter it is ~57 ms at 20,000 triangles and ~312 ms at 80,000, all of it inside one call. Paid at zone activation it is a poll-thread stall of several frames, at the moment players are arriving. Called ahead of time — at startup, or on a background task for zones a server knows it will need — the stall disappears rather than moving: TryLoadZone then does the cheap part only, on the tick thread, where it belongs. Nothing is ever half-loaded, because this adds nothing to the simulation at all. An implementation MUST make this safe to call concurrently with stepping, which usually means not touching engine-owned allocators — see the Bepu adapter, where the prebuild uses a private buffer pool and hands back plain bytes precisely because a BufferPool is not thread-safe and the simulation is using its own every tick.

  • bool TryUnloadZone(string zone)

    Drops a zone's collision and frees it. Not optional bookkeeping. Zones come and go — MoveToZone, instancing, dungeons — and a large zone mesh is hundreds of thousands of triangles held in engine-owned memory. A backend that accumulates every zone it has ever seen is a leak with a very long fuse.

IPhysicsQueryBackend game SPI

OPTIONAL query sibling of IPhysicsBackend: a physics engine that can also answer spatial queries (raycast / swept sphere) against its world. When a game's backend implements this in ADDITION to IPhysicsBackend, AddCrossplayRigidBodiesBackend wires a PhysicsQueryGeometryProvider so the engine's real broad/narrow-phase becomes a fidelity tier of World's IWorldQueries3D — alongside the analytic volumes and baked mesh. Like IPhysicsBackend it is engine-agnostic (only Vec3 + ids) and poll-thread confined; Crossplay never names Bepu/Jolt/PhysX. Snapshot-read (the threading contract): a query MUST read a CONSISTENT post-step snapshot of body poses — never a set mutated mid-step. The physics driver steps and queries on the same poll thread, so reading the engine's current (last-completed-step) state satisfies this; a backend that steps on another thread must expose a stable read view.

  • bool Raycast(string zone, in Vec3 origin, in Vec3 dir, float maxDistance, GeometryMask mask, out GeometryHit hit)

    Raycast against the engine's geometry; the nearest hit within maxDistance.

  • bool SweepSphere(string zone, in Vec3 from, in Vec3 to, float radius, GeometryMask mask, out GeometryHit hit)

    Sweep a sphere of radius from from to to; the first contact.

IPhysicsShapeLibrary game SPI

Where a server gets the shapes a game authored — the last link in "a model in Unity becomes a collision volume on the server". The chain is: author colliders on a prefab → bake to a .shapes3d library in the Unity editor → drop it beside the server → spawn bodies by NAME from configuration. No server code, no engine types, and no shape hand-transcribed into JSON, which is where the numbers would drift from the model they are supposed to match. A seam rather than a concrete loader so a game can serve shapes from wherever it keeps content — a database, an asset bundle, a CDN — instead of a directory.

  • IReadOnlyCollection<string> Names { get; }

    Every name currently available — what a boot report or an admin page lists, and what makes a typo in configuration diagnosable rather than mysterious.

  • bool TryGet(string name, out PhysicsShape shape)

    Resolves a game-chosen shape name. False when nothing is registered under it.

IRigidBodyInputSink game SPI

Where a validated input frame goes — the game's half of client-predicted rigid bodies. By the time this is called the piece has already established the things only IT can: that the body exists, that it is ClientPredicted, that the sender actually OWNS it, and that this sequence is newer than anything already applied. What remains is the only part Crossplay cannot do — deciding what the bytes mean and applying them to an engine it does not know about. Not installing one is legitimate. With no sink the piece still validates and still tracks sequences, and inputs are simply discarded — which is exactly what should happen on a server that broadcasts bodies but predicts none.

  • void Apply(long entityId, uint sequence, byte[] payload, ISession sender)

    Applies one input frame to the body's simulation.

IStructureCatalog game SPI

PHYS-21 — the live connection graph of a built structure, and what it implies. The gap this closes. The connection graph is derived at bake time (StructureJointDeriver), built into solver joints, and then THROWN AWAY. At runtime the joints exist inside the physics backend as constraints; the topology — which piece connects to which, and which of them reach the ground — was not represented anywhere a query could reach. So none of these could be answered on a running server: which pieces still have a path to an anchored piece? if this brick goes, what stops being supported? which pieces are load-bearing? is this group about to fall? This is NOT "make unsupported things fall", and the distinction matters. A donor engine floods its graph and DETACHES any group with no path to a support, because its attached chunks are kinematic and nothing would otherwise make them fall — the flood is what starts the physics. Our pieces are all real dynamic bodies, and an orphaned island genuinely falls, correctly, driven by gravity. The gap is the QUESTION, not the behaviour; the falling already works and no heuristic overrides the solver. Everything here is derived from ONE pass over the live adjacency, cached per generation and invalidated when a joint breaks — so a survey tool asking about every piece in view costs one flood, not one per piece. PHYS-28 — enumerating every registered structure, for a pass that must visit all of them rather than answer a question about one. A separate interface rather than a member on IStructures, for the reason IPhysicsBodySleep is separate from IPhysicsBodyFactory: IStructures is an [Spi] a game may have implemented, and appending to it would stop that implementation compiling (rule 21). A pass that needs the whole list asks for this and does nothing if nobody provides it.

  • int All(List<long> into)

    Every registered structure id, appended to into (not cleared). Returns how many were added.

IStructureDetach game SPI

PHYS-29 — taking a piece OFF a kinematically-held structure: the donor engine's FracturedChunk.DetachFromObject(). Separate from IPhysicsJointRelease because the two answer different questions. Releasing a joint says "this weld is gone" and leaves a body that was already dynamic falling; detaching says "this piece is no longer PART of the building", which in the kinematic mode is what turns it into a body at all.

  • int Detach(long entityId)

    Takes one piece off its structure: its edges in the graph go, it becomes a dynamic body, and anything that was only held up through it goes with it.

  • long Detached { get; }

    Pieces detached since start.

  • long Orphaned { get; }

    Pieces detached because the flood found them unsupported, rather than because they were hit — the donor's CheckDetachNonSupportedChunks, and the number that says whether a structure is behaving structurally at all.

IStructureGraph game SPI

PHYS-29 — walking and CUTTING the structure graph, for the kinematically-held mode. A separate interface for the same reason IStructureCatalog is one: IStructures is an [Spi] a game may have implemented, and appending to it would stop that implementation compiling (rule 21). In the SOLVER-held mode nothing needs these — the engine breaks a weld and the registry hears about it. In the kinematic mode there are no engine welds, so the graph has to be cut by whoever decided the piece came off.

  • int NeighboursOf(long entityId, List<long> into)

    Every piece still joined to this one, appended to into (not cleared). Returns how many were added; 0 for a loose piece or one that is not part of a structure.

  • int NotePieceDetached(long entityId, List<long> joints)

    Cuts every remaining connection this piece has — "it is no longer part of the building". Raises the same PieceDetached and SupportChanged events a broken weld does, so a game watching a solver-held structure and one watching a kinematic one hear the same story.

IStructureIntegrity game SPI
  • int GroupOf(long entityId, List<long> into)

    Every piece in the same connected group as this one — itself included — appended to into. Returns how many were added.

  • bool IsSupported(long entityId)

    Whether this piece currently has a path through unbroken joints to an anchored piece. False for an entity that is not a structure piece.

  • event Action<StructureSupportChanged> SupportChanged

    Raised when a connected group's reachable-anchor count changes — the hook a collapse warning uses. Raised once per affected group per break batch, never per joint.

  • int SupportPathCount(long entityId)

    How many independent paths to ground the group has — a CRUDE load-path count, and the number a warning threshold is written against. 0 means it is already falling. Read the definition, because it is not structural engineering. True load-path analysis is a statics problem. This counts the DISTINCT ANCHORED PIECES the group can still reach, which is wrong in interesting ways — two paths through the same column count twice — but is MONOTONIC: it only ever falls as things break. Monotonic is all a warning threshold needs.

  • int WouldOrphan(long entityId, List<long> into)

    What would stop being supported if this piece were removed — the survey query, and the one a Spotter's scan renders. Appends the entity ids to into and returns how many. A flood per piece, cached per piece per generation, so asking about every piece in view is one flood each ONCE and free thereafter until the next break.

IStructureLibrary game SPI

Where a server gets the STRUCTURES a game authored — the last link in "an artist's wall becomes a building the server holds up and can knock down". The chain, and it is the same one IPhysicsShapeLibrary completes for shapes: build a wall out of prefabs in a Unity scene → bake to a .structure3d → drop it beside the server → build it by NAME. No server code per building, and no thousand joints transcribed by hand — which is the difference between "the framework supports destructible structures" and "a designer can make one". A seam rather than a concrete loader, for the same reason as shapes: a game may keep content in a database, an asset bundle or a CDN rather than a directory.

  • IReadOnlyCollection<string> Names { get; }

    Every name currently available — what a boot report or an admin page lists, and what makes a typo in configuration diagnosable rather than mysterious.

  • bool TryGet(string name, out StructureDefinition structure)

    Resolves a game-chosen structure name. False when nothing is registered under it.

IStructures game SPI

PHYS-20 — every structure currently standing, and what is left of each. The gap this closes.TryBuild returns a StructureInstance that knows everything — every piece's entity id in definition order, every joint id, and the two piece indices behind each joint — and that object was then used once and dropped into a private dictionary inside a Hosting bridge. Nothing outside it could reach any of it, so from anywhere else in a composed server these questions had NO ANSWER: which entities are "the house"? which are "the east wall"? how many of its pieces are still attached? is it still standing? which structure did this entity that just broke belong to? It was worse than a missing convenience, because the information was not merely unexposed but DESTROYED: the entity ids were allocated in build order and that order is not recoverable from anything on the wire, so a game could not rebuild the mapping afterwards either. Every objective in a demolition game names a structure or a part of one — "bring down the east wall", "the chimney stays up", "clear the site". A contract, a score, a progress bar and a minimap marker are all the same query, and none of them could be written. Groups, not just whole buildings. "The east wall" is a SUBSET of one structure, so a piece carries an opaque Group tag (baked from a Unity parent name) and a query can be scoped without inventing a second structure. Empty until something registers. A composition that raises no structure has an empty registry: every query answers nothing, no event ever fires, and nothing is subscribed (rule 21). The Hosting placement bridge registers what it raises; a game calling StructureBuilder directly registers its own.

  • long CarrierOf(long entityId)

    LB-GAP-17 — the body currently simulating this piece in place of its own, or 0. 0 is the answer for every piece of every ordinary structure; a non-zero answer is the chunk carrier's entity id. A game rendering pieces can ignore this entirely — member poses are written back to World either way — and a tool auditing the engine cannot.

  • int GroupPieces(long structureId, string group, List<long> into)

    Every piece of one group, appended to into. Returns how many were added.

  • int Groups(long structureId, List<string> into)

    Every group tag this structure's pieces carry, appended to into. Returns how many were added. A structure whose bake tagged nothing has none.

  • int InZone(string zone, List<long> into)

    Every structure standing in a zone, appended to into (not cleared). Returns how many were added.

  • bool NotePieceAbsorbed(long entityId, long carrierEntityId)

    LB-GAP-17 — a piece's SIMULATION has been taken over by another body, while the piece itself stays exactly what it was. Why the registry needs a third word for this. A chunked member is neither removed nor detached. Removed is wrong: the entity exists, it is still a piece, and every objective and query still keys on it. Detached is wrong: its welds still hold — being welded is precisely WHY it is part of one rigid body. What is true is narrower and had no name: the ENGINE no longer has a body for it, and therefore no longer has the constraints that ran between it and its fellow members. Without this the consistency sweep reports every chunk as a catastrophic divergence — the registry holding joints and pieces the engine "lost" — which is the exact FINALS-8.9 signature that sweep was written to catch, produced by the framework's own feature. The graph is deliberately UNCHANGED: support, degree, group membership and the joint counts all keep reading as they did, because none of them became less true. Only the question "should the engine have a body for this piece" changes its answer.

  • bool NotePieceReleased(long entityId)

    LB-GAP-17 — a piece absorbed by NotePieceAbsorbed simulates itself again. The counterpart of absorption and nothing more: it says the engine should have a body for this piece once more. What happened to the welds that ran between the released members is the caller's to report, through the ordinary paths — see the chunk service, which dissolves into independent bodies and therefore reports those welds as gone.

  • bool NotePieceRemoved(long entityId)

    A piece has been DESPAWNED — debris that expired, or one a game removed outright. Distinct from a detachment: the body is gone, so its joints went with it and it can never be part of the structure again. Told rather than observed, so the registry subscribes to nothing and depends on nothing (rule 21). The composition wires it to whatever removes bodies — see the Hosting placement bridge.

  • event Action<StructurePieceDetached> PieceDetached

    Raised when a piece of a registered structure comes off — the hook a score, an objective or a warning subscribes to. Raised when a piece's LAST joint to the rest of the structure goes, NOT on every joint break, because a wall coming down breaks hundreds of welds and only some of them detach anything. A piece despawned outright raises it too, once.

  • int PieceIndexOf(long entityId)

    The piece's index in its definition, or -1 for an entity that is not a structure piece. Stable for the life of the structure, and how a game keys back to authored data.

  • int Pieces(long structureId, List<long> into)

    Every piece of a structure, in definition order, appended to into — including detached ones, excluding refused and despawned ones. Returns how many were added.

  • long Register(string name, string zone, StructureDefinition definition, StructureInstance instance)

    Registers a built instance under a game-chosen name and returns its id.

  • bool Release(long structureId)

    Forgets a structure — what a zone release does. The bodies are NOT touched; this is the registry letting go, not a demolition. False for an id it does not hold.

  • long StructureOf(long entityId)

    Which structure this entity belongs to. 0 for a body that is not part of one — including a piece that has been despawned.

  • bool TryGet(long structureId, out StructureView view)

    Resolves a structure id. False once it has been released.

  • bool TryGetGroup(long structureId, string group, out StructureGroupView view)

    What is left of one group. False for an unknown structure or a tag it does not use. Ordinal comparison — a bake's tags are data, not prose.

Providers you can swap 1

Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.

IPhysicsBackend provider

OPTIONAL engine seam: a server-authoritative physics engine that RigidBodies drives. A game or sample implements this over Bepu, Jolt, PhysX, a deterministic solver, or a toy integrator; the piece steps it at RigidBodyOptions.PhysicsStepHz and then PULLS each tracked body's pose back out to broadcast it. RigidBodies references NO engine — this interface names only engine-agnostic primitives (Vec3, Quat, entity ids), so a Bepu adapter depends on RigidBodies, never the reverse (Prime Directive). Pull, not push (by design). The backend is a pure read surface: Step advances the simulation and TryReadBody reads a pose — the backend never calls back into IRigidBodyService, so a physics adapter has ZERO knowledge of Crossplay's service and stays a drop-in the game can test in isolation. The drive loop (PhysicsBackendDriver) owns the fixed timestep and the body set (RigidBodies already tracks exactly the registered bodies), so the two never drift. If no backend is registered the drive loop does not exist and the game reports transforms manually via IRigidBodyService.Update — today's model, unchanged.

  • bool OwnsBody(long entityId)

    Reads a body's post-step transform + velocities. Returns false when the backend does not own or simulate entityId (the driver then skips it — a body that lives outside this engine is simply not pulled). Returning false for a sleeping/settled body is a valid cheap optimization: RigidBodies' own sleep gate already suppresses re-broadcast, so a body the backend stops reporting just stops moving on the wire. Must not allocate. LB-GAP-15 — whether this engine holds a body of ANY kind for the entity: dynamic, kinematic or static. The probe a graph/engine consistency sweep asks, and the reason it is not TryReadBody: that one reads a DYNAMIC body's pose and answers false for the kinematic anchors every structure's foundation course is made of.

  • void Step(float deltaSeconds)

    Advances the simulation by one fixed step. Called at RigidBodyOptions.PhysicsStepHz (with catch-up bounded by RigidBodyOptions.MaxPhysicsStepsPerTick). Poll-thread only.

  • bool TryReadBody(long entityId, out Vec3 position, out Quat orientation, out Vec3 linearVelocity, out Vec3 angularVelocity)

Services you call 4

Crossplay implements these. Resolve them from DI and call them from your own systems.

IBodyNeighbourSource

Supplies the bodies near the one being stepped, so a predicted or simulated body can see the others.

  • int GetNeighbours(long selfEntityId, in Vec3 near, float radius, Span<NeighbourBody> into)

    Writes the bodies near near into into, excluding selfEntityId, ordered by entity id ascending.

IPortableBodies

The bodies hosted on the portable step, as the composition layer sees them — the seam GAP-46 needs so a Hosting bridge can give portable bodies pair contact with ZERO piece-to-piece references, exactly as IVehicleService's registration events and ApplyExternalImpulse do for vehicles.

  • bool ApplyExternalDisplacement(long entityId, float deltaX, float deltaZ)

    Moves a hosted body by an exact world-space offset — the receiving end of a contact SEPARATION (and of a swept rewind). Reaches the simulation state itself, which is the only write that sticks; see the interface remarks for why writing the world entity instead would flicker.

  • bool ApplyExternalImpulse(long entityId, float deltaVelocityX, float deltaVelocityZ, float deltaYawSpin)

    Applies an externally computed velocity change to a hosted body — the receiving end of a contact impulse. The change lands on the body's persistent state, so the next step integrates it exactly as it integrates its own forces.

  • event Action<long> BodyHosted

    Raised when an entity becomes a hosted portable body — after its simulation exists and its first transform has been reported, so a subscriber that profiles it acts on a real pose.

  • event Action<long> BodyReleased

    Raised when a hosted body stops being simulated — its entity despawned, or its zone's rule no longer claims it. A subscriber that profiled it on BodyHosted clears that profile here.

  • bool TryDescribeBody(long entityId, out PortableBodyDescription description)

    The footprint of a hosted body, derived from its own shape and mass.

IRigidBodyService

The game's seam onto rigid-body transform sync. A body IS a server entity: the game spawns it through IServerEntities.Spawn (opaque appearance = whatever the body is), then reports its physics transform here every server tick. Crossplay owns none of the physics — it only ships the result: quantizing, sleep-gating (a settled body sends nothing), interest-culling (via World's ObserversOf), batching, and broadcasting to nearby players. Single-threaded (poll-thread) by contract, like every other piece.

  • event Action<long> BodyExpired

    Raised when a body has lain still for its expiry period — "this is finished; retire it". Deliberately does NOT remove anything. The listener despawns the entity, World announces the removal, and tracking drops through the one route that already existed. A second removal path here is how the backend body, the world entity and the broadcast record start disagreeing about what exists. Fires once per settling. If the body moves again it is rearmed, so something knocked back into motion is not retired mid-roll.

  • event Action<long> BodyReleased

    Raised when a body stops being tracked, for ANY reason — expiry, an explicit Remove, or its world entity despawning. This is the signal a physics backend needs and did not have. Before it, despawning a body's entity dropped the broadcast record and left the ENGINE's body behind forever: invisible, un-networked, still in the broadphase, still colliding with things. A leak with no symptom until a demolition-scale world made it one.

  • bool Register(long entityId)

    Starts tracking a body for transform sync in the default ServerInterpolated mode (Pattern A). The id must be a live SERVER entity (never a player). Returns false for an unknown id or a player entity. Optional — Update auto-registers a valid server entity on first call — but explicit registration is clearer when a body exists before it first moves.

  • bool Register(long entityId, PhysicsNetMode mode, long ownerAccountId = 0)

    Starts tracking a body with an explicit networking mode (per-body pattern selection). For ClientPredicted (Pattern B), ownerAccountId is the account of the ONE client that controls and predicts the body: that client receives a full-precision RigidBodyReconcile (and is excluded from the interpolation batch), while every other observer interpolates the body normally. For ServerInterpolated the owner is ignored. Re-registering an existing body updates its mode/owner in place. Returns false for an unknown id or a player entity, or when ClientPredicted is requested with no (0) owner.

  • bool Remove(long entityId)

    Stops tracking a body (its transform stops broadcasting). Returns false for an untracked id. Despawning the underlying server entity removes it automatically too — no leak either way.

  • bool SetExpiry(long entityId, float secondsAtRest)

    Gives one body its own resting lifetime, overriding RigidBodyOptions.ExpireAfterRestingSeconds. Positive = that many seconds; NEGATIVE = never (how a game protects something important in a world that expires debris by default); 0 = inherit the option.

  • bool TryAcceptInput(long entityId, ISession sender, uint sequence)

    Validates one inbound input frame and, if it is good, records its sequence as the latest folded into this body — which is what the next reconcile echoes back as LastProcessedInput. Called by the piece's own handler before it reaches IRigidBodyInputSink; a game normally never calls it. It is on the interface because the four checks it performs are the ones only the piece can make, and a game that builds its own input transport should be able to reuse them rather than reimplement them (and get one wrong). Refuses, silently and without side effects, when: the body is not tracked (a client naming an id that does not exist — note this deliberately does NOT auto-register, unlike the server-side reporting path); it is not ClientPredicted; the sender is not its owner; or the sequence is not strictly newer than the last accepted one.

  • bool Update(long entityId, Vec3 position, Quat orientation, Vec3 linearVelocity, Vec3 angularVelocity)

    Reports a body's current transform for this physics tick. Cheap: it only records the latest state — the broadcast (quantize, cull, batch) happens on the piece's own broadcast tick, which decouples the physics rate from BroadcastHz. Auto-registers a valid, untracked server entity (as ServerInterpolated). Returns false for an unknown id or a player entity.

  • bool Update(long entityId, Vec3 position, Quat orientation, Vec3 linearVelocity, Vec3 angularVelocity, uint lastProcessedInput)

    Reports a ClientPredicted body's transform together with the lastProcessedInput sequence — the last input the game folded into this authoritative state from the owning client. That sequence rides the owner's next RigidBodyReconcile so it can reconcile precisely (the rigid-body analogue of acknowledging a MoveInput.Tick). Harmless on a ServerInterpolated body (the sequence is stored but never sent). Auto-registers a valid, untracked server entity. Returns false for an unknown id or a player entity.

IStructureChunks

LB-GAP-10 — what the broadcast pass needs to know about chunks, and nothing more.

  • long ChunkOf(long entityId)

    The chunk this entity currently rides, or 0. A member's own transform is not sent — the client derives it from the chunk's, which is where the bandwidth win is.

  • event Action<RigidBodyChunkComposition, string> CompositionChanged

    A chunk was created, split or dissolved — the reliable message a client needs, and the zone to send it to.

  • bool TryGetComposition(long chunkEntityId, out RigidBodyChunkComposition composition)

    The composition of a live chunk, for interest-reveal replay. The returned instance is REUSED; serialize it before the next call.

Configuration 9

Every tunable lives in an options object — there are no magic numbers to hunt for.

RigidBodyOptions

Configurable RigidBodies parameters (defaults here; never inlined in logic). The quantum and bit values are also the wire protocol — a client decoding these batches must use the SAME PositionQuantum, OrientationBits and VelocityQuantum.

  • float AngularEpsilon { get; set; }

    Sleep threshold for ORIENTATION (radians): a body whose rotation changed by less than this angle since its last SENT snapshot is not re-broadcast. ~0.01 rad ≈ 0.57°.

  • Dictionary<string, RigidBodyAutoRegisterRule> AutoRegister { get; set; }

    Composition-bridge table (used by the Hosting layer, never by this piece): which spawned entities BECOME simulated physics bodies, keyed by zone id — or by an instance PREFIX, resolved with the same rule every per-zone table uses. The gap this closes. Creating a dynamic body meant game C#: build a shape, call the engine's IPhysicsBodyFactory, then Register so it broadcasts. Nothing in configuration could do it, and the entity-body path deliberately will not (Crossplay:Physics:EntityBodies handles KINEMATIC and STATIC profiles — things something else drives, and scenery — and skips Dynamic ones precisely because their size, pose and velocity come from whatever spawned them). So a game shipping client + content + configuration and no server code could install this piece, install an engine, and never simulate a single barrel. This table is that switch, expressed as data: 886 immovable authored props become barrels that roll and crates that scatter without one line of server code. Empty (the default) = the bridge is not composed at all and a server behaves byte-for-byte as before. Genre-agnostic: "the props in this zone are simulated" is a warehouse, a racetrack, a dungeon and a demolition derby alike, and the piece still never learns which.

  • string Backend { get; set; }

    Which named physics backend this server should run. Empty (the default) or none means no engine: the game reports transforms itself through Update, which is a completely ordinary way to use the piece and not a degraded one. What this does and does not select. A transport is picked from config out of implementations that all SHIP — LiteNetLib, WebSocket, QUIC. Physics is nearly the same now: Crossplay.Physics.Bepu is server infra that ships inside every generated server, and a project which installs this piece gets it composed. But this setting still only chooses among the backends the COMPOSITION offered by NAME, and offers nothing on its own — it is a named socket, not a menu of built-ins. The shipped engine is registered unconditionally, so leaving this empty runs it; a game that offers two named engines uses this to pick. What it buys is real all the same: turning a named engine off, or swapping engines, or running the same build with an engine in one environment and none in another, stops being an edit to a machine-owned generated file.

  • bool BridgeContact { get; set; }

    Composition-bridge knob (used by the Hosting layer, never by this piece): when true, the Hosting contact bridge profiles every PORTABLE-hosted body on the Contact piece — its shape's bounds as a yaw-oriented OBB with a vertical extent, its authored mass as the weight, Impulse resolution — and routes the resulting impulses and separations back into the host's simulation (GAP-46). The exact mirror of Crossplay:Vehicles:BridgeContact, for the portable step. False (the default) = no bridge is composed and portable bodies pass through each other, exactly as BodyIntegrator documents ("no body-vs-body contact"). Engine-backed bodies are never bridged either way: an engine already owns contact for its own bodies, and profiling them on the Contact piece as well would resolve every pair twice.

  • List<BroadcastBand> BroadcastBands { get; set; }

    LB-GAP-13 — per-observer distance bands for how OFTEN a body updates. EMPTY (the default) is today's single rate for everyone, byte-for-byte (rule 21).

  • float BroadcastHz { get; set; }

    How many times per second moving bodies are broadcast. The service is an IServerTickable driven at the host tick rate; it broadcasts at most once per tick and no more often than this. 20 Hz mirrors the movement broadcast.

  • float DormantCheckSeconds { get; set; }

    PHYS-15: seconds between proximity checks for DORMANT structures (StructurePlacement.RealizeRadius). Only runs while something is dormant, so a game that uses no radius pays nothing whatever this says. Not every tick, deliberately: a check walks the zone's entities, and a player cannot cross a realisation radius meaningfully in a fraction of a second. The default trades a half-second of lead time for twenty times less work.

  • float ExpireAfterRestingSeconds { get; set; }

    How long a body may lie at rest before it is retired, seconds. 0 (the default) means bodies live until something removes them, which is today's behaviour and the right one for a world of doors and crates. Why this exists: demolition. A wall that comes apart into 200 pieces adds 200 PERMANENT bodies, and the second wall adds 200 more. Nothing reclaims them, so an hour into a session the simulation is carrying every brick anyone has ever knocked over. The broadcast side is already defended — a settled body sends nothing at all — but it is still in the broadphase, can still be woken by a neighbour, and still costs memory. Measured from when the body last MOVED, not from when it spawned: "gone thirty seconds after it stops rolling" is what a game means, and a spawn-time lifetime would delete debris mid-flight.

  • int FrameBufferInitialBytes { get; set; }

    Initial capacity (bytes) of the reused outbound frame buffer. Grows to the high-water mark and never shrinks; sizing it past the largest batch frame makes steady state allocation-free.

  • Dictionary<string, BodyInputProfile> InputProfiles { get; set; }

    Named input maps for driven bodies — the config-driven default over the IRigidBodyInputSink seam (GAP-48). Each profile says what the axes of a validated input frame BECOME: a force, a torque, a steer angle, a drive force. An InputProfile names an entry here. Empty (the default) composes nothing: inputs stay validated, sequenced and discarded — today's behaviour exactly. A game that registers its own sink still wins the seam; these tables then sit unread. Case-insensitive, for the same reason the physics profile table is: a name typed in two config sections will disagree about capitals eventually, and the failure would be a silently undriven body.

  • float LinearEpsilon { get; set; }

    Sleep threshold for POSITION (world units): a body whose position moved less than this since its last SENT snapshot is not re-broadcast. A settled body sends nothing — the big bandwidth win. Also the position half of the dead-reckoning gate.

  • int MaxBodiesPerFrame { get; set; }

    Cap on the number of MOVING bodies whose transform is broadcast per tick. A mass wake-up (an explosion scatters hundreds of crates) degrades to a bounded per-tick cost — excess bodies broadcast on later ticks, round-robin so none starve (freshest state each time), exactly the graceful trade the movement-broadcast budget makes. 0 = unbounded.

  • int MaxBodiesPerPacket { get; set; }

    Cap on how many bodies ride in ONE state-batch packet. An observer owed more than this gets several packets in the same tick rather than one oversized one. This is a datagram limit, not a tuning preference. The batch is sent UNRELIABLY, and an unreliable datagram cannot be fragmented — LiteNetLib refuses anything past its single-packet maximum (1023 bytes on a default MTU) by THROWING, and that exception unwinds through the broadcast tick and kills the server process. MaxBodiesPerFrame does not protect against it: that budget bounds how many bodies are PROCESSED per tick, not how many end up addressed to one observer, and its own default of 256 is an order of magnitude past what fits in a datagram. A hundred physics bodies in one player's interest is not an exotic load, and it took the server down. 24 (the default) is derived, not picked: a quantized RigidBodyState is 36 bytes (id + 3 packed positions + a smallest-three orientation + 6 velocity shorts), so 24 of them plus the message header sit comfortably inside 1023 with room for a smaller path MTU. Raise it only alongside a measured MTU; 0 means unbounded and restores the crash.

  • int MaxExpiryChecksPerFrame { get; set; }

    How many bodies the expiry sweep examines per broadcast tick. Bounded for the same reason MaxBodiesPerFrame is: a demolition leaves hundreds of settled bodies and not one of them is urgent.

  • int MaxInputPayloadBytes { get; set; }

    Largest accepted RigidBodyInput.Payload, in bytes. 0 disables the cap. The piece cannot infer this: the payload is opaque by design, so it has no idea whether a game's input frame is two bytes or two hundred, and only the composition knows. It has to be a number rather than a guess because the alternative is an unbounded blob from an unauthenticated- as-to-content source arriving at the input rate. 64 covers any plausible frame — a throttle, a steering angle, a few buttons — with room to spare.

  • int MaxPhysicsStepsPerTick { get; set; }

    Cap on how many fixed physics steps the driver runs in one host tick — the spiral-of-death guard: after a hitch the driver runs at most this many catch-up steps, then drops the remaining backlog rather than stepping unbounded (which would deepen the stall). 0 = unbounded.

  • int MaxStructurePiecesPerTick { get; set; }

    PHYS-15: most structure pieces raised in one tick, staged over as many ticks as it takes. 0 (the default) builds every placement at once — today's behaviour exactly. Why it exists. PHYS-08 measured a burst: 200 pieces cost 2.37 ms and a 4.28 ms worst tick, and 500 cost 11.5 ms and spiked a tick to 69.8 ms — four dropped frames of server time at the moment a zone is furnished. A demolishable house is 400-900 pieces before anyone has broken one. Staging removes the spike outright, and a building appearing over a handful of ticks is invisible to a player who is still loading into the zone. It admits whole BUILDINGS until the budget is spent — it never half-builds one. A structure is two passes (every piece becomes a body, THEN every joint is created, because a joint needs both ends present), so a structure stopped between them is a wall with no welds, which falls over. So this bounds how many buildings may be raised in the same tick, not how large one may be: a zone with four 150-piece buildings and a budget of 200 raises one per tick, and a single 900-piece building still costs one tick. Splitting a structure itself is a different and much harder change.

  • int OrientationBits { get; set; }

    Bits per stored quaternion component for smallest-three compression (8–10; 10 → a 32-bit code, ~4 bytes vs 16 raw). Coarser bits give a wider effective sleep tolerance. Must match the client. See SmallestThree.

  • float PhysicsStepHz { get; set; }

    Fixed rate (Hz) at which a registered IPhysicsBackend is stepped by the PhysicsBackendDriver. Independent of BroadcastHz: the sim can run at 60 Hz while transforms broadcast at 20. Only used when a backend is composed (AddCrossplayRigidBodiesBackend); with no backend nothing reads it. 0 disables stepping.

  • int PortableInputLeadSteps { get; set; }

    How many fixed steps a driven body may run AHEAD of server real time before further input frames are dropped until the clock catches up — the anti-speed-hack budget for the portable host. An honest client sends one input per fixed step and lands exactly on budget; this slack absorbs network jitter bunching two or three frames together. A client sending faster than real time gains at most this many steps ONCE, then never again, because the budget refills only as real time passes. A dropped frame's sequence still acks (the client already simulated it locally), so the punishment for flooding is a reconcile, not a desync.

  • float PositionQuantum { get; set; }

    Position wire resolution in world units (one fixed-point step). 0.001 = 1 mm — sub-visible stepping that client interpolation smooths out, while keeping positions exact and deterministic. Must match the client.

  • bool PrioritizeUnderBudget { get; set; }

    LB-GAP-13 — when more bodies are dirty than MaxBodiesPerFrame allows, send the ones that matter first instead of taking whoever the round-robin cursor happened to reach. False (the default) is today's fair rotation.

  • float PriorityAngularWeight { get; set; }

    LB-GAP-13 — how much a body's SPIN counts toward its priority score, relative to its linear speed. Only read when PrioritizeUnderBudget is on. Not zero, because a piece tumbling in place is visibly stuttering when it defers, and not one, because radians and world units are not the same quantity: 0.25 makes a chunk spinning at 4 rad/s rank like one travelling at 1 unit/s, which is roughly how they read.

  • int PriorityMaxSkips { get; set; }

    LB-GAP-13 — the most consecutive broadcasts a dirty body may be deferred before it jumps the ordering outright. Only read when PrioritizeUnderBudget is on. At the default BroadcastHz of 20, 3 means nothing waits longer than about 150 ms.

  • bool ServeGeometryQueries { get; set; }

    Whether a query-capable physics backend also answers World's 3D geometry queries — i.e. whether AddCrossplayRigidBodiesBackend registers the engine's broad/narrow-phase as an IWorldGeometryProvider (GAP-35). True (the default) composes it, exactly as before. What false buys. The engine keeps everything else — pose authority, level geometry, entity bodies, the IPhysicsQueryBackend seam itself — but its broadphase leaves the IWorldQueries3D fan-out entirely, so server-only consumers (AI line of sight, spawn validation) stop seeing engine geometry too. The PREDICTED vehicle path never consults it either way (it injects the mirrored-only view); turn this off when the engine tier should not answer ANY world query — for example when the analytic + baked providers already carry the level and a second copy of it is pure cost.

  • string ShapesPath { get; set; }

    Directory of baked .shapes3d libraries — the last link in "a model in Unity becomes a collision volume on the server". Empty (the default) registers no shape library, so a profile's Shape name resolves to nothing and falls back to its inline primitive. This is the knob that made the chain reachable.FilePhysicsShapeLibrary shipped, was tested, and was registered by NOTHING: there was no configuration that produced an IPhysicsShapeLibrary, so every baked shape a game authored was unreachable and every car was whatever box somebody typed into a profile. Same convention as Collision3D's ContentPath; a missing directory or no files means an EMPTY library, not an exception, because a game that bakes no shapes is not misconfigured.

  • StructureChunkOptions StructureChunks { get; set; }

    LB-GAP-10: connected-chunk debris — a detached section simulates and replicates as ONE body. Off by default, and with it off no service is composed at all (rule 21). The single biggest smoothness + CPU + bandwidth lever at collapse scale: a 40-piece wall corner goes from 40 bodies and 40 transform streams to one of each (~28.8 KB/s → ~0.72 KB/s while moving), lands as a coherent slab you can climb, and — with RideProbeDistance — carries the furniture standing on it down with the floor.

  • StructureConsistencyOptions StructureConsistency { get; set; }

    LB-GAP-15: the graph/engine consistency detector. Off by default (CheckSeconds 0), and with it off no sweep is composed at all (rule 21). This registry is told about changes rather than observing them, so a removal path that forgets to tell it makes the two states drift apart silently — a building reads as more intact than it is, and collapse warnings and objectives quietly stop working. THE FINALS shipped that exact bug (update 8.9). Turn this on for a bench run or a playtest; it inspects one structure per pass and reports, never repairs.

  • StructureDetachOptions StructureDetach { get; set; }

    PHYS-29: how a detach spreads on a KINEMATICALLY-held structure (StructureDefinition.KinematicUntilDetached). Only read by structures in that mode; a solver-held building breaks welds and never consults this.

  • StructureFractureOptions StructureFracture { get; set; }

    PHYS-22: whether, and how readily, a broken weld takes its neighbours with it. Off by default (PropagationChance 0), and with it off no propagation pass is composed at all — a break is exactly what the solver sheared, as it always was (rule 21).

  • StructureIntegrityOptions StructureIntegrity { get; set; }

    PHYS-21: how the structural-integrity queries answer. Everything in it is off by default, so a composition that never touches it behaves byte-for-byte as it did (rule 21).

  • StructureSettleOptions StructureSettle { get; set; }

    PHYS-28: when a structure that has stopped moving is allowed to stop costing anything. Off by default (AfterSeconds 0), and with it off no sweep is composed at all — a structure sleeps exactly when the engine's velocity heuristic says so, as it always did (rule 21). Turn it on for anything finely shattered. Measured on a 1,140-piece slab-on-pillars: standing perfectly still it never slept and cost 14.2 ms a step for ever, because a chunk welded on every face rocks at 0.15–0.23 rad/s and the engine's angular sleep threshold is 0.1. With this on, 0.04 ms.

  • StructureStrainOptions StructureStrain { get; set; }

    LB-GAP-11: load-aware failure for KinematicUntilDetached structures — "strain-lite". Off by default (Enabled false), and with it off no sweep is composed at all (rule 21). The middle this framework did not have: a solver-held building carries real load and costs eight substeps a tick; a kinematic one costs ~0 and is load-BLIND, so it can never sag, be overloaded, or come down from weight redistribution. This decides failures over the graph the registry already owns and lets the ordinary detach cascade do the falling.

  • Dictionary<string, List<StructurePlacement>> Structures { get; set; }

    Composition-bridge table (used by the Hosting layer, never by this piece): which BUILDINGS stand in which zone, keyed by zone id — or by an instance PREFIX, so one row furnishes every instance of a dungeon template. The gap this closes (PHYS-11). PHYS-07 built four links of a five-link chain — a Unity baker, the .structure3d format, IStructureLibrary with a file loader, and StructureBuilder, which turns a definition into bodies and joints in a live world — and then nothing called the last one. A game could bake a house, configure StructuresPath, start a server that reported no error, load the definitions, and get an empty level. Placing it took server C#: resolve five services, find the world's zone-activation event, and call TryBuild with a hardcoded pose. Empty (the default) composes no bridge at all, so a server behaves byte-for-byte as before.

  • string StructuresPath { get; set; }

    Directory of baked .structure3d libraries — an artist's wall, as a building the server holds up and can knock down. Empty (the default) registers no structure library. Exactly the same story as ShapesPath: FileStructureLibrary existed with no way to compose it, which is the difference between "the framework supports destructible structures" and "a designer can make one".

  • float VelocityQuantum { get; set; }

    Velocity wire resolution (units/s per quantized step) for both linear and angular velocity. 1/256 ≈ 0.0039 — animation/extrapolation data, not simulation state. Must match the client.

StructureChunkOptions

LB-GAP-10 tuning — connected-chunk debris. Off by default (rule 21).

  • byte CarrierAppearanceKind { get; set; }

    LB-GAP-17 — the opaque appearance KIND byte a chunk CARRIER entity is spawned with. 0 (the default) spawns it with an empty blob, exactly as it shipped. What a carrier is, and why a game has to be able to tell. A chunk spawns one extra world entity to be the thing the engine simulates and the wire moves; it is not a piece, has no appearance of its own, and a client that draws every entity it is told about will draw something for it. Until now the only way to recognise one was "its blob is empty", which is an accident rather than a contract — an ordinary piece whose bake carried no appearance looks identical. The AUTHORITATIVE answer is not this. A carrier is named by RigidBodyChunkComposition.ChunkEntityId, which is sent reliably to the zone when a chunk forms and replayed on interest reveal, so a client that reads compositions knows every carrier by id and needs nothing here. This option is for the simpler client that only has the spawn: give carriers a blob your game recognises (a reserved kind byte, say) and the spawn alone is enough. Opaque, as every appearance blob in this framework is: the byte means whatever the game says it means and no Crossplay package looks inside it. One byte, and reached from config the same way StructurePlacement.AppearanceKind is, because that is this framework's spelling of "what should a client draw for this".

  • bool ChunkDetachedGroups { get; set; }

    Simulate a detached connected group as ONE compound body instead of one body per piece. False (the default) composes nothing at all.

  • int MaxChunkOpsPerTick { get; set; }

    The most chunk operations — creations, splits, dissolves — one tick may perform. The amortization discipline every other burst in this piece follows: a collapse that detaches six sections at once must not build six compounds in one tick.

  • int MaxChunkPieces { get; set; }

    The most pieces one chunk may hold. A cap, not a tuning knob: a compound with a thousand children is one broadphase entry and a very slow narrowphase, which trades one problem for another.

  • int MinChunkPieces { get; set; }

    The fewest pieces a detached group must have to be worth chunking. Two bricks are two bodies either way; the win starts at a section.

  • float RideProbeDistance { get; set; }

    LB-GAP-10 tier 3 — how far below a world entity to look for a detaching group's piece when deciding whether that entity RIDES the section down. 0 disables riding entirely. "Objects remain attached when structures fall" is the FINALS Season 9 note, and the reason it matters is a collapsing floor with levitating furniture on it reads as broken. Riding is free: the entity's pose is derived from the chunk, so it costs no body and no extra wire.

  • float RideProbeRadius { get; set; }

    How far horizontally a resting entity may be from a chunk piece's centre and still count as standing on it, world units.

StructureConsistencyOptions

LB-GAP-15 tuning. Everything off by default, so a composition that ignores it behaves byte-for-byte as it did (rule 21).

  • float CheckSeconds { get; set; }

    Seconds between passes; each pass inspects ONE structure, round-robin. 0 (the default) composes no sweep at all. A debug/soak instrument, not a production tick cost: 5–15 seconds is enough to catch a missing telling-path during a bench run or a playtest, which is when it is worth catching.

  • float RepeatSeconds { get; set; }

    Seconds before the SAME structure may report a divergence again. A missing telling-path diverges on every pass forever; it is one defect and deserves one line.

StructureDetachOptions

How readily a detach spreads, and how much of one is allowed in a single act.

  • int MaxPerDetach { get; set; }

    Cap on pieces one detach may take, including the cascade. A structure standing on one chunk can legitimately lose all of itself at once, and that is a very large amount of work to do inside one interaction — so it is bounded, and the rest comes off on the next sweep.

  • float PropagationChance { get; set; }

    Chance a neighbour of a detached piece comes off with it — the donor's ChunkConnectionStrength, expressed the way round it is actually used. Its rule is random > strength * depth, so a scene setting of 0.6 means a 40% chance at one hop and never at two. 0 (the default) spreads nothing: a hit takes exactly what it hit.

  • int PropagationDepth { get; set; }

    How many hops a detach may spread. 1 matches the donor at its usual settings.

  • int Seed { get; set; }

    Seed for the propagation dice. The same seed and the same hits give the same rubble.

StructureFractureOptions

PHYS-22 tuning. Ships OFF: with PropagationChance at 0 (the default) nothing is composed, nothing is decided, and a break is exactly what the solver sheared — today's behaviour byte-for-byte (rule 21). A structural game that wants physical honesty changes nothing.

  • int MaxPropagatedBreaksPerStep { get; set; }

    Cap on propagated breaks per step, across every structure. A pathological chain on a 500-piece building must be bounded the same way MaxStructurePiecesPerTick bounds staging. 0 = unbounded.

  • StructureFractureSource PropagateFrom { get; set; }

    LB-GAP-17 — WHICH breaks are allowed to start a shockwave. Ships as AnyBreak, which is what shipped: every break seeds, byte-for-byte as before (rule 21). Set it to BlowsOnly and a weld that gave way under WEIGHT no longer starts a chain — only one that something hit does. What it is for, measured. Everything above is written about a blow: "a sledgehammer and a nudge should not make the same hole". Until PhysicsJointBreakCause existed there was no way to ask, so a structure settling onto its own marginal welds seeded the shockwave exactly as a hammer did. On a baked 34-piece wall (min weld 394 N·s, mean 3 821) three welds gave way to the wall's own weight in the first two steps after it was raised; at chance 0.5 and depth 2 those three became forty-one welds in one step, and the building had lost 63 of its 94 welds before a player connected — silently, because a fractured wall whose welds are gone still stands there interlocked. With this set to BlowsOnly the same wall keeps 89 of 94, and a hammer blow still brings a hole out of it because a blow is what the sample reading measures. It is a choice, not a correction. A game whose collapses are meant to run away as redistributed weight re-breaks welds wants AnyBreak and should keep it.

  • float PropagationChance { get; set; }

    How readily a break spreads to a neighbouring weld, 0..1. 0 (the DEFAULT) is today's behaviour exactly: only what the solver sheared. 1 takes everything connected, within the depth below. Why this exists. A weld breaks when the impulse across it exceeds its threshold, and ONLY then — which is physically defensible and is what PHYS-03 and PHYS-19 built. It also means that to make a HOLE in a wall you must independently deliver enough impulse to shear every weld around the hole's perimeter, because each is evaluated on its own. Measured on the demo house at 400 N.s welds: a 1,200 N.s hammer blow breaks 3 of 66 welds — one brick comes loose. A brick in the middle of a wall has four to six welds, so a hammer that removes a BRICK is affordable and a hammer that makes a HOLE is not; a hole is what a demolition game is about. Every workaround is worse. Lowering the threshold so one blow shears six welds is a building that no longer holds its own weight. Raising the tool's impulse to thousands of newton-seconds launches the pieces rather than dislodging them — momentum that shears six welds is momentum a 12 kg brick leaves at highway speed. Damage-then-detach works and is the drill's verb, but it removes one named piece at a time and cannot express "a chunk came out".

  • int PropagationDepth { get; set; }

    How far a break may spread through the graph. Each step multiplies the chance down, so depth 2 at chance 0.8 is 0.8 for a direct neighbour and 0.64 one further out.

  • float PropagationMinExcess { get; set; }

    Excess below which nothing propagates — a weld that only just gave way should not start a chain. Expressed as a MULTIPLE of the threshold: 2 means "the blow had to be twice what the weld could take". 0 (the default) propagates from any break.

  • float PropagationWeakArea { get; set; }

    Contact area at or below which a weld is treated as FULLY weak for propagation — the ragged-edge dial. Propagating preferentially into small contacts rather than uniformly at random produces plausible holes for free, and the deriver already measured the area of every weld. 0 (the default) ignores area entirely and every neighbour rolls the same chance. Set it to about the area of a full brick face and a corner contact becomes far likelier to go than a bedded one.

  • bool ScaleByExcess { get; set; }

    Whether the chance scales by how much the blow EXCEEDED the weld's threshold. True by default, because it is the more honest version of the same dial and costs one multiply. The donor engine's roll is flat — it has no such number. Ours does: a blow that beat the weld by ten times should spread further than one that beat it by 1.01, and a sledgehammer and a nudge should not make the same hole.

  • ulong Seed { get; set; }

    Seed for the propagation rolls. Combined with each structure's id, so two identical buildings on the same site do not fracture identically and the SAME building fractures the same way on a replay. Why a seed at all.Random.value on one machine is fine when there is one machine. Ours must be identical on the server and reproducible in a replay (Crossplay.Replay would otherwise diverge), so the roll is seeded rather than inherited.

StructureIntegrityOptions

PHYS-21 tuning. Everything here is off by default, so a composition that ignores it behaves byte-for-byte as it did (rule 21).

  • float MaxHorizontalSupportDistance { get; set; }

    How far a piece may be from its nearest anchored piece, measured HORIZONTALLY, and still count as supported. Infinity (the default) turns the rule off entirely and no geometry is consulted. What it buys. Connectivity alone says a beam cantilevered twenty metres out over nothing is "supported", because there is an unbroken chain of welds back to the foundation. No player would accept that, and no survey tool should render it. This is the donor engine's ChunkHorizontalRadiusSupportStrength: a one-line stand-in for a bending moment, and exactly what makes an overhang read correctly. It changes what IsSupported and SupportPathCount REPORT. It changes nothing about the simulation — the solver is never overridden, and a piece this calls unsupported does not start falling because of it (see the interface remarks on why that distinction is the whole design). Measured in the DEFINITION's own space, as built. That is the right frame — "how far out does this beam hang" is a property of the building, not of where it was dropped — and it is unaffected by the structure's placement for every rotation a placement can express, because StructurePlacement offers a YAW and a yaw preserves horizontal distances. A game calling StructureBuilder.TryBuild with an arbitrary quaternion that tips a building onto its side is the one case where "horizontal" in the definition is not horizontal in the world; the rule then measures the building's own horizontal, which is stated here rather than silently assumed.

StructureSettleOptions

PHYS-28 — when a structure that has stopped moving is allowed to stop costing anything. Off by default (AfterSeconds = 0): with it off no sweep is composed at all and a structure sleeps exactly when the engine's own velocity heuristic says so, as it always did (rule 21).

  • float AfterSeconds { get; set; }

    How long every piece of a structure must hold still before its island is put to sleep, seconds. 0 turns this off. Why any number here is safe and a bigger sleep threshold is not. This is not a guess about velocity — it is an observation that the building did not move. A piece that is genuinely creeping never accumulates the window, so the pass cannot freeze a slow collapse; that is the exact failure a raised SleepThreshold produces, and it is recorded in docs/DESTRUCTION-PERFORMANCE.md under "sleep MASKS an under-converged structure". A second or two is right: long enough that a settling collapse is finished, short enough that a player watching a building stop does not see it burn a core afterwards.

  • float CheckSeconds { get; set; }

    Seconds between sweeps. Not every tick, deliberately: the sweep reads every piece of every AWAKE structure, and a building cannot become still and then move again inside a quarter second in a way that matters.

  • int MaxPiecesPerSweep { get; set; }

    Cap on pieces examined in one sweep, so a zone full of structures cannot turn a settle check into the most expensive thing in the tick. The sweep resumes at the next structure the following time, so nothing is skipped — it just takes longer to notice.

  • float MoveEpsilon { get; set; }

    How far a piece may move between sweeps and still count as still, world units. Bigger than the residual jitter of a stiff weld grid (measured: a 1,140-piece slab drifts under 0.0005 units per quarter second at 8 substeps) and far smaller than anything a player could see.

StructureShatterOptions

How to break a solid volume into pieces — PHYS-23 phase 3, for box volumes. Every number that decides what the rubble looks like is here, because a shatter is a content decision and a game will want a slab, a plank and a pane to shatter differently.

  • float AnchorBelowY { get; set; }

    Anchor any piece whose UNDERSIDE is at or below this height (in the same space as the volume's centre). NegativeInfinity, the default, anchors nothing. This is the donor's support plane in one number: everything the plane touches becomes a support chunk. A structure needs at least one anchored piece or it is a free-floating object that simply falls — and it must NOT be all of them, because a weld between two anchored pieces is refused, so a fully anchored shatter derives no joints at all.

  • byte[] Appearance { get; set; }

    PHYS-12's per-piece opaque blob. Null/empty leaves each piece inheriting the structure's, which is what a single-material volume wants.

  • float Density { get; set; }

    Mass per cubic world unit when TotalMass is 0. The default is roughly concrete.

  • string Group { get; set; }

    PHYS-20's part tag for every piece produced. Empty leaves them untagged.

  • float MinMass { get; set; }

    Floor on a single piece's mass, so a small chunk of a light volume is still something the solver can push around predictably.

  • float MinPieceSize { get; set; }

    Smallest a piece may be on ANY axis, world units. This is not a cosmetic bound — see MinContactArea: a sliver welds to its neighbours across a face so small that the area-scaled break impulse rounds to nothing, so a shatter that produces slivers produces a structure whose weakest joints are in a pattern nobody chose.

  • float OffAxisChance { get; set; }

    Chance a cut is taken on a RANDOM axis rather than the widest one. Zero always cuts the widest axis, which drives every piece toward a cube — the structurally sane default, because a cube welds to six neighbours across broad faces. Raise it for sawn-looking debris.

  • int Seed { get; set; }

    Seed. The same seed and volume give byte-identical pieces on both tiers and in a test — which is what makes a shattered structure bakeable content rather than a surprise.

  • float SizeVariation { get; set; }

    How far off centre a cut may fall, as a fraction of the legal range. 0 splits every piece exactly in half — a perfect lattice, and instantly readable as one. 1 puts the cut anywhere the size floor allows, which is what reads as fractured material.

  • int TargetPieces { get; set; }

    How many pieces to aim for. The result can be FEWER: splitting stops when no piece can be cut again without a child going under MinPieceSize, which is the honest answer to "shatter this pane into a thousand" rather than emitting slivers.

  • float TotalMass { get; set; }

    Mass for the WHOLE volume, kilograms, split between the pieces in proportion to their volume (the donor engine's TotalMass × RelativeVolume). 0 uses Density. Usually what you want: a slab's weight is a gameplay number, and deriving it from a density makes a 12-unit slab weigh 276 tonnes, which is correct and unplayable.

StructureStrainOptions

LB-GAP-11 tuning — "strain-lite". Everything off by default, so a composition that ignores it behaves byte-for-byte as it did (rule 21).

  • bool Enabled { get; set; }

    Turns load-aware failure on. False (the default) composes no sweep at all.

  • float EvaluateSeconds { get; set; }

    Seconds between passes. The evaluation is a flood per structure, so it is deliberately not per-tick — and it only visits structures something has actually changed.

  • float ImpactLoadDecay { get; set; }

    How quickly transient impact load bleeds away, as a fraction remaining per evaluation. 0 makes an impact a single-evaluation event; 1 would make it permanent, which is a building that remembers every pebble for ever.

  • float ImpactLoadScale { get; set; }

    Turns a reported impact into transient LOAD on the piece it struck, scaled by this. 0 (the default) ignores impacts entirely. This is the chain reaction between structures. In the kinematic mode a piece has infinite mass, so falling debris bounces off a second building and nothing happens — which is exactly the behaviour THE FINALS' strain system does not have. With this above 0, debris from one collapse can bring down the next.

  • int MaxFailuresPerEvaluation { get; set; }

    The most connections one evaluation may fail. A single overloaded course can put a whole wall over threshold at once; letting it all go in one pass is a spike, and letting it go over the next few passes is a collapse.

  • int MaxStrainEvaluationsPerTick { get; set; }

    The most structures one pass may evaluate. The amortization discipline every other burst in this piece follows, and the one Embark describes: "amortize strain, physics and replication".

  • float SafetyFactor { get; set; }

    How far past a connection's built capacity its load may go before it gives. 1.0 fails a connection the moment it is carrying exactly what it was built for, which is a building that collapses as soon as it is finished; above 1 is the margin a real structure has.

  • float WeakenBeforeFail { get; set; }

    A connection's FIRST offence weakens it to this fraction of its capacity instead of failing it; the second fails. 1.0 skips the warning stage and fails immediately. Creaky, and fair: a player gets one audible, visible round of warning — the weaken raises nothing on the wire by itself, but the support change it eventually causes does — before the wall actually goes.

Wire messages 4

The protocol this piece speaks. Ids are allocated per piece so they can never collide.

4401 RigidBodyStateBatch

Server → one observer: the rigid bodies in that observer's interest that moved this broadcast tick, carried in a single framed message (like EntitySpawnBatch) instead of one message per body — far fewer datagrams and less per-body framing overhead when many bodies are in view. The batch is per-observer because interest differs per player (a body syncs only to players near it). Sent UNRELIABLE at the broadcast rate for steady updates (latest-wins, drop-tolerant); the same message is used, RELIABLE with a single body, to replay a body's current transform the moment it enters a new observer's view.

4402 RigidBodyReconcile

Server → the ONE client that owns a ClientPredicted body: the authoritative transform for that body, so the owner can reconcile its local prediction (soft-correct small drift, snap on large divergence). This is the rigid-body sibling of Movement's MoveState and carries the SAME reconciliation idea: LastProcessedInput mirrors MoveState.AckTick — the sequence number of the last input the server folded into this state, so the client can replay only the inputs after it. Unlike the observer RigidBodyStateBatch (quantized for bandwidth, snapshot-interpolated), this is FULL PRECISION: the owner compares it against its own float-precision predicted state, so a quantization deadzone would show up as permanent residual error. It is a single recipient, so the extra bytes are free. Sent UNRELIABLE at the broadcast rate (latest-wins, drop-tolerant) — a lost reconcile is simply corrected by the next one.

4403 RigidBodyInputFrame

Client → server: one input frame for a ClientPredicted body the sender owns — the missing half of prediction. The gap this closes. RigidBodies could already send a reconcile stamped with LastProcessedInput, and every word of that contract assumed inputs were arriving with sequence numbers — but there was no message to carry one. A game predicting a body had to invent its own channel, its own ownership check and its own sequencing, and the piece's reconciliation was a promise it could not keep on its own. The payload is OPAQUE and that is the whole design. Crossplay does not know whether an input is a throttle and a steering angle, a thrust vector, a jump bit or a chess move; it validates WHO may send it and WHICH ONES ARE NEW, and hands the bytes to the game. The same message therefore serves a racing game and a zero-g shooter without either being mentioned here — the Emotes template applied to input. Sent UNRELIABLE: an input frame that arrives late is worthless, and the sequence number lets the server ignore anything stale rather than a retransmit stalling the stream behind it. Why "Frame" and not just "Input". The client package has owned Crossplay.RigidBodies.Client.RigidBodyInput for a long time, and it means something different and more specific: the DECODED per-tick control (linear, angular, scalar channels) a game feeds to IRigidBodyIntegrator.Step. This type is the ENVELOPE that carries one of those across the wire — an id, a sequence and an opaque blob. Naming it RigidBodyInput too made the two ambiguous in any file that used both, which is exactly what broke the harness's own EditMode tests the first time this shipped. The newcomer renames; the shipped client API does not.

4404 RigidBodyChunkComposition

Server → observer, RELIABLE: a detached section of a structure is now ONE body, and these pieces ride it at these fixed offsets. LB-GAP-10's tier 2 — the bandwidth half of connected-chunk debris.

Unity seams you implement 6

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.

INeighbourAwareRigidBodyIntegrator unity SPI

An integrator that can also see the bodies near the one it is stepping (GAP-28) — so a predicted body stops being alone in the universe.

  • RigidBodyPose Step(long entityId, in RigidBodyPose from, in RigidBodyInput input, float dt, System.ReadOnlySpan<Crossplay.RigidBodies.Contracts.Simulation.NeighbourBody> neighbours)

    Advances from by input over dt, resolving against neighbours.

IRigidBodyBatchView unity SPI

LB-GAP-16 — the presentation seam for MANY rigid bodies at once: every tracked body's render pose, handed over in one call per frame instead of one call per body.

  • void Apply(ReadOnlySpan<long> entityIds, ReadOnlySpan<RigidBodyPose> poses)

    Every tracked body's render pose for this frame, in one call. entityIds and poses are parallel and the same length.

  • void Removed(long entityId)

    The piece has stopped tracking a body — release any per-body slot (an instancing index, a transform-array entry) held for it. The mirror of Release.

IRigidBodyCollisionShapes unity SPI

What a networked body is SHAPED like, for the client's own collision — PHYS-14's one game-facing seam. Why the piece cannot answer this itself. A client is sent a pose and an opaque appearance blob; it is never sent a collision volume, and it must not start interpreting the blob (the Prime Directive: a 2D or text-only client must be able to use this package unchanged). The game already knows — it built the view — so the game says. Half-extents, and the approximation is deliberate. This drives an axis-aligned box per body, not a rotated hull. The server stays authoritative over every contact; what the client needs is to stop walking THROUGH a rubble pile and being dragged back onto it twenty times a second, and a box does that. Claiming to reproduce the solver's exact geometry would be claiming something this cannot deliver — the client renders bodies an interpolation window in the past to begin with. Optional: with no implementation bound, RigidBodyClientGeometryProvider reports nothing and the client's prediction sees exactly what it saw before (rule 21).

  • bool TryGetHalfExtents(long entityId, out Vector3 halfExtents)

    The half-extents of entityId's collision box, in world units.

IRigidBodyIntegrator unity SPI

The game's local-physics seam for bodies THIS client owns and predicts (Pattern B, PhysicsNetMode.ClientPredicted). Prediction can only be as good as the client's copy of the server's step, and only the game knows its physics — so the game supplies the integrator, exactly as Movement's client mirrors the server's kinematic step. The piece owns the bookkeeping (buffer inputs by sequence, snap on reconcile, replay the unacked tail, smooth the residual); the game owns the one function that turns an input into motion. Optional. With no integrator bound, an owned body degrades gracefully: it snaps to each authoritative RigidBodyReconcile with no local replay (still server-authoritative and correct, just less smooth between reconciles) — a game with no client-side physics simply omits it, and the piece is still removable.

  • RigidBodyPose Step(long entityId, in RigidBodyPose from, in RigidBodyInput input, float dt)

    Advances from by input over dt seconds and returns the resulting pose. MUST match how the server integrates the SAME input for the SAME body (deterministically) — any divergence shows up as reconciliation correction. Called once when the input is first recorded, and again for each buffered input during replay after a reconcile, so it must be a pure function of its arguments (no per-call side effects / no reading live physics).

IRigidBodyView unity SPI

The presentation seam for ONE rigid body — the game binds an entity id to its own Rigidbody/Transform (or a 2D body, or a text row: the piece cannot tell). The piece computes an authoritative render pose (interpolated for Pattern A, predicted+reconciled for Pattern B) and pushes it here; the game decides what "a body" looks like. Presentation-agnostic by construction: no mesh, no animator, no asset — just numbers. The game's implementation typically sets the transform each call and either drives a kinematic Rigidbody.MovePosition/MoveRotation or writes the transform directly; the two velocities are supplied for games that want to feed them to their own physics or VFX. A view whose underlying object was destroyed should no-op (the piece tolerates it, exactly as the movement client tolerates a null remote GameObject).

  • void Apply(Vector3 position, Quaternion orientation, Vector3 linearVelocity, Vector3 angularVelocity)

    Renders the body at an authoritative pose for this frame.

IRigidBodyViewFactory unity SPI

The game's factory that resolves an entity id to its IRigidBodyView — the rigid-body analogue of ICharacterFactory. The piece calls Resolve the first time it sees a body (in a state batch or a reconcile) and caches the result; a game that has no view for that id (e.g. the body's entity is not spawned locally, or is off-screen and culled) returns null and the piece simply tracks the body for diagnostics without rendering it. Release is called when the piece stops tracking a body (see IRigidBodyClient.Forget) so the game can drop any per-body binding it held. Optional dependency: with no factory bound, the piece receives and decodes the streams (the diagnostics panel still works) but renders nothing — a headless / text client is valid.

  • IRigidBodyView Resolve(long entityId)

    Returns the view bound to entityId, or null if the game has none.

  • void Release(long entityId)

    Notifies the game that the piece has stopped tracking a body (so it can release the binding).

Unity services you inject 1

Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.

IRigidBodyClient

The RigidBodies piece, client side: renders server-synced physics bodies and drives client-side prediction for the bodies THIS client owns. Two per-body patterns, chosen automatically: Pattern A (default) — a body seen only on the interpolation channel (RigidBodyStateBatch) is snapshot-interpolated. Pattern B — a body for which a RigidBodyReconcile arrives (or that the game declares via PredictOwned) is predicted locally and reconciled; it is skipped on the interpolation channel. The reconcile channel is itself the ownership signal — only the owner receives it — so no un-owned body is ever predicted. Pattern C — a body the game declares via PredictShared is simulated locally by EVERY client at present time (no interpolation delay) and smoothed toward each authoritative batch state — the Rocket-League-ball pattern for an un-owned body whose feel matters. Purely a client-side rendering choice: the server keeps broadcasting the ordinary batch. Presentation is the game's: bind an id to a view through IRigidBodyViewFactory; supply local physics through IRigidBodyIntegrator. Both optional — a headless client still decodes the streams (and the diagnostics panel still works).

  • void PredictOwned(long entityId, RigidBodyPose initialPose)

    Declares a body this client owns and predicts (Pattern B), seeded at initialPose (typically its spawn pose). The body then consumes reconciles only and is skipped on the interpolation channel. Idempotent — safe to call before or after the first reconcile. Optional: the first reconcile also marks a body owned, but calling this lets the game start RecordInput immediately, before the first reconcile lands.

  • void RecordInput(long entityId, uint sequence, RigidBodyInput input)

    Records a locally-produced input for an owned predicted body — the game sends the SAME sequence + input to its own server handler (which folds it in and echoes the sequence back on the next RigidBodyReconcile). Advances local prediction and buffers the input for replay. Sequences must be monotonically increasing and start at 1. No-op for an unknown / non-predicted body, or when no IRigidBodyIntegrator is bound (the body then simply snaps to each reconcile with no local replay).

  • void PredictShared(long entityId)

    Declares a body EVERY client should simulate locally instead of interpolating (Pattern C) — the un-owned but latency-critical body: the match ball, the bomb, the puck. Between batches the body advances through the bound IRigidBodyIntegrator (or ballistically from its last velocities when none is bound); each authoritative batch state is adopted as the new simulation base with the visual jump absorbed by the same smoother prediction uses. Idempotent; an already-interpolated body migrates its view across the switch. A body this client OWNS belongs in PredictOwned instead (input replay beats re-simulation).

  • void Forget(long entityId)

    Stops tracking a body (call on despawn): drops its interpolator/reconciler and releases its view through Release. Harmless for an untracked id.

  • void CollectTrackedBodies(List<RigidBodyPlacement> into)

    Appends every tracked body's id and current RENDER pose to into — PHYS-14's read side (the list is cleared first). Not the diagnostics rows. Monitors is display data, rebuilt on a repaint cadence for a panel that may not even be open; reading collision out of it would make a client's prediction depend on whether someone had a debug window up. This is the same poses, on demand. The RENDER pose, deliberately — the one being drawn. An interpolated body is shown an interpolation window in the past, so standing on one means standing where it is SEEN to be. That is a few centimetres of disagreement with the server, which the reconciler exists to absorb, and it is a vastly smaller error than falling through the thing entirely.

  • int InterpolatedCount

    Number of bodies currently interpolated (Pattern A).

  • int PredictedCount

    Number of bodies currently predicted (Pattern B).

  • int SharedPredictedCount

    Number of bodies currently shared-predicted (Pattern C).

  • float LastCorrectionMeters

    Distance (metres) the most recent reconcile corrected a PREDICTED (Pattern B) body by, across every body this client owns — ~0 when prediction agrees with authority (GAP-37a; the same diagnostic the Movement and Vehicles clients expose). Statistics over raw metres are partly statistics about speed; use LastCorrectionSeconds for the invariant form.

  • float LastCorrectionSeconds

    The last correction expressed as TIME: LastCorrectionMeters divided by the reconcile's authoritative speed — how much travel authority rewound, which means the same thing at every speed. 0 when that body was stationary: a nonzero correction at zero speed cannot be latency and is its own, different signal. Compare against StateIntervalSeconds: ≈1 interval is prediction's floor; ≥3 is a rewind.

  • float StateIntervalSeconds

    One reconcile interval, seconds — the floor LastCorrectionSeconds is compared against. The owner reconcile rides the broadcast pass, so this is 1 / RigidBodyClientOptions.StateSendRateHz (the client's mirror of the server's Crossplay:RigidBodies:BroadcastHz).

  • IReadOnlyList<RigidBodyMonitor> Monitors

    A snapshot of the tracked bodies for diagnostics — rebuilt on the client's repaint cadence.

  • event Action MonitorsChanged

    Raised when the monitor snapshot has been rebuilt (the diagnostics panel repaints here).

UI views you can replace 1

Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.

ICrossplayRigidBodiesView

Narrow contract the presenter drives. UI-only and intent-free: RigidBodies is a push-only render piece (the game reports transforms server-side; the client only receives), so this view only renders — a diagnostics monitor, never a request path.