Sincronizare în timp real a stării: derulare netcode și reconciliere
Imaginează-ți că împuști un inamic în jocul tău: glonțul se stinge, inamicul pare să moară, apoi brusc „reapare” viu la câțiva metri distanță. Acesta este simptomul clasic al a netcode prost proiectat. Într-un joc multiplayer, fiecare client vede o versiune ușor diferită a lumii, treptat în timp din cauza latenței rețelei.
Problema fundamentală a sincronizării multiplayer este următoarea: cum să menții o viziune consistența lumii de joc între zeci de clienți conectați cu latențe diferite, fără introducere decalaj de intrare perceptibil și fără a permite nimănui să trișeze? Răspunsul se numește rollback netcode combinat cu predicție pe partea clientului e reconcilierea serverului. Aceste trei mecanisme lucrează împreună pentru a crea iluzia unui joc receptiv și consistent pe o rețea nesigură.
Ce vei învăța
- Arhitecturi Netcode: lockstep vs rollback vs hibrid state sync
- Predicție pe partea clientului: modul în care clientul anticipează serverul
- Reconciliere server: remediați starea clientului
- Rollback netcode: resimulare retroactivă a intrărilor
- Interpolare entități: mișcări netede în ciuda latenței
- Compresie Delta pentru a reduce lățimea de bandă de stat
Cele trei arhitecturi ale Netcode
| Arhitectură | Mecanism | Pro | Împotriva | Folosit în |
|---|---|---|---|---|
| Lockstep | Toți clienții așteaptă părerea tuturor înainte de a progresa | Consecvență perfectă, determinism garantat | Întârziere de intrare = latență mai mare, vulnerabil la deconectare | RTS clasic (Age of Empires, StarCraft) |
| Rollback | Avansați cu intrările prezise, întoarceți-vă dacă este greșit | Întârziere de intrare zero perceptibilă, receptiv | Pâlpâire vizuală, consumatoare de CPU | Jocuri de luptă (Street Fighter, Guilty Gear) |
| Sincronizare de stat + Predicție | Server autorizat, clientul prezice și reconciliază | Scalabil, sigur, echilibrat | Mai complex de implementat | FPS/TPS (CS2, Overwatch, Valorant) |
Predicție pe partea clientului
La predicție pe partea clientului si tehnica prin care clientul executa imediat introducerea dvs. local, fără a aștepta confirmarea de la server. Acest lucru elimină decalajul de intrare perceptibil: când apăsați „înainte”, personajul se mișcă instantaneu pe ecran.
Trucul este că clientul păstrează un istoric al tuturor intrărilor trimise către server, identificate printr-un număr secvenţial. Când serverul răspunde, clientul compară propria pozitie prezisa cu cea autorizata a serverului.
// 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
};
}
}
Rollback Netcode
Rollback este diferit de simpla predicție: în timp ce predicția se referă la jucătorul local, rollback gestionează intrarea de la toți jucătorii de la distanță. Într-un joc de 60 FPS cu latență de 200 ms, clientul nu cunoaște intrarea playerului de la distanță pentru aproximativ 12 cadre. În loc să așteptați (adăugând întârziere de intrare) sau utilizați ultima intrare cunoscută (care provoacă erori), rollback-ul prezice intrarea de la distanță, avansați simularea, apoi reveniți și re-simulați dacă predicția a fost greșită.
// 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);
}
}
Interpolarea entităților pentru jucători de la distanță
Pronosticul funcționează pentru jucătorul local. Pentru jucătorii de la distanță, se folosește o tehnică diferită: celinterpolare de entitate. În loc să redați jucătorii de la distanță în locația lor „curent” (care ar fi poziția primită de la server, întotdeauna târziu), clientul le redă într-o poziție ușor în trecut, interpolând între ultimele două instantanee primite.
// 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;
}
}
Compresie Delta pentru a reduce lățimea de bandă
Într-un joc cu 64 de jucători care actualizează starea de 60 de ori pe secundă, trimiteți starea completă fiecare bifă este impracticabilă. Acolo compresie delta trimite doar diferențele comparate până la ultima stare confirmată de client.
// 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
);
}
}
Comparație de performanță: abordări de sincronizare
| Tehnică | Lățime de bandă necesară | CPU client | Decalaj vizual de intrare | Dificultate impl. |
|---|---|---|---|---|
| Nicio predicție | Scăzut | Scăzut | = Latența rețelei | Uşor |
| Predicție client + reconciliere | Medie | Medie | ~0 ms percepute | Medie |
| Rollback complet netcode | Medie | Ridicat (resimulare) | 0 ms garantat | Ridicat |
| Compresie delta + interpolare | Foarte scăzut (-60-80%) | Medie | ~100 ms (întârziere) | Mediu-Ridicat |
Cerințe de determinism
Rollback funcționează NUMAI dacă simularea este deterministă: având aceleași intrări în aceeași
comanda, produce exact aceeași ieșire. Aceasta exclude utilizarea Math.random()
operațiuni neînsămânțate, nedeterministe în virgulă mobilă între diferite arhitecturi și orice
sursă de entropie externă. Utilizați întotdeauna un generator de numere pseudoaleatoare cu semințe fixe pentru
logica jocului.
Cele mai bune practici pentru sincronizare
- Utilizați interval de timp fix: Actualizați simularea la pași fixe (de exemplu, 64 Hz), indiferent de framerate
- Buffere de intrare: Mențineți un tampon de intrare pentru a absorbi variațiile de latență (jitter)
- Compensare lag: Pentru accesările FPS, aplicați derulare înapoi pe server pentru a evalua accesările în cronologia clientului
- Limitare istoric: Păstrați numai cadrele necesare pentru rollback (latență maximă / timp de cadru)
- Monitorizarea divergenței: Adăugați valori pentru a urmări când și cât de des apar remedieri
Concluzii
Sincronizarea stării într-un joc multiplayer este o problemă de inginerie de rețea între mai complex. Combinația dintre predicția pe partea client, reconcilierea serverului și interpolarea entităților și soluția standard pentru FPS și TPS moderne, în timp ce rollback netcode rămâne de neînlocuit pentru jocuri de luptă în care fiecare cadru contează.
În articolul următor ne vom adresaarhitectura anti-cheat: cum să-l faci în siguranță serverul autorizat și modul de detectare a comportamentului anormal cu analiza comportamentală.
Următorul în seria Game Backend
- Articolul 05: Arhitectura Anti-Cheat și Analiza Comportamentală
- Articolul 06: Open Match și Nakama - Open-Source Game Backend







