Synchronizacja stanu w czasie rzeczywistym: przywracanie i uzgadnianie kodu sieciowego
Wyobraź sobie, że strzelasz do wroga w swojej grze: kula wystrzeliwuje, a wróg wydaje się umierać nagle „pojawia się” żywy kilka metrów dalej. Jest to klasyczny objaw A źle zaprojektowany kod sieciowy. W grze wieloosobowej każdy klient widzi nieco inną wersję świata, rozłożone w czasie ze względu na opóźnienia w sieci.
Podstawowym problemem synchronizacji wielu graczy jest to, jak zachować wizję spójność świata gry pomiędzy dziesiątkami podłączonych klientów z różnymi opóźnieniami, bez wprowadzania odczuwalne opóźnienie sygnału wejściowego i bez pozwalania nikomu na oszukiwanie? Odpowiedź nazywa się wycofanie kodu sieciowego w połączeniu z przewidywanie po stronie klienta e uzgadnianie serwerów. Te trzy mechanizmy współpracują ze sobą, tworząc iluzja responsywnej, spójnej gry w zawodnej sieci.
Czego się nauczysz
- Architektury kodu sieciowego: blokada vs wycofywanie vs hybrydowa synchronizacja stanu
- Przewidywanie po stronie klienta: jak klient przewiduje serwer
- Uzgodnienie serwera: Napraw status klienta
- Wycofywanie kodu sieciowego: wsteczna resymulacja wejść
- Interpolacja jednostek: płynne ruchy pomimo opóźnienia
- Kompresja delta w celu zmniejszenia przepustowości stanu
Trzy architektury kodu sieciowego
| Architektura | Mechanizm | Zawodowiec | Przeciwko | Używany w |
|---|---|---|---|---|
| Krok blokujący | Wszyscy klienci czekają na wkład innych osób przed przejściem dalej | Doskonała spójność, gwarantowany determinizm | Opóźnienie wejściowe = większe opóźnienie, podatne na rozłączenie | Klasyczny RTS (Age of Empires, StarCraft) |
| Wycofanie | Przejdź dalej z przewidywanymi danymi wejściowymi, wróć, jeśli są błędne | Zero zauważalnego opóźnienia wejściowego, responsywność | Wizualne migotanie, obciążające procesor | Gry walki (Street Fighter, Guilty Gear) |
| Synchronizacja stanu + przewidywanie | Serwer autorytatywny, klient przewiduje i godzi | Skalowalny, bezpieczny, zrównoważony | Bardziej skomplikowane do wdrożenia | FPS/TPS (CS2, Overwatch, Valorant) |
Przewidywanie po stronie klienta
La przewidywanie po stronie klienta oraz technika, dzięki której klient wykonuje natychmiast wprowadzane dane lokalnie, bez czekania na potwierdzenie z serwera. Eliminuje to opóźnienie wejściowe wyczuwalne: po naciśnięciu przycisku „do przodu” postać natychmiast przesuwa się po ekranie.
Sztuczka polega na tym, że klient przechowuje historię wszystkich danych wejściowych wysyłanych do serwera, identyfikowanych według kolejnego numeru. Kiedy serwer odpowiada, klient porównuje własną przewidywaną pozycję z autorytatywnym serwerem.
// 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
};
}
}
Przywróć kod sieciowy
Wycofywanie różni się od prostego przewidywania: podczas gdy przewidywanie dotyczy lokalnego gracza, wycofanie obsługuje dane wejściowe ze wszystkich zdalnych odtwarzaczy. W grze 60 FPS z opóźnieniem 200 ms, klient nie zna sygnału wejściowego zdalnego odtwarzacza przez około 12 klatek. Zamiast czekać (dodając input lag) lub użyj ostatniego znanego wejścia (powodując błędy), wycofanie przewiduje zdalne wejście, przyspieszyć symulację, a następnie wrócić i przeprowadzić ponowną symulację, jeśli prognoza była błędna.
// 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);
}
}
Interpolacja encji dla zdalnych graczy
Przewidywanie sprawdza się w przypadku lokalnego gracza. W przypadku graczy zdalnych stosowana jest inna technika: theinterpolacja jednostek. Zamiast renderować zdalnych graczy do ich lokalizacji „bieżące” (czyli pozycja otrzymana z serwera, zawsze spóźniona), klient je renderuje do pozycji nieco w przeszłości, interpolując pomiędzy dwoma ostatnimi otrzymanymi migawkami.
// 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;
}
}
Kompresja Delta w celu zmniejszenia przepustowości
W grze, w której uczestniczy 64 graczy, status jest aktualizowany 60 razy na sekundę. Wyślij pełny status każdy tik jest niepraktyczny. Tam kompresja delta wysyła tylko porównane różnice do ostatniego stanu potwierdzonego przez klienta.
// 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
);
}
}
Porównanie wydajności: podejścia do synchronizacji
| Technika | Wymagana przepustowość | Procesor klienta | Opóźnienie wejścia wizualnego | Trudność implik. |
|---|---|---|---|---|
| Brak przewidywań | Niski | Niski | = Opóźnienie sieci | Łatwy |
| Predykcja klienta + uzgodnienie | Przeciętny | Przeciętny | Postrzegane ~0 ms | Przeciętny |
| Pełne wycofanie kodu sieciowego | Przeciętny | Wysoka (resymulacja) | Gwarantowane 0 ms | Wysoki |
| Kompresja delta + interpolacja | Bardzo niski (-60-80%) | Przeciętny | ~100 ms (opóźnienie) | Średnio-wysoki |
Wymagania determinizmu
Wycofywanie działa TYLKO wtedy, gdy symulacja jest deterministyczna: przy tych samych danych wejściowych w tym samym
kolejności, daje dokładnie taki sam wynik. Wyklucza to użycie Math.random()
niezainicjowane, niedeterministyczne operacje zmiennoprzecinkowe między różnymi architekturami i dowolne
źródło entropii zewnętrznej. Zawsze używaj generatora liczb pseudolosowych o stałym ziarnie
logika gry.
Najlepsze praktyki dotyczące synchronizacji
- Użyj stałego przedziału czasowego: Aktualizuj symulację w stałych krokach (np. 64 Hz) niezależnie od liczby klatek na sekundę
- Bufory wejściowe: Utrzymuj bufor wejściowy, aby absorbować zmiany opóźnień (jitter)
- Kompensacja opóźnienia: W przypadku trafień FPS zastosuj przewijanie po stronie serwera, aby ocenić trafienia na osi czasu klienta
- Historia limitów: Zachowaj tylko klatki potrzebne do wycofania (maks. opóźnienie/czas ramki)
- Monitoruj rozbieżność: Dodaj wskaźniki, aby śledzić, kiedy i jak często pojawiają się poprawki
Wnioski
Synchronizacja stanu w grze wieloosobowej jest problemem inżynierii sieciowej pomiędzy bardziej złożone. Połączenie przewidywania po stronie klienta, uzgadniania serwera i interpolacji jednostek i standardowe rozwiązanie dla nowoczesnych FPS i TPS, podczas gdy przywracanie kodu sieciowego pozostaje niezastąpione do gier walki, w których liczy się każda klatka.
W następnym artykule zajmiemy sięarchitektura zapobiegająca oszustwom: jak sprawić, żeby było bezpiecznie serwer autorytatywny i jak wykryć nietypowe zachowanie za pomocą analizy behawioralnej.
Następny w serii Game Backend
- Artykuł 05: Architektura zapobiegająca oszustwom i analiza behawioralna
- Artykuł 06: Open Match i Nakama – backend gry typu open source







