Networked Physics Controller

What I Learned

As my first major endeavor into network programming, this project aimed to bring client side prediction to my shmup / action / pinball game. An obvious question to ask is “why not use Unreal Engine’s Character Movement Component with its built in net code?” 

The primary reason is I wanted to learn game network architecture on a more fundamental level. 

Additionally, I get finite control over how the character feels. This is desirable because I found the Character Movement Component did not map onto my game very well.

fig. 0: Comparison of the system running with Unreal Engine’s “average” and “bad” network simulation profiles.

Skills I gained include general networking concepts like server authoritative models, remote procedure calls (RPC), as well as Unreal network architecture like network modes, replication, net roles, and authority. I also gained an understanding of the client side prediction pattern, from executing and submitting moves to the server, evaluating moves, issuing corrections, and resimulating client saved moves in the event of a correction.

Moving, Server Validation, and Corrections

Overview

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

Client Side

The TickComponent() function kicks things off by calling my own UpdatePhysics() function, which constructs a move. Moves are represented by my own FMove struct. PerformMove() is then called and contains functionality to execute an FMove. [fig 1]

Generally, the force contained in FMove will represent forces applied directly by the player, which need to be sent to the server for approval. “NaturalForce” is a class variable which represents force that can be calculated server side. For example gravity or grapple force (see Grapple Hook section). Natural forces are usually calculated at the start of PerformMove().

USTRUCT(BlueprintType)
struct FMove {
	GENERATED_BODY()
public:
	UPROPERTY()
		float Time = -1.0f;
	UPROPERTY()
		FVector Force;
	UPROPERTY()
		FVector EndVelocity;
	UPROPERTY()
		FVector EndPosition;
};

fig. 1: FMove struct.

One important note, PerformMove() moves the component along each axis separately instead of all at once. This is suboptimal, but it yields the most consistent normal impulse calculations, which are critical for my design. I will likely investigate better ways to do this, maybe simplify or outright fake the normal impulse, to get the feel I’m after while still being stable. [fig 2]

Finally, the client puts its final position and velocity in FMove, sends a copy to the server and saves another to the MovesPendingValidation array.

for (int i = 0; i < 3; i++) {
	FVector Mask = FVector(0,0,0);
	Mask[i]++;
	FVector DeltaPos = ComponentVelocity * dt * Mask;
	// Update position
	FHitResult Hit;
	SafeMoveUpdatedComponent(DeltaPos, UpdatedComponent->GetComponentRotation(), true, Hit);
	// Handle overlaps
	if (Hit.IsValidBlockingHit()) {
		ResolveCollision(Hit);	// Normal impulse.
		SlideAlongSurface(DeltaPos, 1.f - Hit.Time, Hit.Normal, Hit);
	}
}

fig. 2: Per-axis movement updates.

Server Side

The server starts by checking the force contained in FMove, making sure it is not larger than allowed. PerformMove() gets called in the same manner as the client, and the server wraps up by checking its final position against the position sent by the client.

Corrections

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 [fig 3].

By and large, this system is lifted from UCharacterMovementComponent’s approach. The next sections will discuss features that are unique to my game. 

void UNetworkedPhysics::ClientCorrection_Implementation(FMove Move) {
	UpdatedComponent->SetWorldLocation(Move.EndPosition);
	ComponentVelocity = Move.EndVelocity;
	
	int Num = MovesPendingValidation.Num();
	for (int i = 0; i < Num; i++) {
		// Remove all moves prior to the corrected move.
		if (MovesPendingValidation[0].Time <= Move.Time) {
			MovesPendingValidation.RemoveAt(0); 
		}
		else {
			PrevTimestamp = MovesPendingValidation[0].Time;
			break;
		}
	}
 
	for (int i = 0; i < MovesPendingValidation.Num(); i++) {
		// Execute the remaining moves in moves pending validation.
		PerformMove(MovesPendingValidation[i]);
	}
	// Reset the prev timestamp.
	PrevTimestamp = GetWorld()->TimeSeconds;
}

fig. 3: Client correction RPC.

Grapple Hook

The grapple Hook should be the player’s primary mode of movement. My intended design is that direct movement won’t overcome gravitational pull down the pinball table, so players will have to use their grapple. As such, I decided the server would spawn and move grapple projectiles in lockstep with the player pawn. [fig 4]

fig. 4: Brief demo of the grapple hook.

This functionality is defined in UPlayerPhysics, which inherits from UNetworkedPhysics. UpdatePhysics() gets overridden. It is largely the same as its parent, but calls a special ServerPerformMoveGrapple() function which includes a bool parameter indicating an active projectile. If this value is true the server checks for an existing projectile and spawns one if needed. The server’s projectile is updated each time a move is received, using the delta time of the current and previous submitted moves. [fig 5]

It is important to note that grapple force is not included in the move sent to the server, but it is included in the move saved to MovesPendingValidation. The server calculates its own grapple force using the authoritative projectile. In the event of a correction, the client does not recalculate grapple force, rather it uses the force that was included in the saved move.

void UPlayerPhysics::ServerPerformMoveGrapple_Implementation(FMove Move, bool bGrapple, FVector2D NewReticleOffset) {
	if (bGrapple) {
		// Spawn grapple projectile if the server does not have one.
		if (GrappleProjectile == nullptr) {
			SpawnGrappleProjectile();
			GrappleProjectile->SetComponentTickEnabled(false);
		}
		// Update the grapple projectile in lockstep with client moves.
		GrappleProjectile->UpdatePhysics(Move.Time - PrevTimestamp);
	}
	else {
		DespawnGrappleProjectile();
	}

	ReticleOffset = NewReticleOffset;
	ServerPerformMove(Move);
}

fig. 5: Server Perform Move Grapple RPC.

Reticle

The reticle determines rotation of spawned projectiles. Players slide the mouse to move it, and it is locked to a certain radius around the player pawn. [fig 6]

Locally controlled pawns update reticle position via blueprint. The blueprint also updates a reticle offset variable in UPlayerPhysics, which gets sent to the server alongside the move and grapple bool. The authoritative pawn blueprint uses this value to update its reticle and a replicated RetPos variable. Simulated proxies update their reticle when RetPos gets replicated.

fig. 6: Reticles, represented by the default dinosaur billboard, replicating across a network simulation.

As of now, the server never issues corrections over the reticle position. Later I will likely implement corrections for dramatic issues, for instance the reticle leaving its fixed radius. But the reticle should move quickly, so in most cases the server will accept whatever the client sends.