Synchronizace stavu v reálném čase: vrácení a odsouhlasení Netcode
Představte si, že ve své hře zastřelíte nepřítele: kulka vyletí a nepřítel se zdá, že umírá najednou se o pár metrů dál "objeví" živý. Toto je klasický příznak a špatně navržený síťový kód. Ve hře pro více hráčů vidí každý klient trochu jinou verzi světa, rozfázované v čase kvůli latenci sítě.
Základní problém multiplayerové synchronizace je tento: jak si udržet vizi konzistence herního světa mezi desítkami připojených klientů s různou latencí, aniž by bylo nutné představovat postřehnutelné input lag a aniž by někdo mohl podvádět? Odpověď se nazývá vrácení zpětného síťového kódu v kombinaci s predikce na straně klienta e odsouhlasení serveru. Tyto tři mechanismy spolupracují na vytvoření iluze citlivé a konzistentní hry na nespolehlivé síti.
Co se naučíte
- Architektury síťového kódu: lockstep vs rollback vs hybridní stavová synchronizace
- Predikce na straně klienta: jak klient očekává server
- Odsouhlasení serveru: Opravte stav klienta
- Rollback netcode: zpětná resimulace vstupů
- Interpolace entit: hladké pohyby navzdory latenci
- Delta komprese pro snížení šířky pásma stavu
Tři architektury Netcode
| Architektura | Mechanismus | Pro | Proti | Používá se v |
|---|---|---|---|---|
| Lockstep | Všichni klienti před pokračováním čekají na vstup všech ostatních | Dokonalá konzistence, zaručený determinismus | Input lag = vyšší latence, náchylné k odpojení | Klasická RTS (Age of Empires, StarCraft) |
| Vrátit zpět | Pokračovat s předpokládanými vstupy, vraťte se zpět, pokud jsou špatné | Nulové vnímatelné vstupní zpoždění, citlivé | Vizuální blikání, náročné na CPU | Bojové hry (Street Fighter, Guilty Gear) |
| Synchronizace stavu + předpověď | Autoritativní server, klient předpovídá a smiřuje | Škálovatelné, bezpečné, vyvážené | Složitější na implementaci | FPS/TPS (CS2, Overwatch, Valorant) |
Predikce na straně klienta
La predikce na straně klienta a technika, kterou klient provede okamžitě váš vstup lokálně, bez čekání na potvrzení ze serveru. Tím se eliminuje vstupní zpoždění znatelné: když stisknete "vpřed", postava se okamžitě pohybuje po obrazovce.
Trik je v tom, že klient uchovává historii všech identifikovaných vstupů odeslaných na server podle pořadového čísla. Když server odpoví, klient porovná vlastní předpokládanou pozici s autoritativní pozicí serveru.
// 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
};
}
}
Vrátit zpět Netcode
Vrácení zpět se liší od jednoduché predikce: zatímco predikce se týká místního hráče, rollback zpracovává vstup od všech vzdálených hráčů. Ve hře 60 FPS s latencí 200 ms, klient nezná vstup vzdáleného hráče asi 12 snímků. Místo čekání (přid input lag) nebo použijte poslední známý vstup (způsobující závady), rollback předpovídá vzdálený vstup, pokročte v simulaci a poté se vraťte a znovu simulujte, pokud byla předpověď špatná.
// 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);
}
}
Interpolace entit pro vzdálené hráče
Předpověď funguje pro místního hráče. Pro vzdálené hráče se používá jiná technika: aentitní interpolace. Místo vykreslování vzdálených hráčů na jejich místo "aktuální" (což by byla pozice přijatá ze serveru, vždy pozdě), klient je vykreslí do pozice mírně v minulosti, interpolací mezi posledními dvěma přijatými snímky.
// 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;
}
}
Delta komprese pro snížení šířky pásma
Ve hře s 64 hráči, kteří aktualizují stav 60krát za sekundu, odešlete celý stav každé klíště je nepraktické. Tam delta komprese posílá pouze porovnávané rozdíly do posledního stavu potvrzeného klientem.
// 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
);
}
}
Porovnání výkonu: Synchronizační přístupy
| Technika | Je vyžadována šířka pásma | Klientský CPU | Vizuální vstupní zpoždění | Obtížnost impl. |
|---|---|---|---|---|
| Žádná předpověď | Nízký | Nízký | = Latence sítě | Snadný |
| Predikce klienta + odsouhlasení | Průměrný | Průměrný | ~0 ms vnímáno | Průměrný |
| Úplné vrácení zpětného kódu sítě | Průměrný | Vysoká (resimulace) | 0 ms garantováno | Vysoký |
| Delta komprese + interpolace | Velmi nízké (-60-80 %) | Průměrný | ~100 ms (zpoždění) | Středně vysoká |
Požadavky na determinismus
Vrácení funguje POUZE v případě, že je simulace deterministická: při stejných vstupech
objednávky, produkuje přesně stejný výstup. To vylučuje použití Math.random()
nenasazené, nedeterministické operace s pohyblivou řádovou čárkou mezi různými architekturami a jakýmikoli
zdroj vnější entropie. Vždy používejte generátor pseudonáhodných čísel s pevným zdrojem
herní logika.
Nejlepší postupy pro synchronizaci
- Použít pevný časový krok: Aktualizujte simulaci v pevných krocích (např. 64 Hz) bez ohledu na snímkovou frekvenci
- Vstupní vyrovnávací paměti: Udržujte vstupní vyrovnávací paměť pro absorbování změn latence (jitter)
- Kompenzace zpoždění: U požadavků na server FPS použijte k vyhodnocení požadavků na serverovou časovou osu přetáčení na straně serveru
- Historie omezení: Ponechat pouze snímky potřebné pro vrácení zpět (maximální latence / doba snímku)
- Sledovat divergenci: Přidejte metriky ke sledování, kdy a jak často dochází k opravám
Závěry
Synchronizace stavu ve hře pro více hráčů je problémem síťového inženýrství mezi nimi složitější. Kombinace predikce na straně klienta, odsouhlasení serveru a interpolace entit a standardní řešení pro moderní FPS a TPS, zatímco rollback netcode zůstává nenahraditelný pro bojové hry, kde se počítá každý snímek.
V příštím článku se budeme věnovatanti-cheat architektura: jak to udělat bezpečné autoritativní server a jak odhalit anomální chování pomocí analýzy chování.
Další v sérii Game Backend
- Článek 05: Anti-cheat architektura a analýza chování
- Článek 06: Open Match a Nakama - Open-Source Game Backend







