Movement
Server-authoritative movement: input, 20Hz broadcast, prediction/reconciliation, interpolation.
Seams you implement 4
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayMovement(); and it wins.
IBroadcastCullingPolicy game SPI
Network-LOD seam: decides, per subject→observer pair, how often that observer receives the subject's EntityMove. Consulted by the broadcast pipeline at emission time. Not registered → every observer gets every tick (the previous behavior) — the culling layer adds/removes at composition time.
public sealed class DistanceCullingPolicy : IBroadcastCullingPolicy
{
public int SendDivisorFor(WorldEntity subject, WorldEntity observer)
{
float d2 = SquaredDistance(subject, observer);
if (d2 > _farSq) return 0; // beyond the far ring: suppress entirely
if (d2 > _midSq) return 4; // far: every 4th tick
if (d2 > _nearSq) return 2; // mid: every other tick
return 1; // near: full rate
}
} -
int SendDivisorFor(WorldEntity subject, WorldEntity observer)Send divisor for this pair: 1 = every tick (full rate), N = every Nth tick (reduced rate for far/unimportant pairs), 0 = never (suppressed entirely). Called on the poll thread at broadcast rates — implementations must be cheap and allocation-free.
IControlResolver game SPI
Possession seam: decides which entity a session's movement input drives. The default is the session's own avatar (entity id == connection id); a game layer redirects it to a server entity — vehicle seats, mind control, GM drive-an-NPC — without Movement knowing why.
public sealed class VehicleControlResolver : IControlResolver
{
public long ControlledEntityOf(ISession session)
=> _seats.TryGetVehicle(session.ConnectionId, out long vehicleId)
? vehicleId // input drives the seated vehicle
: session.ConnectionId; // otherwise the player's own avatar
} -
long ControlledEntityOf(ISession session)The entity this session's inputs currently control.
IMovementGate game SPI
Mobility seam: may this entity move right now? Movement asks it for every player MoveInput AND every AI-driven Move, so one game rule ("the dead don't walk", stuns, roots, cutscenes…) silences both players and NPCs — server-authoritatively, whatever a client sends. Movement never learns WHY an entity is immobile; the game's gate owns that.
public sealed class EliminationMovementGate : IMovementGate
{
public bool CanMove(long entityId) => !_matchPolicy.IsEliminated(entityId);
}
// services.AddSingleton<IMovementGate, EliminationMovementGate>(); // BEFORE AddCrossplayMovement() -
bool CanMove(long entityId)Whether this entity may move right now. The default always allows.
IMovementSpeedProvider game SPI
Per-entity move-speed seam: how fast may THIS entity move right now? Movement asks it for every player MoveInput; a game supplies per-character, upgraded, or buffed/slowed speed — typically from a Stats-backed Hosting bridge — without Movement ever knowing what a "character" or an "upgrade" is. Absent (no game registers one), every entity uses the global WalkSpeed/RunSpeed — today's behavior exactly. Compose freely with IMovementGate (may it move) and IControlResolver (which entity moves): this decides HOW FAST.
-
bool TryGetSpeed(long entityId, bool runHeld, out float speed)The move speed (world units/second) for this entity, or false to use the options default.
Services you call 2
Crossplay implements these. Resolve them from DI and call them from your own systems.
IMovementService
Applies validated, server-authoritative player movement and broadcasts it to observers. The MoveInput handler calls ProcessInput; game code typically only calls Teleport. (NPCs/props move through IServerEntityMovement instead.)
-
void Impulse(long entityId, float dirX, float dirZ, float distance, float speed)Applies a server-driven positional impulse (knockback, a pull) to entityId: a smooth horizontal displacement of distance world units along (dirX, dirZ) at speed units/second, integrated over the next ticks and reconciled with the mover's client prediction through the ordinary MoveState/ack stream — NOT a Teleport snap (which discards prediction). The shared world bounds stop the slide at a wall (no push-through). A second impulse re-aims any in-flight one on the same entity (latest force wins — it never stacks into an unbounded slide). A no-op for an unknown entity, a zero/negative distance or speed, or a zero-length direction. Entity-keyed (not session-keyed) so a hit bridge can knock any entity.
-
void ProcessInput(ISession session, MoveInput input)Validates one movement intent and applies it to the entity the session controls: rejects malformed or stale-tick input, clamps the direction to a unit vector (anti-cheat), integrates a step, reconciles the mover with a MoveState, and marks the entity for the batched observer broadcast. Normally invoked by the wire handler, not game code.
-
void Teleport(ISession session, float x, float y, float z, float yaw)Authoritatively relocates the session's entity and tells the mover to hard-snap (a MoveState with AckTick = 0), so games can teleport a player without the client smearing/replaying stale prediction. A no-op if the session has no world entity.
IServerEntityMovement
Moves server-spawned entities (NPCs/props) with the full movement pipeline: position + interest via the World piece, then the same batched, budgeted, per-poll-sliced EntityMove broadcast players get — so NPCs interpolate on clients exactly like players do, and a horde of them enjoys the same pile-up protection. The game's AI decides WHERE things move; this only ships the result.
-
bool Move(long entityId, float x, float y, float z, float yaw, byte locoState, float velocityX = 0, float velocityY = 0, float velocityZ = 0)Moves a server entity to a position and broadcasts it to current observers. The locomotion byte and optional velocity feed client-side animation mapping and extrapolation, exactly as for players. Returns false for an unknown id or a player entity (players move via inputs).
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
MovementOptions
Configurable movement parameters (defaults here; never inlined in logic).
-
int FrameBufferInitialBytes { get; set; }Initial capacity (bytes) of the reused outbound frame buffer (EntityMove broadcast and per-input MoveState reconcile). Grows to the high-water mark and never shrinks.
-
float InputStepSeconds { get; set; }The time one input represents (seconds), used to integrate a step from speed each ProcessInput. Default 0.05 (a 20 Hz input cadence). Keep it equal to the client's input send interval so one client input maps to one server step of the same distance.
-
int MaxBroadcastsPerPump { get; set; }Maximum observer sends emitted per server poll from the current tick's batch (0 = emit the whole batch at tick time, the legacy behavior). Slicing the burst across the continuously running poll loop keeps every transport flush small, so a ping never waits behind a whole tick's broadcast going out — the difference between p99 ≈ burst time and p99 ≈ slice time under heavy fan-out. Entity update cadence is unchanged (the batch still forms once per tick).
-
int MaxBroadcastsPerTick { get; set; }Maximum observer EntityMove sends per broadcast tick (0 = unlimited). A mass pile-up is O(movers × observers) — 1000 co-located players demand ~20M sends/s, far beyond any socket — and without a cap the transport queues that backlog as unbounded memory. Entities past the budget simply stay dirty and broadcast on the following ticks, oldest first, always with their freshest state — so under overload every entity's effective update rate degrades smoothly instead of the server drowning. The default sustains ~400k sends/s at the default 20 Hz tick; tune to the NIC.
-
float MoveEpsilon { get; set; }Desired-direction magnitude (0..1) at or below which the entity is treated as idle, so the derived locomotion byte is LocomotionState.Idle rather than Walk/Run. Default 0.01. Raise it to widen the analog-stick dead zone that counts as "standing still".
-
int MovePoolMaxRetained { get; set; }Most idle EntityMove instances the broadcast pipeline retains for reuse (the pool that replaces one allocation per input). Sized around the expected concurrently-dirty entity count; past it, returns fall to the GC — never unbounded growth.
-
float RunSpeed { get; set; }Run speed, in world units per second — the applied speed when MoveInput.RunHeld is set. Default 6. Must match the client's prediction speed or reconciliation will constantly correct.
-
ushort RunSpeedStatId { get; set; }Per-entity RUN speed stat (0 = use the global RunSpeed). Read when the input requested the run modifier; see WalkSpeedStatId.
-
float WalkSpeed { get; set; }Walk speed, in world units per second — the applied speed when the run modifier is not held. Default 3. Set to the game's base locomotion pace; the client predicts with the same value.
-
ushort WalkSpeedStatId { get; set; }Per-entity WALK speed stat (0 = use the global WalkSpeed). When set, a Stats-backed provider reads the entity's value of this stat as its walk speed — per-character speed and movement upgrades as data. Composes with status slows (D7's MoveSpeedMultiplier). Inert without the Stats-backed provider (a Hosting bridge over IMovementSpeedProvider).
Wire messages 3
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
MoveInput Client -> server movement intent (unreliable, sent at a fixed rate). The server is authoritative; this only expresses desired direction/facing, never a final position.
MoveState Server -> the moving client: authoritative state acknowledging an input tick, so the client can reconcile its prediction (soft-correct small drift, snap on large divergence).
EntityMove Server -> observers: an entity's movement broadcast. LocoState is the discrete idle/walk/run byte (see LocomotionState); clients interpolate position and map the locomotion state onto their animator.
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.
IAnimatorParameterMapper unity SPI
SPI the game implements to map Crossplay's universal locomotion state onto its own animator. The base sends a discrete idle/walk/run byte plus velocity/yaw; the game decides which animator parameters that drives (a Speed float, an IsMoving bool, a blend tree, …).
-
void Apply(Animator animator, byte locoState, Vector3 velocity, float yaw)Applies locomotion to animator. locoState is the idle/walk/run byte (see Crossplay.Movement.Contracts.Protocol.LocomotionState); velocity and yaw (radians) allow finer blends.
IGameplayInput unity SPI
SPI the game implements to feed movement intent from any input source (new Input System, AI, replay, …). Crossplay samples it once per fixed send tick and forwards it as a MoveInput.
-
MovementIntent Sample()Returns the current desired movement for this frame.
IMovementDiagnostics unity SPI
Live movement-quality numbers for diagnostics overlays — how far behind the render timeline sits, and how wrong the last prediction was. Reading costs nothing; the values are updated as a side effect of work the movement loop already does.
-
float InterpolationDelaySecondsThe adaptive interpolation delay remote entities are rendered behind (seconds).
-
float LastCorrectionMetersDistance (metres) the last server reconciliation corrected the predicted self by — ~0 when prediction agrees with authority; spikes reveal desync/latency events.
-
int RemoteEntityCountRemote entities currently being interpolated.