movement

Movement

Server-authoritative movement: input, 20Hz broadcast, prediction/reconciliation, interpolation.

Category Spatial Seams 4 Services 1 Options 1 Wire ids 4 Unity types 1
Server
services.AddCrossplayMovement();
Unity package
com.crossplay.movement
Depends on
world

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.

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.

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

ILocomotionResolver game SPI

Locomotion seam: which state id is THIS entity in right now? Movement asks it for every player MoveInput and puts the answer on the wire as EntityMove.LocoState, where the game's IAnimatorParameterMapper gives it meaning. Absent (no game registers one), Movement derives Idle/Walk/Run from the desired direction and the run modifier — today's behavior exactly. This is what makes the locomotion byte game-defined rather than something the base decides. The base cannot know whether a character is grinding a rail, swimming, gliding or shifting gear — only the game can — so the base stops guessing and asks. A game adds a state by allocating an id at or above FirstGameDefined and returning it here; no server change, no edit to any Movement file. Compose freely with the other movement seams: IMovementGate (may it move), IMovementSpeedProvider (how fast), IControlResolver (which entity moves) — this one decides WHAT IT LOOKS LIKE IT IS DOING.

  • byte Resolve(long entityId, MoveInput input, float moveMagnitude)

    The locomotion state id to broadcast for this entity.

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.

Example
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 1

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.

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

  • bool AllowMoveTo { get; set; }

    Whether the server accepts MoveToRequest and walks the entity there itself (GAP-08 click-to-move). Off by default: the message id simply stays unhandled and is dropped, so nothing changes for a game whose players steer with a stick or a keyboard. Needs a navigation service to route with — without one the request is refused rather than degrading to the straight line the client could already walk on its own.

  • bool DeltaBroadcast { get; set; }

    Whether the observer broadcast sends CHANGED-FIELD deltas instead of a full state every tick. Default false.

  • bool DropExcessInputs { get; set; }

    What happens when an entity's input queue is full. true (default): the OLDEST queued input is discarded to make room, so a flooding client's position simply tracks its newest intent and it gains no ground. false: the queue is held intact and the NEWEST arrival is refused, so a genuine lag-burst still executes every input it managed to buffer, in order.

  • int EffectiveMaxQueuedInputs { get; }

    The resolved queue cap: MaxQueuedInputs when set, else InputBufferSeconds / InputStepSeconds, and never below one entry.

  • 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 InputBufferSeconds { get; set; }

    How many seconds of inputs an entity's arrival queue holds. Default 0.5. Sizes MaxQueuedInputs when that is left at its derive-from-this default.

  • double InputRateHeadroom { get; set; }

    Multiplier over the configured input rate that the per-message flood cap allows, so jitter and legitimate bursts are never mistaken for abuse. Default 2 (a client may momentarily send at twice its cadence). Values below 1 are ignored — a cap under the correct send rate would throttle a well-behaved client.

  • 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 KeyframeIntervalTicks { get; set; }

    How many broadcast ticks may pass before an entity's next update is sent as a FULL state rather than a delta. Default 20 — one second at the default tick rate.

  • 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 MaxInputCatchUpSeconds { get; set; }

    Seconds of unused integration time an entity may bank, so a client whose inputs arrive in a burst after a hitch can still execute them. Default 0.25.

  • int MaxInputsPerTick { get; set; }

    How many queued inputs one entity may have INTEGRATED per broadcast tick. Default 1 — one client input maps to one server step, which is what the prediction model on the other end already assumes.

  • int MaxQueuedInputs { get; set; }

    Hard cap on an entity's queued inputs. 0 (default) derives it as InputBufferSeconds / InputStepSeconds — see EffectiveMaxQueuedInputs. The queue is preallocated at this size per moving entity, so it is a memory knob as well as a latency one.

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

  • bool ReportQueueDepth { get; set; }

    Whether MoveState reports the sender's remaining input-queue depth, so the client can steer its own send cadence toward the buffer depth the server actually wants. Default true; costs one byte on a message that already goes to exactly one recipient.

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

  • float WaypointArrivalRadius { get; set; }

    How close counts as reaching a waypoint, in world units. Too small and the entity oscillates around a corner it can never land exactly on; too large and it cuts corners into walls.

Wire messages 4

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

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

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

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

403 MoveToRequest

Client -> server "walk to here" (GAP-08). The server paths to the destination and then drives the ordinary per-tick direction pipeline, so it remains authoritative and the client keeps predicting against the same MoveInput-shaped stream it always did. Why this exists. The whole movement wire was direction-intent only, and the client-side ClickToMoveScheme / ArpgInputScheme emit a STRAIGHT LINE toward the click. Clicking behind a wall therefore walked the avatar into it and let the bounds gate slide along — correct, but not pathing. INavigationService could already find a route (8-direction A* over the same grid-mask bounds) and explicitly "never moves anything": it was wired only to AI. For a game whose primary control scheme is click-to-move, that is a functional gap rather than polish. A destination is a REQUEST, not a position: the server decides whether a route exists and how the entity travels it, so this can no more teleport a player than MoveInput can.

Unity seams you implement 1

The client half's sockets. Bind your implementation in the client context and Crossplay's client calls it — this is where your models, animators, UI and controls plug in.

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 InterpolationDelaySeconds

    The adaptive interpolation delay remote entities are rendered behind (seconds).

  • float LastCorrectionMeters

    Distance (metres) the last server reconciliation corrected the predicted self by — ~0 when prediction agrees with authority; spikes reveal desync/latency events. Never judge this number without the speed beside it (GAP-37a): a healthy one-interval reconciliation scales linearly with how fast the entity moves, so statistics over raw metres are partly statistics about speed. Use LastCorrectionSeconds for the speed-invariant form.

  • float LastCorrectionSeconds

    The last correction expressed as TIME: LastCorrectionMeters divided by the acked (authoritative) speed at that reconciliation — how much travel time the server rewound, which means the same thing at every speed (GAP-37a). 0 when the acked velocity was zero: a nonzero correction at zero speed cannot be latency and is its own, different signal (Movement also hard-snaps on a tick-0 teleport, which reports here the same way). How to read it: divide by StateIntervalSeconds — ≈1 interval is prediction's irreducible floor; ≥3 intervals is a genuine rewind worth investigating.

  • float StateIntervalSeconds

    One reconcile interval, seconds — the floor LastCorrectionSeconds is compared against. Movement acks per accepted input, so this is 1 / MovementOptions.SendRateHz (the client's own input cadence), exposed so no consumer has to know that detail.

  • int RemoteEntityCount

    Remote entities currently being interpolated.