Salta al contenuto principale

06 - Open Match e Nakama: Game Backend Open-Source

Nakama gestisce account, dati e partite autoritative, Open Match solo il matchmaking. Cosa scrivi tu e perché Nakama open source non fa cluster.

Cosa ne pensi di questo articolo?

06 - Open Match e Nakama: Game Backend Open-Source
Game BackendFoto:Christina MorilloPexels

In Breve

Nakama gestisce account, dati e partite autoritative, Open Match solo il matchmaking. Cosa scrivi tu e perché Nakama open source non fa cluster.

Nakama e Open Match: che cosa ti danno davvero, e che cosa devi mettere tu

Se stai scegliendo il backend di un gioco multiplayer, hai tre strade: scrivere tutto, comprare un servizio gestito, oppure partire da un progetto open source. Nakama e Open Match sono i due nomi che ricorrono di più nella terza strada, e vengono spesso presentati come un'unica soluzione. Non lo sono. Nakama (di Heroic Labs) è un server di gioco completo: account, archivio dei dati dei giocatori, amici, chat, classifiche e un motore per partite in tempo reale. Open Match (di Google) fa una cosa sola, il matchmaking, e non sa nulla di account né di partite.

La differenza che decide tutto il resto è questa: la versione open source di Nakama gira come un solo nodo. Il cluster è una funzione della versione Enterprise. Chi legge «scala orizzontalmente su Kubernetes» e mette tre repliche dietro un servizio sta costruendo qualcosa che la documentazione non descrive. L'articolo parte da qui: che cosa fa ciascun progetto, il codice di una partita autoritativa che puoi eseguire senza server, il modo in cui i due si collegano, e dove si fanno male i team che li scelgono per il motivo sbagliato.

Prerequisito: il modello client-server autoritativo spiegato in Anatomia di un game backend multiplayer. Il matchmaking come problema (code, rating, attese) è in Sistema di matchmaking con Elo, Glicko-2 e code.

Che cosa c'è dentro Nakama

Nakama è un unico programma in Go che espone un'API gRPC e HTTP e una console di amministrazione. Il suo archivio è un database relazionale: la pagina di configurazione ufficiale dichiara che richiede un'istanza CockroachDB, con indirizzo predefinito root@localhost:26257, e che crea e gestisce il proprio database nakama.

  • Identità: autenticazione con email, identificativo del dispositivo e provider sociali, più sessioni
  • Archivio a collezioni: oggetti JSON per giocatore con permessi di lettura e scrittura distinti
  • Sociale: amici, gruppi, chat in tempo reale, notifiche
  • Competitivo: classifiche e tornei
  • Multiplayer: partite relay (il server inoltra i messaggi), partite autoritative (il server calcola lo stato) e un matchmaker integrato

L'elenco ha un ordine di importanza per chi sceglie: identità e archivio sono ciò che quasi ogni gioco con account deve avere comunque, e Nakama li dà pronti. Le partite autoritative sono la parte che ti permette di applicare l'idea di autorità del server contro i cheat. Il matchmaker integrato esiste: Open Match serve quando la sua logica non basta, non come condizione per avere un matchmaking.

Come si estende: un runtime TypeScript che non è Node

Per aggiungere logica a Nakama scrivi un modulo di runtime in Go, TypeScript/JavaScript o Lua. Qui sta la prima trappola. La documentazione del runtime JavaScript dice che gira su goja, una macchina virtuale JavaScript scritta in Go, che supporta la specifica ES5, senza librerie che richiedano Node, API del browser o codice nativo. Non c'è Deno, e quel che tipicamente dai per scontato in TypeScript moderno (Map, Math.sign, Array.from) va controllato prima di usarlo. Inoltre le funzioni passate all'Initializer devono stare nello scope globale, e InitModule è il punto d'ingresso.

Per non fidarmi della memoria ho fatto così: ho scaricato i tipi ufficiali (nakama-runtime, dal repository heroiclabs/nakama-common), ho scritto il modulo con "target": "ES5" e "lib": ["ES5"] in tsconfig.json, e ho lasciato che tsc in modalità strict bocciasse tutto ciò che non esiste. Poi ho caricato il JavaScript prodotto in un contesto vm di Node (uno scope globale unico, come in goja) e ho pilotato le funzioni con un finto nk. Non è Nakama: è un modello didattico che prova la logica, non l'integrazione con il server.

Il primo blocco è il punto d'ingresso. registerMatch collega al nome game_match i sette callback del ciclo di vita di una partita, e registerRpc espone una funzione che il client può chiamare. Se un callback manca, tsc lo segnala: l'interfaccia MatchHandler li richiede tutti e sette.

InitModule: registrare la partita e una chiamata RPC
function InitModule(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer) {
  initializer.registerMatch<GameState>('game_match', {
    matchInit: matchInit, matchJoinAttempt: matchJoinAttempt, matchJoin: matchJoin,
    matchLeave: matchLeave, matchLoop: matchLoop, matchTerminate: matchTerminate, matchSignal: matchSignal,
  });
  initializer.registerRpc('start_matchmaking', startMatchmakingRpc);
  logger.info('runtime caricato');
}

// Output:
// [log] runtime caricato
// registrati: [ 'game_match' ] [ 'start_matchmaking' ]

L'uscita mostra che dopo il caricamento risultano registrati una partita e una RPC. Se togli la riga del registerRpc la seconda lista resta vuota e il client non ha nessuna funzione da chiamare.

La partita autoritativa, passo per passo

Una partita autoritativa è un ciclo che gira sul server a una frequenza fissa. Il tickRate che ritorni da matchInit è il numero di chiamate a matchLoop al secondo; con 10, la documentazione spiega che ogni ciclo parte 100 ms dopo l'inizio del precedente. Ad ogni chiamata ricevi la lista dei messaggi arrivati dai client dall'ultimo tick: tu li convalidi, aggiorni lo stato e, se vuoi, trasmetti. Lo stato vive in un solo nodo, quello che ospita la partita: è la ragione per cui non serve nessuna sincronizzazione distribuita, e per cui una partita non si «sposta» su un'altra replica.

Nel codice del primo blocco, il client propone uno spostamento e il server decide. applyMove scarta i numeri non finiti, limita ogni asse a MAX_STEP unità e tiene il giocatore dentro la mappa. applyAttack non legge nulla dal messaggio tranne chi attacca: il danno è la costante ATTACK_DAMAGE. È una scelta di sicurezza e non di comodità. Se il client potesse mandare damage, ogni giocatore diventerebbe un bersaglio con un solo messaggio.

Movimento e attacco: il client propone, il server decide
function applyMove(p: Player, dx: number, dy: number): void {
  // ignora i numeri non finiti e limita il passo: il client propone, il server decide
  if (!isFinite(dx) || !isFinite(dy)) { return; }
  p.x = clamp(p.x + sign(dx) * Math.min(Math.abs(dx), MAX_STEP), 0, MAP_SIZE);
  p.y = clamp(p.y + sign(dy) * Math.min(Math.abs(dy), MAX_STEP), 0, MAP_SIZE);
}

function applyAttack(state: GameState, attackerId: string): void {
  var a = state.players[attackerId];
  if (!a || !a.alive) { return; }
  Object.keys(state.players).forEach(function (id) {
    var t = state.players[id];
    if (id === attackerId || !t.alive) { return; }
    var dist = Math.sqrt(Math.pow(t.x - a.x, 2) + Math.pow(t.y - a.y, 2));
    if (dist <= ATTACK_RANGE) {
      t.health = Math.max(0, t.health - ATTACK_DAMAGE);
      if (t.health === 0) { t.alive = false; a.score += 100; }
    }
  });
}

// Output:
// A dopo dx=9999, dy=-3: {"x":15,"y":47,"health":30,"score":0,"alive":true}
// A dopo 3 passi da 5: {"x":30,"y":47,"health":30,"score":0,"alive":true} | B: {"x":30,"y":50,"health":30,"score":0,"alive":true}
// B dopo un attacco con damage=9999 nel payload: {"x":30,"y":50,"health":20,"score":0,"alive":true}

Le tre righe di uscita provano le tre difese. Un messaggio con dx=9999 sposta il giocatore di 5 unità (da 10 a 15), tre passi lo portano a 30, e un attacco il cui contenuto dichiara damage: 9999 toglie comunque 10 punti: la salute del bersaglio passa da 30 a 20. Nota anche il caso che non vedi: un movimento con dx assente produce NaN, e isFinite lo scarta invece di corrompere la posizione.

Il ciclo vero e proprio fa quattro cose nell'ordine in cui conta. Decodifica ogni messaggio con nk.binaryToString (il campo data è un ArrayBuffer, non una stringa) dentro un try, perché il contenuto arriva dalla rete e può non essere JSON. Applica solo i messaggi dei giocatori vivi e solo in fase di gioco. Calcola la fase successiva. Ritorna { state }, oppure null per chiudere la partita: la forma dell'oggetto di ritorno è fissata dai tipi ufficiali, e se ritorni lo stato nudo il compilatore lo segnala subito.

matchLoop: validare, aggiornare, chiudere
var matchLoop: nkruntime.MatchLoopFunction<GameState> = function (ctx, logger, nk, dispatcher, tick, state, messages) {
  messages.forEach(function (m) {
    var p = state.players[m.sender.userId];
    if (!p || !p.alive || state.phase !== 'playing') { return; }
    var data: { dx?: number; dy?: number };
    try { data = JSON.parse(nk.binaryToString(m.data)); } catch (e) { return; } // payload malformato: scartato
    if (m.opCode === OP_MOVE) { applyMove(p, Number(data.dx), Number(data.dy)); }
    if (m.opCode === OP_ATTACK) { applyAttack(state, m.sender.userId); }
  });

  var ids = Object.keys(state.players);
  var alive = ids.filter(function (id) { return state.players[id].alive; });
  if (state.phase === 'waiting' && alive.length >= 2) { state.phase = 'playing'; }
  if (state.phase === 'playing' && (alive.length <= 1 || tick >= state.maxTicks)) {
    state.phase = 'ended';
    dispatcher.broadcastMessage(OP_RESULT, JSON.stringify(state.players), null, null, true);
    return null; // restituire null chiude il match
  }
  return { state: state };
};

var matchTerminate: nkruntime.MatchTerminateFunction<GameState> = function (ctx, logger, nk, dispatcher, tick, state, grace) { return { state: state }; };
var matchSignal: nkruntime.MatchSignalFunction<GameState> = function (ctx, logger, nk, dispatcher, tick, state, data) { return { state: state, data: data }; };

// Output:
// payload malformato: nessuna eccezione, fase = playing
// match chiuso al tick 9 - messaggi finali: 1 op 99

Nella prova, il payload non e json non solleva eccezioni e la fase resta playing. Poi il giocatore A attacca finché B non muore: con 30 punti di salute e 10 di danno servono tre colpi, e la partita si chiude al tick 9 con un messaggio finale di codice 99. Il tempo è misurato in tick e non con Date.now(): così la partita è riproducibile e un test non dipende dall'orologio. La durata massima, TICK_RATE * 30 tick, vale 30 secondi a 10 tick al secondo.

Quando non è la scelta giusta

Se il gioco è a turni lenti o cooperativo e senza incentivi a barare, una partita relay o la sola archiviazione dei dati bastano e costano meno. La partita autoritativa ha un costo reale: CPU del server per ogni tick e il lavoro di scrivere la fisica due volte, nel client per la prontezza e nel server per la verità.

Come lo metti in produzione senza il cluster

La pagina sulle partite autoritative è esplicita: la partita e il suo stato stanno su un nodo, che ne diventa l'ospite, e solo la versione Enterprise replica le presenze tra nodi e bilancia automaticamente le partite. Nella pagina di configurazione la sezione del cluster è marcata «Nakama Enterprise Only». Per un team con la versione open source, ciò significa tre cose concrete.

  • Un'istanza per ambiente: una sola replica del processo, con riavvio automatico, non un Deployment con tre repliche
  • Scala verticale: più CPU e memoria sul nodo, e un database separato che regge i picchi di scrittura
  • Più istanze indipendenti: se serve più capacità, istanze separate con database separati, ciascuna con i propri giocatori (per regione, per esempio), e non un'unica istanza distribuita

La terza voce è quella che cambia l'architettura: se i giocatori di due istanze devono incontrarsi, il matchmaking e l'identità devono stare sopra, e lì entra in gioco un livello esterno. Non pubblico qui un manifest Kubernetes: non posso eseguirlo, e lo schema più comune, con tre repliche e un autoscaler, promette un comportamento che questa edizione non ha. Il confine fra ciò che Nakama risolve e ciò che resta tuo lo trovi anche in Game server dedicati: GameLift e Agones, che copre l'orchestrazione dei server di gioco a parte.

Open Match: il matchmaking a pezzi che scrivi tu

Open Match è un framework in Go con licenza Apache 2.0, pensato per Kubernetes. La documentazione ufficiale è alla release 1.8.0 e ha un'area separata per Open Match 2. Non è un matchmaker pronto: ti dà il piano di controllo (raccolta dei ticket, ricerca, sincronizzazione) e ti chiede di scrivere due componenti.

  • Frontend: riceve i ticket dai giocatori, via gRPC o via HTTP (porta 51504 nell'esempio della guida)
  • Query: permette alla match function di leggere i ticket che soddisfano i filtri di un profilo
  • Match function (tua): servizio gRPC o HTTP con un metodo Run: riceve un MatchProfile e restituisce in streaming le proposte di partita
  • Backend: espone FetchMatches e AssignTickets al tuo Director
  • Director (tuo): un processo che chiede le partite, ottiene un server di gioco e assegna i ticket
  • Evaluator e Synchronizer: risolvono le proposte in conflitto, quando lo stesso ticket finisce in due partite; c'è un valutatore predefinito

Il Director non è un servizio che Open Match ti consegna: la guida lo presenta come il processo che chiede le partite e assegna i server, e lo scrivi tu. Scrivere Director e match function è dunque il lavoro, e questo significa che Open Match non ti libera dal progettare l'algoritmo di abbinamento: ti libera dal gestire lo stato dei ticket su larga scala.

Il Director, nella versione Go della guida, è un ciclo che chiama FetchMatches con l'indirizzo della match function, riceve le partite in streaming, ottiene dal tuo sistema di allocazione la stringa di connessione del server e la associa ai ticket con AssignTickets. Non l'ho eseguito, perché Go non è installato nella macchina in cui ho lavorato: i nomi di metodi e messaggi sono quelli della pagina del Director, ma va considerato uno schema da adattare e da compilare nel tuo progetto.

Director: da ticket a partita assegnata
// Director (Go): schema da adattare, NON eseguito (Go non è installato sulla macchina di lavoro)
package main

import (
	"context"
	"io"

	"open-match.dev/open-match/pkg/pb"
)

func fetchAndAssign(ctx context.Context, be pb.BackendServiceClient, profile *pb.MatchProfile) error {
	stream, err := be.FetchMatches(ctx, &pb.FetchMatchesRequest{
		Config:  &pb.FunctionConfig{Host: "matchfunction", Port: 50502, Type: pb.FunctionConfig_GRPC},
		Profile: profile,
	})
	if err != nil {
		return err
	}
	for {
		resp, err := stream.Recv()
		if err == io.EOF {
			return nil
		}
		if err != nil {
			return err
		}
		conn := allocateGameServer(resp.GetMatch()) // a carico tuo: Agones, un'API di allocazione, un tuo pool
		ids := make([]string, 0, len(resp.GetMatch().GetTickets()))
		for _, t := range resp.GetMatch().GetTickets() {
			ids = append(ids, t.GetId())
		}
		_, err = be.AssignTickets(ctx, &pb.AssignTicketsRequest{
			Assignments: []*pb.AssignmentGroup{{TicketIds: ids, Assignment: &pb.Assignment{Connection: conn}}},
		})
		if err != nil {
			return err
		}
	}
}

Il pezzo che non è un dettaglio è allocateGameServer: Open Match non crea né trova server di gioco. La guida dice che il Director può prenderne uno dal sistema di allocazione e impostarlo come assegnazione sui ticket. In pratica quel sistema è Agones, un servizio tuo o un pool fisso: lo vedi nell'articolo sull'orchestrazione linkato sopra. Un errore di assegnazione lascia i ticket in attesa: la funzione ritorna l'errore e il chiamante deve decidere se ritentare.

La logica di abbinamento: che cosa fa una match function

La match function riceve il profilo con i suoi pool (gruppi di ticket che soddisfano un filtro, per esempio un intervallo di MMR) e propone partite. Il compito vero è un compromesso fra qualità e attesa. Con un'ampiezza massima di rating stretta le partite sono eque ma qualcuno aspetta; con una larga nessuno aspetta ma le partite sono sbilanciate. La guida suggerisce di recuperare i ticket con la libreria QueryPools e di mandare le proposte in streaming. Poiché non eseguo Go, ho riscritto la sola logica di raggruppamento in JavaScript, con un seme fisso, come modello didattico.

Raggruppare per MMR: eque o veloci
// Modello didattico della logica di una match function: raggruppa ticket per MMR vicino.
function seeded(seed) { let s = seed; return () => (s = (s * 1664525 + 1013904223) % 4294967296) / 4294967296; }

function proposeMatches(tickets, size, maxSpread) {
  const sorted = [...tickets].sort((a, b) => a.mmr - b.mmr);
  const proposals = [];
  let i = 0;
  while (i + size <= sorted.length) {
    const group = sorted.slice(i, i + size);
    const spread = group[size - 1].mmr - group[0].mmr;
    if (spread <= maxSpread) { proposals.push({ tickets: group.map(t => t.id), spread }); i += size; }
    else { i += 1; } // il ticket più basso non entra in questo gruppo: si prova dal successivo
  }
  const used = new Set(proposals.flatMap(p => p.tickets));
  return { proposals, left: sorted.filter(t => !used.has(t.id)).map(t => t.id) };
}

const rnd = seeded(7);
const tickets = Array.from({ length: 20 }, (_, n) => ({ id: 't' + n, mmr: Math.round(800 + rnd() * 1200) }));
console.log('mmr:', tickets.map(t => t.mmr).sort((a, b) => a - b).join(' '));
for (const spread of [200, 400]) {
  const r = proposeMatches(tickets, 4, spread);
  console.log(`spread max ${spread}: ${r.proposals.length} partite, ${r.left.length} ticket in attesa`,
    r.proposals.map(p => p.spread));
}

// Output:
// mmr: 859 864 883 1087 1233 1302 1342 1369 1438 1457 1526 1535 1733 1736 1823 1827 1896 1912 1967 1981
// spread max 200: 4 partite, 4 ticket in attesa [ 136, 97, 94, 85 ]
// spread max 400: 5 partite, 0 ticket in attesa [ 228, 136, 97, 94, 85 ]

Con 20 giocatori sintetici e partite da 4, un'ampiezza massima di 200 punti di MMR produce 4 partite e lascia 4 ticket in attesa; con 400 si formano 5 partite e nessuno aspetta, ma la prima mette insieme giocatori con 228 punti di scarto. Le cifre vengono da questo generatore, non da un caso reale: servono a mostrare che il parametro non ha un valore giusto in assoluto. La scelta dipende da quanto traffico hai: con poche code attive un'ampiezza stretta lascia i giocatori in attesa per sempre, quindi in produzione l'ampiezza cresce con il tempo passato in coda. Come si calcola il rating, per esempio con Glicko-2, è un altro argomento: qui il rating è un numero già dato.

Far parlare Nakama e Open Match

I due sistemi si collegano con un ponte sottile. Il client chiede a Nakama di cercare una partita; una RPC di Nakama legge il rating dall'archivio, crea un ticket sul frontend di Open Match e salva l'identificativo del ticket per il giocatore. Quando il Director assegna il server, il client lo scopre interrogando il ticket (la guida del frontend elenca un GET su /v1/frontendservice/tickets/{ticket_id} e uno streaming WatchAssignments) e si connette. Il percorso del frontend per creare un ticket è /v1/frontendservice/tickets e il corpo ha un campo ticket: è così nella guida del frontend.

RPC di Nakama: un ticket su Open Match
/// <reference path="../node_modules/nakama-runtime/index.d.ts" />

var OPEN_MATCH_FRONTEND = 'http://open-match-frontend:51504/v1/frontendservice/tickets';

var startMatchmakingRpc: nkruntime.RpcFunction = function (ctx, logger, nk, payload) {
  if (!ctx.userId) { throw new Error('serve una sessione autenticata'); }
  var userId = ctx.userId;
  var req = JSON.parse(payload) as { mode: string; region: string };
  var rows = nk.storageRead([{ collection: 'player_stats', key: 'rating', userId: userId }]);
  var mmr = rows.length > 0 ? (rows[0].value as { mmr: number }).mmr : 1000;

  var body = JSON.stringify({
    ticket: { search_fields: {
      double_args: { mmr: mmr },
      string_args: { mode: req.mode, region: req.region },
      tags: ['player.' + userId],
    } },
  });
  var res = nk.httpRequest(OPEN_MATCH_FRONTEND, 'post', { 'Content-Type': 'application/json' }, body, 5000);
  if (res.code !== 200) { throw new Error('Open Match ha risposto ' + res.code); }
  var ticketId = (JSON.parse(res.body) as { id: string }).id;

  nk.storageWrite([{ collection: 'matchmaking', key: 'current_ticket', userId: userId,
    value: { ticket_id: ticketId, mode: req.mode }, permissionRead: 1, permissionWrite: 0 }]);
  return JSON.stringify({ ticket_id: ticketId, status: 'searching' });
};

// Output:
// POST http://open-match-frontend:51504/v1/frontendservice/tickets
// {"ticket":{"search_fields":{"double_args":{"mmr":1480},"string_args":{"mode":"deathmatch","region":"eu"},"tags":["player.A"]}}}
// {"ticket_id":"t-42","status":"searching"}
// salvato: {"ticket_id":"t-42","mode":"deathmatch"}
// senza sessione: serve una sessione autenticata

L'uscita dimostra la forma della richiesta e la gestione del caso senza sessione. Tre righe del codice meritano una spiegazione. search_fields contiene i campi su cui i filtri del profilo cercano: il rating come double_args, modalità e regione come string_args. Il timeout di 5000 ms dentro httpRequest evita che una RPC resti appesa se il frontend non risponde. E se Open Match non risponde con 200, la funzione lancia un errore invece di salvare un ticket che non esiste. Non ho potuto eseguire la chiamata contro un Open Match vero (nessun server né cluster in questa macchina): la prova usa un httpRequest finto, quindi la forma del JSON è quella della guida, non una risposta osservata.

Open source o servizio gestito: la scelta onesta

Una tabella di costi per milione di utenti attivi, come ne circolano tante, non regge: dipende da quanto tempo di ingegneri conta e da come si valuta l'attesa. Meglio ragionare per tre domande, che ti dicono cosa stai davvero comprando.

  • Chi gestisce il database e i picchi? Con Nakama open source: tu, e un'istanza sola è un punto unico di guasto
  • Quanto devi cambiare le regole? Se matchmaking e logica di partita sono il tuo gioco, il codice sorgente a disposizione conta più di ogni funzione già pronta
  • Dove stanno i dati? Con un'installazione tua decidi tu la regione e la retention; con un servizio gestito vale il contratto del fornitore, da leggere

Se rispondi «non voglio gestire un database e un nodo di produzione» a una sola delle tre, un servizio gestito è onesto e rapido. Se rispondi «le regole sono il mio prodotto» a una sola, il costo operativo di Nakama e Open Match è un prezzo ragionevole. Una via di mezzo che funziona: parti con un servizio gestito per validare il gioco e migra dopo, tenendo l'accesso ai dati dietro un'interfaccia tua. Le operazioni live (eventi, feature flag) sono nell'articolo successivo della serie.

Domande frequenti

Nakama open source si può mettere in cluster?

No, secondo la documentazione ufficiale: la sezione del cluster e la replica delle presenze tra nodi sono marcate «Nakama Enterprise Only». Con la versione open source si scala in verticale o con istanze indipendenti.

Il runtime TypeScript di Nakama usa Node o Deno?

Nessuno dei due: gira su goja, una macchina virtuale JavaScript in Go che supporta ES5. Niente librerie che richiedono Node o API del browser, e le funzioni passate all'Initializer devono stare nello scope globale.

Open Match è un matchmaker pronto all'uso?

No. Fornisce frontend, backend e query; la match function e il Director li scrivi tu. Ti toglie il peso dello stato dei ticket su larga scala, non la progettazione dell'algoritmo di abbinamento.

Che cosa sai fare adesso

Sai distinguere ciò che Nakama copre (account, archivio, chat, partite autoritative) da ciò che Open Match aggiunge solo sul matchmaking, e sai perché la versione open source non è un cluster. Hai un modulo di partita scritto per ES5, controllato dai tipi ufficiali e provato con un finto server, in cui il client propone e il server decide. Sai come un ticket nasce da una RPC e come l'assegnazione torna al giocatore. Prima di impegnarti, prova il tuo caso con tre partite vere su un'istanza singola: ti dirà più di qualunque tabella di confronto.

Fonti

Il modulo TypeScript è stato compilato con tsc 5 (target ES5, strict) contro i tipi di nakama-runtime ed eseguito in un contesto vm di Node 24 con un finto nk; il raggruppamento per MMR è un modello didattico con seme fisso. Il Director in Go è uno schema da adattare, non eseguito. Nessuna chiamata a un Nakama o a un Open Match reali.

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