Move Containers - Vaei/PredictedMovement GitHub Wiki
[!IMPORTANT] Learn about Move Containers here This is a CMC feature, not something new added by PredictedMovement
What are Move Containers?
Move Containers allow sending complex and varied data through the prediction system.
Move Containers vs. Compressed Flags
Compressed Flags pack various booleans into a single uint8
which makes them very cheap.
For a sprint ability that has an on/off state, compressed flags is the correct use-case.
But what about a sprint ability with multiple levels of sprinting? At the very least, we would want to replicate an enum or FGameplayTag that represents the level. This is where move containers become very useful.
How to use Move Containers
[!TIP] For a 1:1 replacement of Compressed Flags with Move Containers check the
sprint_containers
branch found here
Send Client Data to Server
Where we use GetCompressedFlags()
, we now use FCharacterNetworkMoveData::ClientFillNetworkMoveData()
This is where the client packs the inputs (e.g. bWantsToSprint
) for sending to the server, and there is a matching required Serialize()
function as well.
Unpack Client Data on Server
Where we use UpdateFromCompressedFlags()
, we now use UCharacterMovementComponent::ServerMove_PerformMovement()
This is where the server unpacks the inputs that was sent from the client
[!IMPORTANT]
Super::ServerMove_PerformMovement()
must occur after your code, otherwise it will complete the movement without your unpacked changes
Move Combining
[!TIP] This wasn't necessary with compressed flags because it is handled internally
Here is an explanation of how move combining works, you can read this optionally.
We combine moves for the purpose of reducing the number of moves sent to the server, especially when exceeding 60 fps (by default, see ClientNetSendMoveDeltaTime
). By combining moves, we can send fewer moves, but still have the same outcome.
If we didn't handle move combining, and then we used OnStartSprint()
to modify our Velocity
directly, it would de-sync if we exceed 60fps. This is where move combining kicks in and starts using Pending Moves instead.
When combining moves, the PendingMove
is passed into the NewMove
. Locally, before sending a Move to the Server, the AutonomousProxy
Client will already have processed the current PendingMove
(it's only pending for being sent, not processed).
Since combining will happen before processing a move, PendingMove
might end up being processed twice; once last frame, and once as part of the new combined move.
To counter that, we need to make sure to reset the state of the CMC back to the "InitialPosition" (state) it had before the PendingMove
got processed.
[!TIP] You can test this by disabling
p.NetEnableMoveCombining
Can Combine With
We can only combine moves if they will result in the same state as if both moves were processed individually, because the AutonomousProxy
Client processes them individually prior to sending them to the server.
Override UCharacterMovementComponent::CanCombineWith()
. It might look like this:
if (bWantsToSprint != SavedMove->bWantsToSprint)
{
return false;
}
Set Initial Position
Override UCharacterMovementComponent::SetInitialPosition()
and retrieve the value from our CMC to revert the saved move value back to this.
It might look like this:
bWantsToSprint = Cast<ASprintCharacter>(C)->GetSprintCharacterMovement()->bWantsToSprint;
This is a rewrite of the explanation provided by Cedric eXi
Delayed Moves
With more complex predicted movement abilities, even if we prevent combining of these moves, they can still be delayed, which results in de-sync regardless if we attempt anything time sensitive, e.g. modifying Velocity during OnStart()
. Our sprint implementation is simple and does not require this.
[!CAUTION] More work is required to investigate when and why delayed move implementation is required with certain setups and not others. This setup was required for Modifiers to not de-sync during their
OnStart()
functions. This section may change in the future!
To counter this, we need to check if our data changed over the course of the move.
And to check if it changed over the course of a move, we need to save the Start and End.
This means we need a Start and End state stored in the SavedMove, e.g. bIsSomething
, and bEndIsSomething
. We cache the state of bIsSomething
in PrepMoveFor()
, however the modifiers don't do this, they have a more complex use-case so it is not always the correct answer.
Post Update
This is where we store the End property and also check if we further need to prevent move combining.
Example:
void FSavedMove_Character_Something::PostUpdate(ACharacter* C, EPostUpdateMode PostUpdateMode)
{
if (ASomethingCharacter* SomethingCharacter = CastChecked<ASomethingCharacter>(C))
{
bEndIsSomething = SomethingCharacter->bIsSomething;
}
if (PostUpdateMode == PostUpdate_Record)
{
if (bIsSomething != bEndIsSomething)
{
bForceNoCombine = true;
}
}
Super::PostUpdate(C, PostUpdateMode);
}
Can Delay Sending Move
Here we do a simple check to see if it has changed
bool USomethingMovement::CanDelaySendingMove(const FSavedMovePtr& NewMove)
{
const TSharedPtr<FSavedMove_Character_Something>& SavedMove = StaticCastSharedPtr<FSavedMove_Character_Something>(NewMove);
if (SavedMove->bIsSomething != SavedMove->bEndIsSomething)
{
return false;
}
return Super::CanDelaySendingMove(NewMove);
}
[!NOTE] So long as you replace these 4/6 functions, the remainder of the predicted movement code can be identical to using compressed flags!
What else can Move Containers do?
A lot!
Such as having the server send the client data by using FCharacterMoveResponseDataContainer::ServerFillResponseData()
and implementing the matching Serialize()
, and the client receives this in UCharacterMovementComponent::OnClientCorrectionReceived()
You can further use UCharacterMovementComponent::ServerCheckClientError()
to compare the difference between server and client and force a net correction by returning true if it differs -- I do this for Snares which can be applied externally! Then you can handle the correction on the client in UCharacterMovementComponent::ServerMoveHandleClientError()
[!CAUTION] Do not use
ClientHandleMoveResponse()
in lieu ofOnClientCorrectionReceived()
, it appears more correct because it has a MoveResponse passed in, and it doesn't require the ugly versioning pre-compiler macro, but it occurs at the wrong point in the execution, causing IsCorrection() to fail and introducing de-sync.
[!NOTE] In the future the modifier branch will demonstrate the use of Snares and other modifiers :)