Cloudflare Durable Objects: Costruzione di un Game Server Stateful all'Edge
Cosa Sono Realmente i Durable Objects
Un Durable Object è un oggetto JavaScript/TypeScript single-threaded che vive all'edge. Può essere pensato come un attore nel modello ad attori, ma il runtime gestisce tutte le parti difficili: routing delle richieste all'istanza giusta, persistenza dello stato, e garanzia di accesso single-threaded.
Ogni Durable Object ha:
- Un ID globalmente unico
- Un contesto di esecuzione single-threaded (bug di concorrenza impossibili)
- Un'API di storage key-value transazionale (strongly consistent)
- La capacità di accettare connessioni WebSocket
- Un sistema di allarmi per lavoro programmato
La proprietà critica: esiste una sola istanza di un dato Durable Object in tutto il mondo in qualsiasi momento. La piattaforma lo garantisce.
La Game Room: Modello ad Attori in Pratica
Ecco lo scheletro di un'implementazione di game server. Ogni stanza di gioco è un Durable Object:
export class GameRoom implements DurableObject {
private state: DurableObjectState;
private env: Env;
private gameState: GameState | null = null;
private sessions: Map<string, WebSocket> = new Map();
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
// CRITICO: Blocca tutte le richieste finché lo stato non è caricato
this.state.blockConcurrencyWhile(async () => {
const stored = await this.state.storage.get<GameState>("gameState");
this.gameState = stored ?? createEmptyGame();
});
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/ws") {
return this.handleWebSocket(request);
}
if (url.pathname === "/state") {
return Response.json(this.gameState);
}
return new Response("Not found", { status: 404 });
}
private handleWebSocket(request: Request): Response {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
const playerId = new URL(request.url).searchParams.get("playerId");
if (!playerId) {
return new Response("playerId mancante", { status: 400 });
}
this.state.acceptWebSocket(server, [playerId]);
this.sessions.set(playerId, server);
server.send(JSON.stringify({
type: "sync",
state: this.gameState,
yourId: playerId,
}));
this.broadcast({
type: "player_joined",
playerId,
playerCount: this.sessions.size,
}, playerId);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: string) {
const data = JSON.parse(message);
const tags = this.state.getTags(ws);
const playerId = tags[0];
switch (data.type) {
case "place_tile":
this.handlePlaceTile(playerId, data);
break;
case "draw_tiles":
this.handleDrawTiles(playerId);
break;
case "end_turn":
this.handleEndTurn(playerId);
break;
}
}
async webSocketClose(ws: WebSocket) {
const tags = this.state.getTags(ws);
const playerId = tags[0];
this.sessions.delete(playerId);
this.broadcast({
type: "player_left",
playerId,
playerCount: this.sessions.size,
});
if (this.sessions.size === 0) {
await this.state.storage.setAlarm(Date.now() + 300_000);
}
}
async alarm() {
if (this.sessions.size === 0) {
await this.state.storage.deleteAll();
}
}
}
La bellezza è l'assenza di complessità. Nessun mutex, nessun lock, nessun CAS loop, nessun controllo di concorrenza ottimistico. Il runtime garantisce che le chiamate webSocketMessage sono serializzate. Quando il Giocatore A piazza una tessera e il Giocatore B pesca tessere simultaneamente, si eseguono una dopo l'altra, mai interleaved.
Il Layer di Routing: Worker come Matchmaker
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname.startsWith("/game/")) {
const roomId = url.pathname.split("/")[2];
const id = env.GAME_ROOMS.idFromName(roomId);
const room = env.GAME_ROOMS.get(id);
return room.fetch(request);
}
if (url.pathname === "/matchmake") {
return handleMatchmaking(request, env);
}
return env.ASSETS.fetch(request);
}
};
L'intuizione chiave: idFromName() è un hash deterministico. La stringa "waiting:en:1200" mappa sempre allo stesso ID di Durable Object, quindi tutti i giocatori anglofoni con ~1200 di rating finiscono nella stessa stanza di matchmaking senza alcun layer di coordinazione.
Gestione dello Stato: Storage Transazionale
private async handlePlaceTile(playerId: string, data: PlaceTileAction) {
if (this.gameState!.currentTurn !== playerId) {
this.sendError(playerId, "Non è il tuo turno");
return;
}
const { x, y, letter } = data;
const validation = validatePlacement(this.gameState!, x, y, letter, playerId);
if (!validation.valid) {
this.sendError(playerId, validation.reason);
return;
}
this.gameState!.board[y][x] = { letter, playerId, timestamp: Date.now() };
this.gameState!.players[playerId].tiles = this.gameState!.players[playerId].tiles
.filter(t => t !== letter);
// Persisti atomicamente
await this.state.storage.put("gameState", this.gameState);
this.broadcast({
type: "tile_placed",
playerId,
x, y, letter,
scores: this.calculateScores(),
});
}
Per operazioni più complesse, hai le transazioni:
await this.state.storage.transaction(async (txn) => {
const game = await txn.get<GameState>("gameState");
const stats = await txn.get<PlayerStats>(`stats:${playerId}`);
game!.currentTurn = nextPlayer(game!);
stats!.movesPlayed += 1;
stats!.lastActive = Date.now();
await txn.put("gameState", game);
await txn.put(`stats:${playerId}`, stats);
});
Questo è ACID all'interno di un singolo Durable Object. Le scritture o committano tutte o rollbackano tutte. In un sistema distribuito tradizionale, ottenere questa garanzia attraverso multiple chiavi richiede un protocollo di consenso. Qui è una riga perché il modello single-threaded rende banale l'isolamento serializzabile.
Performance: Misurazioni Reali
Un load test con 500 giocatori simulati in 50 stanze produce questi risultati:
| Metrica | Valore | |---------|--------| | Latenza messaggio WebSocket (stessa regione) | 8-15ms | | Latenza messaggio WebSocket (cross-continente) | 40-90ms | | Latenza persistenza stato | 2-5ms | | Cold start (DO nuovo) | 15-25ms | | Elaborazione messaggio warm | <1ms |
La latenza cross-continente merita una spiegazione. Quando un giocatore a Tokyo si connette a una stanza creata da un giocatore a Berlino, il Durable Object gira vicino a Berlino. I messaggi del giocatore di Tokyo vengono instradati attraverso il backbone di Cloudflare fino a Berlino, processati, e la risposta torna indietro. Sono ~80ms di round trip. Non istantaneo, ma meglio di un server in us-east-1 per entrambi i giocatori.
Considerazioni Operative e Limitazioni
Limiti di memoria. Ogni Durable Object ha 128 MB. Sembra tanto finché non stai tenendo 1000 connessioni WebSocket con stato per-giocatore.
Nessuna primitiva di comunicazione inter-DO. Se la Stanza A deve sapere della Stanza B (es. per un bracket di torneo), devi passare per un Worker fetch. Non c'è pub/sub built-in tra Durable Object.
Ibernazione e costi. I Durable Object con WebSocket Hibernation vengono fatturati solo quando processano messaggi attivamente. Ma ogni operazione di storage costa. Persistere lo stato ad ogni mossa può risultare in $12/giorno in storage write durante i test. Raggruppare le scritture ogni 500ms riduce il costo a $0.80/giorno:
private pendingWrite = false;
private async scheduleWrite() {
if (this.pendingWrite) return;
this.pendingWrite = true;
setTimeout(async () => {
await this.state.storage.put("gameState", this.gameState);
this.pendingWrite = false;
}, 500);
}
Pattern Architetturale: Durable Objects + Vercel
L'architettura finale:
[Giocatori] → [Cloudflare Worker (routing)] → [Durable Objects (game state)]
↓
[Vercel (Next.js)] ← [Cloudflare Worker (static proxy)]
↓
[Vercel API Routes (account utente, leaderboard)]
Vercel gestisce il frontend Next.js e l'API non-realtime. Cloudflare gestisce il gameplay real-time. La separazione è pulita: tutto ciò che richiede consistenza forte e aggiornamenti in tempo reale va ai Durable Objects; tutto ciò che è eventually consistent e request/response va a Vercel.
Quando NON Usare Durable Objects
Non sono un backend general-purpose. Anti-pattern specifici:
- API CRUD: Usa un database normale. I DO aggiungono latenza per pattern semplici di lettura/scrittura.
- Computazione pesante: 128 MB di memoria, 30 secondi di CPU time limit.
- Aggregazione globale: Se devi contare tutti i giocatori attivi in tutte le stanze, serve un sistema separato.
- Dati relazionali: Niente join, niente indici, niente query. L'API di storage è un key-value store.
Brillano per: collaborazione in tempo reale, giochi multiplayer, chat room, coordinazione dispositivi IoT, rate limiting, e qualsiasi scenario dove un piccolo gruppo di client ha bisogno di stato condiviso strongly consistent con bassa latenza.
Il Verdetto
Dopo tre mesi in produzione con ~2000 giocatori attivi giornalieri, questo tipo di game server costa circa $34/mese su Cloudflare. L'equivalente su AWS sarebbero almeno due istanze EC2 più un Application Load Balancer più ElastiCache — probabilmente $150+/mese con latenza peggiore fuori da us-east-1.
Ancora più importante, sono necessari zero aggiornamenti infrastrutturali. Nessun patching, nessuna configurazione di scaling, nessun health check. La piattaforma gestisce tutto. I Durable Objects non sono la risposta a tutto. Ma per il problema specifico di "piccoli gruppi che necessitano stato condiviso real-time con consistenza forte" — rappresentano una delle soluzioni più solide disponibili.