Gerçek Zamanlı Durum Senkronizasyonu: Netcode Geri Alma ve Mutabakat
Oyununuzda bir düşmanı vurduğunuzu hayal edin: Mermi patlıyor, düşman ölüyormuş gibi görünüyor ve ardından aniden birkaç metre ötede canlı olarak "yeniden ortaya çıkar". Bu bir hastalığın klasik belirtisidir. kötü tasarlanmış ağ kodu. Çok oyunculu bir oyunda her müşteri biraz farklı bir versiyon görür ağ gecikmesi nedeniyle zaman içinde aşamalı olarak dünyanın her yerinde.
Çok oyunculu senkronizasyonun temel sorunu şudur: bir vizyonun nasıl sürdürüleceği oyun dünyasının farklı gecikme sürelerine sahip düzinelerce bağlı istemci arasında tutarlılığı algılanabilir giriş gecikmesi ve kimsenin hile yapmasına izin vermeden? Cevap denir ağ kodunu geri alma ile kombine istemci tarafı tahmini e sunucu mutabakatı. Bu üç mekanizma birlikte çalışarak güvenilmez bir ağ üzerinde duyarlı, tutarlı oyun yanılsaması.
Ne Öğreneceksiniz
- Netcode mimarileri: lockstep vs geri alma vs hibrit durum senkronizasyonu
- İstemci tarafı tahmini: istemcinin sunucuyu nasıl tahmin ettiği
- Sunucu mutabakatı: İstemci durumunu düzeltin
- Geri alma ağ kodu: girişlerin geriye dönük resimlenmesi
- Varlık enterpolasyonu: gecikmeye rağmen yumuşak hareketler
- Durum bant genişliğini azaltmak için delta sıkıştırması
Netcode'un Üç Mimarisi
| Mimarlık | Mekanizma | Profesyonel | Aykırı | Kullanılan |
|---|---|---|---|---|
| Kilit adımı | Tüm müşteriler ilerlemeden önce herkesin girdisini bekler | Mükemmel tutarlılık, garantili determinizm | Giriş gecikmesi = daha yüksek gecikme, bağlantı kopmasına karşı savunmasız | Klasik RTS (Age of Empires, StarCraft) |
| Geri alma | Tahmin edilen girdilerle ilerleyin, yanlışsa geri dönün | Sıfır algılanabilir giriş gecikmesi, duyarlı | Görsel titreme, CPU yoğun | Dövüş oyunları (Street Fighter, Guilty Gear) |
| Durum Senkronizasyonu + Tahmin | Yetkili sunucu, istemci tahmin eder ve uzlaştırır | Ölçeklenebilir, güvenli, dengeli | Uygulaması daha karmaşık | FPS/TPS (CS2, Overwatch, Valorant) |
İstemci Tarafı Tahmini
La istemci tarafı tahmini ve müşterinin hemen uyguladığı teknik sunucudan onay beklemeden girişinizi yerel olarak yapın. Bu, giriş gecikmesini ortadan kaldırır algılanabilir: "ileri" tuşuna bastığınızda karakter anında ekranınızda hareket eder.
İşin püf noktası, istemcinin sunucuya gönderilen tüm girişlerin geçmişini tutmasıdır. sıralı bir sayıyla. Sunucu yanıt verdiğinde istemci verileri karşılaştırır. Sunucunun yetkilisi ile kendi tahmin edilen konumu.
// 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
};
}
}
Geri Alma Net Kodu
Geri alma basit tahminden farklıdır: tahmin yerel oyuncuyu ilgilendirirken, geri alma, tüm uzak oynatıcılardan gelen girdileri yönetir. 200 ms gecikmeli 60 FPS'lik bir oyunda, istemci yaklaşık 12 kare boyunca uzaktaki oynatıcının girişini bilmiyor. Beklemek yerine (ekleme giriş gecikmesi) veya bilinen son girişi kullanın (aksaklıklara neden olur), geri alma, uzaktan girişi tahmin eder, simülasyonu ilerletin ve ardından geri dönüp tahminin yanlış olması durumunda yeniden simülasyon yapın.
// 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);
}
}
Uzak Oynatıcılar için Varlık Enterpolasyonu
Tahmin yerel oyuncu için işe yarıyor. Uzaktaki oyuncular için farklı bir teknik kullanılır: thevarlık enterpolasyonu. Uzaktaki oyuncuları konumlarına yönlendirmek yerine "geçerli" (bu, sunucudan her zaman geç alınan konum olacaktır), istemci bunları işler alınan son iki anlık fotoğrafın arasına enterpolasyon yaparak biraz geçmişteki bir konuma.
// 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;
}
}
Bant Genişliğini Azaltmak için Delta Sıkıştırma
64 oyuncunun saniyede 60 kez durumunu güncellediği bir oyunda tam durumu gönderin her tıklama pratik değildir. Orada delta sıkıştırma yalnızca karşılaştırılan farkları gönderir müşteri tarafından onaylanan son duruma.
// 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
);
}
}
Performans Karşılaştırması: Senkronizasyon Yaklaşımları
| Teknik | Gerekli bant genişliği | İstemci CPU'su | Görsel giriş gecikmesi | Zorluk uygulaması |
|---|---|---|---|---|
| Tahmin yok | Düşük | Düşük | = Ağ gecikmesi | Kolay |
| Müşteri tahmini + mutabakat | Ortalama | Ortalama | ~0ms algılandı | Ortalama |
| Tam ağ kodu geri alma | Ortalama | Yüksek (benzetme) | 0ms garantili | Yüksek |
| Delta sıkıştırma + enterpolasyon | Çok düşük (-60-80%) | Ortalama | ~100ms (gecikme) | Orta-Yüksek |
Determinizm Gereksinimleri
Geri alma YALNIZCA simülasyon deterministik ise çalışır: aynı girdiler aynı durumda verildiğinde
sipariş, tam olarak aynı çıktıyı üretir. Bu, aşağıdakilerin kullanımını hariç tutar: Math.random()
farklı mimariler arasında tohumlanmamış, deterministik olmayan kayan nokta işlemleri ve herhangi
Dış entropinin kaynağı. için her zaman sabit tohumlu sözde rastgele sayı üreteci kullanın.
oyun mantığı.
Senkronizasyon için En İyi Uygulamalar
- Sabit zaman adımını kullan: Kare hızından bağımsız olarak simülasyonu sabit adımlarla (örn. 64Hz) güncelleyin
- Giriş arabellekleri: Gecikme değişikliklerini (titreşim) absorbe etmek için bir giriş arabelleği koruyun
- Gecikme telafisi: FPS isabetleri için, isabetleri istemci zaman çizelgesinde değerlendirmek üzere sunucu tarafı geri sarmayı uygulayın
- Sınır geçmişi: Yalnızca geri alma için gereken kareleri saklayın (maks. gecikme/kare süresi)
- Farklılığı izleyin: Düzeltmelerin ne zaman ve ne sıklıkta gerçekleştiğini izlemek için ölçümler ekleyin
Sonuçlar
Çok oyunculu bir oyunda durum senkronizasyonu, aralarında bir ağ mühendisliği problemidir. daha karmaşık. İstemci tarafı tahmini, sunucu mutabakatı ve varlık enterpolasyonunun birleşimi ve modern FPS ve TPS için standart çözüm; geri alma ağ kodu ise yeri doldurulamaz olmaya devam ediyor Her karenin önemli olduğu dövüş oyunları için.
Bir sonraki yazımızda bu konuyu ele alacağızHile karşıtı mimari: nasıl güvenli hale getirilir yetkili sunucu ve davranışsal analizle anormal davranışların nasıl tespit edileceği.
Oyun Arka Uç Serisinin Sonraki Bölümü
- Madde 05: Hile Önleyici Mimari ve Davranış Analizi
- Madde 06: Açık Maç ve Nakama - Açık Kaynaklı Oyun Arka Ucu







