Salta al contenuto principale

04 - Sincronizzazione Stato Real-Time: Rollback e Riconciliazione

Previsione lato client, rollback, interpolazione e delta compression messi alla prova con simulazioni deterministiche eseguite, per nascondere la latenza.

Cosa ne pensi di questo articolo?

04 - Sincronizzazione Stato Real-Time: Rollback e Riconciliazione
Game BackendFoto:Brett SaylesPexels

In Breve

Previsione lato client, rollback, interpolazione e delta compression messi alla prova con simulazioni deterministiche eseguite, per nascondere la latenza.

Il nemico che riappare: perché ogni client vede un mondo diverso

Spari, il nemico cade, poi ricompare in piedi due metri più in là. Non è un bug del rendering: è la rete. Ogni client vede il mondo con un ritardo diverso, e il server, che decide come stanno le cose, lo racconta con qualche decina di millisecondi di ritardo. Per nascondere questo scarto si usano quattro tecniche, che qui costruisci una alla volta con un modello eseguito: la previsione lato client con riconciliazione, il rollback, l'interpolazione degli altri giocatori e la compressione delta degli snapshot. Quale usare dipende da una domanda sola: quanta parte del gioco può essere simulata uguale su tutte le macchine?

Il quadro di rete (server autoritativo, tick, latenza) è nell'articolo sull'anatomia del game backend. I listati qui sono simulazioni deterministiche con seme fisso e un orologio finto, eseguite con Node.js 24: dimostrano la meccanica, non misurano un gioco reale.

Tre famiglie di netcode, e quando ciascuna regge

Il lockstep fa avanzare tutti solo quando tutti hanno consegnato gli input del turno: consistenza perfetta, ma la latenza di ognuno diventa quella del più lento (lo scrive Glenn Fiedler a proposito degli RTS, dove i comandi di ogni turno si eseguono identici su tutte le macchine). Il rollback, descritto dal repository di GGPO, usa la previsione dell'input e l'esecuzione speculativa per dare l'illusione di una rete a latenza zero. Il modello server autoritativo con previsione lascia la decisione al server e usa le stesse tecniche per nascondere l'attesa: è quello descritto da Gabriel Gambetta in quattro articoli di cui questo ripercorre la meccanica.

FamigliaChi decideChe cosa pagaQuando regge
LockstepTutti, in attesa di tuttiLatenza del più lento; determinismo assolutoMolte unità, pochi dati per turno
RollbackOgni client, con correzione a posterioriCPU per risimulare; scatti visivi alla correzione; determinismo assolutoPochi giocatori, simulazione piccola e veloce
Server autoritativo con previsioneIl serverPiù codice; il client vede gli altri nel passatoMolti giocatori, necessità di anti-cheat

Nella tabella conta la colonna del costo, perché è lì che le famiglie si distinguono. Il rollback non ti chiede di fidarti della rete ma ti chiede di far girare una simulazione identica bit per bit su ogni macchina e abbastanza veloce da essere rifatta più volte in un frame; il server autoritativo ti chiede un server per partita ma ti regala l'anti-cheat quasi gratis, come spiega l'articolo sull'anti-cheat.

Previsione e riconciliazione: eseguire subito, correggere dopo

Senza previsione, il giocatore preme «avanti» e aspetta un giro completo di rete prima di muoversi: con un RTT di 100 ms il personaggio risponde con un decimo di secondo di ritardo, e il gioco sembra fatto di gomma. La previsione esegue l'input subito in locale; il server fa lo stesso con il suo ritardo e rimanda lo stato insieme al numero dell'ultimo input che ha elaborato. Quel numero di sequenza è tutto il trucco: il client sa quali input il server ha già visto (che può scartare) e quali no (che deve riapplicare sopra lo stato ricevuto).

Il listato simula una retta con 3 tick di latenza per direzione. Il giocatore tiene premuto «avanti» per dieci tick e il server ignora l'input numero 4 (è stordito: il client non lo sa). Il listato esegue lo scenario due volte: una riconciliazione con replay (stato del server più gli input ancora in volo) e una naïf che sostituisce la posizione con quella del server e basta.

Previsione e riconciliazione con e senza replay - TypeScript
// Prediction + reconciliation su una retta. Latenza: 3 tick all'andata e 3 al ritorno.
interface Input { readonly seq: number; readonly move: number }
interface ServerMsg { readonly x: number; readonly lastSeq: number }
const LATENCY = 3;
const STUNNED_SEQ = 4; // il server ignora questo input (il giocatore e' stordito), il client non lo sa

function run(replay: boolean) {
  let clientX = 0, serverX = 0, lastSeq = 0;
  let pending: Input[] = [];
  const toServer: { at: number; input: Input }[] = [];
  const toClient: { at: number; msg: ServerMsg }[] = [];
  const shown: number[] = [];
  for (let t = 0; t < 16; t++) {
    if (t < 10) {                                   // il giocatore tiene premuto "avanti" per 10 tick
      const input = { seq: t + 1, move: 1 };
      clientX += input.move; pending = [...pending, input];
      toServer.push({ at: t + LATENCY, input });
    }
    for (const m of toServer.filter(m => m.at === t)) {   // il server elabora gli input arrivati
      if (m.input.seq !== STUNNED_SEQ) serverX += m.input.move;
      lastSeq = m.input.seq;
      toClient.push({ at: t + LATENCY, msg: { x: serverX, lastSeq } });
    }
    for (const m of toClient.filter(m => m.at === t)) {   // il client riceve lo stato autoritativo
      pending = pending.filter(i => i.seq > m.msg.lastSeq);
      clientX = replay ? pending.reduce((x, i) => x + i.move, m.msg.x) : m.msg.x;
    }
    shown.push(clientX);
  }
  const jumps = shown.slice(1).map((x, i) => x - shown[i]);
  return { shown, maxBack: Math.max(0, ...jumps.map(j => -j)), final: shown[shown.length - 1], serverX };
}

const a = run(true), b = run(false);
console.log("con replay :", a.shown.join(" "), `| salto indietro max ${a.maxBack}, finale ${a.final}, server ${a.serverX}`);
console.log("senza replay:", b.shown.join(" "), `| salto indietro max ${b.maxBack}, finale ${b.final}, server ${b.serverX}`);

// Output:
// con replay : 1 2 3 4 5 6 7 8 9 9 9 9 9 9 9 9 | salto indietro max 0, finale 9, server 9
// senza replay: 1 2 3 4 5 6 1 2 3 3 4 5 6 7 8 9 | salto indietro max 5, finale 9, server 9

Con il replay il giocatore vede avanzare 1, 2, ... 9 senza mai tornare indietro: al tick 9 arriva la correzione che riguarda l'input stordito e il client toglie un'unità dalla posizione che aveva predetto (10 diventa 9), una correzione piccola e proporzionata all'errore. Senza replay, appena arriva il primo stato del server (che è vecchio di un RTT) la posizione salta da 6 a 1: un balzo all'indietro di cinque unità, uguale ai sei tick di rete. La riga che fa la differenza è quella del reduce sugli input pendenti, che rimette sopra lo stato del server ciò che il server non ha ancora visto. La previsione ha un confine: funziona per ciò che il client può simulare da solo (il suo movimento) e non per gli esiti che dipendono dagli altri, come i danni, che restano in attesa del server.

Rollback: prevedere anche gli altri, e rifare quando si sbaglia

La previsione riguarda il giocatore locale. Il rollback applica la stessa idea agli input degli altri: non aspetti il loro input (aggiungeresti ritardo), lo ipotizzi (di solito ripetendo l'ultimo), simuli avanti, e quando l'input vero arriva e differisce dall'ipotesi torni a un frame salvato e risimuli fino al presente. A 60 fotogrammi al secondo, una latenza di 200 ms vale 12 frame da rifare nel caso peggiore.

Il listato ha due giocatori su una retta, con un input remoto che arriva con 4 frame di ritardo e una regola di collisione volutamente dipendente dall'ordine. La funzione step è deterministica e usa solo interi. Il programma confronta lo stato finale con quello di un riferimento che conosce gli input veri fin dall'inizio, e mostra anche la variante sbagliata che si trova in molte implementazioni: ripartire dallo stato dopo il frame errato, cioè applicando due volte l'ultimo passo.

Rollback con confronto sul riferimento - TypeScript
// Rollback con due giocatori e input remoto in ritardo di 4 frame. Stato intero: niente float.
interface State { readonly p1: number; readonly p2: number }
interface Frame { readonly local: number; readonly remote: number }   // input in {-1,0,1}
const DELAY = 4, FRAMES = 120;

function mulberry32(a: number) {
  return () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
// Funzione di passo deterministica; l'ordine conta (una spinta se i due si sovrappongono)
const step = (s: State, f: Frame): State => {
  const p1 = s.p1 + f.local, p2 = s.p2 + f.remote;
  return { p1, p2: p1 === p2 ? p2 + 1 : p2 };
};

const rnd = mulberry32(5);
const localInputs = Array.from({ length: FRAMES }, () => Math.floor(rnd() * 3) - 1);
const remoteInputs = Array.from({ length: FRAMES }, () => Math.floor(rnd() * 3) - 1);

// Riferimento: tutti gli input veri fin dall'inizio
let ref: State = { p1: 0, p2: 3 };
for (let f = 0; f < FRAMES; f++) ref = step(ref, { local: localInputs[f], remote: remoteInputs[f] });

function play(restoreBeforeFrame: boolean) {
  const known: number[] = [];                        // input remoti confermati
  const used: Frame[] = [];                          // input realmente usati in ogni frame
  const before: State[] = [];                        // stato PRIMA di ogni frame
  let state: State = { p1: 0, p2: 3 }, rollbacks = 0, resimulated = 0;

  const confirm = (k: number, f: number) => {        // arriva l'input remoto del frame k, siamo al frame f
    known[k] = remoteInputs[k];
    if (used[k].remote === known[k]) return;         // predizione giusta: non si fa nulla
    rollbacks++;
    used[k] = { ...used[k], remote: known[k] };
    let s = restoreBeforeFrame ? before[k] : (before[k + 1] ?? state);   // la variante sbagliata riparte dallo stato dopo il frame con l'input vecchio
    for (let g = restoreBeforeFrame ? k : k + 1; g <= f; g++) {
      before[g] = s; s = step(s, used[g]); resimulated++;
    }
    state = s;
  };

  for (let f = 0; f < FRAMES; f++) {
    const predicted = known.length ? known[known.length - 1] : 0;      // ripeti l'ultimo input noto
    const frame = { local: localInputs[f], remote: predicted };
    before[f] = state; used[f] = frame; state = step(state, frame);
    if (f - DELAY >= 0) confirm(f - DELAY, f);
  }
  for (let k = FRAMES - DELAY; k < FRAMES; k++) confirm(k, FRAMES - 1);   // ultimi input in arrivo a partita finita
  return { state, rollbacks, resimulated };
}

for (const [nome, ok] of [["rollback dallo stato prima del frame", true], ["variante errata (stato dopo il frame)", false]] as const) {
  const r = play(ok);
  const diverso = JSON.stringify(r.state) !== JSON.stringify(ref);
  console.log(`${nome}: ${r.rollbacks} rollback, ${r.resimulated} frame risimulati, stato finale ${JSON.stringify(r.state)}, diverso dal riferimento: ${diverso}`);
}
console.log("riferimento (input veri):", JSON.stringify(ref));

// Output:
// rollback dallo stato prima del frame: 80 rollback, 392 frame risimulati, stato finale {"p1":-5,"p2":3}, diverso dal riferimento: false
// variante errata (stato dopo il frame): 80 rollback, 312 frame risimulati, stato finale {"p1":-5,"p2":0}, diverso dal riferimento: true
// riferimento (input veri): {"p1":-5,"p2":3}

La versione corretta ripristina lo stato prima del frame sbagliato (before[k]), risimula 392 frame in 80 rollback, e finisce nello stato identico al riferimento. La variante che riparte dallo stato dopo il frame termina con p2 a 0 invece di 3: il tipo di errore che si presenta solo dopo minuti di partita. Due precisazioni sul modello. Il generatore cambia l'input remoto quasi ad ogni frame, quindi 80 rollback su 120 frame è un caso estremo: i giocatori veri tengono premuto lo stesso tasto a lungo e la predizione «ripeti l'ultimo» indovina molto più spesso. E ogni rollback costa in media 392/80 = 4,9 frame di calcolo in più: se la tua simulazione non sta comodamente in una frazione del budget di un frame, il rollback non è la strada.

Il requisito che tutto sorregge è il determinismo: gli stessi input nello stesso ordine devono produrre lo stesso stato su ogni macchina, il che esclude generatori casuali senza seme, dipendenze dall'orologio e virgola mobile non identica tra architetture. Con la virgola mobile ottenerlo è difficile, e va deciso all'inizio del progetto: non è un'opzione che si accende alla fine.

Interpolazione: gli altri si disegnano nel passato

Per i giocatori remoti la previsione non può funzionare, perché i loro input non li conosci. Si fa il contrario: si disegnano un po' nel passato, interpolando tra due snapshot già arrivati, così il movimento è fluido anche se i pacchetti arrivano a intervalli di 50 ms. Il prezzo è che li vedi in ritardo di una quantità che scegli tu, e la scelta ha un minimo: l'interpolazione ha bisogno di due snapshot che racchiudano l'istante di disegno, quindi il ritardo deve coprire un intervallo tra snapshot, la variabilità della rete e, se vuoi sopportare perdite, un altro intervallo per ogni pacchetto perso.

Il listato prova la regola. Un'entità si muove a 100 unità al secondo e inverte la direzione a metà; il server manda 20 snapshot al secondo (uno ogni 50 ms), la rete ha tra 40 e 70 ms di ritardo e il 10% dei pacchetti si perde. Confronta il disegno dell'ultimo snapshot ricevuto con l'interpolazione a tre ritardi diversi.

Ultimo snapshot contro interpolazione a ritardi diversi - TypeScript
// Interpolazione di entita': snapshot a 20 Hz, rete con ritardo variabile e perdita del 10%.
const SNAP_MS = 50, DURATION = 2000;
function mulberry32(a: number) {
  return () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
const rnd = mulberry32(9);
// Posizione vera: 100 unita'/s in avanti, poi inversione a t = 1000 ms
const truth = (t: number) => (t <= 1000 ? 0.1 * t : 100 - 0.1 * (t - 1000));

interface Snap { readonly sentAt: number; readonly arrivesAt: number; readonly x: number }
const snaps: Snap[] = [];
for (let t = 0; t <= DURATION; t += SNAP_MS) {
  if (rnd() < 0.10) continue;                                    // pacchetto perso
  snaps.push({ sentAt: t, arrivesAt: t + 40 + rnd() * 30, x: truth(t) });   // 40-70 ms di rete
}

function render(useInterpolation: boolean, DELAY_MS = 0) {
  let prev: number | null = null, maxJump = 0, ageSum = 0, frames = 0, holes = 0;
  for (let now = 300; now < DURATION; now += 1000 / 60) {
    const got = snaps.filter(s => s.arrivesAt <= now);           // cio' che e' arrivato finora
    let x: number, age: number;
    if (!useInterpolation) {
      const last = got[got.length - 1]; x = last.x; age = now - last.sentAt;
    } else {
      const rt = now - DELAY_MS;                                  // si disegna nel passato
      const before = [...got].reverse().find(s => s.sentAt <= rt);
      const after = got.find(s => s.sentAt >= rt);
      if (!before || !after) { holes++; continue; }
      const t = after.sentAt === before.sentAt ? 0 : (rt - before.sentAt) / (after.sentAt - before.sentAt);
      x = before.x + (after.x - before.x) * t; age = DELAY_MS;
    }
    if (prev !== null) maxJump = Math.max(maxJump, Math.abs(x - prev));
    prev = x; ageSum += age; frames++;
  }
  return { maxJump, meanAge: ageSum / frames, holes, frames };
}
const casi: [string, boolean, number][] = [["ultimo snapshot", false, 0], ["interpolazione, ritardo 100 ms", true, 100],
  ["interpolazione, ritardo 150 ms", true, 150], ["interpolazione, ritardo 250 ms", true, 250]];
for (const [nome, flag, d] of casi) {
  const r = render(flag, d);
  console.log(`${nome.padEnd(31)} scatto max ${r.maxJump.toFixed(2)}, eta' media ${r.meanAge.toFixed(0)} ms, frame senza coppia ${r.holes}/${r.frames + r.holes}`);
}
console.log("snapshot persi:", Math.round(DURATION / SNAP_MS) + 1 - snaps.length, "su", Math.round(DURATION / SNAP_MS) + 1);

// Output:
// ultimo snapshot                 scatto max 10.00, eta' media 82 ms, frame senza coppia 0/102
// interpolazione, ritardo 100 ms  scatto max 6.67, eta' media 100 ms, frame senza coppia 24/102
// interpolazione, ritardo 150 ms  scatto max 1.67, eta' media 150 ms, frame senza coppia 0/102
// interpolazione, ritardo 250 ms  scatto max 1.67, eta' media 250 ms, frame senza coppia 0/102
// snapshot persi: 2 su 41

Disegnare l'ultimo snapshot dà il dato più fresco (82 ms) ma sposta l'entità a scatti di 10 unità. L'interpolazione a 100 ms è la scelta intuitiva e la sbagliata: in 24 frame su 102 manca uno dei due snapshot (il successivo non è ancora arrivato, perché 50 ms di intervallo più 70 di rete sono 120 ms) e il disegno si blocca. A 150 ms tutto è fluido, con scatto massimo di 1,67 unità (100 unità/s su 60 frame), pari al movimento di un frame; a 250 ms è altrettanto fluido ma ritarda inutilmente di altri 100. Glenn Fiedler lo mette in numeri per snapshot a 10 al secondo: il ritardo minimo è 100 ms, 150 ms con il jitter, 250 o 350 per proteggersi da uno o due pacchetti persi consecutivi.

Compressione delta: spedire solo ciò che il client non ha già

Con 64 entità e 60 snapshot al secondo, mandare il mondo intero ad ogni pacchetto è sprecato: quasi tutto non è cambiato. La compressione delta invia solo le differenze rispetto a uno snapshot che il server sa che il client possiede. Il dettaglio che la rende robusta è la parola «sa»: la baseline è l'ultimo snapshot confermato dal client, non quello inviato per ultimo. Così un pacchetto perso non rompe nulla: il pacchetto successivo è calcolato sulla stessa baseline vecchia e basta da solo.

Nel listato ogni tick cambia circa il 10% delle entità, il 20% dei pacchetti va perso e le conferme tornano dopo 2 tick. Il programma controlla ad ogni pacchetto ricevuto che lo stato ricostruito dal client sia identico a quello del server. Le dimensioni sono la lunghezza del JSON: non sono un formato di rete ottimizzato, danno solo la proporzione.

Delta rispetto alla baseline confermata, con perdite - TypeScript
// Compressione delta rispetto all'ultimo snapshot CONFERMATO dal client. Pacchetti persi: 20%.
interface Ent { readonly id: number; readonly x: number; readonly hp: number }
type World = ReadonlyMap<number, Ent>;
function mulberry32(a: number) {
  return () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
const rnd = mulberry32(21);
const same = (a: World, b: World) => a.size === b.size && [...a].every(([i, e]) => JSON.stringify(b.get(i)) === JSON.stringify(e));

let world: World = new Map(Array.from({ length: 64 }, (_, id) => [id, { id, x: id * 10, hp: 100 }]));
const history = new Map<number, World>([[0, world]]);      // snapshot del server per tick
let ackedByServer = 0;                                    // cio' che il server sa essere arrivato
const acks: { at: number; tick: number }[] = [];
let fullBytes = 0, deltaBytes = 0, packets = 0, wrong = 0, maxBaselineAge = 0;

for (let tick = 1; tick <= 300; tick++) {
  world = new Map([...world].map(([id, e]) => [id, rnd() < 0.1 ? { ...e, x: e.x + 1, hp: e.hp - (rnd() < 0.3 ? 1 : 0) } : e]));
  history.set(tick, world);
  for (const a of acks.filter(a => a.at === tick)) ackedByServer = Math.max(ackedByServer, a.tick);

  const base = history.get(ackedByServer)!;               // il delta e' calcolato su cio' che il client ha di sicuro
  const changed = [...world.values()].filter(e => JSON.stringify(base.get(e.id)) !== JSON.stringify(e));
  const packet = { baseline: ackedByServer, tick, changed };
  deltaBytes += JSON.stringify(packet).length; fullBytes += JSON.stringify({ tick, all: [...world.values()] }).length; packets++;
  maxBaselineAge = Math.max(maxBaselineAge, tick - ackedByServer);

  if (rnd() < 0.2) continue;                              // pacchetto perso: il client non vede nulla
  const next = new Map(history.get(packet.baseline)!);    // il client parte dalla baseline che ha gia'
  for (const e of packet.changed) next.set(e.id, e);
  if (!same(next, world)) wrong++;
  acks.push({ at: tick + 2, tick });                      // la conferma torna dopo 2 tick
}
console.log(`pacchetti: ${packets}, stato ricostruito sbagliato: ${wrong}`);
console.log(`byte medi per pacchetto: pieno ${(fullBytes / packets).toFixed(0)}, delta ${(deltaBytes / packets).toFixed(0)} (${((deltaBytes / fullBytes) * 100).toFixed(0)}% del pieno, misurato come lunghezza del JSON)`);
console.log(`baseline piu' vecchia usata: ${maxBaselineAge} tick prima`);

// Output:
// pacchetti: 300, stato ricostruito sbagliato: 0
// byte medi per pacchetto: pieno 1671, delta 380 (23% del pieno, misurato come lunghezza del JSON)
// baseline piu' vecchia usata: 5 tick prima

Su 300 pacchetti lo stato ricostruito è sempre corretto, e il delta pesa il 23% del pacchetto pieno (380 byte contro 1671). La baseline più vecchia usata dista 5 tick: è il prezzo delle conferme in ritardo e dei pacchetti persi, e segnala che il server deve conservare gli ultimi snapshot di ogni client (qui history) finché non sono certamente superati. Il risparmio dipende interamente da quanto cambia il mondo: in una scena dove tutti si muovono ad ogni tick, il delta sparisce, e conviene passare a codifiche più compatte per ogni campo.

Compensare il lag per i colpi: il server torna indietro

Previsione e interpolazione producono una conseguenza che Gambetta chiama con precisione: vedi te stesso nel presente e gli altri nel passato. Quando spari a un bersaglio mobile, stai mirando dove era prima. La lag compensation risolve il problema nel server: il client manda l'istante esatto del colpo e la mira, e il server, che conserva la storia recente del mondo, ricostruisce la scena com'era per quel giocatore in quel momento e valuta il colpo lì. Il costo è sulle spalle di chi si è appena messo al riparo: può essere colpito qualche istante dopo essere stato al sicuro sul proprio schermo. È un compromesso dichiarato: a favore di chi spara, perché mancare un colpo perfetto è percepito peggio.

Regole pratiche per non sbagliare

  • Passo di simulazione fisso: la logica avanza a tick costanti, mai a «delta del frame».
  • Numero di sequenza su ogni input: senza, la riconciliazione non sa cosa riapplicare.
  • Storia limitata: tieni solo i frame di cui il rollback può aver bisogno (latenza massima accettata diviso durata del frame).
  • Baseline confermata per i delta, con la storia degli snapshot sul server.
  • Un contatore delle correzioni: quante volte e di quanto il client viene corretto è la misura diretta della qualità del netcode.

Le cinque regole hanno un tema comune: ogni tecnica scambia memoria e calcolo contro latenza percepita, e funziona finché i due lati restano d'accordo su cosa è «passato» (un numero di sequenza, un tick, una baseline). L'ultima è quella che di solito manca: senza misurare le correzioni, un netcode che si degrada con la rete reale si accorge dai giocatori e non dai grafici. Gli strumenti per raccoglierle stanno nell'articolo sull'osservabilità del game backend.

Che cosa sai fare adesso

Sai scegliere tra lockstep, rollback e server autoritativo in base a quanta simulazione puoi rendere deterministica. Sai implementare previsione con riconciliazione per replay, e distinguerla dal balzo all'indietro della correzione ingenua. Sai dimensionare il ritardo di interpolazione sulla base dell'intervallo tra snapshot e del jitter, e sai costruire un delta che sopravvive alla perdita di pacchetti. Il passo successivo è proteggere questo schema da chi mente al server: l'architettura anti-cheat.

Domande frequenti

Qual è la differenza tra previsione lato client e rollback?

La previsione anticipa il giocatore locale e lascia decidere al server; il rollback anticipa anche gli input degli altri e rifà la simulazione quando la previsione era sbagliata. Il secondo richiede una simulazione deterministica identica su tutti i client.

Quanto deve essere grande il ritardo di interpolazione?

Almeno un intervallo tra snapshot più la variabilità della rete, e un intervallo in più per ogni pacchetto perso che vuoi tollerare. Nell'esempio, con snapshot a 50 ms e rete tra 40 e 70 ms, 100 ms lascia buchi e 150 ms no.

Perché il delta si calcola sulla baseline confermata?

Perché solo ciò che il client ha confermato è certamente in suo possesso. Un delta calcolato sul pacchetto precedente smette di essere valido se quel pacchetto si perde, mentre uno calcolato sulla baseline confermata resta sempre applicabile.

Fonti

I quattro listati sono stati eseguiti con Node.js 24 (TypeScript con rimozione dei tipi); le uscite sono quelle reali. Sono modelli deterministici con orologio simulato e seme fisso: dimostrano la meccanica e le proporzioni, non misurano un gioco in produzione.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti