Background

All programming for this project was done by myself. I have used it to explore various concepts at my own pace. However, given the time I would like to polish it into a publishable product. My custom character controller implements physically accurate collision response and client side prediction based networking. I paired that controller with context steering driven AI enemies. Also described here is my input replay system, inspired by first person shooters and racing games.

Custom Character Controller

Motivation

My custom pinball character controller is designed to replace Unreal Engine’s built in UCharacterMovementComponent. The motivation for a custom character controller was to model movement after real world physics. I would like to support online multiplayer eventually, so I chose to create my own solution with support for client side prediction instead of using a physics engine.

Implementation

I defined basic features in the UNetworkedPhysics component, which inherits from UMovementComponent. UNetworkedPhysics is closely modeled after Unreal Engine’s UCharacterMovementComponent class. The client moves locally, submits the move to the server, which evaluates the move before submitting either a correction or approval.

On the client, the TickComponent() function kicks things off by calling the UpdatePhysics() function, which constructs a move. Moves are represented by my own FMove struct. PerformMove() is then called, taking FMinimalMove as an argument. The difference between FMove and FMinimalMove is that FMove contains data needed to verify a move, while FMinimalMove only contains data needed to perform a move.

PerformMove() integrates applied forces over the time since the last move. Before updating the Actor’s position, it queries the environment for collisions. I resolve collisions in the constructor logic for the FNetPhysCollisionResolution struct. The constructor takes in the UNetworkedPhysicsComponents involved, hit result, and current velocity then calculates linear and angular impulse which are stored in the resulting struct. The UNetworkedPhysicsComponent that originally attempted the move applies these impulses to itself using the ResolveCollisionWithRotation() function.

FNetPhysCollisionResolution::FNetPhysCollisionResolution(UNetworkedPhysics* Self, UNetworkedPhysics* Other, FHitResult Hit, FVector LinearVelocity, FVector AngularVelocity)
{
    FVector Location = Self->UpdatedComponent->GetComponentLocation();
    float InverseMass = Self->InverseMass();
    float InverseInertia = Self->InverseInertia();
    float Restitution = Self->GetRestitution();
    float FrictionConstant = Self->GetFrictionConstant();
    
    FVector RVector = Hit.ImpactPoint - (Hit.TraceStart + (Hit.Time * (Hit.TraceEnd - Hit.TraceStart)));
    FVector RelativeVelocity = LinearVelocity + AngularVelocity.Cross(RVector);
    
    bool bHasOther = IsValid(Other);
    float SumInvMass = InverseMass;
    float MinRestitution = Restitution;
    
    /* Normal Impulse calculation */
    {
        FVector NormalImpulse = FVector::Zero();
    
        FVector J1 = (InverseInertia * RVector.Cross(Hit.Normal)).Cross(RVector);
        FVector J2 = FVector::Zero();
    
        if (bHasOther) {
            FVector OtherLinearVelocity = Other->GetLinearVelocity();
            FVector OtherAngularVelocity = Other->GetAngularVelocity();
            FVector OtherRVector = Other->GetRVectorAtPoint(Hit.ImpactPoint);
            float OtherInvInertia = Other->InverseInertia();
            float OtherInvMass = Other->InverseMass();
    
            J2 += (OtherInvInertia * OtherRVector.Cross(Hit.Normal)).Cross(OtherRVector);
            RelativeVelocity -= OtherLinearVelocity + OtherAngularVelocity.Cross(OtherRVector);
            SumInvMass += OtherInvMass;
            MinRestitution = FMath::Min(MinRestitution, Other->GetRestitution());
        }
    
        float J = (Hit.Normal.Dot(Hit.Normal) * SumInvMass) + (J1 + J2).Dot(Hit.Normal);
    
        J = (RelativeVelocity.Dot(Hit.Normal) * -(1 + MinRestitution)) / J;
    
        NormalImpulse = J * Hit.Normal;
        LinearImpulse = NormalImpulse;
        AngularImpulse = RVector.Cross(NormalImpulse);
    }
    
    /* Friction Impulse Calculation */
    {
        FVector Tangent = LinearVelocity - LinearVelocity.Dot(Hit.Normal) * Hit.Normal;
    
        if (Tangent.Size() < .01f) return;
        else Tangent.Normalize();
    
        FVector J1 = (InverseInertia * RVector.Cross(Tangent)).Cross(RVector);
        FVector J2 = FVector::Zero();
    
        if (bHasOther) {
            FVector OtherRVector = Other->GetRVectorAtPoint(Hit.ImpactPoint);
            float OtherInvInertia = Other->InverseInertia();
    
            J2 += (OtherInvInertia * OtherRVector.Cross(Tangent)).Cross(OtherRVector);
        }
    
        float J = (Tangent.Dot(Tangent) * SumInvMass) + (J1 + J2).Dot(Tangent);
        J = (RelativeVelocity.Dot(Tangent) * -(1 + MinRestitution)) / J;
    
        J *= FrictionConstant;
    
        FVector FrictionImpulse = J * Tangent;
        LinearImpulse += FrictionImpulse; 
        AngularImpulse = RVector.Cross(FrictionImpulse);
    }
}
FNetPhysCollisionResolution constructor calculates and impulse based on real world physics.

An important distinguishing factor of my character controller is that it can push and be pushed by other networked physics components in a physically accurate manner. If the collision query finds another UNetworkedPhysics component, it will use FNetPhysCollisionResolution’s Inverse() function to get the inverse collision response and apply it directly to the other UNetworkedPhysics component involved in the collision.

INetworkPhysicsInterface* OtherInterface = Cast<INetworkPhysicsInterface>(TestHit.GetActor());
if (OtherInterface) {
    FNetPhysCollisionResolution ResolveOurCollision = FNetPhysCollisionResolution(this, OtherInterface->GetNetworkPhysics(), TestHit, LinearVelocity, AngularVelocity);
    FNetPhysCollisionResolution ResolveOtherCollision = FNetPhysCollisionResolution::Inverse(ResolveOurCollision);
    
    OtherInterface->GetNetworkPhysics()->ResolveCollisionWithRotation(ResolveOtherCollision);
    ResolveCollisionWithRotation(ResolveOurCollision);
}
else {
    FNetPhysCollisionResolution ResolveOurCollision = FNetPhysCollisionResolution(this, nullptr, TestHit, LinearVelocity, AngularVelocity);
    ResolveCollisionWithRotation(ResolveOurCollision);
}
    
if (bUseAngularMovement && IsValid(AngularBody)) {
    FRotator Rot = UKismetMathLibrary::RotatorFromAxisAndAngle(AngularVelocity.GetSafeNormal(), AngularVelocity.Size());
    AngularBody->AddWorldRotation(Rot);
}
How the PerformMove() function resolves a collision.

After resolving collisions, PerformMove() physically updates the controlled component’s location.

After the move is performed locally, the client stores its final position in the FMove struct and submits it to the server. Server side PerformMove() is called again, and the final position is checked against the final position submitted by the client. If they are within a very small threshold the move is approved, otherwise the server sends a correction back to the client.

When the client receives a correction, it removes every move prior to the correction in MovesPendingValidation. It then sets its position and velocity to the correction, and re-simulates the remaining moves in MovesPendingValidation.

void UNetworkedPhysics::ClientCorrection_Implementation(FVector EndPosition, FVector EndVelocity, float Time) {
    UpdatedComponent->SetWorldLocation(EndPosition);
    LinearVelocity = EndVelocity;
    
    // prolly can change this to a call to clientapprovemove.
    int Num = MovesBuffer.Num();
    for (int i = 0; i < Num; i++) {
        // Remove all moves prior to the corrected move.
        if (MovesBuffer[0].Time <= Time) {
            MovesBuffer.RemoveAt(0); 
        }
        else {
            PrevTimestamp = MovesBuffer[0].Time;
            break;
        }
    }
    
    for (int i = 0; i < MovesBuffer.Num(); i++) {
        // Execute the remaining moves in moves pending validation.
        PerformMove(MovesBuffer[i]);
    }
    // Reset the prev timestamp.
    PrevTimestamp = GetWorld()->TimeSeconds;
    
    FMove Move = FMove();
    Move.EndPosition = EndPosition;
    Move.Time = Time;
    LastValidatedMove = Move;
}
Client correction logic.