iTranslated by AI
[UE5] The Dark Side of Implementing Crouch with Mover: Anatomy of Source Code and the 1-Frame Trap
📖 Introduction
In my previous article, I explained how to break free from the constraints of a bloated ACharacter and implement "walking" and "jumping" using a lightweight custom Pawn with the next-generation movement system, the "Mover plugin."
With a loosely coupled, beautiful architecture now in place, I was eager to "quickly implement 'Crouching,' an essential action for any action game!" ... However, what awaited me there was a profound darkness lurking deep within the experimental Mover plugin.
This article is a record of me facing "the ideal of design versus the technical challenges stemming from immature features" when implementing crouching in Mover.
The article is divided into two major parts:
-
First Half: Implementation & Design
I will explain the opt-in design (custom Input Producers and camera integration) for beautifully implementing "crouching" in Mover by extending the architecture from the previous article. -
Second Half: Verification & Issues
I will document my journey of investigating the "mysterious jitter phenomenon" I encountered immediately after implementation, tracking down its true nature through an anatomical study of the official sample (GASP) and tracing the engine code.
Using Experimental features sometimes means going head-to-head with official bugs and unfinished specifications. I hope this article serves as some form of hint for those who are currently struggling with Mover's behavior or for engineers confronting cutting-edge features!
🏃 Implementing Crouching: Delivering Input to the Simulation
Now that the architectural foundation is set, let's implement the process of feeding "crouch" input into the Mover simulation. To add a new action in the Mover plugin, you must follow these four steps:
- Define a custom Input Struct
- Extend the Input Producer to generate and send input
- Extend the Mover Component to process the received input
- Register
StanceSettingsto Mover (※ The Trap)
Let's take a look in order.
1. Defining a Custom Input Struct
Mover comes with FCharacterDefaultInputs standard, which handles basic inputs like movement direction and jumping. However, game-specific actions like "Crouch" or "Sprint" are not defined there. Therefore, if you want to add a unique action, you need to create a new struct that inherits from FMoverDataStructBase and have the system recognize it.
Also, since Mover supports powerful network synchronization and rollback (rewind) by default, we will also write the packing process (serialization) to save communication bandwidth.
USTRUCT(BlueprintType)
struct GAMECOREFRAMEWORK_API FGCFHumanoidInputs : public FMoverDataStructBase
{
GENERATED_USTRUCT_BODY()
// Flag for crouch request
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "GCF|Mover")
bool bWantsToCrouch = false;
// TODO: I plan to add inputs like Sprint here in the future
// bool bWantsToSprint = false;
// --- Implementation of FMoverDataStructBase ---
// Determines if there is a discrepancy between server and client states (if rollback is needed)
virtual bool ShouldReconcile(const FMoverDataStructBase& AuthorityState) const override
{
const FGCFHumanoidInputs& TypedAuthority = static_cast<const FGCFHumanoidInputs&>(AuthorityState);
return (TypedAuthority.bWantsToCrouch != bWantsToCrouch);
}
// Interpolation (Smoothing) process
virtual void Interpolate(const FMoverDataStructBase& From, const FMoverDataStructBase& To, float LerpFactor) override
{
// Since bool values cannot be Lerped, use the From (past) value if LerpFactor is < 0.5, and To (future) if >= 0.5
const FGCFHumanoidInputs& SourceInputs = static_cast<const FGCFHumanoidInputs&>((LerpFactor < 0.5f) ? From : To);
bWantsToCrouch = SourceInputs.bWantsToCrouch;
}
// Synthesis of states
virtual void Merge(const FMoverDataStructBase& From) override
{
const FGCFHumanoidInputs& TypedFrom = static_cast<const FGCFHumanoidInputs&>(From);
bWantsToCrouch |= TypedFrom.bWantsToCrouch;
}
virtual FMoverDataStructBase* Clone() const override
{
return new FGCFHumanoidInputs(*this);
}
// Serialization during network sync
virtual bool NetSerialize(FArchive& Ar, UPackageMap* Map, bool& bOutSuccess) override
{
Super::NetSerialize(Ar, Map, bOutSuccess);
// Sending a bool flag as-is consumes extra capacity, so pack (compress) the flag as 1-bit data (bandwidth optimization)
Ar.SerializeBits(&bWantsToCrouch, 1);
bOutSuccess = true;
return true;
}
virtual UScriptStruct* GetScriptStruct() const override { return StaticStruct(); }
// Conversion to string for debug display
virtual void ToString(FAnsiStringBuilderBase& Out) const override
{
Super::ToString(Out);
Out.Appendf("bWantsToCrouch: %i\n", bWantsToCrouch);
}
virtual void AddReferencedObjects(FReferenceCollector& Collector) override { Super::AddReferencedObjects(Collector); }
};
Of particular importance here is the NetSerialize function. Because sending a bool variable as-is to the network consumes unnecessary data capacity, I used Ar.SerializeBits(&bWantsToCrouch, 1); to pack (compress) the flag as 1-bit data.
2. Defining a Humanoid InputProducer
Next, we extend the InputProducer that generates the input struct and registers it to the Mover command context. Here, we retrieve the player's input state (whether the crouch button is held) via an interface implemented by the Pawn, set it into the FGCFHumanoidInputs created earlier, and add it to the collection.
void UGCFHumanoidInputProducer::ProduceInput_Implementation(int32 SimTime, FMoverInputCmdContext& InputCmdResult)
{
// Call the base class to process standard movement direction, jump input, etc., first
Super::ProduceInput_Implementation(SimTime, InputCmdResult);
APawn* OwnerPawn = Cast<APawn>(GetOwner());
if (OwnerPawn && OwnerPawn->IsLocallyControlled()) {
// Check if it implements the input interface
if (OwnerPawn->Implements<UGCFLocomotionInputProvider>()) {
// --- Handling crouch input ---
// Find or add the unique struct (FGCFHumanoidInputs) from Mover's input collection
FGCFHumanoidInputs& HumanoidInputs = InputCmdResult.InputCollection.FindOrAddMutableDataByType<FGCFHumanoidInputs>();
// Retrieve "is crouch button pressed" from Pawn via interface and set it to the struct
HumanoidInputs.bWantsToCrouch = IGCFLocomotionInputProvider::Execute_GetWantsToCrouch(OwnerPawn);
}
}
}
By calling FindOrAddMutableDataByType, we can safely secure and add our custom input struct within the collection.
3. Handling Input in MoverComponent
Finally, we receive the input sent from the Input Producer in the MoverComponent just before the simulation runs. We override OnMoverPreSimulationTick, extract FGCFHumanoidInputs from the input collection, and evaluate the flag.
void UGCFCharacterMoverComponent::OnMoverPreSimulationTick(const FMoverTimeStep& TimeStep, const FMoverInputCmdContext& InputCmd)
{
// Check if the unique FGCFHumanoidInputs exists in the input collection sent from Producer
if (const FGCFHumanoidInputs* HumanoidInputs = InputCmd.InputCollection.FindDataByType<FGCFHumanoidInputs>()) {
// Determine if the character is currently airborne (falling) using a tag
const bool bIsAirborne = HasGameplayTag(Mover_IsFalling, true);
if (bIsAirborne) {
// If airborne, forcibly disable crouch input as a safety mechanism
bWantsToCrouch = false;
} else {
// If on the ground, apply the sent input flag directly to the MoverComponent property
bWantsToCrouch = HumanoidInputs->bWantsToCrouch;
}
}
// Proceed to the base class Tick (actual simulation)
Super::OnMoverPreSimulationTick(TimeStep, InputCmd);
}
Here, we don't just receive the flag; we also insert safety logic that depends on the state, such as "disabling crouch input when airborne (when the Mover_IsFalling tag is applied)."
4. Registering StanceSettings to Mover (※ The Trap)
With the implementation so far, we have successfully communicated the input (intent) of "wanting to crouch" to Mover. However, without specifying concrete parameters like "how much to shrink the capsule when crouching?" or "what is the move speed while crouching?", the character cannot physically crouch.
In the Mover plugin, settings related to posture (Stance) are managed by a data class called UStanceSettings, and the internal FStanceModifier reads these settings to execute the actual logic for shrinking the capsule and limiting speed.
There is a massive trap here that everyone who starts using Mover will inevitably fall into. In fact, StanceSettings are not configured at all in the default WalkingMode.
Therefore, if you want to perform a crouch during WalkingMode like in this case, you must manually add StanceSettings to the SharedSettingsClasses array.

※If you forget this, the character will never crouch even if the input is processed correctly.
Once added, the StanceSettings item will appear under the SharedSettings section of the Mover component. You can adjust parameters such as "Crouch Half Height" as needed from there.

🎬 Integration with ABP: Getting States with GameplayTags
Since I explained the foundation of the custom AnimInstance (GCFAvatarAnimInstance) and thread-safe implementation methods in the previous article, I will focus here only on the key points of the "crouch" integration.
A major strength of the Mover plugin is that it allows you to manage the character's current state (e.g., whether they are crouching or airborne) as native GameplayTags in a centralized manner. Therefore, you don't need to perform cumbersome casts or complex flag calculations on the AnimInstance side; you can complete the process simply by checking for the presence of tags within the game thread update logic.
void UGCFAvatarAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
Super::NativeUpdateAnimation(DeltaSeconds);
if (!OwningPawn) {
return;
}
// Get Mover (including handling initialization delay)
if (!MoverComponent) {
MoverComponent = OwningPawn->FindComponentByClass<UMoverComponent>();
}
// ------------------------------------------------------------------------
// [Game Thread]
// Only perform safe data collection from actors and components
// ------------------------------------------------------------------------
if (MoverComponent) {
Velocity = MoverComponent->GetVelocity();
CachedActorRotation = OwningPawn->GetActorRotation();
bHasAcceleration = HasAcceleration();
CurrentMovementMode = MoverComponent->GetMovementModeName();
// Determine falling state using native GameplayTag
bIsFalling = MoverComponent->HasGameplayTag(Mover_IsFalling, true);
// ★ Determine crouching state using native GameplayTag
bIsCrouched = MoverComponent->HasGameplayTag(Mover_IsCrouching, true);
}
}
All that remains is to read this updated bIsCrouched variable in the animation blueprint (ABP) state machine and connect it to the transition rules between the "standing pose" and "crouching pose," and the animation integration is complete.
🎥 Changing Camera Processing: Lowering the Pivot Point (EyeHeight) when Crouching
It's not finished just because the animation switches. Because the capsule shrinks when crouching, you must properly lower the camera's pivot point (EyeHeight) as well. If you don't, the camera will be left in a high position even though the character is crouching.
The Trap of "Tight Coupling" in Lyra's Implementation
How should I implement the pivot height calculation? Looking at the camera processing (ULyraCameraMode) in Epic's official sample game, "Lyra Starter Game," the implementation looked like this:
// Conventional Lyra camera processing (excerpt)
FVector ULyraCameraMode::GetPivotLocation() const
{
// (...)
if (const APawn* TargetPawn = Cast<APawn>(TargetActor))
{
// ★ Forces a cast to ACharacter and calculates height compensation during crouch on the camera side
if (const ACharacter* TargetCharacter = Cast<ACharacter>(TargetPawn))
{
const ACharacter* TargetCharacterCDO = TargetCharacter->GetClass()->GetDefaultObject<ACharacter>();
const UCapsuleComponent* CapsuleComp = TargetCharacter->GetCapsuleComponent();
const UCapsuleComponent* CapsuleCompCDO = TargetCharacterCDO->GetCapsuleComponent();
const float DefaultHalfHeight = CapsuleCompCDO->GetUnscaledCapsuleHalfHeight();
const float ActualHalfHeight = CapsuleComp->GetUnscaledCapsuleHalfHeight();
const float HeightAdjustment = (DefaultHalfHeight - ActualHalfHeight) + TargetCharacterCDO->BaseEyeHeight;
return TargetCharacter->GetActorLocation() + (FVector::UpVector * HeightAdjustment);
}
return TargetPawn->GetPawnViewLocation();
}
// ...
}
Do you see? Lyra's implementation is tightly coupled to ACharacter and its CDO (Class Default Object), and it performs the capsule height difference calculation—something the Pawn itself should know—on the Camera Component side. Naturally, this will not work at all with the lightweight custom Pawns we are building that do not inherit from ACharacter.
Delegating Calculation Responsibility to the Pawn (GCF Approach)
Therefore, in this framework (GCF), I changed the design to be more object-oriented: "Delegate the responsibility for calculating pivot height entirely from the camera to the Pawn."
First, make the camera side clean so it doesn't care what class the target is; it simply calls the Pawn's virtual function GetPawnViewLocation().
FVector UGCFCameraMode::GetPivotLocation() const
{
const AActor* TargetActor = GetTargetActor();
check(TargetActor);
// If the target is a Pawn, leave all pivot calculations to the Pawn itself
if (const APawn* TargetPawn = Cast<APawn>(TargetActor)) {
return TargetPawn->GetPawnViewLocation();
}
return TargetActor->GetActorLocation();
}
Then, the actual pivot calculation is appropriately overridden and processed in the base Pawn (AGCFPawn) and the humanoid-specific Pawn (AGCFHumanoid).
// --- Implementation of AGCFPawn (Base Class) ---
FVector AGCFPawn::GetPawnViewLocation() const
{
// Add the configured base eye height to the actor's world location and return it
return GetActorLocation() + (FVector::UpVector * BaseEyeHeight);
}
// --- Implementation of AGCFHumanoid (Humanoid class with Mover) ---
FVector AGCFHumanoid::GetPawnViewLocation() const
{
float CrouchOffset = 0.0f;
// Check if the character is in a crouching state (tag is applied)
if (MoverComponent && MoverComponent->HasGameplayTag(Mover_IsCrouching, true)) {
// Get the capsule height while crouching from Mover's shared settings (StanceSettings)
if (const UStanceSettings* StanceSettings = MoverComponent->FindSharedSettings<UStanceSettings>()) {
if (UCapsuleComponent* CapsuleComp = GetCapsuleComponent()) {
// Calculate the difference between default height and crouching height to determine the offset amount to lower by
const float DefaultHalfHeight = CapsuleComp->GetUnscaledCapsuleHalfHeight();
CrouchOffset = DefaultHalfHeight - StanceSettings->CrouchHalfHeight;
}
}
}
// Lower the pivot point by the crouch offset from the base pivot height calculated by the parent class
return Super::GetPawnViewLocation() - (FVector::UpVector * CrouchOffset);
}
With this design, the crouching height set in Mover's StanceSettings (data-driven) is automatically reflected in the camera's pivot drop. Implementation of input, animation, and camera linkage is now complete! …That should have been it, and I should have been able to crouch comfortably on the device. Little did I know that I was at the entrance to a bottomless swamp.
🚨 Issue Encountered: Field Verification and Mysterious Jitter (Ghosting)
I built the input bucket brigade and completed the integration with the animation (ABP). In theory, this should allow for perfect crouching. With high expectations, I pressed the Play button and triggered the crouch key (C).
The character transitioned smoothly from a standing pose to a crouching pose. Up to this point, it was as expected. However, looking closely at the movement, there was something strange.
There was an unnatural vibration (jitter) like a ghost image on the character's mesh at the moment they crouched.
Actual behavior of the jitter phenomenon (Click to open GIF)

If you play the GIF, you can see that the character's mesh momentarily jitters at the moment the state switches.
At first, I thought, "It's an Experimental feature; maybe I'm missing some mesh offset settings for crouching?" I took it lightly. As you can see in the GIF, the camera itself is interpolated and lowers smoothly. Despite this, only the character's mesh is jittering.
I'm embarrassed to admit it, but at this point, I had no idea what was happening inside the engine or why this phenomenon was occurring.
To find the source of this mysterious ghosting phenomenon, I decided to trace the source code of the traditional CharacterMovementComponent (CMC), which achieves a perfectly normal crouching behavior, and solve the mystery.
🔍 Clues to the Cause: How Did the Old Standard (CMC) Crouch?
What is physically happening behind the scenes of a "crouch" action? It involves more than just changing animations; it runs a process to shrink the height of the "capsule collision," which is the character's hit detection.
Since the capsule is designed to shrink relative to its center, simply halving the height would cause the character's feet to float off the ground. How did the CharacterMovementComponent (CMC), the long-standing standard for Unreal Engine movement components, handle this?
Peeking into the C++ source code of the official UCharacterMovementComponent::Crouch function, I found that it performs a very gritty, yet perfectly calculated "three-step" process.
// Actual CMC source code (extracted/simplified)
// 1. Shrink the capsule to the crouching size
CharacterOwner->GetCapsuleComponent()->SetCapsuleSize(OldUnscaledRadius, ClampedCrouchedHalfHeight);
// 2. Move (teleport) the entire actor downward to match the feet to the ground
UpdatedComponent->MoveComponent(ScaledHalfHeightAdjust * GetGravityDirection(), ...);
// 3. [Important] Reverse-shift the relative coordinates of the mesh to offset
// so that the camera and mesh don't drop suddenly
if (ClientData)
{
ClientData->MeshTranslationOffset -= MeshAdjust * -GetGravityDirection();
}
CMC's crouching process relied on a brute-force transform operation: "1. Shrink the capsule -> 2. Lower the whole actor -> 3. Shift only the mesh back up."
And most importantly, the fact remains that the CMC executed these three steps "within a single function, perfectly synchronized without even a frame of lag." That is why traditional characters were able to crouch cleanly without their feet leaving the ground or showing any ghosting or jitter.
"I see, so I just need to shift the mesh to compensate the moment the capsule shrinks!" I completely understood the principle.
Having understood the gritty but perfect synchronization of the CMC, what is the state of the current Mover implementation? Perhaps the Mover's crouching process only performs the capsule shrinkage, and the visual correction process of "shifting the mesh to compensate" is missing entirely?
Before diving into the depths of the source code, I decided to test this hypothesis in the live project first.
🛠️ Hypothesis and Verification: Can it be fixed by adding it yourself?
If there is a missing process, adding it myself should eliminate the ghosting (jitter). I immediately checked what was happening in the current implementation and attempted a brute-force fix.
-
Is the capsule actually shrinking?
First, I confirmed if the physical capsule is correctly shrinking when the crouch input is received. I typedshow collisionin the console command to visualize the collision in-game and observed the behavior. -
Try adding the mesh offset compensation manually
If the capsule is shrinking, I will force-insert the process of "shifting the mesh in the opposite direction by the amount the capsule shrank," following the CMC implementation. Specifically, I subscribed to the delegate that fires when the stance changes and added a process to apply a relative offset to the Z-coordinate of the character mesh at that timing.
"With this, it should be able to crouch cleanly with its feet stuck to the ground, just like the CMC!" I enthusiastically pressed the play button, but... the result was a crushing defeat.

The image shows the frame transition from the start of the crouch without the offset process applied. While it can be confirmed that the capsule collision itself is correctly halved as expected, it can be seen that the character is floating in the air at the start of the crouch.
As a result of the check, the capsule itself was correctly shrinking by half at the same time as the input. However, observing the transition frame by frame, I realized that a phenomenon was occurring where "the capsule is shrinking, but the process of lowering the entire actor to the ground (teleport) is not catching up, causing the character to float in the air for an instant."
"If the teleport process is delayed, wouldn't the timing be off even if I just add the mesh offset myself?" As expected, while the offset process I added manually worked cleanly by chance sometimes, the execution timing fluctuated randomly every time I played—the character would either float high into the air or sink deep into the ground—so it was completely unstable.
Why does the timing fluctuate this much?
As I found out later, the root cause of this phenomenon lay in the "Tick rate mismatch" occurring between the rendering frame (the GameThread's variable tick) and the simulation of the Network Prediction Plugin (NPP), which is the foundation of Mover (fixed tick).
Because the simulation and rendering times are not perfectly synchronized, even if I try to force an offset on the GameThread, the timing at which it is applied varies frame by frame.
In other words, this was not just a matter of "the offset calculation formula being wrong" or "the process being insufficient."
It suggested a desperate fact that the "timing at which the capsule shrinks" and the "timing at which the mesh or actor coordinates are updated" are completely asynchronous inside the engine, causing them to pass each other by at the frame level.
"Simply adding a process will never fix this. Something is fundamentally occurring where the execution order is misaligned deep inside the engine..."
However, a question crossed my mind here.
"Actually, there was a phenomenon where the character was shaking jittery even when moving normally... Could it be that my initial settings or the implementation of Mover itself is fundamentally wrong?"
Suspecting my own mistake, before diving into the engine's source code, I decided to go and check the implementation of Epic Games' official sample, "Game Animation Sample Project (GASP)."
🕵️ Deviation and Discovery: Why is the Official Sample (GASP) Smooth?
Since the Mover plugin is also adopted in GASP, I should be able to find a clue to a solution.
1. Solving Another Issue: The Trap of Tick Policy
While investigating the GASP project settings, I noticed a decisive difference.
According to the official documentation, the Preferred Ticking Policy for Mover is recommended to be Fixed, which is robust for multiplayer synchronization. However, in GASP, this was set to Independent (variable).
When I tried changing my project to Independent as a test, the "jittery shaking while moving" disappeared instantly! It seems that the discrepancy between the rendering frame rate and the fixed tick time of the NPP (Network Prediction Plugin) was causing the stuttering during movement.
"Alright, this should fix the crouching ghosting too!"
...However, the result was heartless. Although movement became smooth, the intense jitter (ghosting) during crouching remained completely unchanged.
This confirmed that "movement jitter" and "crouch jitter" are completely different problems occurring inside the engine.
2. The Mystery of GASP: Why Can the Official Sample Crouch Smoothly?
Here I hit a major puzzle.
Even when I operate the GASP character and try crouching, the unnatural jitter seen in my project does not occur at all.
"GASP must be applying some special offset compensation on the animation side (ABP)!"
I searched the Animation Blueprint of GASP with bloodshot eyes, believing that, but I couldn't find any such process. I fell into a state of total confusion.
While half-resigned, I attached an ABP debugger to the GASP character during game execution and was looking at the animation preview screen when it happened.
"...Huh? Is the character in the preview screen not shaking jittery in exactly the same way as my project?"
3. The Uncovered Truth: A "Cover" Called IK
It is smooth on the game screen, but jitter occurs on the ABP debug preview. There is only one meaning to this fact. "Some post-processing is forcibly correcting the jitter at the final stage of runtime (execution)."
Powerful post-processing that grounds the feet... could it be IK (Inverse Kinematics)?
I hurriedly disabled the "Control Rig" nodes applied in GASP and pressed the play button again.
GAKU!!!
【⚠️ Warning: Screen Shake】 Actual behavior of GASP with IK removed (Click to open)

Behavior when crouching with GASP's Control Rig (Foot IK) disabled. You can see that because the foot compensation is gone, the camera (the entire screen) is shaking violently up and down, pulled by the character's jitter.
Believe it or not, even GASP, which crouched so smoothly, contained the exact same jitter problem as my project!
Because the GASP camera is set to perfectly follow the character, that hidden defect bared its fangs not in the form of "character mesh ghosting" like in my project, but as intense camera jitter where "the character (the whole screen) shakes violently up and down" being pulled by the character's shaking.
💡 Conclusion: Even the Official Sample Couldn't Fix It
At this moment, all the dots connected.
My implementation wasn't weird. There is a fatal flaw in the Mover plugin itself where "the frame inevitably misaligns and jitters when crouching," and Epic's official sample (GASP) was simply "visually deceiving (hiding)" it with the powerful foot-alignment function of IK.
If you remove IK, even the official sample breaks down. In other words, this is not a problem that can be solved at the level of Blueprints or settings.
"Fundamental processing order is going wrong deep inside the engine."
Having gained this conviction, I decided to dive into the abyss of the deep C++ source code (core logic) of the Mover plugin to expose the identity of this 1-frame discrepancy.
💻 To the Abyss: The "1-Frame Trap" Told by Source Code
Having stripped away the cover of IK and confirmed that even the official sample breaks down, I dove into the C++ source code of the Mover plugin without hesitation. The answer must surely lie within the engine's core logic, invisible from Blueprints.
1. What was FStanceModifier doing?
When I peeked into the implementation of FStanceModifier::AdjustCapsule, the Mover extension that manages crouching, I found the same "3 steps" described as in the senior CMC I had checked earlier.
// Actual Mover source code (excerpt/simplified)
// 1. Shrink the capsule immediately
CapsuleComponent->SetCapsuleSize(...);
// 2. Queue the teleportation
MoverComp->QueueInstantMovementEffect(TeleportEffect);
// 3. Shift the relative coordinate of the mesh
MoverComp->SetBaseVisualComponentTransform(MoverVisualComponentOffset);
"What? It's processing synchronously, just like CMC. Then why does the timing misalign and jitter?" As I stared at the code, the contents of QueueInstantMovementEffect (teleport scheduling) in step 2 jumped out at me.
This was the root of everything.
2. The Identity of the Trap: The IsInGameThread() Branch Bug
I implemented it assuming it was a function that "instantly applies a movement effect (teleport)," but when I opened the implementation, I found a terrifying conditional branch.
// UMoverComponent::QueueInstantMovementEffect implementation
void UMoverComponent::QueueInstantMovementEffect(const FScheduledInstantMovementEffect& InstantMovementEffect)
{
// TODO Move QueueInstantMovementEffect to UMoverSimulation and implement differently in sync or async mode
if (IsInGameThread())
{
// Enters the MoverComponent (outer) queue
QueuedInstantMovementEffects.Add(InstantMovementEffect);
}
else
{
// Enters the FSM (inner) simulation queue
ModeFSM->QueueInstantMovementEffect_Internal(InstantMovementEffect);
}
}
Mover's simulation Finite State Machine (FSM) is designed to check its "internal queue" at the end of a Tick to execute teleports. However, due to the IsInGameThread() branch mentioned above, a frightening "mismatch" was occurring.
Currently, the Mover simulation (Fixed Tick of the Network Prediction Plugin) is forced to run synchronously on the GameThread.
Because of this, despite requesting a teleport from within the simulation (specifically StanceModifier), it is judged as IsInGameThread() == true.
The teleport process, which should normally enter the "inner queue" and execute immediately, was mistaken as an "external request" and discarded as a "lost child" into the outer queue.
3. Why does this branch exist? (The Truth Told by TODO Comments)
Why did Epic go to the trouble of adding this thread check? The answer lies in the // TODO ... implement differently in sync or async mode comment left at the top of the function and the massive ambition of "Async Physics" that UE5 aims for.
Epic envisions running the Mover simulation on a completely separate thread (the Async Physics thread) in the future. Therefore, they likely wanted to branch the logic: "If called from the GameThread, it's an external event, so hold it (send to the outer queue)"; "If called from a separate thread, it's an internal simulation process, so execute immediately (send to the inner queue)."
However, we are currently in a transition period where the simulation runs on the GameThread. This contradiction between the ideal architectural design and the current operating environment created this fatal bug.
4. The Timeline of Tragedy (Full Explanation)
Due to this "lost queue bug," the following tragedy was being played out behind the jitter I witnessed on screen:
- [Current Frame] The capsule shrinks. The mesh is shifted upward.
- [Mismatch] The teleportation should have executed, but because it became a "lost child" in the outer queue, it was not detected by the FSM, and the teleport process was skipped, ending the simulation.
- [Rendering] An "invalid state where the character is floating," with the capsule shrunk and only the mesh shifted up, is rendered to the screen.
- [Next Frame] At the beginning of the next Tick, the queue that had been lost externally is finally collected by the FSM, and the teleport triggers with a 1-frame delay. The character is abruptly slammed into the ground.
A "1-frame lag" was forcibly created inside the engine due to a bug between the "capsule shrinking/mesh movement" and the "actor teleportation."
With the execution timing misaligned frame-by-frame, it is physically impossible to erase the jitter, no matter how hard I try to offset the mesh coordinates by brute force. The mystery ghosting phenomenon I struggled with for hours was simply "the self-contradiction of architecture due to the transition period of the Experimental Mover."
📝 Summary of Results: Conclusion on Implementing Crouch in Mover
Here is the conclusion drawn from our long validation process and engine code dissection.
-
Root cause of crouching jitter (ghosting)
In the Experimental Mover (StanceModifier), the capsule shrinking process and the teleportation process are not synchronized within a single frame, leading to an architectural bug (a hole in the specifications) where the teleport is delayed to the next frame. -
Why the delay occurs (The
IsInGameThreadtrap)
The queue branching logic, intended for the future transition to "Async Physics," conflicts with the current "synchronous simulation running on the GameThread," causing teleport reservations to get lost. -
The truth about the official sample (GASP)
Even the official samples have not fundamentally solved this issue and are merely using the strong grounding correction of Foot IK (Inverse Kinematics) to forcibly hide the visual stuttering. -
Relationship with Tick Policy
While small jitters during movement can be fixed by settingPreferred Ticking PolicytoIndependent(variable), the jitter during crouching is a logical 1-frame delay and cannot be solved by changing settings or brute-force Blueprint hacks (offset compensation).
🏳️ Epilogue: Strategic Withdrawal and Introduction to the Framework (GCF)
Now that the cause is 100% fully explained, if this were my own personal game project, I could have chosen the path of directly rewriting the C++ source code (the Mover plugin itself) and performing a "dark evolution."
However, I chose to "wait for official updates (strategic withdrawal)."
This is because "GCF," which I am developing, is an OSS framework in plugin form that anyone can incorporate into their projects. If I were to make engine-level code modifications (custom engine builds) a prerequisite for introduction, the framework's greatest strengths—portability and ease of use—would be completely lost.
Furthermore, implementing my own workarounds (avoidance code) would certainly become technical debt in future updates, as this involves the core of an Experimental feature. I concluded that the wisest decision is to wait for the day when the next-generation features, "Async Physics" and "Network Prediction," are fully integrated into Mover and Epic's talented engineers provide perfect Smoothing mechanisms.
As the TODO comments left in the source code suggest, it is highly likely that Epic is also aware of this structural issue, and I have already reported these verification results to them as a bug report just in case.
This time, I started with the implementation of a seemingly simple action called "crouching" and eventually ended up in the transition period of Mover's deep thread architecture—a very dense journey of verification.
"When it doesn't work, suspect your own implementation. But when it's still weird, suspect the engine code."
It was a great experience that reminded me how important this attitude is when dealing with Experimental features. While the current conclusion is to wait for a fundamental solution via official updates, the Mover philosophy itself is undoubtedly a wonderful next-generation standard.
Finally, the implementation code introduced in this article is an excerpt/simplification of parts of the OSS game framework I am developing personally, "GCF (Game Core Framework)."
GCF is released under the MIT license with the goal of creating a character control foundation that leverages the latest UE5 features (Mover, Enhanced Input, etc.) while being easy for anyone to extend.
If this article has piqued your interest in Mover design or framework construction, please take a look at the code in the repository.
Thank you for reading until the end!
Discussion