Reconciliation System
The Reconciliation System implements client-side prediction with server reconciliation to provide smooth, responsive gameplay while maintaining server authority.
Problem
In online multiplayer games, there's always network latency between the client and server. Without client-side prediction: - Player movement feels laggy (inputs take RTT/2 to show effect) - Game feels unresponsive and difficult to control
With naive client-side prediction: - Client predicts movement immediately (feels responsive) - But when server state arrives, position "snaps" to correct location - Creates jarring visual artifacts
Solution: Server Reconciliation
The reconciliation system solves this by: 1. Predicting - Client applies inputs immediately for responsive feel 2. Storing - Client keeps history of unacknowledged inputs 3. Rewinding - When server state arrives, rewind to that authoritative position 4. Replaying - Replay all unacknowledged inputs from that point 5. Smooth correction - Small errors are ignored to avoid jitter
Architecture
InputHistoryComponent
Stores a history of inputs sent to the server but not yet acknowledged:
struct InputHistoryEntry {
std::uint32_t sequenceId; // Unique ID for this input
std::uint16_t flags; // Movement/action flags
float posX, posY, angle; // State when input was sent
float deltaTime; // Time step for replay
};
struct InputHistoryComponent {
std::deque<InputHistoryEntry> history;
std::uint32_t lastAcknowledgedSequence;
std::size_t maxHistorySize = 128;
void pushInput(uint32_t seq, uint16_t flags, ...);
void acknowledgeUpTo(uint32_t seq);
std::deque<InputHistoryEntry> getInputsAfter(uint32_t seq);
};
ReconciliationSystem
Manages the rewind-and-replay process:
class ReconciliationSystem : public ISystem {
public:
void reconcile(Registry& registry,
EntityId entityId,
float authoritativeX,
float authoritativeY,
uint32_t acknowledgedSequence);
};
Usage
1. Setup Input History
When creating a player entity that needs prediction:
EntityId player = registry.createEntity();
registry.emplace<TransformComponent>(player);
registry.emplace<InputHistoryComponent>(player);
2. Store Inputs When Sending
When sending input to server, also store in history:
// In InputSystem or similar
void sendInput(uint16_t flags, float posX, float posY, float angle) {
uint32_t sequenceId = nextSequence();
// Send to server
InputPacket packet;
packet.header.sequenceId = sequenceId;
packet.flags = flags;
packet.x = posX;
packet.y = posY;
packet.angle = angle;
sendPacket(packet);
// Store in history for reconciliation
if (registry.has<InputHistoryComponent>(playerEntity)) {
auto& history = registry.get<InputHistoryComponent>(playerEntity);
history.pushInput(sequenceId, flags, posX, posY, angle, deltaTime);
}
}
3. Reconcile When Server State Arrives
When receiving authoritative state from server:
// In your network receive handler or ReplicationSystem
void onServerStateReceived(EntityId entity, float serverX, float serverY,
uint32_t lastProcessedInputSeq) {
reconciliationSystem.reconcile(registry, entity,
serverX, serverY,
lastProcessedInputSeq);
}
How It Works
Example Timeline
Time Client Server
---- ------ ------
t=0 Input seq=1: Move Right →
Predict: pos = (5, 0)
t=16 Input seq=2: Move Right → Receives seq=1
Predict: pos = (10, 0) Process seq=1
pos = (5, 0)
t=32 Input seq=3: Move Right → Receives seq=2
Predict: pos = (15, 0) Process seq=2
pos = (10, 0)
← Sends state: pos=(10,0), lastSeq=2
t=48 Receives server state!
Current prediction: (15, 0)
Server says: (10, 0) @ seq=2
Reconciliation:
1. Rewind to (10, 0)
2. Replay seq=3
3. Result: (15, 0) ✓
4. Acknowledge seq=1,2
With Prediction Error
If the client and server disagree (e.g., due to collision):
t=48 Receives server state
Current prediction: (15, 0)
Server says: (9, 0) @ seq=2 ← Collision stopped movement!
Reconciliation:
1. Rewind to (9, 0) ← Use authoritative position
2. Replay seq=3
3. Result: (14, 0) ← Corrected prediction
4. Visual change: 15→14 ← Smooth 1-unit correction
Configuration
Movement Speed
Critical: Client and server must use the same movement speed!
// Client
ReconciliationSystem reconciliation;
reconciliation.playerMoveSpeed_ = 200.0F; // Must match server
// Server (in movement system)
const float PLAYER_SPEED = 200.0F; // Must match client
Reconciliation Threshold
Controls when to apply corrections (avoids jitter from small errors):
Lower values: More accurate but potential jitter Higher values: Smoother but allows more error
Recommended: 0.5 - 2.0 units depending on game speed
History Size
Maximum number of stored inputs:
Too small: Old inputs lost if high latency Too large: Memory waste
Recommended: 128-256 (supports 500ms-1s RTT)
Integration with Existing Systems
With ReplicationSystem
The ReplicationSystem receives server snapshots. Extend it to trigger reconciliation:
// In ReplicationSystem::applyTransform()
void ReplicationSystem::applyTransform(Registry& registry, EntityId id,
const SnapshotEntity& entity) {
if (!entity.posX.has_value() && !entity.posY.has_value()) {
return;
}
// Check if this is a player-controlled entity
if (registry.has<InputHistoryComponent>(id)) {
// Use reconciliation instead of direct position update
reconciliationSystem_->reconcile(
registry, id,
entity.posX.value_or(0.0F),
entity.posY.value_or(0.0F),
entity.lastProcessedInput // Server must send this!
);
} else {
// Non-player entities: direct update
auto& transform = registry.get<TransformComponent>(id);
if (entity.posX.has_value()) transform.x = *entity.posX;
if (entity.posY.has_value()) transform.y = *entity.posY;
}
}
With InputSystem
Store inputs when sending:
void InputSystem::update(Registry& registry, float deltaTime) {
uint16_t flags = mapper_->getFlags();
float angle = calculateAngle();
uint32_t seq = nextSequence();
// Send to server
InputCommand cmd;
cmd.sequenceId = seq;
cmd.flags = flags;
cmd.posX = *posX_;
cmd.posY = *posY_;
cmd.angle = angle;
buffer_->push(cmd);
// Store for reconciliation (player entity assumed to be ID 1)
EntityId player = getLocalPlayer(registry);
if (registry.has<InputHistoryComponent>(player)) {
auto& history = registry.get<InputHistoryComponent>(player);
history.pushInput(seq, flags, *posX_, *posY_, angle, deltaTime);
}
}
Server Requirements
For reconciliation to work, the server must:
- Track last processed input per client
- Send this sequence ID in state updates
- Use deterministic simulation (same as client)
Example server state packet extension:
struct PlayerStateUpdate {
uint32_t entityId;
float x, y;
uint32_t lastProcessedInputSeq; // ← Required for reconciliation
};
Best Practices
1. Deterministic Simulation
Client and server movement logic MUST be identical:
// ✓ Good: Same code on client and server
void applyMovement(Transform& t, uint16_t flags, float dt, float speed) {
float dx = 0, dy = 0;
if (flags & MoveUp) dy -= 1;
if (flags & MoveDown) dy += 1;
// ...
t.x += dx * speed * dt;
t.y += dy * speed * dt;
}
// ✗ Bad: Different logic
// Client: instant movement
// Server: accelerated movement
2. Input Sequence Management
Use monotonically increasing sequence IDs:
Never reuse or skip sequence IDs!
3. Handling Packet Loss
If server acknowledges seq=10 but client never sent seq=8:
// In reconciliation
void acknowledgeUpTo(uint32_t seq) {
// Remove ALL inputs <= seq, even if some were lost
while (!history.empty() && history.front().sequenceId <= seq) {
history.pop_front();
}
}
This is safe because server is authoritative - if an input was lost, server didn't process it, so we shouldn't replay it.
4. Delta Time Accuracy
Store delta time with each input for accurate replay:
// When sending input
history.pushInput(seq, flags, x, y, angle, deltaTime);
// Server should also use same delta time
// OR client can store server tick rate
5. Reconciliation Threshold Tuning
Test different thresholds:
// High-speed game (fast movement)
threshold = 2.0F; // Ignore small errors
// Precision game (slow, tactical)
threshold = 0.1F; // Very accurate
// Test by adding artificial lag
Debugging
Visualizing Reconciliation
Add debug rendering to see when reconciliation occurs:
void ReconciliationSystem::reconcile(...) {
float errorMagnitude = std::sqrt(errorX * errorX + errorY * errorY);
#ifdef DEBUG_RECONCILIATION
if (errorMagnitude >= reconciliationThreshold_) {
std::cout << "Reconciliation: error=" << errorMagnitude
<< " rewind to (" << authoritativeX << "," << authoritativeY << ")"
<< " replay " << unacknowledgedInputs.size() << " inputs"
<< std::endl;
}
#endif
// ...
}
Common Issues
Jittery movement: - Threshold too low → increase reconciliationThreshold_ - Different delta times → ensure client and server use same dt
Rubber-banding: - Movement speed mismatch → verify client/server use same speed - Missing inputs → check input history size
Inputs not acknowledged: - Server not sending lastProcessedInputSeq → update server protocol - Sequence ID mismatch → verify sequence generation
Performance Considerations
Memory
Each input entry: ~32 bytes With 128 max history: 4 KB per player
For 100 players with prediction: 400 KB total
CPU
Reconciliation cost: - Threshold check: O(1) - Rewind: O(1) - Replay: O(n) where n = unacknowledged inputs
Typical case: 2-5 inputs to replay (60fps, 100ms RTT) Worst case: 32 inputs (60fps, 500ms RTT)
Very cheap compared to rendering!
Future Enhancements
Smooth Error Correction
Instead of instant rewind, lerp over a few frames:
void reconcile(...) {
float errorX = predictedX - authoritativeX;
if (errorMagnitude > threshold) {
// Store error for gradual correction
transform.correctionX = -errorX;
transform.correctionSpeed = 5.0F; // Correct over 5 frames
}
}
Entity Interpolation
Combine with interpolation for non-player entities:
if (isLocalPlayer) {
reconcile(); // Prediction + reconciliation
} else {
interpolate(); // Smooth interpolation
}
Lag Compensation
For shooting/interactions, raycast using historical positions.