Lo Hobbit Experience 2014

Aggiunta del gameplay WebRTC all'esperienza Hobbit

Daniel Isaksson
Daniel Isaksson

In previsione del nuovo film sullo Hobbit "Lo Hobbit: La battaglia delle cinque armate", abbiamo lavorato per estendere l'esperimento Chrome dell'anno scorso, A Journey attraverso la Terra di Mezzo, aggiungendo nuovi contenuti. Questa volta l'obiettivo principale è stato ampliare l'utilizzo di WebGL, in quanto un numero maggiore di browser e dispositivi può visualizzare i contenuti e utilizzare le funzionalità WebRTC di Chrome e Firefox. Quest'anno avevamo tre obiettivi da raggiungere:

  • Gameplay P2P con WebRTC e WebGL su Chrome per Android
  • Crea un gioco multiplayer facile da usare e basato sull'input del tocco
  • Hosting sulla piattaforma Google Cloud

Definizione del gioco

La logica del gioco si basa su una configurazione a griglia con truppe che si muovono su un tabellone di gioco. In questo modo, abbiamo potuto provare il gameplay su carta mentre stavamo definendo le regole. L'utilizzo di una configurazione basata su griglia aiuta anche il rilevamento delle collisioni nel gioco per mantenere buone prestazioni, dato che è sufficiente verificare la presenza di collisioni con oggetti nello stesso riquadro o nei riquadri vicini. Sapevamo fin dall'inizio che volevamo focalizzare il nuovo gioco su una battaglia tra le quattro principali forze della Terra di Mezzo: Umani, Nani, Elfi e Orchi. Inoltre doveva essere abbastanza informale da poter essere usato in un esperimento Chrome e non avere troppe interazioni da imparare. Abbiamo iniziato definendo cinque campi di battaglia nella mappa della Terra di Mezzo che fungono da sale giochi in cui più giocatori possono competere in una battaglia tra pari. Mostrare più partecipanti in una stanza sullo schermo di un dispositivo mobile e consentire agli utenti di selezionare chi sfidare era già una sfida. Per semplificare l'interazione e la scena, abbiamo deciso di usare un solo pulsante per sfidare e accettare e usare la stanza solo per mostrare gli eventi e chi è l'attuale re della collina. Questa direzione ha anche risolto alcuni problemi sul lato della ricerca degli incontri e ci ha permesso di trovare i migliori candidati per una battaglia. Nel nostro precedente esperimento di Chrome Cube Slam abbiamo imparato che occorre molto lavoro per gestire la latenza in un gioco multiplayer se il risultato del gioco si basa su di esso. Devi costantemente fare ipotesi su dove sarà lo stato dell'avversario, dove quest'ultimo pensa che tu sia e sincronizzarlo con animazioni su dispositivi diversi. Questo articolo spiega queste sfide in modo più dettagliato. Per semplificare le cose, abbiamo reso questo gioco a turni.

La logica del gioco si basa su una configurazione a griglia con truppe che si muovono su un tabellone di gioco. In questo modo, abbiamo potuto provare il gameplay su carta mentre stavamo definendo le regole. L'utilizzo di una configurazione basata su griglia aiuta anche il rilevamento delle collisioni nel gioco per mantenere buone prestazioni, dato che è sufficiente verificare la presenza di collisioni con oggetti nello stesso riquadro o nei riquadri vicini.

Parti del gioco

Per realizzare questo gioco multiplayer, abbiamo dovuto costruire alcune parti fondamentali:

  • Un'API per la gestione dei giocatori lato server gestisce gli utenti, la ricerca della partita, le sessioni e le statistiche del gioco.
  • Server che aiutano a stabilire la connessione tra i giocatori.
  • Un'API per la gestione della segnalazione dell'API AppEngine Channels, utilizzata per connettersi e comunicare con tutti i giocatori nelle sale giochi.
  • Un motore di gioco JavaScript che gestisce la sincronizzazione dello stato e dei messaggi RTC tra i due giocatori/peer.
  • La vista del gioco WebGL.

Gestione giocatori

Per supportare un gran numero di giocatori, utilizziamo molte sale giochi parallele per ogni campo di battaglia. Il motivo principale per limitare il numero di giocatori per ogni stanza è consentire ai nuovi giocatori di raggiungere la vetta della classifica in un tempo ragionevole. Il limite è collegato anche alle dimensioni dell'oggetto json che descrive la sala giochi inviata tramite l'API Channel, che ha un limite di 32 kB. Dobbiamo memorizzare i giocatori, le stanze, i punteggi, le sessioni e la loro relazione nel gioco. Per farlo, per prima cosa abbiamo utilizzato NDB per le entità e utilizzato l'interfaccia di query per gestire le relazioni. NDB è un'interfaccia per il datastore di Google Cloud. L'utilizzo di NDB ha funzionato alla grande all'inizio, ma presto ci siamo imbattuti in problemi nel modo in cui dovevamo farlo. La query è stata eseguita sulla versione "commessa" del database (le scritture NDB sono spiegate in dettaglio in questo articolo di approfondimento), che può avere un ritardo di diversi secondi. Ma le entità stesse non hanno avuto questo ritardo poiché rispondono direttamente dalla cache. Potrebbe essere un po' più semplice da spiegare con un codice di esempio:

// example code to explain our issue with eventual consistency
def join_room(player_id, room_id):
    room = Room.get_by_id(room_id)
    
    player = Player.get_by_id(player_id)
    player.room = room.key
    player.put()
    
    // the player Entity is updated directly in the cache
    // so calling this will return the room key as expected
    player.room // = Key(Room, room_id)

    // Fetch all the players with room set to 'room.key'
    players_in_room = Player.query(Player.room == room.key).fetch()
    // = [] (an empty list of players)
    // even though the saved player above may be expected to be in the
    // list it may not be there because the query api is being run against the 
    // "committed" version and may still be empty for a few seconds

    return {
        room: room,
        players: players_in_room,
    }

Dopo aver aggiunto i test delle unità, abbiamo potuto vedere il problema chiaramente e abbiamo abbandonato le query per mantenere le relazioni in un elenco separato da virgole in memcache. Sembrava un trucco, ma ha funzionato e la memcache di AppEngine ha un sistema simile a transazioni per le chiavi che utilizzava l'eccellente funzionalità "Confronta e imposta", quindi ora i test sono stati superati di nuovo.

Purtroppo memcache non è composta solo da arcobaleni e unicorni, ma prevede alcuni limiti, i più noti sono la dimensione del valore di 1 MB (non è possibile avere troppe stanze per un campo di battaglia) e la scadenza della chiave, o come spiega la documentazione:

Abbiamo preso in considerazione l'utilizzo di un altro grande archivio chiave-valore, Redis. All'epoca, però, la configurazione di un cluster scalabile era un po' scoraggiante e, poiché volevamo concentrarci sulla creazione dell'esperienza piuttosto che sulla gestione dei server, non abbiamo fatto questa strada. D'altra parte, la piattaforma Google Cloud ha rilasciato di recente una semplice funzionalità Click-to-deploy, con un'opzione relativa a un cluster Redis, che sarebbe stata un'opzione molto interessante.

Infine, abbiamo trovato Google Cloud SQL e spostato le relazioni in MySQL. È stato un lavoro impegnativo, ma alla fine ha funzionato alla grande, gli aggiornamenti ora sono completamente atomici e i test vengono comunque superati. Inoltre, questo approccio ha reso molto più affidabili l'implementazione degli abbinamenti e il mantenimento del punteggio.

Nel corso del tempo, più dati sono stati lentamente spostati da NDB e memcache a SQL, ma in generale le entità player, campo di battaglia e stanza sono ancora archiviate in NDB, mentre le sessioni e le relazioni tra loro sono archiviate in SQL.

Dovevamo anche tenere traccia di chi stava giocando e accoppiare i giocatori l'uno con l'altro utilizzando un meccanismo di abbinamento che tiene conto del livello di abilità e dell'esperienza dei giocatori. Abbiamo basato la ricerca degli abbinamenti sulla libreria open source Glicko2.

Poiché si tratta di un gioco multiplayer, vogliamo informare gli altri partecipanti della stanza di eventi come "chi è entrato o uscito", "chi ha vinto o perso" e se c'è una sfida da accettare. Per gestire questo problema, abbiamo integrato la possibilità di ricevere notifiche nell'API Player Management.

Configurazione di WebRTC

Quando due giocatori vengono abbinati per una battaglia, viene utilizzato un servizio di segnalazione per far parlare tra loro e per avviare una connessione con i compagni.

Esistono diverse librerie di terze parti che puoi utilizzare per il servizio di segnalazione e che semplificano anche la configurazione di WebRTC. Alcune opzioni sono PeerJS, SimpleWebRTC e SDK WebRTC PubNub. PubNub utilizza una soluzione server ospitata e per questo progetto volevamo eseguire l'hosting sulla piattaforma Google Cloud. Le altre due librerie utilizzano server node.js che avremmo potuto installare su Google Compute Engine, ma dovremmo anche assicurarci che possa gestire migliaia di utenti simultanei, cosa che sapevamo già che l'API Channel può fare.

Uno dei principali vantaggi dell'utilizzo della piattaforma Google Cloud in questo caso è la scalabilità. La scalabilità delle risorse necessarie per un progetto AppEngine è facilmente gestita tramite Google Developers Console e non è necessario alcun lavoro aggiuntivo per scalare il servizio di segnalazione quando utilizzi l'API Channels.

Eravamo preoccupati per la latenza e la potenza dell'API Channels, ma in precedenza l'avevamo utilizzata per il progetto CubeSlam e aveva dimostrato di funzionare per milioni di utenti in quel progetto, quindi abbiamo deciso di riutilizzarla.

Poiché non abbiamo scelto di utilizzare una libreria di terze parti per supportare WebRTC, abbiamo dovuto crearne una tutta nostra. Fortunatamente abbiamo potuto riutilizzare gran parte del lavoro svolto per il progetto CubeSlam. Quando entrambi i giocatori si sono uniti a una sessione, la sessione è impostata su "attiva" ed entrambi i giocatori utilizzeranno l'ID sessione attivo per avviare la connessione peer-to-peer tramite l'API Channel. Dopodiché, tutte le comunicazioni tra i due giocatori verranno gestite tramite un RTCDataChannel.

Abbiamo anche bisogno dei server STUN e TURN per stabilire la connessione e far fronte ai NAT e ai firewall. Leggi ulteriori informazioni sulla configurazione di WebRTC nell'articolo di HTML5 Rocks WebRTC in the real world: STUN, TURN, and Signalsing.

Anche il numero di server TURN utilizzati deve essere in grado di scalare a seconda del traffico. Per farlo, abbiamo testato Google Deployment Manager. Ci consente di eseguire il deployment dinamico delle risorse su Google Compute Engine e di installare i server TURN utilizzando un modello. È ancora in versione alpha, ma per i nostri scopi ha funzionato perfettamente. Per i server TURN utilizziamo coturn, un'implementazione molto rapida, efficiente e apparentemente affidabile di STUN/TURN.

L'API Channel

L'API Channel viene utilizzata per inviare tutte le comunicazioni da e verso la sala giochi sul lato client. La nostra API Player Management usa l'API Channel per le notifiche sugli eventi del gioco.

La collaborazione con l'API Channels presentava alcuni accelerazione. Per fare un esempio è che, poiché i messaggi possono non essere ordinati, abbiamo dovuto aggregare tutti i messaggi in un oggetto e ordinarli. Ecco alcuni esempi di codice che spiegano come funziona:

var que = [];  // [seq, packet...]
var seq = 0;
var rcv = -1;

function send(message) {
  var packet = JSON.stringify({
    seq: seq++,
    msg: message
  });
  channel.send(packet);
}

function recv(packet) {
  var data = JSON.parse(packet);

  if (data.seq <= rcv) {
    // ignoring message, older or already received
  } else if (data.seq > rcv + 1) {
    // message from the future. queue it up.
    que.push(data.seq, packet);
  } else {
    // message in order! update the rcv index and emit the message
    rcv = data.seq;
    emit('message', data.message);

    // and now that we have updated the `rcv` index we 
    // will check the que for any other we can send
    setTimeout(flush, 10);
  }
}

function flush() {
  for (var i=0; i<que.length; i++) {
    var seq = que[i];
    var packet = que[i+1];
    if (data.seq == rcv + 1) {
      recv(packet);
      return; // wait for next flush
    }
  }
}

Volevamo inoltre mantenere le diverse API del sito modulari e separate dall'hosting del sito e abbiamo iniziato a utilizzare i moduli integrati in GAE. Sfortunatamente, dopo aver fatto in modo che tutto funzioni nello sviluppo, ci siamo resi conto che l'API Channel non funziona con i moduli in produzione. Siamo passati all'utilizzo di istanze GAE separate e abbiamo riscontrato problemi CORS che ci hanno costretto a utilizzare un bridge postMessage iframe.

Motore di gioco

Per rendere il motore grafico il più dinamico possibile, abbiamo creato l'applicazione front-end utilizzando l'approccio entity-component-system (ECS). Quando abbiamo iniziato lo sviluppo, non erano state impostate le specifiche funzionali e i frame metallici, quindi è stato molto utile poter aggiungere funzionalità e logica man mano che lo sviluppo procedeva. Ad esempio, il primo prototipo utilizzava un semplice sistema di rendering a canvas per visualizzare le entità in una griglia. Un paio di iterazioni dopo è stato aggiunto un sistema per le collisioni e uno per i giocatori controllati dall'IA. Nel corso del progetto potremmo passare a un sistema di rendering 3D senza modificare il resto del codice. Quando le parti del networking erano attive e in esecuzione, era possibile modificare l'AI per utilizzare i comandi remoti.

Pertanto, la logica di base del multiplayer è inviare la configurazione del comando azione all'altro peer tramite DataChannels e lasciare che la simulazione si comporti come se fosse un giocatore IA. Inoltre, c'è una logica per decidere quale turno deve essere, se il giocatore preme i pulsanti di passaggio/attacco, i comandi di coda se entrano mentre il giocatore continua a guardare l'animazione precedente, ecc.

Se fossero solo due utenti a cambiare turno, entrambi potrebbero condividere la responsabilità di passare il turno all'avversario quando hanno finito, ma c'è un terzo giocatore coinvolto. Il sistema di IA è tornato a essere utile (non solo a scopo di test) quando abbiamo dovuto aggiungere nemici come ragni e troll. Per farli entrare nel flusso a turni, dovevano essere generati ed eseguiti esattamente allo stesso modo su entrambi i lati. Il problema è stato risolto consentendo a un collega di controllare il sistema di svolta e di inviare lo stato attuale al peer remoto. Poi, quando gli spider girano, il responsabile dei turni consente all'ai-system di creare un comando che viene inviato all'utente remoto. Poiché il motore di gioco agisce solo su comandi e entity-id:s, il gioco verrà simulato allo stesso modo su entrambi i lati. Tutte le unità possono anche avere il componente per l'IA, che consente di eseguire facilmente test automatici.

All'inizio dello sviluppo era ottimale avere un rendering del canvas più semplice, concentrandosi sulla logica del gioco. Ma il vero divertimento è iniziato quando è stata implementata la versione 3D e le scene hanno preso vita con ambienti e animazioni. Utilizziamo three.js come motore 3D ed è stato facile ottenere uno stato riproducibile grazie all'architettura.

La posizione del mouse viene inviata più spesso all'utente remoto e vengono visualizzati lievi suggerimenti luminosi a 3D sulla posizione del cursore in quel momento.