🎉 Celebrating 25 Years of GameDev.net! 🎉

Not many can claim 25 years on the Internet! Join us in celebrating this milestone. Learn more about our history, and thank you for being a part of our community!

Client Side Prediction and Server Reconciliation When Sending Player Speeds

Started by
8 comments, last by hplus0603 5 years ago

Hello everyone,

So I have finally gotten around to implementing client side prediction. It seemed rather simple at first; set the state on the client to what it was at the tick the server sends back, then re-simulate your inputs. I've read the likes of Gabriel Gambetta, and I'm really close to something that works.However, I think my math is off or I am misunderstanding something.

You see, the entity the client controls moves essentially at a constant velocity based on input. Most examples of client side prediction, as far as I can tell, implement movement instructions as a series of 'nudges', i.e. "Move 10 to the left", followed by another message saying "Move 10 to the left". I didn't want to do this because A) I theoretically don't need to send that much information if I know when this linear movement started, and B) Using this strategy may allow multiple instructions to execute in the same frame, resulting in a small but noticeable hitch. So my solution was just to send a packet to the server saying "Move this portion of your top speed starting now". On the server side, this appears to work without a hitch.

Then's there's the client. Here's my strategy regarding reconciliation and prediction; as usual, I eliminate inputs from my prediction list that have already been executed by the server. However, as I do that I keep track of the last velocity at which the player was confirmed to be moving. When I reconcile, I start from the last acknowledged input frame, and move as far as I can at the saved velocity until I reach another input in my prediction list or the current frame number of the client. If I find another input, I change the velocity and the start frame and continue onward.

This, at least to me, seems like a sound means to handle both reconciliation and prediction. In fact it almost works. However, at surprising regularity the entity being predicted jitters around a bit. It looks as though it happens when I actually receive new state from the server. Specifically, it doesn't look like my prediction method is aware that now that I've received a new frame from the server, my predicted change in position needs to be smaller. But I have no clue what the math is to do this correctly. I'm sure its something quite obvious, but it currently eludes me. I've been struggling with this issue all week, and I could really use some help.

I apologize if I'm not clear at the moment; I'm a bit tired. If needed, I can provide clarifications tomorrow.

Thank you very much for your help.

Advertisement

do you keep 2 positions for predicted entities? one true position, and one visual with an offset which is slowly reduced towards the true position?

on received update you snap the true position and calculate a new offset towards the position the entity is currently seen at on the client. that should reduce erratic position changes upon updates coming in.

4 hours ago, ninnghazad said:

do you keep 2 positions for predicted entities? one true position, and one visual with an offset which is slowly reduced towards the true position?

on received update you snap the true position and calculate a new offset towards the position the entity is currently seen at on the client. that should reduce erratic position changes upon updates coming in.

No I do not. I didn't think it was necessary, and I still don't. Because the information I'm receiving from the server appears to be accurate and smooth; my prediction is what's jittering around. Ergo, I must be making some sort of mathematical error somewhere, or an errant assumption as to how this works.

I should have included the relevant source earlier, but here's the code I'm using for player movement, both on the server and on the client:


//Server Movement Code
//...

for (auto currentInput = inputsToHandle.begin(); currentInput != inputsToHandle.end(); currentInput++) {

  uint16_t inputCode = currentInput->inputCode;
  uint16_t playerIndex = inputCode & 0xff;
  uint16_t command = inputCode >> 8;

  //Player movement commands
  if (command == PLAYER_VELOCITY) {

    uint32_t endFrame = frameNumber;

    auto endMovement = std::find_if(currentInput + 1, inputsToHandle.end(), [inputCode](PlayerInputStruct other) {
      return other.inputCode == inputCode;
    });

    if (endMovement != inputsToHandle.end()) {
      endFrame = endMovement->frameNumber;
    }

    int duration = (int)(endFrame - currentInput->frameNumber);

    size_t transformIndex = transformTable.getEntityIndex(playerControllerTable.entities[playerIndex]);
    glm::vec3& position = transformTable.positions[transformIndex];
    position.x -= playerControllerTable.speeds[playerIndex] * currentInput->value * duration;


    playerTargetVelocities[playerIndex] = currentInput->value;

  }

  //Other, non-relvant inputs handled here...
  
}

for (size_t i = 0; i < playerControllerTable.getNumberOfComponents(); i++) {

  size_t transformIndex = transformTable.getEntityIndex(playerControllerTable.entities[i]);
  transformTable.positions[transformIndex].x -= playerControllerTable.speeds[i] * playerTargetVelocities[i];

}
  
  

//Client Code
//...
//selectionStruct contains information needed for prediction, including player entity ID numbers and speeds for players on this client

int snapDiff = currentSnapshot.snapshotNumber - previousSnapshot.snapshotNumber;

float alpha = 1.0f;

//The only reason snapDiff would be more than 1 is if we dropped a packet somewhere,
//in that case, we need to interpolate between the states
if (snapDiff > 1) {
  alpha = (std::min)((float)(frameNumber - previousSnapshot.snapshotNumber) / (snapDiff), 1.0f);
}

//reset player positions to where they were at the current snapshot
for (size_t i = 0; i < selectionStruct.numberOfPlayersOnClient; i++) {

  Entity currentPlayerEntity = selectionStruct.playerControllerEntityIDs[i];

  size_t transformIndex = transformTable.getEntityIndex(currentPlayerEntity);

  if (transformIndex == transformTable.getNumberOfComponents()) {
    continue;
  }

  auto prevTransform = std::find_if(previousSnapshot.getEntityTransformData().begin(), previousSnapshot.getEntityTransformData().end(),
[currentPlayerEntity](EntityTransformData data) {
  return data.entity == currentPlayerEntity;
});

  auto currTransform = std::find_if(currentSnapshot.getEntityTransformData().begin(), currentSnapshot.getEntityTransformData().end(),
[currentPlayerEntity](EntityTransformData data) {
  return data.entity == currentPlayerEntity;
});

  transformTable.positions[transformIndex] = ((1 - alpha) * prevTransform->position) + (alpha * currTransform->position);

}

//reconcile inputs not yet applied in this snapshot
std::vector<float> currentVelocities = lastConfirmedVelocities;
std::vector<uint32_t> velStartFrames = std::vector<uint32_t>(2, currentSnapshot.lastAcknowledgedInputNumber);

for (auto inputIt = predictionQueue.begin(); inputIt != predictionQueue.end(); inputIt++) {

  uint16_t inputCode = inputIt->inputCode;
  uint16_t playerIndex = inputCode & 0xff;
  uint16_t command = inputCode >> 8;
  float value = inputIt->value;

  if (command == PLAYER_VELOCITY) {

    int duration = (inputIt->frameNumber) - velStartFrames[playerIndex];

    Entity playerEntity = selectionStruct.playerControllerEntityIDs[playerIndex];

    size_t transformIndex = transformTable.getEntityIndex(playerEntity);

    if (transformIndex == transformTable.getNumberOfComponents()) {
      continue;
    }

    glm::vec3& position = transformTable.positions[transformIndex];
    position.x -= selectionStruct.playerSpeeds[playerIndex] * currentVelocities[playerIndex] * duration;

    currentVelocities[playerIndex] = inputIt->value;
    velStartFrames[playerIndex] = inputIt->frameNumber;

  }

}

for (size_t playerIndex = 0; playerIndex < selectionStruct.numberOfPlayersOnClient; playerIndex++) {

  int duration = frameNumber - velStartFrames[playerIndex];

  Entity playerEntity = selectionStruct.playerControllerEntityIDs[playerIndex];

  size_t transformIndex = transformTable.getEntityIndex(playerEntity);

  glm::vec3& position = transformTable.positions[transformIndex];

  position.x -= selectionStruct.playerSpeeds[playerIndex] * currentVelocities[playerIndex] * duration;

}

I think I boiled this down to the bare essentials to understand my problem; if you need more information, please let me know.

Unfortunately, I have not made much in the way of progress. I keep looking for where I am specifically going wrong, but I'm just not seeing it. I have been printing out some debug information; maybe you guys can see something? A slight change made for this input; instead of velStartFrames being initialized with the last acknowledged input, I start it off with the last processed input, and I add an artificial "null" input every frame to make sure that number increments correctly. For the record, the position starts at 250, and moves with a velocity of 10 for 10 frames, so it should end up at position 350.



Player x position set to 250.000000 before prediction
Player x position set to 250.000000 in prediction queue
Input start: 57
Input end: 60
Duration: 3
Velocity Value: 0.000000
Player x position set to 260.000000 with prediction remainder
Next Frame Number: 61
Input Start: 60
Duration: 1
Velocty Value: -1.000000
Current Ack: 57
Previous Ack: 56
Current Snapshot: 60
Previous Snapshot: 59
Current Performed Input: 57
Previous Performed Input: 56
Previous X Position: 250.000000
-----------------------------------------------------
Player x position set to 250.000000 before prediction
Player x position set to 250.000000 in prediction queue
Input start: 58
Input end: 60
Duration: 2
Velocity Value: 0.000000
Player x position set to 270.000000 with prediction remainder
Next Frame Number: 62
Input Start: 60
Duration: 2
Velocty Value: -1.000000
Current Ack: 59
Previous Ack: 57
Current Snapshot: 61
Previous Snapshot: 60
Current Performed Input: 58
Previous Performed Input: 57
Previous X Position: 260.000000
-----------------------------------------------------
Player x position set to 250.000000 before prediction
Player x position set to 250.000000 in prediction queue
Input start: 59
Input end: 60
Duration: 1
Velocity Value: 0.000000
Player x position set to 280.000000 with prediction remainder
Next Frame Number: 63
Input Start: 60
Duration: 3
Velocty Value: -1.000000
Current Ack: 59
Previous Ack: 59
Current Snapshot: 62
Previous Snapshot: 61
Current Performed Input: 59
Previous Performed Input: 58
Previous X Position: 270.000000
-----------------------------------------------------
Player x position set to 260.000000 before prediction
Player x position set to 300.000000 with prediction remainder
Next Frame Number: 64
Input Start: 60
Duration: 4
Velocty Value: -1.000000
Current Ack: 61
Previous Ack: 59
Current Snapshot: 63
Previous Snapshot: 62
Current Performed Input: 60
Previous Performed Input: 59
Previous X Position: 280.000000
-----------------------------------------------------
Player x position set to 270.000000 before prediction
Player x position set to 310.000000 with prediction remainder
Next Frame Number: 65
Input Start: 61
Duration: 4
Velocty Value: -1.000000
Current Ack: 61
Previous Ack: 61
Current Snapshot: 64
Previous Snapshot: 63
Current Performed Input: 61
Previous Performed Input: 60
Previous X Position: 300.000000
-----------------------------------------------------
Player x position set to 280.000000 before prediction
Player x position set to 320.000000 with prediction remainder
Next Frame Number: 66
Input Start: 62
Duration: 4
Velocty Value: -1.000000
Current Ack: 63
Previous Ack: 61
Current Snapshot: 65
Previous Snapshot: 64
Current Performed Input: 62
Previous Performed Input: 61
Previous X Position: 310.000000
-----------------------------------------------------
Player x position set to 290.000000 before prediction
Player x position set to 330.000000 with prediction remainder
Next Frame Number: 67
Input Start: 63
Duration: 4
Velocty Value: -1.000000
Current Ack: 63
Previous Ack: 63
Current Snapshot: 66
Previous Snapshot: 65
Current Performed Input: 63
Previous Performed Input: 62
Previous X Position: 320.000000
-----------------------------------------------------
Player x position set to 300.000000 before prediction
Player x position set to 340.000000 with prediction remainder
Next Frame Number: 68
Input Start: 64
Duration: 4
Velocty Value: -1.000000
Current Ack: 64
Previous Ack: 63
Current Snapshot: 67
Previous Snapshot: 66
Current Performed Input: 64
Previous Performed Input: 63
Previous X Position: 330.000000
-----------------------------------------------------
Player x position set to 310.000000 before prediction
Player x position set to 350.000000 with prediction remainder
Next Frame Number: 69
Input Start: 65
Duration: 4
Velocty Value: -1.000000
Current Ack: 65
Previous Ack: 64
Current Snapshot: 68
Previous Snapshot: 67
Current Performed Input: 65
Previous Performed Input: 64
Previous X Position: 340.000000
-----------------------------------------------------
Player x position set to 320.000000 before prediction
Player x position set to 360.000000 with prediction remainder
Next Frame Number: 70
Input Start: 66
Duration: 4
Velocty Value: -1.000000
Current Ack: 66
Previous Ack: 65
Current Snapshot: 69
Previous Snapshot: 68
Current Performed Input: 66
Previous Performed Input: 65
Previous X Position: 350.000000
-----------------------------------------------------
Player x position set to 330.000000 before prediction
Player x position set to 360.000000 in prediction queue
Input start: 67
Input end: 70
Duration: 3
Velocity Value: -1.000000
Player x position set to 360.000000 with prediction remainder
Next Frame Number: 71
Input Start: 70
Duration: 1
Velocty Value: 0.000000
Current Ack: 67
Previous Ack: 66
Current Snapshot: 70
Previous Snapshot: 69
Current Performed Input: 67
Previous Performed Input: 66
Previous X Position: 360.000000
-----------------------------------------------------
Player x position set to 340.000000 before prediction
Player x position set to 360.000000 in prediction queue
Input start: 68
Input end: 70
Duration: 2
Velocity Value: -1.000000
Player x position set to 360.000000 with prediction remainder
Next Frame Number: 72
Input Start: 70
Duration: 2
Velocty Value: 0.000000
Current Ack: 68
Previous Ack: 67
Current Snapshot: 71
Previous Snapshot: 70
Current Performed Input: 68
Previous Performed Input: 67
Previous X Position: 360.000000
-----------------------------------------------------

 

Hello again, it's been a while.

After a long time bashing my head against this particular wall, I think I made some more progress. But I've been struggling with this issue so much that I'm not even sure my logic is sound. It currently feels a little bit like a kludge (see the TODO comment), but it almost works.

So the primary problem seemed to be with server reconciliation rather than the prediction itself. Specifically with the last confirmed player velocity, I was choosing the wrong start point to determine the duration. I was struggling for a while to find a decent start point, doing all sorts of funky math, but it looks like I just needed to start from the previous frame number. So that was rather infuriating.

Also, instead of basing my predictions on the current snapshot, I'm basing them on the previous snapshot. As I am using a snapshot interpolation scheme, I thought it would make more sense to do it that way.

So, as far as I can tell, my only current issue exists (once again) in the initial part of reconciliation. It appears the duration is occasionally one frame short, and I have no idea as to why.

So, if anyone could help me figure out my math issue, or link to an example where a client sends player speeds to the server and performs server reconciliation, that would be great. I would really like to move on from this issue.

My updated server reconciliation and prediction code is below; the server movement code is unchanged.

 


int snapDiff = currentSnapshot.snapshotNumber - previousSnapshot.snapshotNumber;

float alpha = 1.0f;

//The only reason snapDiff would be more than 1 is if we dropped a packet somewhere,
//in that case, we need to interpolate between the states
if (snapDiff > 1) {
  alpha = (std::min)((float)(frameNumber - previousSnapshot.snapshotNumber) / (snapDiff), 1.0f);
}

//reset player positions to where they were at the current snapshot
for (size_t i = 0; i < selectionStruct.numberOfPlayersOnClient; i++) {

  Entity currentPlayerEntity = selectionStruct.playerControllerEntityIDs[i];

  size_t transformIndex = transformTable.getEntityIndex(currentPlayerEntity);

  if (transformIndex == transformTable.getNumberOfComponents()) {
    continue;
  }

  auto prevTransform = std::find_if(previousSnapshot.getEntityTransformData().begin(), previousSnapshot.getEntityTransformData().end(),
                                    [currentPlayerEntity](EntityTransformData data) {
                                      return data.entity == currentPlayerEntity;
                                    });

  if (prevTransform == previousSnapshot.getEntityTransformData().end()) {

    if (i == 0) {
      transformTable.positions[transformIndex] = lastRecievedPosition;
    }

    continue;
  }

  auto currTransform = std::find_if(currentSnapshot.getEntityTransformData().begin(), currentSnapshot.getEntityTransformData().end(),
                                    [currentPlayerEntity](EntityTransformData data) {
                                      return data.entity == currentPlayerEntity;
                                    });

  transformTable.positions[transformIndex] = ((1 - alpha) * prevTransform->position) + (alpha * currTransform->position);

}

for (size_t playerIndex = 0; playerIndex < selectionStruct.numberOfPlayersOnClient; playerIndex++) {

  uint32_t endFrame = frameNumber;

  auto search = std::find_if(predictionQueue.begin(), predictionQueue.end(), [playerIndex](PlayerInputStruct input) {
    return input.inputCode == ((PLAYER_VELOCITY << 8) | playerIndex);
  });

  if (search != predictionQueue.end()) {
    endFrame = search->frameNumber + 120;
  }

  Entity currentEntity = selectionStruct.playerControllerEntityIDs[playerIndex];
  size_t transformIndex = transformTable.getEntityIndex(currentEntity);

  if (transformIndex >= transformTable.getNumberOfComponents()) {
    continue;
  }

  uint32_t startFrame = frameNumber - 1;

  int duration = endFrame - startFrame;

  if (duration < 0) {
    duration = 0;
  }

  float previousX = transformTable.positions[transformIndex].x;

  transformTable.positions[transformIndex].x -= selectionStruct.playerSpeeds[playerIndex] * lastConfirmedVelocities[playerIndex] * duration;

}

for (auto it = predictionQueue.begin(); it != predictionQueue.end(); it++) {

  uint16_t inputCode = it->inputCode;
  uint16_t playerIndex = inputCode & 0xff;
  uint16_t command = inputCode >> 8;
  float value = it->value;

  if (command == PLAYER_VELOCITY) {

    uint32_t endFrame = frameNumber;

    auto search = std::find_if(it + 1, predictionQueue.end(), [inputCode](PlayerInputStruct input) {
      return input.inputCode == inputCode;
    });

    if (search != predictionQueue.end()) {
      endFrame = search->frameNumber;
    }

    int duration = endFrame - it->frameNumber;

    //TODO: this is stupid, find a better solution for this issue
    if (it->frameNumber == currentSnapshot.lastPerformedInputFrameNumber) {
      duration -= 1;
    }

    Entity currentEntity = selectionStruct.playerControllerEntityIDs[playerIndex];
    size_t transformIndex = transformTable.getEntityIndex(currentEntity);

    if (transformIndex >= transformTable.getNumberOfComponents()) {
      continue;
    }

    transformTable.positions[transformIndex].x -= selectionStruct.playerSpeeds[playerIndex] * value * duration;

  }

}

for (size_t i = 0; i < selectionStruct.numberOfPlayersOnClient; i++) {

  Entity currentEntity = selectionStruct.playerControllerEntityIDs[i];
  size_t transformIndex = transformTable.getEntityIndex(currentEntity);

  if (transformIndex >= transformTable.getNumberOfComponents()) {
    continue;
  }

  transformTable.positions[transformIndex].x -= selectionStruct.playerSpeeds[i] * resultantVelocities[i];

}

}

Entity currentEntity = selectionStruct.playerControllerEntityIDs[0];
size_t transformIndex = transformTable.getEntityIndex(currentEntity);

 

"interpolation durations are sometimes one frame short" is the kind of bug that's usually almost impossible to debug without having the actual code, knowing how it works, and stepping through it with known input data.

Do your server messages all have their own ticks in the message, and you use those ticks, not just assume they're exactly matching up with your client ticks?

enum Bool { True, False, FileNotFound };
1 hour ago, hplus0603 said:

"interpolation durations are sometimes one frame short" is the kind of bug that's usually almost impossible to debug without having the actual code, knowing how it works, and stepping through it with known input data.

Do your server messages all have their own ticks in the message, and you use those ticks, not just assume they're exactly matching up with your client ticks?

The messages do indeed have their own tick numbers. I believe I am using the numbers correctly, but I'll put all the relevant code below to be sure. 

Some things that might bare mentioning;

When I receive a snapshot from the server, I add the number of frames I intend to buffer it for to its snapshot number. Thus, I would ideally use that snapshot in that many frames, and interpolate in the event that I dropped any snapshots before it.

I am currently testing this on a local server, so actual latency should be near zero.

The error seems to occur in the first part of the reconciliation; sometimes it is only for a single frame, sometimes the entire duration of that movement. Either way, it's annoying and I wish it would stop.

Without further ado, the code;


void GameplayState::handleInput(std::vector<InputEvent> input) {

	for (size_t i = 0; i < inputMappers.size(); i++) {

		PlayerInputMapper currentMapper = inputMappers[i];
		currentMapper.mapInputs(input);

		float resultantVelocity = 0.0f;

		for (PlayerInputChange change : currentMapper.getInputChanges()) {

			uint16_t command = 255;

			switch (change.input) {

			case SUMMON_1:
				command = SUMMON_1_START + (1 - (change.newValue >= .5f));
				break;

			case SUMMON_2:
				command = SUMMON_2_START + (1 - (change.newValue >= .5f));
				break;

			case SUMMON_3:
				command = SUMMON_3_START + (1 - (change.newValue >= .5f));
				break;

			case SUMMON_4:
				command = SUMMON_4_START + (1 - (change.newValue >= .5f));
				break;

			case ITEM:
				command = ITEM_START + (1 - (change.newValue >= .5f));
				break;

			case PAUSE:

				if (change.newValue >= .5f) {
					command = PAUSE_REQUEST;
				}

				break;

			case MOVE_DOWN:
				downVelocities[i] = change.newValue;
				break;

			case MOVE_UP:
				upVelocities[i] = change.newValue;
				break;

			case SET_DOWN_VELOCITY:
				resultantVelocity -= change.newValue;
				break;

			case SET_UP_VELOCITY:
				resultantVelocity += change.newValue;
				break;

			}

			//we set the command code, we should send it along
			if (command != 255) {

				uint16_t commandCode = (command << 8) | (uint8_t) i;
				client.queuePlayerInput(commandCode, frameNumber);
				predictionQueue.emplace_back(PlayerInputStruct(0, commandCode, frameNumber)); // packet number is not relevant to prediction, just set it to zero

			}

		}

		resultantVelocity += upVelocities[i] - downVelocities[i];
		resultantVelocity = (std::min)(1.0f, (std::max)(-1.0f, resultantVelocity));

		if (resultantVelocity != resultantVelocities[i]) {

			uint16_t commandCode = (PLAYER_VELOCITY << 8) | (uint8_t) i;
			client.queuePlayerInput(commandCode, frameNumber, resultantVelocity);
			resultantVelocities[i] = resultantVelocity;

			predictionQueue.emplace_back(PlayerInputStruct(frameNumber, commandCode, frameNumber, resultantVelocity));

		}

	}

}

void GameplayState::update(GameTime time) {

	client.sendPlayerInput();

	client.update();

	if (!client.isConnected()) {
		game.getStateManager().popState();
		return;
	}

	while (frameNumber > currentSnapshot.snapshotNumber && client.hasNextSnapshot()) {

		uint32_t previousInputFrame = previousSnapshot.lastPerformedInputFrameNumber;

		previousSnapshot = currentSnapshot;
		currentSnapshot = client.getNextSnapshot();

		currentDelta = SnapshotDelta(previousSnapshot, currentSnapshot);
		applyNonInterpolatedDelta();

		uint32_t lastInputFrame = previousSnapshot.lastPerformedInputFrameNumber;

		auto lastProcessed = std::find_if(predictionQueue.begin(), predictionQueue.end(), [lastInputFrame](PlayerInputStruct& s) {
			return s.frameNumber > lastInputFrame;
		});

		for (auto currentInput = predictionQueue.begin(); currentInput != lastProcessed; currentInput++) {

			uint16_t playerIndex = currentInput->inputCode & 0xff;
			uint16_t command = currentInput->inputCode >> 8;

			if (command == PLAYER_VELOCITY) {
				lastConfirmedVelocities[playerIndex] = currentInput->value;
			}

		}

		predictionQueue.erase(predictionQueue.begin(), lastProcessed);

		if (lastInputFrame != previousInputFrame) {

			inputProcessedConfirmationFrame = lastInputFrame;
			inputProcessedConfirmationSnapshot = previousSnapshot.snapshotNumber;

		}

	}

	interpolateState();

	doClientSidePrediction();

	frameNumber++;

}

void GameplayState::interpolateState() {

	int snapDiff = currentSnapshot.snapshotNumber - previousSnapshot.snapshotNumber;

	float alpha = 1.0f;

	//The only reason snapDiff would be more than 1 is if we dropped a packet somewhere,
	//in that case, we need to interpolate between the states
	if (snapDiff > 1) {
		alpha = (std::min)((float)(frameNumber - previousSnapshot.snapshotNumber) / (snapDiff), 1.0f);
	}

	const std::vector<EntityPositionData>& positionUpdates = currentDelta.getUpdatedEntityPositionData();

	std::vector<glm::vec3>& positions = world.transformTable.positions;

	for (auto it = positionUpdates.begin(); it != positionUpdates.end(); it++) {

		Entity currentEntity = it->entity;
		size_t currentIndex = world.transformTable.getEntityIndex(currentEntity);

		positions[currentIndex] = (it->previousPosition * (1.0f - alpha)) + (alpha * it->position);

	}

	const std::vector<EntityRotationData>& rotationUpdates = currentDelta.getUpdatedEntityRotationData();

	std::vector<glm::quat>& rotations = world.transformTable.rotations;

	for (auto it = rotationUpdates.begin(); it != rotationUpdates.end(); it++) {

		Entity currentEntity = it->entity;
		size_t currentIndex = world.transformTable.getEntityIndex(currentEntity);
		rotations[currentIndex] = (it->previousRotation * (1.0f - alpha)) + (alpha * it->rotation);

	}

	const std::vector<EntityScaleData>& scaleUpdates = currentDelta.getUpdatedEntityScaleData();

	std::vector<glm::vec3>& scales = world.transformTable.scales;

	for (auto it = scaleUpdates.begin(); it != scaleUpdates.end(); it++) {

		Entity currentEntity = it->entity;
		size_t currentIndex = world.transformTable.getEntityIndex(currentEntity);
		scales[currentIndex] = (it->previousScale * (1.0f - alpha)) + (alpha * it->scale);

	}

}

void GameplayState::applyNonInterpolatedDelta() {

	const std::vector<Entity>& removedTransforms = currentDelta.getRemovedTransformEntities();

	for (auto it = removedTransforms.begin(); it != removedTransforms.end(); it++) {
		world.transformTable.unregisterEntity(*it);
	}

	const std::vector<Entity>& removedTextures = currentDelta.getRemovedTextureEntities();

	for (auto it = removedTextures.begin(); it != removedTextures.end(); it++) {
		world.spriteTable.unregisterEntity(*it);
	}

	const std::vector<EntityTransformData>& fullTransforms = currentDelta.getFullTransformData();

	for (auto it = fullTransforms.begin(); it != fullTransforms.end(); it++) {
		world.transformTable.registerEntity(it->entity, it->parentEntity, it->position, it->rotation,
			it->scale);
	}

	const std::vector<EntityTextureData>& updatedTextures = currentDelta.getUpdatedEntityTextureData();

	for (auto it = updatedTextures.begin(); it != updatedTextures.end(); it++) {
		world.spriteTable.registerEntity(it->entity, game.getTextureAssetLibrary().acquire(it->textureAssetID),
			it->dimensions, it->origin);
	}

}

void GameplayState::doClientSidePrediction() {

	TransformComponentTable& transformTable = world.transformTable;
	ShieldControllerComponentTable& playerTable = world.playerControllerTable;

	int snapDiff = currentSnapshot.snapshotNumber - previousSnapshot.snapshotNumber;

	float alpha = 1.0f;

	//The only reason snapDiff would be more than 1 is if we dropped a packet somewhere,
	//in that case, we need to interpolate between the states
	if (snapDiff > 1) {
		alpha = (std::min)((float)(frameNumber - previousSnapshot.snapshotNumber) / (snapDiff), 1.0f);
	}

	//reset player positions to where they were at the current snapshot
	for (size_t i = 0; i < selectionStruct.numberOfPlayersOnClient; i++) {

		Entity currentPlayerEntity = selectionStruct.playerControllerEntityIDs[i];

		size_t transformIndex = transformTable.getEntityIndex(currentPlayerEntity);

		auto prevTransform = std::find_if(previousSnapshot.getEntityTransformData().begin(), previousSnapshot.getEntityTransformData().end(),
			[currentPlayerEntity](EntityTransformData data) {
			return data.entity == currentPlayerEntity;
		});

		auto currTransform = std::find_if(currentSnapshot.getEntityTransformData().begin(), currentSnapshot.getEntityTransformData().end(),
			[currentPlayerEntity](EntityTransformData data) {
			return data.entity == currentPlayerEntity;
		});

		transformTable.positions[transformIndex] = ((1 - alpha) * prevTransform->position) + (alpha * currTransform->position);

	}

	for (size_t playerIndex = 0; playerIndex < selectionStruct.numberOfPlayersOnClient; playerIndex++) {

		uint32_t endFrame = frameNumber;

		auto search = std::find_if(predictionQueue.begin(), predictionQueue.end(), [playerIndex](PlayerInputStruct input) {
			return input.inputCode == ((PLAYER_VELOCITY << 8) | playerIndex);
		});

		if (search != predictionQueue.end()) {
			endFrame = search->frameNumber + BUFFER_FRAMES;
		}

		Entity currentEntity = selectionStruct.playerControllerEntityIDs[playerIndex];
		size_t transformIndex = transformTable.getEntityIndex(currentEntity);

		if (transformIndex >= transformTable.getNumberOfComponents()) {
			continue;
		}
		
		uint32_t startFrame = frameNumber - 1;

		int duration = endFrame - startFrame;

		if (duration < 0) {
			duration = 0;
		}

		float previousX = transformTable.positions[transformIndex].x;

		transformTable.positions[transformIndex].x -= selectionStruct.playerSpeeds[playerIndex] * lastConfirmedVelocities[playerIndex] * duration;

	}

	for (auto it = predictionQueue.begin(); it != predictionQueue.end(); it++) {

		uint16_t inputCode = it->inputCode;
		uint16_t playerIndex = inputCode & 0xff;
		uint16_t command = inputCode >> 8;
		float value = it->value;

		if (command == PLAYER_VELOCITY) {

			uint32_t endFrame = frameNumber;

			auto search = std::find_if(it + 1, predictionQueue.end(), [inputCode](PlayerInputStruct input) {
				return input.inputCode == inputCode;
			});

			if (search != predictionQueue.end()) {
				endFrame = search->frameNumber;
			}

			int duration = endFrame - it->frameNumber;

			//TODO: this is stupid, find a better solution for this issue
			if (it->frameNumber == currentSnapshot.lastPerformedInputFrameNumber) {
				duration -= 1;
			}

			Entity currentEntity = selectionStruct.playerControllerEntityIDs[playerIndex];
			size_t transformIndex = transformTable.getEntityIndex(currentEntity);

			transformTable.positions[transformIndex].x -= selectionStruct.playerSpeeds[playerIndex] * value * duration;

		}

	}

	for (size_t i = 0; i < selectionStruct.numberOfPlayersOnClient; i++) {

		Entity currentEntity = selectionStruct.playerControllerEntityIDs[i];
		size_t transformIndex = transformTable.getEntityIndex(currentEntity);

		transformTable.positions[transformIndex].x -= selectionStruct.playerSpeeds[i] * resultantVelocities[i];

	}

}

I'm pretty sure the error is here in the client; before I started to implement prediction stuff looked nice and smooth.

Thank you for your assistance! I will of course continue to investigate on my own.

Well, it took me FAR too long, but I managed to piece together the issues, and it appears to now be working pretty much flawlessly, even with severe simulated packet loss. The issue was indeed with the math in the initial part of reconciliation, I'll paste the all the client prediction and reconciliation code here, and then explain what I got wrong.


void GameplayState::doClientSidePrediction() {

TransformComponentTable& transformTable = world.transformTable;
PlayerControllerComponentTable& playerTable = world.playerControllerTable;

//reset player positions to where they were at the current snapshot
for (size_t i = 0; i < selectionStruct.numberOfPlayersOnClient; i++) {
                                                               
Entity currentPlayerEntity = selectionStruct.playerControllerEntityIDs[i];

size_t transformIndex = transformTable.getEntityIndex(currentPlayerEntity);

auto currTransform = std::find_if(currentSnapshot.getEntityTransformData().begin(), currentSnapshot.getEntityTransformData().end(),
                    [currentPlayerEntity](EntityTransformData data) { return data.entity == currentPlayerEntity;});

transformTable.positions[transformIndex] = currTransform->position;

}

for (size_t playerIndex = 0; playerIndex < selectionStruct.numberOfPlayersOnClient; playerIndex++) {

	uint32_t endFrame = frameNumber + BUFFER_FRAMES;

	auto search = std::find_if(predictionQueue.begin(), predictionQueue.end(), [playerIndex](PlayerInputStruct input) {
   	return input.inputCode == ((PLAYER_VELOCITY << 8) | playerIndex);});

  
 	if (search != predictionQueue.end()) {
   		endFrame = search->frameNumber + BUFFER_FRAMES;
  	}

	Entity currentEntity = selectionStruct.playerControllerEntityIDs[playerIndex];
	size_t transformIndex = transformTable.getEntityIndex(currentEntity);

	uint32_t startFrame = currentSnapshot.snapshotNumber - ((RTT / (TIME_STEP * 2) + JITTER_FRAMES) + 1;

	int duration = endFrame - startFrame;

	transformTable.positions[transformIndex].x -= selectionStruct.playerSpeeds[playerIndex] * lastConfirmedVelocities[playerIndex] * duration;

  }

for (auto it = predictionQueue.begin(); it != predictionQueue.end(); it++) {

  uint16_t inputCode = it->inputCode;
  uint16_t playerIndex = inputCode & 0xff;
  uint16_t command = inputCode >> 8;
  float value = it->value;

  if (command == PLAYER_VELOCITY) {

  	uint32_t endFrame = frameNumber;

  	auto search = std::find_if(it + 1, predictionQueue.end(), [inputCode](PlayerInputStruct input) {
  	return input.inputCode == inputCode;
  	});

  	if (search != predictionQueue.end()) {
  	endFrame = search->frameNumber;
  	}

  	int duration = endFrame - it->frameNumber;

  	Entity currentEntity = selectionStruct.playerControllerEntityIDs[playerIndex];
  	size_t transformIndex = transformTable.getEntityIndex(currentEntity);

  	transformTable.positions[transformIndex].x -= selectionStruct.playerSpeeds[playerIndex] * value * duration;

  	}

  }

  for (size_t i = 0; i < selectionStruct.numberOfPlayersOnClient; i++) {

    Entity currentEntity = selectionStruct.playerControllerEntityIDs[i];
	size_t transformIndex = transformTable.getEntityIndex(currentEntity);

	transformTable.positions[transformIndex].x -= selectionStruct.playerSpeeds[i] * resultantVelocities[i];

  }

}

My client uses an interpolation scheme, meaning the server will add a few frames to its frame number to account for jitter, in addition to half the round trip time in frames. This additional time needs to be taken into account when calculating the start frame of the initial reconciliation. Also, make sure the number of jitter frames is constant; I was performing some rounding on the server side that would round up to the nearest frame count in regards to the round trip time, making the number of jitter frames inconsistent, leading to some inconsistent movement. Also, instead of the actual frame number, I use the current snapshot number in the calculations.

Interpolation is not actually necessary, and actually counter-productive. Just set the player position to the current snapshot's position before performing reconciliation. This means that I have gone back to using the current snapshot as the basis of my prediction and reconciliation.

Finally, the end frame for duration calculations didn't take into account buffer frames if you held the button longer than the number of buffer frames. Easy enough to fix, just add them on.

That was far more painful than it needed to be, but I managed to muddle through. Hopefully this will help others with similar plights.

That was far more painful than it needed to be, but I managed to muddle through.

Give a person a program, and they will be frustrated for a day.

Teach a person software development, and they will be frustrated for the rest of their life!

enum Bool { True, False, FileNotFound };

This topic is closed to new replies.

Advertisement