Predicted Command Manager

How to utilize the predicted command manager to easily create server authoritative commands with client prediction.

Basic Concepts

The predicted command system allow you to create a set of logic and data that will be predicted on the client when using Server Authoritative character networking. For example, if you want to create an ability that launches the player in the air when activated, you would need to ensure that the server and client agree on when the ability was used, as well as ensure that both the client and server use the same logic to apply the force.

Commands

Commands are a set of logic that will run as part of the character movement processing. To make a new command, you extend the PredictedCommand class. Commands should implement the GetCommand(), OnTick(), OnCaptureSnapshot(), ResetToSnapshot(), and CompareSnapshots() functions. Do not yield in any of the predicted command functions.

Predicted Command Manager

The PredictedCommandManager is a singleton running on both the client and server. The manager handles starting new commands and ensuring the proper execution of running commands. The PredictedCommandManager can get information about which commands are running for a character, validate which commands should run, and provides command related signals such as when a command stops running.

Example

The following example implementation is an ability which impulses your character upwards after charging for a short amount of time. While the command is charging, the character will move slowly.

Command

TestPredictedCommand.ts
import { Keyboard } from "../../UserInput";
import PredictedCustomCommand from "./PredictedCustomCommand";

// The input data for this command. This is added to every character input command.
// We use arrays for these since it's less data to send over the network
type InputCommand = [charging: boolean, completed: boolean];

// The state data associated with this command. We network the progress so that if the client and
// server disagree on how much progress has been made, the client will reconcile to use the server
// progress.
type StateData = [progress: number];

// We define our predicted command by extending PredictedCustomCommand and passing in our data types.
// We will register this predicted command with the PredictedCommandManager later. Keeping mind, this is
// NOT an AirshipBehavior. You don't need to add it to a game object.
export class TestPredictedCommand extends PredictedCustomCommand<InputCommand, StateData> {
	// state
	private progress = 0; // How far along we are in the charge process.
	private completed = false;

	// constants
	private CHARGE_TIME_SEC = 1; // How long we want to charge for. 

        // This function is called every time a new instance of this command is created. On replays, this command
        // might be destroyed and recreated many time. Keep that in mind since any state you include (like progress
        // above) will be reset. ResetToSnapshot is always called after Create(), so you should load any state you
        // need from the snapshot there.
	Create(): void {
		// Slow the character movement speed down while this command is running.
		this.character.movement.movementSettings.speed *= 0.1;
	}

	Destroy(): void {
		// Reset the character movement speed back to normal when it ends.
		this.character.movement.movementSettings.speed /= 0.1;
	}

	// Called when the client is generating a new command. This is where you collect any data you
	// need to process in OnTick(). Remember that this is being generated on the client, so you should
	// pass things like input here.
	GetCommand(): false | InputCommand {
		// If the player is holding down the ability button, we continue to charge the ability.
		if (Airship.Input.IsDown("Ability")) {
			return [true]; // Send true since the ability is being held down.
		}
		return false; // If the ability is not being held down, we just exit the command commpletely by returning false.
	}

	// Runs on both the client and the server either before (default) or after character movement is processed depending
	// on the configuration provided when registering the command with the PredictedCommandManager. We can access
	// the data we generated in GetCommand() and perform our logic based on that data.
	override OnTick(input: Readonly<InputCommand> | undefined, replay: boolean, fullInput: CharacterInputData) {
		if (!input) return false;

		this.progress++;

		if (this.progress >= this.CHARGE_TIME_SEC / Time.fixedDeltaTime) {
			this.character.movement.AddImpulse(Vector3.up.normalized.mul(20));
			this.completed = true;
			return false;
		}
	}

	// This runs after OnTick() and after the physics simulation completes. Here we return the progress data since that's
	// what we want to make sure stays in sync between the client and server.
	OnCaptureSnapshot(): StateData {
		return [this.progress, this.completed];
	}

	// This function gets called whenever there's a mismatch between the client and server. It may also get called
	// if the client replays or if the server is processing lag compensation. The purpose of this function is to
	// reset the state of this command to match whatever snapshot is provided to it.
	ResetToSnapshot(state: Readonly<StateData>): void {
		this.progress = state[0];
		this.completed = state[1];
	}

	// Called whenever the client needs to compare it's local predicted result to the actual result provided by the server.
	// We want to ensure that the state data we have locally actually matches the state data returned by the server.
	CompareSnapshots(a: Readonly<StateData>, b: Readonly<StateData>): boolean {
		if (a[0] !== b[0]) return false; // If it doesn't match, then we return false
		if (a[1] !== b[1]) return false;
		return true; // If it matches we are good!
	}
}

Registering the Command

To register a command, you need to call PredictedCommandManager.Get().RegisterCommands(). You can call RegisterCommands() multiple times if needed. The command needs to be registered on both the client and the server for it to work. Here's a manager singleton that registers the command, and sets up the other configuration needed to actually use our test command.

Last updated