Hey DEV! 👋
Last week I shared an update on my open-world space sim Sirius (on reddit KadmiumTech), built on my custom Aether Framework in Unreal Engine 5. Aether leverages UE5's Network Prediction Plugin (NPP) and Large World Coordinates (LWC) to handle deterministic 6DOF space physics.
One of the trickiest mechanics in space games is Relative Docking—landing a small, agile fighter onto a massive, fast-moving capital carrier without physics jitter or netcode desync. Here is a deep dive into four specific physics bugs I encountered and how I solved them in C++!
This is still work in progress and needs some adjustmends but the core works.
1. Jittery Rotation on Moving Carriers
The Problem: Whenever a ship entered a relative movement zone, rotational smoothing was completely bypassed. Every micro-rotation of the carrier hit the player's ship as a harsh, frame-by-frame stutter. Additionally, the camera aim director was running post-physics, creating a phase shift between camera, mesh, and physics state.
The Solution: I synchronized the Aim Director updates directly with netcode prediction ticks and re-enabled rotational smoothing for relative zones in AetherMovementComponent.cpp. Rotation offsets are now smoothly interpolated toward Identity using FQuat::Slerp.
// AetherMovementComponent.cpp - FinalizeSmoothingFrame()
if (Sync->bIsRelative && Sync->ZoneID > 0)
{
if (UAetherGravitySubsystem* Subsystem = GetGravitySubsystem())
{
FTransform ParentTransform;
if (Subsystem->GetZoneTransform(Sync->ZoneID, ParentTransform))
{
TargetLocation = ParentTransform.TransformPosition(TargetLocation);
TargetRotation = ParentTransform.TransformRotation(TargetRotation);
}
}
// Smoothly interpolate translation and rotation offsets back to identity
SmoothingTranslationOffset = FMath::VInterpTo(SmoothingTranslationOffset, FVector::ZeroVector, DeltaTime, 15.0f);
SmoothingRotationOffset = FQuat::Slerp(SmoothingRotationOffset, FQuat::Identity, FMath::Clamp(DeltaTime * 15.0f, 0.0f, 1.0f));
VisualComponent->SetWorldLocationAndRotation(
TargetLocation + SmoothingTranslationOffset,
(SmoothingRotationOffset * TargetRotation).GetNormalized()
);
return;
}
2. Violently Oscillating / "Sticky" Landing Gear
The Problem: Landing gear suspension forces were calculated using raw spring velocity multiplied by friction and mass—missing delta-time scaling and impulse limits. Pressing into the ground created an unscaled force that catapulted the ship into space in an violent feedback loop.
The Solution: In AetherLandingGearSim.cpp, damping forces are now properly scaled by mass, frequency, and time step, with impulses clamped against maximum allowed Gs. I also isolated the "Sticky" (magnetic) landing gear behavior to apply a controlled, downward force vector.
// AetherLandingGearSim.cpp - Step()
const float ActiveStiffness = ScaledStiffness * ProgressiveMultiplier;
const float ActiveDamping = (AxisVelocity < 0.0f) ? (ScaledDamping * 0.2f) : ScaledDamping;
// Calculate spring force and clamp max impulses to prevent violent bounce-backs
float SpringForce = (Compression * ActiveStiffness) - (AxisVelocity * ActiveDamping);
SpringForce = FMath::Clamp(SpringForce, 0.0f, (MassPerGear * 980.0f) * Gear.MaxSuspensionGs);
FVector CurrentGearForce = GearUpDir * SpringForce;
// Separate magnetic ground attraction force
if (Gear.GearMode == EAetherLandingGearMode::Sticky && Compression > 0.001f)
{
const float AdhesionForce = MassPerGear * 980.0f * 1.5f;
CurrentGearForce -= GearUpDir * AdhesionForce;
}
3. Rolling 90° Caused Unwanted Sideways Drift
The Problem: The aerodynamic lift vector calculation contained a matrix transformation flaw that simplified to applying lift straight up against world gravity, regardless of ship orientation. Rolling 90° put the wings vertical, but lift kept pushing world-up, producing unintended lateral drift.
The Solution: Lift must always evaluate relative to the ship’s local Up axis (Sync->Rotation.GetAxisZ()). In AetherAeroSim.cpp, the lift force vector is now computed directly along the local roof vector and scaled by atmospheric density.
// AetherAeroSim.cpp - Step()
if (EnvDensity > 0.001f)
{
const FQuat InvShipRot = Sync->Rotation.Inverse();
const FVector BodyVel = InvShipRot.RotateVector(Sync->LinearVelocity);
const float ForwardSpeed = FMath::Max(0.0f, BodyVel.X);
const float InvMaxLiftSpeed = 1.0f / FMath::Max(1.0f, Aux->MaxSpeed * Aux->OptimalLiftSpeedRatio);
const float LiftAlpha = FMath::Clamp(ForwardSpeed * InvMaxLiftSpeed, 0.0f, 1.0f);
// FIX: Get local ship up direction instead of world up!
const FVector ShipUpDir = Sync->Rotation.GetAxisZ();
const float GravityMag = LocalGravity.IsNearlyZero() ? 980.0f : LocalGravity.Size();
// Apply aerodynamic lift aligned with local ship orientation
Sync->LinearVelocity += ShipUpDir * (LiftAlpha * GravityMag * EnvDensity * DeltaSeconds);
}
4. Loss of Inherited Speed When Exiting Moving Carriers
The Problem: When exiting a carrier, zone transition logic calculated inherited world velocity correctly (e.g., combining 2,000 cm/s ship speed + 55,000 cm/s carrier speed). However, on the exact same frame, the velocity was clamped against the ship's standalone engine MaxSpeed (e.g., 5,000 cm/s), causing the ship to instantly lose over 90% of its momentum.
The Solution: I removed the hard top-speed clamp during world-zone handshakes in FAetherSpaceUtils::ProcessZoneHandshake. Instead, excess momentum bleeds off dynamically over time (Dynamic Bleed) inside AetherAeroSim.cpp.
// AetherAeroSim.cpp - Step()
const float SpeedSq = Sync->LinearVelocity.SizeSquared();
const float MaxSpeedSq = FMath::Square(EffectiveMaxSpeed);
// Exponentially bleed off excess inherited velocity instead of hard-clamping
if (SpeedSq > MaxSpeedSq)
{
const float CurrentSpeed = FMath::Sqrt(SpeedSq);
const float ExcessSpeed = CurrentSpeed - EffectiveMaxSpeed;
const float DynamicBleed = Aux->DynamicBleedBase + (ExcessSpeed * Aux->DynamicBleedFactor);
const float NewSpeed = EffectiveMaxSpeed + (ExcessSpeed * FMath::Exp(-DynamicBleed * DeltaSeconds));
Sync->LinearVelocity *= (NewSpeed / CurrentSpeed);
}
Key Takeaway
Developing networked physics for space games requires strict separation of local and world reference frames. By dividing the simulation tick into modular sub-simulations (Kinematics -> Aerodynamics -> Landing Gear -> Collision) within UE5's Network Prediction Plugin, we can guarantee deterministic simulation across server and client views.
Have you worked with relative physics frames or NPP in Unreal Engine 5? I’d love to hear your thoughts or questions in the comments below!
Top comments (0)