Replication
Read this properly if you are shipping multiplayer. The short version: it works out of the box, the server decides everything that matters, and there are two settings worth knowing about.
What goes over the wire
Two things, and nothing else.
FEDMountState, on the rider. One replicated struct: mount actor, seat handle, previous seat,
phase, entry side, a sequence number, and the server time the transition started. Replicated to
everyone rather than only the owner, because a spectator watching somebody else get into a car has to
see the animation.
Held together as one struct so it replicates atomically. Split into separate properties, clients
observe seated in seat 2 of nothing for a frame, and every OnRep has to defend against the other
two not having landed.
FEDSeatArray, on the mountable. A fast array, so occupancy replicates one seat at a time. A plain
replicated array resends every element when any of them changes: on a twelve-seat bus that is eleven
redundant entries every time somebody sits down, to every client that can see the bus.
Everything else — which montage is playing, how far through it is, where the rider is being interpolated to — is derived locally on each machine from those two. Same inputs, same animation set, same answer.
Where a seat is, where its doors are, what plays and who is allowed are properties of a component every machine already has, because it is part of the actor. Sending any of it would be sending the actor's own construction back to itself.
The flow
CLIENT SERVER
RequestMountNearest()
evaluate locally ── seat? door? in reach? ──┐
(no) → OnMountFailed, done │
(yes) │
predict: set state, start animation │
ServerRequestMount(request) ──────────────►│
│ evaluate again, with distance tolerance
│ ReserveSeat ← the race is decided here
│ publish FEDMountState (sequence + 1)
│ start the same transition locally
◄───────────── replicated state ───────────┤
sequence matches → prediction confirmed │
sequence differs → adopt the server's │
│
◄────────── ClientRejectRequest(reason) ───┤ (only when it refused)
roll back to the stored pre-request state │
The sequence number
A uint8 bumped on every committed transition. Without it a client cannot tell the server confirmed
what I predicted from the server started a different transition that happens to end in the same
phase — and the difference decides whether the local montage keeps playing or restarts.
Why rejection needs its own RPC
When the server refuses, its copy of FEDMountState never changed, so it has nothing new to send. And
property replication compares against what it last sent, not against what the client did on its own —
so it will never correct the client. ClientRejectRequest closes that hole; the client restores the
copy of its pre-request state that it kept.
If even that RPC is lost, Prediction Timeout rolls the client back anyway. A player is never left
stuck in a phase.
Prediction
Project Settings → Predict Transitions, on by default.
On, the animation starts on the frame the key is pressed and the server's answer arrives during it. A rejection reads to the player as the door not opening. Off, every mount costs a round trip — at 120 ms that is noticeable on a control players press constantly.
Prediction never touches authority. A predicting client sets its local phase and starts its local animation; the seat is only ever assigned by the server. Two players pressing the key on the same frame both predict, both play a get-in, and exactly one keeps it.
ED.Mounting.DisablePrediction 1 forces it off at runtime. If the problem survives with prediction
off, it was never about prediction.
Distance tolerance
Server Distance Tolerance, default 0.35.
A client that was in range when it pressed the key can be out of range by the time the RPC lands, because both it and the vehicle moved. Validating on the server with the exact same number is how a system ends up rejecting perfectly honest requests on high-ping connections. The server allows 35% further — enough for a moving vehicle at a few hundred milliseconds, and still a refusal for the player boarding from across the street.
Late joiners
FEDMountState carries ServerStartTime, taken from the game state rather than from local world time.
A client joining mid-transition starts the montage at now − ServerStartTime, so it sees the rest of
the animation rather than all of it, a second late. A client joining after it finished simply sees
Seated and attaches with no animation, which is correct.
Possession
Possession changes happen on the server only. Under PossessMount the controller possesses the mount
at the attach moment and re-possesses the rider on detach; the rider component remembers which
controller it was, so a mount destroyed mid-ride cannot strand it.
Under KeepRider — the default — no possession happens at all. That removes a whole class of bugs
where a controller possesses a vehicle that is then destroyed, and it means nothing else in your game
has to learn that "the pawn" is sometimes a car.
Driving input under KeepRider
The mount is not possessed, so nothing in the engine carries a client's input to the server. The rider
component does: ApplyDriveInput applies the input to the local copy of the mount — so the wheel turns
and the lights come on before any round trip — and, on a client, also sends ServerApplyDriveInput.
It is unreliable on purpose: it is sent every frame a key is down and the next one supersedes it, so a
lost packet costs a frame of steering rather than a stuck throttle. The server re-checks that the
sender is seated, is the driver and is under KeepRider before it applies anything.
That means ApplyDriverInput runs on two machines for a client driver, and a mount has to know
which it is on. The rule: simulate where the mount is simulated, show everywhere else.
AEDMountableVehicle does this for you. The server simulates. So does the driver's own client,
ahead of the server and corrected by replicated movement — the same arrangement a possessed vehicle
has, and the only one that feels like driving rather than like watching a replicated car slide round a
corner. Every other client is a spectator: its copy is replicated movement, and feeding its Chaos would
have a local simulation fight the correction.
To make the driver's client a predicting client, the vehicle gives its ownership to the driver's
controller for as long as they drive (SetOwner in OnRiderSeated, cleared in OnRiderLeft). Under
KeepRider the car is never possessed, so without an owner it has no owning connection and the Chaos
movement component's own input RPCs from that client are dropped. IsDrivenLocally() and
SimulatesInputHere() are the two questions a vehicle subclass asks before touching physics.
One consequence of that ownership: Chaos's own input path comes alive. The owning client's movement
component sends ServerUpdateState every tick, and on the server that overwrites the raw inputs with
whatever the client's component last processed — which would silently zero a brake the rider applied
through ApplyDriverInput on a machine where nobody holds the key locally. AEDMountableVehicle
therefore remembers the last ApplyDriverInput and pushes it back into the movement component every
tick where it simulates, after the network has been received and before the component ticks. The
framework's input path is the one that counts; Chaos's is left alone.
Seat events on clients
OnRiderSeated and OnRiderLeft fire on clients as well, from the seat array's replication, with
the same arguments the server got. A car that starts its engine when a driver sits down starts it on
the passengers' machines too. Implementations that touch authority-only state should check for it.
Iris
The seat array declares Iris replication fragments and the module links IrisCore, so the framework
replicates the same way under the legacy network driver and under Iris. There is nothing to turn on.
What is deliberately not replicated
- Montage playback. Every machine plays its own from the same state.
- Doors. Driven by anim notifies inside a montage every machine is playing.
- Seat shape. Part of the actor.
- Failure reasons. They exist where the request was made and nowhere else.