Il client è in mano al nemico
In un gioco competitivo il programma che gira sul computer del giocatore è, per definizione, sotto il controllo di chi potrebbe barare: può leggerne la memoria, modificarne i pacchetti, sostituirne l'input con un programma. Gabriel Gambetta lo dice in una riga che regge tutta l'architettura anti-cheat: don't trust the player, parti dall'ipotesi peggiore. Per un'infrastruttura backend significa spostare ciò che si può spostare nel server, dove il giocatore non arriva, e trattare il resto come un problema di statistica.
La difesa ha quindi tre strati, e ognuno fa una cosa diversa. L'autorità del server previene: rende impossibile ciò che il server può calcolare da solo. L'analisi statistica segnala: mette in fila i comportamenti improbabili. La revisione e la sanzione decidono, con cautela, perché sbagliare significa punire una persona innocente. Qui costruisci i tre strati con modelli eseguiti (seme fisso, dati sintetici). Il server autoritativo è già stato descritto nell' articolo sull'anatomia del game backend e la compensazione del lag in quello sulla sincronizzazione dello stato.
Che cosa può prevenire l'autorità del server e che cosa no
Il server autoritativo non è una pozione magica. Per ogni tipo di cheat conviene chiedersi quale informazione il cheater manipola e se il server può ricalcolarla.
| Cheat | Che cosa manipola | Il server può prevenirlo? | Come |
|---|---|---|---|
| Speed hack, teletrasporto | La posizione dichiarata | Sì | Il server verifica che il movimento sia fisicamente possibile |
| Wall hack con dati in memoria | Posizioni di nemici che il client riceve ma non dovrebbe vedere | Sì, se non le invii | Visibilità calcolata sul server |
| Aimbot | L'input stesso (la mira) | No: l'input è valido | Analisi statistica del comportamento |
| Duplicazione di oggetti o valuta | Sequenze di richieste in parallelo | Sì | Transazioni idempotenti e controlli sull'inventario |
| Manipolazione dei pacchetti | Il contenuto dei messaggi | In parte | Validazione dello schema e dei valori; cifratura del canale |
La riga dell'aimbot è il motivo per cui servono gli strati successivi: un programma che muove il mouse al posto tuo produce input identici a quelli di un umano molto bravo, e nessun controllo di validità li distingue. Il server non può sapere chi ha generato l'input, può solo dire se è plausibile nel tempo. Per le righe dove la prevenzione funziona, invece, il principio è uno: il client manda intenzioni («mi sposto di qui», «sparo in questa direzione») e il server ricalcola le conseguenze, senza mai accettare un risultato dichiarato dal client.
Validare movimento, colpi e visibilità sul server
Il listato è un modello 2D a 60 tick al secondo con tre controlli. Il primo accetta un movimento solo se la velocità implicita non supera il massimo con una tolleranza del 10% per il jitter. Il secondo valuta un colpo: il client manda la direzione di mira e il tick in cui vedeva il mondo, non la posizione del bersaglio (se la mandasse, il server starebbe accettando un fatto dal client), e il server ricostruisce il mondo a quel tick con il raggio di mira. Il terzo decide quali nemici spedire a un client: chi sta dietro un muro non viene inviato, e non c'è nulla da leggere in memoria.
// Validazione lato server: movimento, colpo con lag compensation, visibilita'. Modello 2D, tick a 60 Hz.
interface V { readonly x: number; readonly y: number }
const TICK_S = 1 / 60, MAX_SPEED = 6, TOLERANCE = 1.1, MAX_REWIND_TICKS = 12, HIT_RADIUS = 1.5;
// 1. Movimento: il client dichiara dove vuole andare, il server accetta solo cio' che e' fisicamente possibile
function acceptMove(from: V, wanted: V): V {
const dist = Math.hypot(wanted.x - from.x, wanted.y - from.y);
return dist / TICK_S <= MAX_SPEED * TOLERANCE ? wanted : from; // altrimenti resta dov'e'
}
const legit = acceptMove({ x: 0, y: 0 }, { x: 0.105, y: 0 }); // 6,3 unita'/s: jitter ammesso
const hack = acceptMove({ x: 0, y: 0 }, { x: 0.3, y: 0 }); // 18 unita'/s: tre volte il massimo
console.log("movimento legittimo (6,3/s):", legit.x, "| speed hack (18/s):", hack.x);
// 2. Colpo: il client manda direzione e tick in cui vedeva il mondo, NON la posizione del bersaglio
const history = new Map<number, ReadonlyMap<string, V>>();
for (let tick = 0; tick <= 100; tick++) history.set(tick, new Map([["bersaglio", { x: 50, y: 8 * (tick - 90) }]]));
const serverTick = 100;
function rayHits(origin: V, angle: number, target: V): boolean {
const dx = Math.cos(angle), dy = Math.sin(angle);
const t = (target.x - origin.x) * dx + (target.y - origin.y) * dy; // proiezione sul raggio
if (t < 0) return false;
return Math.hypot(origin.x + dx * t - target.x, origin.y + dy * t - target.y) <= HIT_RADIUS;
}
function validateShot(origin: V, angle: number, claimedViewTick: number): string {
const rewind = Math.max(serverTick - MAX_REWIND_TICKS, Math.min(serverTick, claimedViewTick)); // il client non sceglie il passato
const world = history.get(rewind)!;
return [...world].some(([, p]) => rayHits(origin, angle, p)) ? `colpito (rewind al tick ${rewind})` : `mancato (rewind al tick ${rewind})`;
}
const shooter = { x: 0, y: 0 };
const seenAt = history.get(97)!.get("bersaglio")!; // il client vedeva il bersaglio 3 tick fa
const angle = Math.atan2(seenAt.y - shooter.y, seenAt.x - shooter.x);
console.log("mira onesta, vista di 3 tick fa :", validateShot(shooter, angle, 97));
console.log("stessa mira valutata nel presente :", validateShot(shooter, angle, 100));
const old = history.get(60)!.get("bersaglio")!; // dove stava 40 tick fa
console.log("cheater dichiara un tick di 40 fa :", validateShot(shooter, Math.atan2(old.y - shooter.y, old.x - shooter.x), 60));
// 3. Visibilita': non spedire a un client la posizione di chi non puo' vedere (contro il wall hack)
const wall = { x: 10, y0: -5, y1: 5 }; // muro verticale
const blocked = (a: V, b: V) => {
if ((a.x - wall.x) * (b.x - wall.x) >= 0) return false; // stesso lato: nessun attraversamento
const y = a.y + ((b.y - a.y) * (wall.x - a.x)) / (b.x - a.x);
return y >= wall.y0 && y <= wall.y1;
};
const me = { x: 0, y: 0 };
const enemies: [string, V][] = [["e1", { x: 20, y: 0 }], ["e2", { x: 20, y: 30 }], ["e3", { x: 5, y: 3 }], ["e4", { x: 30, y: -2 }]];
const sent = enemies.filter(([, p]) => !blocked(me, p));
console.log(`posizioni inviate a me: ${sent.map(([id]) => id).join(", ")} su ${enemies.length}`);
// Output:
// movimento legittimo (6,3/s): 0.105 | speed hack (18/s): 0
// mira onesta, vista di 3 tick fa : colpito (rewind al tick 97)
// stessa mira valutata nel presente : mancato (rewind al tick 100)
// cheater dichiara un tick di 40 fa : mancato (rewind al tick 88)
// posizioni inviate a me: e2, e3 su 4
Nell'uscita si legge il comportamento atteso. Il passo da 6,3 unità/s passa per effetto della tolleranza e quello da 18 unità/s no,
e il giocatore resta fermo dov'era. Il bersaglio si muove di lato, quindi la mira «onesta» costruita su ciò che il giocatore
vedeva 3 tick fa colpisce se il server riavvolge a quel tick (rewind al tick 97), e manca se il server
valuta nel presente: è la ragione della lag compensation. Il punto delicato è la riga con
MAX_REWIND_TICKS: il client dichiara il tick, ma il server lo limita a 12 tick (200 ms) all'indietro. Se
accettasse qualunque tick, un baro potrebbe sparare «nel passato» dove il bersaglio era comodo; nel terzo caso
dell'uscita il tick dichiarato è 60, il server lo porta a 88 e il colpo manca. Il prezzo è che un giocatore con più di 200 ms di
latenza non beneficia della compensazione completa. Infine, il filtro di visibilità invia 2 nemici su 4: gli altri due
sono dietro il muro.
Una precisazione sul perimetro. Questo modello non protegge dalle vulnerabilità del server stesso (Gambetta lo esclude dalla sua serie) e il filtro di visibilità ha costi: più calcolo per tick, e il rischio che un nemico compaia all'ultimo momento da dietro un angolo se il filtro è troppo aggressivo. Si risolve estendendo il filtro di qualche decimo di secondo di movimento, tanto quanto basta a rendere impercettibile la comparsa.
Statistica: segnalare i comportamenti improbabili senza accusare nessuno
Per l'aimbot si guarda la distribuzione. La metrica (qui la quota di colpi alla testa) di un giocatore si confronta con quella della popolazione, e si segnala chi è molto oltre. La soglia classica è «media più tre deviazioni standard». Il listato verifica quanto vale in pratica, con una popolazione sintetica di giocatori onesti (quota alla testa attorno al 20%, deviazione 6 punti, distribuzione asimmetrica come ci si aspetta da una proporzione) e di imbroglioni (attorno al 60%). Lo ripete in tre scenari, con lo 0,5%, il 5% e il 25% di imbroglioni, e confronta due soglie: la classica e una robusta, che usa la mediana e la deviazione assoluta mediana (MAD) al posto di media e deviazione standard.
import random
import statistics as st
def population(n_honest, n_cheat, seed=4):
rng = random.Random(seed)
honest = [rng.betavariate(8, 32) for _ in range(n_honest)] # quota di headshot ~20% +-6%
cheat = [rng.betavariate(30, 20) for _ in range(n_cheat)] # ~60%
return honest, cheat
def flag_z(values, k=3.0):
m, s = st.fmean(values), st.pstdev(values)
return [v > m + k * s for v in values]
def flag_mad(values, k=3.0):
med = st.median(values)
mad = st.median(abs(v - med) for v in values) * 1.4826 # scala la MAD come una deviazione standard
return [v > med + k * mad for v in values]
def report(nome, flags, n_honest, n_cheat):
fp = sum(flags[:n_honest]); tp = sum(flags[n_honest:])
prec = tp / (tp + fp) if tp + fp else 0
print(f" {nome:<22} onesti segnalati {fp:>4} su {n_honest} | imbroglioni presi {tp:>3} su {n_cheat} | precisione {prec:5.1%}")
for n_honest, n_cheat in ((19900, 100), (19000, 1000), (15000, 5000)):
h, c = population(n_honest, n_cheat)
vals = h + c
print(f"{n_cheat} imbroglioni su {n_honest + n_cheat} ({n_cheat / (n_honest + n_cheat):.1%})")
report("soglia media + 3 sigma", flag_z(vals), n_honest, n_cheat)
report("soglia mediana + 3 MAD", flag_mad(vals), n_honest, n_cheat)
# Output:
# 100 imbroglioni su 20000 (0.5%)
# soglia media + 3 sigma onesti segnalati 36 su 19900 | imbroglioni presi 100 su 100 | precisione 73.5%
# soglia mediana + 3 MAD onesti segnalati 96 su 19900 | imbroglioni presi 100 su 100 | precisione 51.0%
# 1000 imbroglioni su 20000 (5.0%)
# soglia media + 3 sigma onesti segnalati 0 su 19000 | imbroglioni presi 799 su 1000 | precisione 100.0%
# soglia mediana + 3 MAD onesti segnalati 46 su 19000 | imbroglioni presi 996 su 1000 | precisione 95.6%
# 5000 imbroglioni su 20000 (25.0%)
# soglia media + 3 sigma onesti segnalati 0 su 15000 | imbroglioni presi 0 su 5000 | precisione 0.0%
# soglia mediana + 3 MAD onesti segnalati 0 su 15000 | imbroglioni presi 4197 su 5000 | precisione 100.0%
Il primo scenario dà la lezione più importante: anche con una soglia «a tre sigma», in una popolazione con lo 0,5% di imbroglioni vengono segnalati 36 innocenti oltre ai 100 colpevoli, quindi solo il 73,5% dei segnalati è davvero un baro. Gli onesti sono tanti e una frazione minuscola di loro cade comunque oltre soglia, perché la distribuzione della quota è asimmetrica. Gli altri due scenari mostrano il difetto opposto: quando gli imbroglioni diventano il 5% la media e la deviazione standard sono già gonfiate da loro e la soglia a tre sigma ne prende 799 su 1000; al 25% non ne prende nessuno (fenomeno del masking). La soglia robusta li prende quasi tutti (996 e 4.197), ma segna più onesti con pochi imbroglioni (96). Nessuna soglia vince in tutti gli scenari, e i numeri valgono per questa popolazione sintetica, non per un gioco reale. Ne seguono due regole. Una segnalazione statistica non è una prova: è un motivo per guardare meglio. E la soglia si tara sui tuoi dati, contando i falsi positivi, non copiando un «3».
Machine learning: un segnale in più, non un giudice
I cheat sofisticati (un aimbot che imita il tremore di una mano) sfuggono a una soglia su una singola metrica, e per quelli si usano modelli che guardano sequenze di azioni. Un esempio pubblico è AntiCheatPT (agosto 2025): un Transformer addestrato su dati di gioco di Counter-Strike 2, con un dataset etichettato di 795 partite reso pubblico (CS2CD). I risultati dichiarati dagli autori sull'insieme di prova non aumentato sono un'accuratezza dell'89,17% e un AUC del 93,36%. Non ho riprodotto il modello (non c'è PyTorch su questa macchina) e quei numeri valgono per quel dataset, non per il tuo gioco.
Anche se tu li ottenessi, il conto dell'esempio precedente resta: con lo 0,5% di imbroglioni, un classificatore che sbaglia l'1% degli onesti segnala 199 innocenti ogni 100 colpevoli (anche ipotizzando di prenderli tutti). La conseguenza di progetto è netta: l'output di un modello è un punteggio che entra nella pipeline di valutazione, mai un verdetto automatico. Un buon modello è un modello che sai spiegare a chi fa il ricorso.
Dalla segnalazione alla sanzione: tante prove, una persona
Il punto più delicato dell'anti-cheat è decidere. Il listato mostra una politica che accumula le prove nel tempo: ogni
segnalazione perde peso con un'emivita di 7 giorni, le prove si combinano con un OR probabilistico
(1 − Π(1 − s·w)), nessuna sanzione automatica scatta con meno di tre partite distinte, e il ban permanente
non è mai automatico. Le soglie (0,50, 0,80, 0,95) sono parametri che dichiaro e non consiglio: si scelgono guardando i
casi che i revisori umani confermano o smentiscono.
HALF_LIFE_DAYS = 7
MIN_MATCHES = 3 # mai sanzioni automatiche su meno di 3 partite distinte
def combined(evidence):
"""evidence: elenco di (giorni_fa, punteggio 0-1, id_partita). Combina le prove con un OR probabilistico."""
p_innocent = 1.0
for days_ago, score, _ in evidence:
weight = 0.5 ** (days_ago / HALF_LIFE_DAYS) # una prova dimezza il suo peso ogni 7 giorni
p_innocent *= 1 - score * weight
return 1 - p_innocent
def decide(evidence):
n_matches = len({m for _, _, m in evidence})
score = combined(evidence)
if n_matches < MIN_MATCHES:
return score, "solo monitoraggio (prove insufficienti)"
if score >= 0.95:
return score, "sospensione di 24 h + revisione umana prioritaria (il ban permanente lo decide una persona)"
if score >= 0.80:
return score, "coda di revisione umana, nessuna sanzione automatica"
if score >= 0.50:
return score, "monitoraggio rafforzato"
return score, "solo registro"
casi = {
"un solo segnale forte oggi": [(0, 0.9, "m1")],
"5 segnali da 0,6 negli ultimi 5 giorni": [(d, 0.6, f"m{d}") for d in range(5)],
"gli stessi 5 segnali di 40 giorni fa": [(d + 40, 0.6, f"m{d}") for d in range(5)],
"10 segnali da 0,9 in 3 giorni": [(d % 3, 0.9, f"m{d}") for d in range(10)],
"10 segnali da 0,9, tutti nella stessa partita": [(0, 0.9, "m1") for _ in range(10)],
}
for nome, ev in casi.items():
score, azione = decide(ev)
print(f"{nome:<46} punteggio {score:.3f} -> {azione}")
# Output:
# un solo segnale forte oggi punteggio 0.900 -> solo monitoraggio (prove insufficienti)
# 5 segnali da 0,6 negli ultimi 5 giorni punteggio 0.969 -> sospensione di 24 h + revisione umana prioritaria (il ban permanente lo decide una persona)
# gli stessi 5 segnali di 40 giorni fa punteggio 0.046 -> solo registro
# 10 segnali da 0,9 in 3 giorni punteggio 1.000 -> sospensione di 24 h + revisione umana prioritaria (il ban permanente lo decide una persona)
# 10 segnali da 0,9, tutti nella stessa partita punteggio 1.000 -> solo monitoraggio (prove insufficienti)
I cinque casi mostrano ciò che la politica protegge. Un solo segnale forte (0,90) non basta: serve prova ripetuta in partite distinte. Cinque segnali recenti da 0,6 danno 0,969 e portano a sospensione e revisione, mentre gli stessi cinque di quaranta giorni fa pesano 0,046 e restano in registro: un giocatore che ha cambiato periferica, o una pessima serata, non si porta dietro una condanna a vita. Il quinto caso (dieci segnali, ma tutti nella stessa partita) resta in monitoraggio, perché la ripetizione di una stessa osservazione non è indipendente. Non ho incluso il moltiplicatore per l'età dell'account che si vede in molte pipeline: punire di più gli account nuovi significa colpire chi è appena arrivato, e il modo giusto per trattarlo è un prior da calibrare e giustificare, non un coefficiente fisso.
Replay e analisi dopo la partita
Alcuni cheat non si vedono in tempo reale e si vedono ricostruendo la partita: la telemetria che il server registra permette di rieseguire controlli più pesanti sui giocatori già segnalati da altri, senza il vincolo del budget del tick. È un lavoro asincrono: i dati di un giocatore selezionato vanno letti da una coda e il risultato arriva al sistema di revisione come un punteggio in più, con il resto delle prove. La telemetria di partita e le sue pipeline sono nell'articolo sulla telemetria di gioco.
Errori che si ripetono
- Sanzionare per un solo indicatore (per esempio un rapporto uccisioni/morti alto): un giocatore molto bravo ha numeri alti.
- Ignorare la latenza nei controlli: un giocatore con ping alto può sembrare un cheater se la tolleranza è fissa.
- Dire al cheater che cosa ti ha scoperto: ogni risposta precisa è un'informazione per aggirare il controllo.
- Non offrire ricorso: anche il sistema migliore sbaglia, e senza ricorso l'errore è permanente.
Le quattro voci sono lo stesso errore visto da lati diversi: trattare un segnale come una prova. L'indicatore singolo è il segnale più debole, la latenza ne è una causa confusa, la risposta precisa insegna a eludere, e il ricorso è l'unico modo di scoprire che l'indicatore sbagliava. Se devi sceglierne una sola da applicare subito, scegli il ricorso: è quella che trasforma i tuoi falsi positivi in dati con cui tarare le soglie.
Che cosa sai fare adesso
Sai distinguere ciò che il server può impedire (movimento, dati nascosti, transazioni) da ciò che può solo segnalare (la mira assistita), e sai scrivere i controlli che chiudono le prime tre falle, compresa la compensazione del lag con il rewind limitato. Sai misurare una soglia statistica contando falsi positivi, non fidandoti di un numero, e sai usare un modello come segnale in una pipeline in cui l'ultima parola è di una persona. Il passo successivo è appoggiarsi a componenti già pronti: Open Match e Nakama.
Domande frequenti
Il server autoritativo basta a fermare i cheater?
Ferma quelli che alterano dati che il server può ricalcolare: velocità, posizione, inventario, informazioni nascoste. Non ferma chi automatizza l'input legittimo, come un aimbot: per quello servono statistica e revisione.
Perché non banno automaticamente chi supera tre sigma?
Perché anche tra gli onesti una piccola frazione supera quella soglia: nell'esempio con lo 0,5% di imbroglioni, un segnalato su quattro era innocente. Una soglia statistica è un motivo per guardare meglio, non una prova.
Quanto indietro può andare la lag compensation?
Quanto decidi tu, con un limite: l'esempio usa 12 tick, cioè 200 ms a 60 Hz. Senza limite un cheater potrebbe dichiarare un istante lontano in cui il bersaglio era comodo; con il limite, i giocatori a latenza molto alta ricevono una compensazione solo parziale.
Fonti
- Gabriel Gambetta, Fast-Paced Multiplayer, parte I: Client-Server Game Architecture - il principio «non fidarti del giocatore» e il server autoritativo, consultata il 2026-11-12
- Gabriel Gambetta, parte IV: Lag Compensation - il server ricostruisce il mondo al tick del colpo, consultata il 2026-11-12
- AntiCheatPT: A Transformer-Based Approach to Cheat Detection in Competitive Computer Games (arXiv:2508.06348) - dataset CS2CD di 795 partite, accuratezza 89,17% e AUC 93,36% sull'insieme di prova non aumentato, consultata il 2026-11-12
I tre listati sono stati eseguiti (Node.js 24 per il server, Python 3 per statistica e sanzioni) e le uscite sono quelle reali; popolazioni, bersagli e prove sono sintetici con seme fisso. Il modello Transformer non è stato eseguito (PyTorch non è disponibile): i suoi risultati sono quelli dichiarati dagli autori.

Commenti
Caricamento commenti...
Accedi per commentare