Perché un game backend non si costruisce come un backend web
Un backend web risponde a una richiesta e dimentica chi gliel'ha fatta: il client chiede, il server legge dal database, risponde, e se la risposta arriva con mezzo secondo di ritardo nessuno se ne accorge. Un game backend multiplayer fa un mestiere diverso. Tiene in memoria una simulazione condivisa, la fa avanzare a passi regolari (a 64 passi al secondo ognuno dura 15,6 ms) e a ogni passo racconta a ciascun giocatore che cosa è successo. Se quel processo si ferma, la partita si ferma con lui.
Da questa differenza nascono quasi tutte le scelte di questa serie: server che sono stateful e quindi non si replicano dietro un load balancer come un'API, protocolli che accettano di perdere dati, messaggi compressi a mano, sessioni che sopravvivono a una caduta di rete. Questo primo articolo costruisce la mappa: ogni componente, che problema risolve e dove si sbaglia. Gli articoli successivi ne scavano uno alla volta, a partire dai game server dedicati.
Web e game a confronto: dove cambiano le regole
La tabella mette in fila le differenze che incidono sul progetto. Non contiene soglie di latenza universali, perché non esistono: dipendono dal genere di gioco e dalla tolleranza dei giocatori, e chi le scrive come numeri secchi sta copiando da qualcun altro.
| Aspetto | Backend web | Game backend multiplayer |
|---|---|---|
| Comunicazione | Richiesta e risposta | Flusso continuo nei due sensi |
| Ritmo degli aggiornamenti | Quando l'utente agisce | A frequenza fissa, decine di volte al secondo (tick rate) |
| Stato | Nel database; il server può essere sostituito a piacere | In memoria sul server della partita |
| Perdita di dati | Mai tollerata sulle transazioni | Tollerata per le posizioni, mai per gli acquisti |
| Scalare | Aggiungi repliche dietro un bilanciatore | Aggiungi partite, ognuna con il proprio processo |
La riga che pesa di più è «Stato». Un web server si butta e si ricrea: il prossimo lo trova tutto nel database. Un game server contiene le posizioni, i proiettili in volo e i punteggi di una partita in corso, e se il processo muore nessun'altra istanza sa dove eravate. Per questo i game server si trattano come bestiame con una vita breve e una sola missione, non come repliche intercambiabili, e per questo la persistenza vera (profili, acquisti) sta fuori, in un database, e si scrive solo nei momenti giusti.
Un esempio numerico spiega perché il ritardo non è un dettaglio. Con un RTT (andata e ritorno) di 50 ms, supponendo il percorso simmetrico, il tuo tasto impiega circa 25 ms ad arrivare al server e lo stato aggiornato altri 25 per tornare: tra la pressione e ciò che vedi passano almeno 50 ms, più l'attesa del prossimo tick. Gabriel Gambetta spiega nella sua serie sul multiplayer veloce come questa distanza obblighi a nascondere la rete con client-side prediction e compensazione del lag: il tema dell'articolo sulla sincronizzazione dello stato.
Modelli di rete: chi decide che cosa è successo
La prima scelta è chi ha l'ultima parola sullo stato del gioco. Ci sono tre risposte e ognuna sposta il costo in un punto diverso: sui server, sulla sicurezza o sulla complessità dei client.
Client-server con server autoritativo
Il server è l'unica autorità. I client mandano intenzioni («voglio muovermi a destra»), non risultati («sono a x=20»): il server le valida, aggiorna lo stato e rispedisce l'esito. L'esempio di Gambetta è chiarissimo: se il client potesse dire «sono in (20,10)» subito dopo «sono in (10,10)», un cheater attraverserebbe i muri; se dice «mi sposto di una casella», il server calcola (11,10) e lo ribatte. Il prezzo è il costo di un processo server per partita e la latenza di un giro completo per ogni azione.
Peer-to-peer
Ogni giocatore parla con tutti gli altri e nessuno ha un server. Con N giocatori le connessioni sono N·(N−1)/2: 28 per otto giocatori, 4.950 per cento. Glenn Fiedler osserva che questo modello sopravvive negli RTS, dove i comandi di ogni turno vengono eseguiti identici su tutte le macchine (lockstep), e ne elenca i limiti: serve un determinismo perfetto, e ogni giocatore ha la latenza del più lento. Nessuno controlla nessuno, quindi un client alterato non ha un giudice.
Relay
Un server inoltra i messaggi senza capire il gioco: risolve il NAT (i giocatori non devono raggiungersi direttamente) e costa poco, ma la simulazione resta sui client e con essa la fiducia. È un compromesso onesto per giochi cooperativi o casual, dove barare costa poco a chi gioca con te.
| Modello | Chi decide | Costo server | Barare | Quando ha senso |
|---|---|---|---|---|
| Client-server | Il server | Un processo per partita | Difficile | Competitivo, molti giocatori |
| Peer-to-peer | Ogni client | Quasi nullo | Facile | Pochi giocatori, tra amici |
| Relay | I client, con un postino | Basso | Possibile | Cooperativo, casual |
Il game loop lato server: il tick e il suo budget
Il cuore del server autoritativo è un ciclo a frequenza fissa: raccogli gli input, avanza la simulazione, spedisci lo stato. Ogni giro è un tick. Il tick rate scelto fissa il budget: a 64 Hz hai 1000/64 = 15,625 ms per fare tutto, rete compresa. Se un giro costa di più, il successivo parte in ritardo e ogni giocatore lo percepisce come scatto.
Il listato qui sotto non apre sockets né timer: simula 6.400 tick (100 secondi) con un orologio finto e costi
generati da un seme fisso, così l'uscita è identica ad ogni esecuzione. Ogni tick costa di norma 4-8 ms, e in
uno su cinquanta una raffica da 20-30 ms (numeri inventati per il modello, non misure di un gioco reale).
Due dettagli contano. debt accumula il ritardo: un tick da 25 ms consuma il budget dei successivi,
ed è il meccanismo che trasforma un picco isolato in uno scatto visibile. E maxCatchUp è la soglia
oltre la quale il server smette di rincorrere e scarta tick: nell'uscita qui sotto non scatta mai (il ritardo si riassorbe nei tick leggeri), ma senza soglia un server in ritardo prova a recuperare
girando a vuoto più veloce possibile e peggiora tutto (la «spirale della morte»).
// Server con tick fisso: il tempo e' simulato, quindi il risultato e' riproducibile.
const TICK_RATE = 64;
const TICK_MS = 1000 / TICK_RATE; // 15,625 ms di budget per tick
function mulberry32(seed: number): () => number {
let a = seed;
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;
};
}
// Costo simulato di un tick: di norma 4-8 ms, in 1 tick su 50 una raffica da 20-30 ms.
function tickCost(rand: () => number): number {
return rand() < 0.02 ? 20 + rand() * 10 : 4 + rand() * 4;
}
function percentile(sorted: readonly number[], p: number): number {
// nearest-rank: il valore sotto cui cade almeno il p% dei campioni
const rank = Math.ceil((p / 100) * sorted.length);
return sorted[Math.min(sorted.length, Math.max(1, rank)) - 1];
}
function simulate(totalTicks: number, maxCatchUp: number) {
const rand = mulberry32(42);
const costs: number[] = [];
let overruns = 0;
let dropped = 0;
let debt = 0; // millisecondi di ritardo accumulati rispetto all'orologio ideale
for (let tick = 1; tick <= totalTicks; tick++) {
const cost = tickCost(rand);
costs.push(cost);
if (cost > TICK_MS) overruns++;
debt = Math.max(0, debt + cost - TICK_MS); // il tick lungo mangia il budget dei successivi
while (debt >= TICK_MS * maxCatchUp) { // oltre la soglia si scartano tick, non si recupera
debt -= TICK_MS;
dropped++;
}
}
const sorted = [...costs].sort((x, y) => x - y);
const mean = costs.reduce((s, c) => s + c, 0) / costs.length;
return { mean, p50: percentile(sorted, 50), p99: percentile(sorted, 99), max: sorted[sorted.length - 1], overruns, dropped };
}
const r = simulate(6400, 3); // 100 secondi a 64 tick/s
console.log(`budget per tick : ${TICK_MS.toFixed(3)} ms`);
console.log(`durata media : ${r.mean.toFixed(2)} ms (${((r.mean / TICK_MS) * 100).toFixed(0)}% del budget)`);
console.log(`p50 / p99 / max : ${r.p50.toFixed(2)} / ${r.p99.toFixed(2)} / ${r.max.toFixed(2)} ms`);
console.log(`tick oltre budget: ${r.overruns} su 6400, tick scartati: ${r.dropped}`);
// Output:
// budget per tick : 15.625 ms
// durata media : 6.35 ms (41% del budget)
// p50 / p99 / max : 6.04 / 25.18 / 29.83 ms
// tick oltre budget: 114 su 6400, tick scartati: 0
L'uscita contiene la lezione più utile dell'articolo. La durata media è il 41% del budget:
guardando solo la media il server sembra riposato. Ma il p99 è 25 ms, quasi una volta e mezza il budget, e 114
tick su 6.400 (l'1,8%) lo sforano: in media uno ogni 0,9 secondi, qualcuno vede uno scatto. Per questo in produzione
si sorveglia un percentile alto, non la media. La funzione percentile usa il metodo del rango
più vicino: ordina i campioni e prende quello in posizione ⌈p·n⌉. Non usare questo codice così com'è in produzione: un
setInterval reale deriva dal tempo ideale, e va corretto con il tempo effettivamente trascorso.
I tre livelli dello stato
Lo stato di gioco non è un blocco unico. Conviene dividerlo per frequenza di cambiamento e per quanto fa male perderlo, perché da questo discende dove stai in memoria, cosa mandi in rete e cosa scrivi su disco.
| Livello | Esempi | Quando cambia | Dove vive |
|---|---|---|---|
| Frame state | Posizioni, velocità, proiettili | Ad ogni tick | Solo nella RAM del game server |
| Session state | Salute, inventario, punteggio | Su evento (danno, raccolta) | RAM, con copia in una cache come Redis se la partita deve sopravvivere a un crash |
| Persistent state | Profilo, acquisti, classifica | A fine partita o in transazione | Database relazionale |
Leggi la tabella dal basso verso l'alto come una scala di dolore: perdere una posizione costa un istante, perdere un acquisto costa un rimborso e un cliente. Il frame state va tenuto compatto perché lo spedisci ad ogni tick, il session state si manda solo quando cambia, il persistent state non va mai in broadcast e si scrive in modo asincrono. Scrivere il profilo su PostgreSQL a ogni colpo subito sarebbe il modo più rapido di rompere il budget del tick.
I protocolli: quanto vale l'ordine garantito
TCP consegna tutto, nell'ordine giusto. Se un pacchetto si perde, quelli successivi aspettano che sia ritrasmesso: è il head-of-line blocking. Per una chat va bene; per la posizione di un giocatore è dannoso, perché mentre aspetti il pacchetto vecchio ne sono arrivati di più nuovi che non puoi usare. UDP rinuncia alle garanzie e ti lascia scartare il dato superato. Il prezzo è che l'affidabilità, dove serve, la scrivi tu.
Nel browser UDP non esiste direttamente. Il WebSocket (RFC 6455) è un canale bidirezionale su TCP: semplice e universale, con il blocco in testa alla coda. I canali dati di WebRTC (RFC 8831) usano SCTP sopra DTLS sopra UDP, e per ogni canale puoi scegliere se essere affidabile o no: la stessa RFC cita come caso d'uso un gioco in tempo reale con posizioni e stato mandati su canali non affidabili. WebTransport poggia su HTTP/3: è oggi una Candidate Recommendation del W3C (snapshot del 30 luglio 2026), e il supporto lato server resta più scarso di quello di WebSocket, quindi si valuta caso per caso.
| Protocollo | Trasporto | Ordine e consegna | Blocco in testa | Nel browser |
|---|---|---|---|---|
| TCP | TCP | Garantiti | Sì | No (diretto) |
| UDP | UDP | Nessuna garanzia | No | No |
| WebSocket | TCP | Garantiti | Sì | Sì |
| WebRTC data channel | SCTP su DTLS su UDP | Configurabili per canale | No, se non affidabile | Sì |
I giochi seri non scelgono un solo canale: ne usano due, uno per dati che invecchiano e uno per dati che non devono perdersi. Ciò che passa dal canale non affidabile (posizioni, rotazioni, effetti) viene rimpiazzato dal tick dopo, quindi ritrasmetterlo è tempo sprecato. Ciò che passa dal canale affidabile (danni, cambio arma, chat, comandi di matchmaking, transazioni) non ha un tick dopo che lo corregga: se si perde, lo stato resta sbagliato. La regola per decidere è chiedersi cosa succede se questo messaggio non arriva e nessuno lo ripete.
Serializzazione: contare i byte
Moltiplica: se ogni client riceve ad ogni tick lo stato di 100 giocatori, ogni byte di un singolo stato pesa 100
volte 64 al secondo, cioè 6.400, e per ogni client collegato. Il listato misura lo stesso stato di un giocatore in
quattro formati. Per Protocol Buffers e MessagePack ho usato le librerie
protobufjs e @msgpack/msgpack, installate in una cartella temporanea di prova; il
«binario a mano» è un DataView con posizioni in centimetri su 32 bit e angoli in decimi di grado su 16.
import protobuf from "protobufjs";
import { encode as mpEncode } from "@msgpack/msgpack";
const state = {
type: "player_state", playerId: "p_abc123",
posX: 123.456, posY: 78.901, posZ: 45.678,
rotYaw: 180.5, rotPitch: -12.3, health: 85, weapon: "rifle", tick: 15042,
};
const root = protobuf.parse(`
syntax = "proto3";
message PlayerState {
uint32 player_id = 1;
float pos_x = 2; float pos_y = 3; float pos_z = 4;
float rot_yaw = 5; float rot_pitch = 6;
uint32 health = 7; uint32 weapon = 8; uint32 tick = 9;
}`).root;
const PlayerState = root.lookupType("PlayerState");
const pb = PlayerState.encode(PlayerState.create({
playerId: 123, posX: state.posX, posY: state.posY, posZ: state.posZ,
rotYaw: state.rotYaw, rotPitch: state.rotPitch, health: 85, weapon: 3, tick: 15042,
})).finish();
// Formato fatto a mano: posizioni in centimetri su int32, angoli in decimi di grado su int16
const buf = new ArrayBuffer(20);
const v = new DataView(buf);
v.setUint16(0, 123);
v.setInt32(2, Math.round(state.posX * 100)); v.setInt32(6, Math.round(state.posY * 100));
v.setInt32(10, Math.round(state.posZ * 100)); v.setInt16(14, Math.round(state.rotYaw * 10));
v.setInt16(16, Math.round(state.rotPitch * 10)); v.setUint8(18, 85); v.setUint8(19, 3);
const sizes = {
"JSON (testo)": Buffer.byteLength(JSON.stringify(state)),
"MessagePack": mpEncode(state).length,
"Protocol Buffers": pb.length,
"binario a mano": buf.byteLength,
};
const base = sizes["JSON (testo)"];
for (const [name, n] of Object.entries(sizes)) {
console.log(`${name.padEnd(17)} ${String(n).padStart(4)} byte ${((n / base) * 100).toFixed(0)}% del JSON`);
}
// Ogni client riceve ad ogni tick lo stato di 100 giocatori
for (const [name, n] of Object.entries(sizes)) {
console.log(`${name.padEnd(17)} ${((n * 100 * 64) / 1000).toFixed(1)} KB/s verso ogni client`);
}
// Output:
// JSON (testo) 162 byte 100% del JSON
// MessagePack 142 byte 88% del JSON
// Protocol Buffers 34 byte 21% del JSON
// binario a mano 20 byte 12% del JSON
// JSON (testo) 1036.8 KB/s verso ogni client
// MessagePack 908.8 KB/s verso ogni client
// Protocol Buffers 217.6 KB/s verso ogni client
// binario a mano 128.0 KB/s verso ogni client
I numeri smentiscono due luoghi comuni. MessagePack, essendo «un JSON binario», porta con sé i nomi dei campi in ogni messaggio e qui risparmia solo il 12%: dà un vantaggio vero quando i dati sono pochi o poco ripetuti, non per flussi di stato dove i campi sono sempre gli stessi. Protocol Buffers (guida alla codifica) sostituisce i nomi con numeri di campo e codifica gli interi con una lunghezza variabile, e arriva al 21% del JSON; il formato a mano, che quantizza le coordinate, scende al 12%. Il guadagno, però, si paga: lo schema va mantenuto d'accordo con tutti i client, e i valori quantizzati perdono precisione (un centimetro, qui).
La seconda sorpresa sono le ultime quattro righe: anche con il formato più compatto, mandare ad ogni client tutti i 100 giocatori ad ogni tick costa 128 KB/s, e con JSON oltre un megabyte al secondo, per client. Nessun gioco lo fa davvero: filtra ciò che il giocatore può vedere (interest management) e invia solo le differenze rispetto all'ultimo stato confermato. Prima di cercare il formato più piccolo, chiediti quanti stati mandi.
Sessioni, heartbeat e riconnessione
Una connessione mobile salta quando cambia cella, il Wi-Fi muore, il giocatore entra in ascensore. Se ogni caduta distruggesse la sessione, il giocatore perderebbe la partita per un secondo di rete. La soluzione è separare la connessione, che è fragile, dalla sessione, che il server tiene viva per una finestra di tempo: se il giocatore ritorna dentro la finestra, riceve lo stato intero e riprende.
Il listato modella tre ruoli. L'heartbeat aggiorna lastHeartbeatMs e riporta RTT e jitter;
sweep è il controllo periodico che sospende (dopo 15 s di silenzio) e poi chiude (dopo altri 60 s) la
sessione; reconnect funziona solo dallo stato sospeso. Due scelte da notare. Primo, l'RTT lo misura
il client (invia un ping, riceve l'eco, sottrae i propri orologi) e lo comunica al server: calcolare
Date.now() - timestampDelClient sul server, come si vede spesso, confronta due orologi non
sincronizzati e produce numeri senza senso. Secondo, le sessioni sono valori immutabili: ogni transizione
restituisce un nuovo oggetto, e questo rende i casi limite testabili uno per uno.
// Sessione con heartbeat e finestra di riconnessione. Orologio simulato, nessuna rete.
type Status = "connected" | "disconnected" | "closed";
interface Session {
readonly playerId: string;
readonly status: Status;
readonly lastHeartbeatMs: number;
readonly disconnectedAtMs: number | null;
readonly rttMs: number;
readonly jitterMs: number;
}
const DISCONNECT_TIMEOUT_MS = 15_000;
const RECONNECT_WINDOW_MS = 60_000;
const open = (playerId: string, nowMs: number): Session =>
({ playerId, status: "connected", lastHeartbeatMs: nowMs, disconnectedAtMs: null, rttMs: 0, jitterMs: 0 });
// L'RTT lo misura il client: manda un ping, il server lo rimanda indietro, il client
// sottrae il proprio orologio dal proprio orologio. Il server riceve solo il risultato.
const onHeartbeat = (s: Session, nowMs: number, rttReportedMs: number): Session =>
({ ...s, lastHeartbeatMs: nowMs, rttMs: rttReportedMs, jitterMs: Math.abs(rttReportedMs - s.rttMs) });
function sweep(s: Session, nowMs: number, log: (m: string) => void): Session {
if (s.status === "connected" && nowMs - s.lastHeartbeatMs > DISCONNECT_TIMEOUT_MS) {
log(`t=${nowMs / 1000}s ${s.playerId}: nessun heartbeat da 15 s, sessione sospesa`);
return { ...s, status: "disconnected", disconnectedAtMs: nowMs };
}
if (s.status === "disconnected" && nowMs - (s.disconnectedAtMs ?? nowMs) > RECONNECT_WINDOW_MS) {
log(`t=${nowMs / 1000}s ${s.playerId}: finestra di 60 s scaduta, sessione chiusa`);
return { ...s, status: "closed" };
}
return s;
}
function reconnect(s: Session, nowMs: number, log: (m: string) => void): Session {
if (s.status !== "disconnected") {
log(`t=${nowMs / 1000}s ${s.playerId}: riconnessione rifiutata (stato ${s.status}), si riparte da login`);
return s;
}
log(`t=${nowMs / 1000}s ${s.playerId}: riconnesso, il server invia lo stato intero`);
return { ...s, status: "connected", lastHeartbeatMs: nowMs, disconnectedAtMs: null };
}
const log = (m: string) => console.log(m);
let ana = open("ana", 0);
let bruno = open("bruno", 0);
const rttAna = [40, 44, 90, 41]; // il terzo campione e' un picco di rete
for (let i = 0; i < 4; i++) { ana = onHeartbeat(ana, (i + 1) * 2000, rttAna[i]); }
console.log(`ana: ultimo RTT ${ana.rttMs} ms, jitter ${ana.jitterMs} ms`);
for (let t = 1000; t <= 100_000; t += 1000) {
ana = sweep(ana, t, log);
bruno = sweep(bruno, t, log);
if (t === 50_000) ana = reconnect(ana, t, log); // dentro la finestra
if (t === 100_000) bruno = reconnect(bruno, t, log); // finestra gia' chiusa
}
// Output:
// ana: ultimo RTT 41 ms, jitter 49 ms
// t=16s bruno: nessun heartbeat da 15 s, sessione sospesa
// t=24s ana: nessun heartbeat da 15 s, sessione sospesa
// t=50s ana: riconnesso, il server invia lo stato intero
// t=66s ana: nessun heartbeat da 15 s, sessione sospesa
// t=77s bruno: finestra di 60 s scaduta, sessione chiusa
// t=100s bruno: riconnessione rifiutata (stato closed), si riparte da login
Nell'uscita compaiono i due destini. bruno non manda heartbeat: a 16 s viene sospeso, a 77 s la
finestra scade, e il tentativo di rientro a 100 s è rifiutato, quindi il client deve rifare il login. ana
si riconnette a 50 s, dentro la finestra, e riparte dallo stato intero. Poi, non avendo più heartbeat nella
simulazione, viene sospesa di nuovo a 66 s: è un artefatto del modello, non un difetto. I tempi (15 e 60 secondi) sono scelte
di prodotto, non standard: un gioco a squadre punitivo accorcia la finestra, uno cooperativo la allunga.
Lo stack dei dati: un database per ogni dato
Nessun database serve bene tutti i dati di un gioco, e per questo si compone uno stack. Il livello più veloce è la memoria del processo, dove sta il frame state. Una cache distribuita come Redis tiene lo stato di sessione, le classifiche (i sorted set si prestano bene all'ordinamento per punteggio) e le code di matchmaking. Un database relazionale come PostgreSQL conserva profili, inventari e acquisti perché dà transazioni, che in un acquisto non sono negoziabili. Un database di serie temporali raccoglie la telemetria, e uno storage a oggetti tiene replay e backup. Ogni livello è più lento e più durevole del precedente: l'errore classico è scrivere ad ogni evento nel livello sbagliato, cioè nel più lento.
Infrastruttura: dal processo alla flotta
Il dedicated server è un processo che simula una partita su hardware tuo, a differenza del
listen server, dove un giocatore fa anche da host e la partita muore con la sua connessione. Si impacchetta
in un container, perché avviare un'istanza deve costare secondi, non minuti.
Resta il problema di gestire centinaia di partite: chi le avvia, chi le assegna ai giocatori, chi le chiude. Lo
risolve un orchestratore. Agones, per esempio, è una libreria
per ospitare, eseguire e scalare dedicated game server su Kubernetes, e usa risorse personalizzate
(GameServer, Fleet, Fleetautoscaler) per far scalare la flotta con la domanda.
Le alternative gestite e il confronto sono nell'articolo sui
server dedicati.
Lo schema seguente mette i pezzi in fila. Si legge dall'alto: il giocatore entra dal bordo (bilanciatore), parla con i servizi «da web» (login, negozio, classifica, matchmaking) e viene indirizzato a un game server assegnato dall'orchestratore, che poi lavora in autonomia durante la partita.
Client --> Bordo (bilanciatore, TLS) --> Gateway API
|
+-----------+-----------+------+-------+-------------+
Auth Matchmaker Negozio Classifica Social
|
Orchestratore (assegna una partita a un game server)
|
+---------------+---------------+
Game server 1 Game server 2 Game server N (una partita ciascuno)
|
Cache (stato di sessione) --> Database (persistenza, a fine partita)
Il disegno è uno schema, non un listato eseguibile: nessuna tecnologia specifica è prescritta. Fa vedere il punto strutturale: i servizi a sinistra sono stateless e si scalano come qualsiasi API, il blocco a destra è stateful e si scala per partita. Se mischi i due mondi nello stesso processo, perdi i vantaggi di entrambi.
Pattern di scaling: partita, mondo, istanza
Il modo di scalare dipende dal genere. Nei giochi a partite (sparatutto, battle royale) ogni partita è un'istanza isolata con un numero fisso di giocatori: finisce, il processo viene distrutto, e scalare significa avere più istanze. Nei mondi persistenti il mondo è diviso in zone, ciascuna gestita da un server, e i giocatori passano da una zona all'altra con un trasferimento del loro stato (handoff). Una via di mezzo sono le zone istanziate: il mondo è persistente ma un dungeon viene copiato per ogni gruppo, così l'alta densità resta confinata in istanze che puoi creare e spegnere.
Quando comprare un BaaS e quando costruire
Esistono piattaforme che offrono matchmaking, classifiche, autenticazione e archiviazione già pronti. Tra quelle open source, Nakama (server distribuito per giochi sociali e in tempo reale) e Colyseus (framework multiplayer per Node.js) si ospitano da sé; ne parla l'articolo su Open Match e Nakama. La decisione non è tecnica prima di essere economica, e queste tre combinazioni funzionano nei casi seguenti.
- Piattaforma pronta: se hai pochi sviluppatori, un genere ordinario (carte, puzzle, casual) e la data di uscita come vincolo principale.
- Costruzione su misura: se la logica di gioco o il netcode sono il tuo vantaggio competitivo, o se il controllo sull'anti-cheat non è delegabile.
- Ibrido: piattaforma per identità, classifiche e negozio, game server tuo per la simulazione.
Il criterio che unisce le tre voci è dove sta la tua differenza: costruisci quello che ti distingue e compra ciò che è uguale per tutti. L'ibrido è la scelta più frequente perché la simulazione è specifica del gioco, mentre un sistema di login o una classifica non lo sono. Il rischio da pesare è la dipendenza: ogni funzione che appoggi a un fornitore chiuso è una funzione che migrerai con fatica.
Che cosa misurare in produzione
Un game backend non osservato è una bomba a orologeria, e il punto è scegliere metriche che dicano come si sente il giocatore, non come sta la macchina. Le sette sotto sono quelle da cui partire.
- CCU: giocatori connessi in contemporanea, il carico grezzo.
- Durata del tick, al p99: confrontata con il budget del tick, come nel listato.
- RTT a p50, p95 e p99: il ritardo di andata e ritorno, per distribuzione e non per media.
- Perdita di pacchetti e jitter: la qualità della rete del giocatore, che non controlli.
- Banda in uscita per giocatore: il segnale che interest management e delta funzionano.
- Tasso di riconnessioni riuscite: dice se la finestra di sessione fa il suo lavoro.
- Tempo di avvio di un server: da quanto aspetta un giocatore quando la flotta è piena.
Le prime tre sono quelle che decidono se aggiungere capacità; le altre dicono dove sta il problema. Nessuna di queste soglie ha un valore universale: scegli le tue dai dati della tua beta, e imposta gli allarmi sulla coda (p99) e non sulla media, come ha mostrato il listato del tick. Alla serie dedicata all'osservabilità del game backend lasciamo gli strumenti.
Che cosa sai fare adesso
Sai spiegare perché un server di gioco non si replica come un'API e che cosa sta in memoria rispetto a ciò che va in database. Sai calcolare il budget di un tick e riconoscere che la media nasconde gli scatti. Sai decidere quali messaggi viaggiano su un canale non affidabile e quali no, e misurare il peso di un formato invece di fidarti di una tabella. Sai progettare una sessione che sopravvive a una caduta di rete. Il passo successivo è come si assegnano le partite ai server: l'articolo sui game server dedicati; la sincronizzazione dello stato, cioè come nascondere i 50 ms, è nel quarto.
Domande frequenti
Perché un game server non si scala come un'API web?
Perché contiene lo stato della partita in memoria: due istanze non sono intercambiabili, e un bilanciatore che sposta una richiesta altrove la manderebbe a un processo che ignora la partita. Si scala aggiungendo partite, non repliche della stessa.
UDP o WebSocket per un gioco nel browser?
UDP puro nel browser non c'è. Per un gioco a turni o ritmi lenti WebSocket basta; per un gioco rapido i canali dati di WebRTC permettono la consegna non affidabile e senza blocco in testa. WebTransport è la strada futura, ma il supporto lato server è ancora limitato.
Quale tick rate devo scegliere?
Quello che il tuo server riesce a mantenere al p99 dentro il budget, con la banda che ti puoi permettere: un tick rate più alto riduce il ritardo di simulazione, ma moltiplica CPU e traffico. Parti basso, misura, poi sali.
Fonti
- Gabriel Gambetta, Fast-Paced Multiplayer, parte I: Client-Server Game Architecture - il principio del server autoritativo e il caso del client che dichiara la propria posizione, consultata il 2026-11-12
- Glenn Fiedler, What Every Programmer Needs To Know About Game Networking - il lockstep peer-to-peer e i suoi limiti, consultata il 2026-11-12
- RFC 6455, The WebSocket Protocol - il canale bidirezionale su TCP, consultata il 2026-11-12
- RFC 8831, WebRTC Data Channels - canali su SCTP/DTLS/UDP, affidabili o no, e il caso d'uso del gioco in tempo reale, consultata il 2026-11-12
- W3C, WebTransport - stato di Candidate Recommendation Snapshot del 30 luglio 2026, consultata il 2026-11-12
- Protocol Buffers, Encoding - numeri di campo e interi a lunghezza variabile, consultata il 2026-11-12
- Agones Documentation - la definizione («hosting, running and scaling dedicated game servers on Kubernetes») e le risorse GameServer, Fleet, Fleetautoscaler, consultata il 2026-11-12
I listati sono stati eseguiti con Node.js 24 (TypeScript con rimozione dei tipi, protobufjs e @msgpack/msgpack installati in una cartella temporanea); le uscite riportate sono quelle reali. I costi dei tick e le sessioni sono un modello didattico con orologio simulato e seme fisso, non misure di un gioco in produzione. Lo schema dei componenti non è eseguibile.

Commenti
Caricamento commenti...
Accedi per commentare