Real-time statussynchronisatie: Netcode Rollback en afstemming
Stel je voor dat je in je spel een vijand neerschiet: de kogel gaat af, de vijand lijkt dan te sterven plotseling "verschijnt" levend een paar meter verderop. Dit is het klassieke symptoom van a slecht ontworpen netcode. In een multiplayergame ziet elke klant een iets andere versie van de wereld, gefaseerd in de tijd vanwege netwerklatentie.
Het fundamentele probleem van multiplayersynchronisatie is dit: hoe behoud je een visie? consistentie van de gamewereld tussen tientallen verbonden clients met verschillende latenties, zonder introductie waarneembare input lag en zonder dat iemand vals kan spelen? Het antwoord wordt genoemd netcode terugdraaien gecombineerd met Voorspelling aan de klantzijde e serverafstemming. Deze drie mechanismen werken samen om te creëren de illusie van responsief, consistent spelen op een onbetrouwbaar netwerk.
Wat je gaat leren
- Netcode-architecturen: lockstep versus rollback versus hybride statussynchronisatie
- Voorspelling aan de clientzijde: hoe de client op de server anticipeert
- Serverafstemming: clientstatus herstellen
- Rollback netcode: resimulatie met terugwerkende kracht van invoer
- Entiteitsinterpolatie: vloeiende bewegingen ondanks latentie
- Delta-compressie om de statusbandbreedte te verminderen
De drie architecturen van Netcode
| Architectuur | Mechanisme | Pro | Tegen | Gebruikt binnen |
|---|---|---|---|---|
| Lockstep | Alle clients wachten op de input van alle anderen voordat ze verder gaan | Perfecte consistentie, gegarandeerd determinisme | Invoervertraging = hogere latentie, kwetsbaar voor verbroken verbinding | Klassieke RTS (Age of Empires, StarCraft) |
| Terugdraaien | Ga verder met voorspelde invoer, ga terug als het fout is | Geen waarneembare invoervertraging, responsief | Visueel flikkeren, CPU-intensief | Vechtspellen (Street Fighter, Guilty Gear) |
| Staatssynchronisatie + voorspelling | Gezaghebbende server, client voorspelt en verzoent | Schaalbaar, veilig, gebalanceerd | Complexer om te implementeren | FPS/TPS (CS2, Overwatch, Valorant) |
Voorspelling aan de klantzijde
La Voorspelling aan de klantzijde en de techniek waarmee de cliënt onmiddellijk uitvoert uw invoer lokaal, zonder te wachten op bevestiging van de server. Dit elimineert invoervertraging waarneembaar: wanneer je op "vooruit" drukt, beweegt het personage onmiddellijk over je scherm.
De truc is dat de client een geschiedenis bijhoudt van alle invoer die naar de server wordt verzonden, geïdentificeerd door een volgnummer. Wanneer de server reageert, vergelijkt de client de eigen voorspelde positie met de gezaghebbende van de server.
// Client-side prediction con storico input
interface PlayerInput {
sequence: number; // Numero progressivo di ogni input
timestamp: number;
moveX: number; // -1, 0, 1
moveY: number;
actions: string[]; // 'jump', 'shoot', 'crouch'
deltaTime: number; // Tempo dall'ultimo input
}
interface PlayerState {
x: number;
y: number;
velocityX: number;
velocityY: number;
health: number;
sequence: number; // Ultimo input applicato
}
class ClientPrediction {
private pendingInputs: PlayerInput[] = []; // Input inviati ma non ancora confermati
private predictedState: PlayerState;
private lastConfirmedSequence = 0;
constructor(initialState: PlayerState) {
this.predictedState = { ...initialState };
}
// Processa un nuovo input del giocatore locale
processInput(input: PlayerInput): void {
// 1. Applica immediatamente l'input alla predizione locale
this.predictedState = this.simulatePhysics(this.predictedState, input);
// 2. Aggiungi allo storico degli input pendenti
this.pendingInputs.push(input);
// 3. Invia al server (non-blocking, fire-and-forget)
this.network.sendInput(input);
// 4. Aggiorna il rendering con la posizione predetta
this.renderer.setPlayerPosition(this.predictedState.x, this.predictedState.y);
}
// Riceve lo stato autoritativo dal server
onServerState(serverState: PlayerState): void {
// 1. Scarta gli input precedenti alla conferma del server
this.pendingInputs = this.pendingInputs.filter(
input => input.sequence > serverState.sequence
);
// 2. Controlla se c'è divergenza significativa
const dx = Math.abs(this.predictedState.x - serverState.x);
const dy = Math.abs(this.predictedState.y - serverState.y);
const CORRECTION_THRESHOLD = 0.5; // Unita di gioco
if (dx > CORRECTION_THRESHOLD || dy > CORRECTION_THRESHOLD) {
// 3. Server reconciliation: riparti dallo stato server
this.predictedState = { ...serverState };
// 4. Riapplica tutti gli input pendenti (quelli dopo la conferma)
for (const input of this.pendingInputs) {
this.predictedState = this.simulatePhysics(this.predictedState, input);
}
// 5. Aggiorna il renderer con la nuova posizione corretta
this.renderer.setPlayerPosition(this.predictedState.x, this.predictedState.y);
}
this.lastConfirmedSequence = serverState.sequence;
}
// Simulazione deterministica della fisica (deve essere identica client e server)
private simulatePhysics(state: PlayerState, input: PlayerInput): PlayerState {
const SPEED = 200; // px/sec
const FRICTION = 0.85;
const newVelX = (state.velocityX + input.moveX * SPEED * input.deltaTime) * FRICTION;
const newVelY = (state.velocityY + input.moveY * SPEED * input.deltaTime) * FRICTION;
return {
...state,
x: state.x + newVelX * input.deltaTime,
y: state.y + newVelY * input.deltaTime,
velocityX: newVelX,
velocityY: newVelY,
sequence: input.sequence
};
}
}
Netcode terugdraaien
Terugdraaien verschilt van eenvoudige voorspelling: terwijl voorspelling betrekking heeft op de lokale speler, rollback verwerkt de invoer van alle externe spelers. In een 60 FPS-game met een latentie van 200 ms, de client kent de invoer van de externe speler gedurende ongeveer 12 frames niet. In plaats van te wachten (toevoegen invoervertraging) of gebruik de laatst bekende invoer (veroorzaakt storingen), voorspelt rollback invoer op afstand, ga verder met de simulatie, en ga dan terug en simuleer opnieuw als de voorspelling verkeerd was.
// Rollback netcode - gestione input remoti
interface GameFrame {
frameNumber: number;
states: Map<string, PlayerState>; // playerId -> state
inputs: Map<string, PlayerInput>; // playerId -> input di quel frame
}
class RollbackManager {
private frameHistory: GameFrame[] = [];
private readonly MAX_HISTORY = 60; // 1 secondo a 60fps
private currentFrame = 0;
// Salva lo stato di ogni frame (necessario per il rollback)
saveFrame(states: Map<string, PlayerState>, inputs: Map<string, PlayerInput>): void {
const frame: GameFrame = {
frameNumber: this.currentFrame++,
states: new Map(states),
inputs: new Map(inputs)
};
this.frameHistory.push(frame);
// Mantieni solo gli ultimi MAX_HISTORY frame
if (this.frameHistory.length > this.MAX_HISTORY) {
this.frameHistory.shift();
}
}
// Riceve l'input di un giocatore remoto con ritardo
onRemoteInput(playerId: string, input: PlayerInput): { needsRollback: boolean; fromFrame: number } {
const targetFrame = this.frameHistory.find(f => f.frameNumber === input.sequence);
if (!targetFrame) {
// Input troppo vecchio, ignoriamo
return { needsRollback: false, fromFrame: -1 };
}
const predictedInput = targetFrame.inputs.get(playerId);
const inputChanged = !this.inputsEqual(predictedInput, input);
if (inputChanged) {
// L'input reale differisce dalla predizione: serve rollback
targetFrame.inputs.set(playerId, input);
return { needsRollback: true, fromFrame: input.sequence };
}
return { needsRollback: false, fromFrame: -1 };
}
// Esegue il rollback: risimula dalla frame indicata
rollback(fromFrame: number, simulate: (state: Map<string, PlayerState>, inputs: Map<string, PlayerInput>) => Map<string, PlayerState>): Map<string, PlayerState> {
const startIdx = this.frameHistory.findIndex(f => f.frameNumber === fromFrame);
if (startIdx === -1) throw new Error(`Frame ${fromFrame} non trovata in history`);
// Parte dallo stato del frame precedente a quello errato
let currentStates = this.frameHistory[startIdx].states;
// Risimula tutti i frame fino al corrente con gli input corretti
for (let i = startIdx; i < this.frameHistory.length; i++) {
const frame = this.frameHistory[i];
currentStates = simulate(currentStates, frame.inputs);
frame.states = new Map(currentStates); // Aggiorna la history
}
return currentStates;
}
// Predice l'input di un giocatore remoto basandosi sull'ultimo noto
predictRemoteInput(playerId: string): PlayerInput {
// Strategia comune: ripeti l'ultimo input conosciuto
for (let i = this.frameHistory.length - 1; i >= 0; i--) {
const input = this.frameHistory[i].inputs.get(playerId);
if (input) return { ...input, sequence: this.currentFrame };
}
// Default: input neutro (nessun movimento)
return { sequence: this.currentFrame, timestamp: Date.now(), moveX: 0, moveY: 0, actions: [], deltaTime: 1/60 };
}
private inputsEqual(a: PlayerInput | undefined, b: PlayerInput): boolean {
if (!a) return false;
return a.moveX === b.moveX && a.moveY === b.moveY &&
JSON.stringify(a.actions) === JSON.stringify(b.actions);
}
}
Entiteitsinterpolatie voor externe spelers
De voorspelling werkt voor de lokale speler. Voor spelers op afstand wordt een andere techniek gebruikt: deentiteitsinterpolatie. In plaats van externe spelers naar hun locatie te leiden "huidig" (wat de positie zou zijn die van de server wordt ontvangen, altijd te laat), geeft de client deze weer naar een positie iets in het verleden, waarbij wordt geïnterpoleerd tussen de laatste twee ontvangen snapshots.
// Entity interpolation per giocatori remoti
interface Snapshot {
timestamp: number;
positions: Map<string, { x: number; y: number; rotation: number }>;
}
class EntityInterpolation {
private snapshots: Snapshot[] = [];
private readonly INTERPOLATION_DELAY_MS = 100; // Renderizziamo 100ms nel passato
private readonly MAX_SNAPSHOTS = 20;
// Riceve un nuovo snapshot dal server
onSnapshot(snapshot: Snapshot): void {
this.snapshots.push(snapshot);
this.snapshots.sort((a, b) => a.timestamp - b.timestamp);
if (this.snapshots.length > this.MAX_SNAPSHOTS) {
this.snapshots.shift();
}
}
// Calcola la posizione interpolata di ogni entità al tempo corrente
getInterpolatedPositions(): Map<string, { x: number; y: number; rotation: number }> {
// Renderizziamo nel passato per avere sempre due snapshot tra cui interpolare
const renderTime = Date.now() - this.INTERPOLATION_DELAY_MS;
// Trova i due snapshot che racchiudono il renderTime
let before: Snapshot | null = null;
let after: Snapshot | null = null;
for (const snap of this.snapshots) {
if (snap.timestamp <= renderTime) before = snap;
if (snap.timestamp >= renderTime && !after) after = snap;
}
if (!before || !after) return new Map();
// Interpola tra i due snapshot
const t = (renderTime - before.timestamp) / (after.timestamp - before.timestamp);
const result = new Map<string, { x: number; y: number; rotation: number }>();
for (const [playerId, posAfter] of after.positions) {
const posBefore = before.positions.get(playerId);
if (!posBefore) continue;
result.set(playerId, {
x: this.lerp(posBefore.x, posAfter.x, t),
y: this.lerp(posBefore.y, posAfter.y, t),
rotation: this.lerpAngle(posBefore.rotation, posAfter.rotation, t)
});
}
return result;
}
private lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
// Interpolazione angolare: gestisce il wrap-around 0-360
private lerpAngle(a: number, b: number, t: number): number {
let diff = b - a;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
return a + diff * t;
}
}
Deltacompressie om de bandbreedte te verminderen
In een spel met 64 spelers die de status 60 keer per seconde bijwerken, verzendt u de volledige status elke teek is onpraktisch. Daar delta-compressie verzendt alleen de vergeleken verschillen naar de laatste door de klant bevestigde staat.
// Delta compression per snapshot di gioco
interface EntityState {
id: string;
x: number;
y: number;
health: number;
animation: string;
sequence: number;
}
class DeltaCompressor {
private lastAcknowledgedState = new Map<string, EntityState>();
// Crea un delta tra lo stato corrente e quello confermato dal client
createDelta(
currentStates: Map<string, EntityState>,
clientAckSequence: number
): { changed: EntityState[]; removed: string[]; baseline: number } {
const changed: EntityState[] = [];
const removed: string[] = [];
for (const [id, current] of currentStates) {
const last = this.lastAcknowledgedState.get(id);
if (!last || this.hasChanged(last, current)) {
changed.push(current);
}
}
// Entità rimosse (non presenti nello stato corrente)
for (const [id] of this.lastAcknowledgedState) {
if (!currentStates.has(id)) {
removed.push(id);
}
}
return { changed, removed, baseline: clientAckSequence };
}
// Applica un delta lato client
applyDelta(
currentStates: Map<string, EntityState>,
delta: { changed: EntityState[]; removed: string[] }
): Map<string, EntityState> {
const newStates = new Map(currentStates);
for (const entity of delta.changed) {
newStates.set(entity.id, entity);
}
for (const id of delta.removed) {
newStates.delete(id);
}
return newStates;
}
private hasChanged(prev: EntityState, curr: EntityState): boolean {
// Tolleranza per floating point
const EPSILON = 0.01;
return (
Math.abs(prev.x - curr.x) > EPSILON ||
Math.abs(prev.y - curr.y) > EPSILON ||
prev.health !== curr.health ||
prev.animation !== curr.animation
);
}
}
Prestatievergelijking: synchronisatiebenaderingen
| Techniek | Bandbreedte vereist | Klant-CPU | Visuele invoervertraging | Moeilijkheid impl. |
|---|---|---|---|---|
| Geen voorspelling | Laag | Laag | = Netwerklatentie | Eenvoudig |
| Klantvoorspelling + afstemming | Gemiddeld | Gemiddeld | ~0ms waargenomen | Gemiddeld |
| Volledige netcode-rollback | Gemiddeld | Hoog (resimulatie) | 0 ms gegarandeerd | Hoog |
| Deltacompressie + interpolatie | Zeer laag (-60-80%) | Gemiddeld | ~100ms (vertraging) | Middelhoog |
Determinisme Vereisten
Terugdraaien werkt ALLEEN als de simulatie deterministisch is: gegeven dezelfde invoer in hetzelfde
bestelling, levert precies dezelfde output op. Dit is exclusief het gebruik van Math.random()
niet-gezaaide, niet-deterministische drijvende-kommabewerkingen tussen verschillende architecturen, en welke dan ook
bron van externe entropie. Gebruik altijd een pseudo-willekeurige nummergenerator met vaste zaden
spellogica.
Beste praktijken voor synchronisatie
- Gebruik een vaste tijdstap: Update de simulatie met vaste stappen (bijvoorbeeld 64 Hz), ongeacht de framerate
- Invoerbuffers: Onderhoud een invoerbuffer om latentievariaties (jitter) te absorberen
- Vertragingscompensatie: Voor FPS-hits past u terugspoelen aan de serverzijde toe om hits in de clienttijdlijn te evalueren
- Limietgeschiedenis: Bewaar alleen de frames die nodig zijn voor het terugdraaien (max. latentie/frametijd)
- Monitor divergentie: Voeg statistieken toe om bij te houden wanneer en hoe vaak oplossingen optreden
Conclusies
Statussynchronisatie in een multiplayer-game is een netwerktechnisch probleem tussen complexer. De combinatie van voorspelling aan de clientzijde, serverafstemming en entiteitsinterpolatie en de standaardoplossing voor moderne FPS en TPS, terwijl rollback-netcode onvervangbaar blijft voor vechtspellen waarbij elk frame telt.
In het volgende artikel gaan we in op deanti-cheat-architectuur: hoe maak je het veilig? de gezaghebbende server en hoe u afwijkend gedrag kunt detecteren met gedragsanalyse.
Volgende in de Game Backend-serie
- Artikel 05: Anti-Cheat-architectuur en gedragsanalyse
- Artikel 06: Open Match en Nakama - Open-source game-backend







