Spatial

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.

Server
services.AddCrossplayRigidBodies();
Unity package
com.crossplay.rigidbodies
Depends on
world

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.

  • 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)

    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.

Services you call 1

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

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.

  • 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 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.

Configuration 1

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°.

  • 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.

  • 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.

  • 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 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 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.

  • 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.

  • 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.

Wire messages 2

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.

Unity seams you implement 3

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.

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.

  • 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).

  • 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.