Lo Hobbit Experience 2014

Aggiunta del gameplay WebRTC all'esperienza Hobbit

Daniel Isaksson
Daniel Isaksson

In vista dell'uscita del nuovo film sull'Hobbit "Lo Hobbit: la battaglia dei cinque eserciti", abbiamo lavorato per estendere l'esperimento di Chrome dell'anno scorso, Un viaggio nella Terra di Mezzo, con alcuni nuovi contenuti. L'obiettivo principale di questa volta è stato ampliare l'utilizzo di WebGL, in modo che un maggior numero di browser e dispositivi possa visualizzare i contenuti e lavorare con le funzionalità WebRTC in Chrome e Firefox. Quest'anno avevamo tre obiettivi e avevamo tre obiettivi:

  • Gameplay P2P che utilizza WebRTC e WebGL su Chrome per Android
  • Crea un gioco multiplayer che sia facile da usare e che si basi sull'input tattile
  • Hosting sulla piattaforma Google Cloud

Definizione del gioco

La logica di gioco si basa su una configurazione basata su griglia con le truppe che si muovono su un tabellone di gioco. In questo modo, è stato facile per noi provare il gameplay su carta mentre definisce le regole. L'utilizzo di una configurazione basata su griglia aiuta anche il rilevamento delle collisioni nel gioco per mantenere buone prestazioni, dato che è necessario verificare solo la presenza di collisioni con oggetti nelle caselle stesse o vicine. Fin dall'inizio abbiamo voluto che il nuovo gioco ruotasse attorno a una battaglia tra le quattro forze principali della Terra di Mezzo: umani, nani, elfi e orchi. Inoltre, doveva essere abbastanza informale da essere utilizzato all'interno di un esperimento Chrome e non avere troppe interazioni per imparare. Abbiamo iniziato definendo cinque campi di battaglia sulla mappa della Terra di Mezzo che fungono da sale da gioco in cui più giocatori possono competere in una battaglia peer-to-peer. Mostrare più giocatori nella stanza sullo schermo di un dispositivo mobile e consentire agli utenti di selezionare chi sfidare era una sfida di per sé. Per semplificare l'interazione e la scena, abbiamo deciso di inserire un solo pulsante da sfidare e accettare e di usare la stanza solo per mostrare gli eventi e chi è l'attuale re della collina. Questa direzione ha anche risolto alcuni problemi a livello di abbinamento e ci ha permesso di abbinare i migliori candidati a una battaglia. Nel nostro precedente esperimento su Chrome Cube Slam abbiamo appreso che è necessario molto lavoro per gestire la latenza in un gioco multiplayer se il risultato del gioco si basa su questa. Devi costantemente fare supposizioni su dove sarà lo stato dell'avversario, dove pensa che tu sia e sincronizzarlo con le 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 di gioco è basata su una griglia e le truppe si muovono su un tabellone. In questo modo, è stato facile per noi provare il gameplay su carta mentre definisce 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 nelle caselle stesse o vicine.

Componenti del gioco

Per realizzare questo gioco multiplayer, abbiamo dovuto creare alcuni componenti chiave:

  • Un'API di gestione dei giocatori lato server gestisce utenti, matchmaking, sessioni e statistiche di gioco.
  • Server per facilitare la connessione tra i giocatori.
  • Un'API per gestire l'API AppEngine Channels Signaling utilizzata per connettere e comunicare con tutti i giocatori nelle sale giochi.
  • Un motore di gioco JavaScript che gestisce la sincronizzazione dello stato e della messaggistica RTC tra i due giocatori/peer.
  • La visualizzazione del gioco WebGL.

Gestione dei giocatori

Per supportare un gran numero di giocatori, utilizziamo molte sale giochi parallele per ogni campo di battaglia. Il motivo principale per cui limitiamo il numero di giocatori per sala da gioco è consentire ai nuovi giocatori di raggiungere la cima 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 archiviare giocatori, stanze, punteggi, sessioni e le loro relazioni nel gioco. Per farlo, abbiamo prima utilizzato NDB per le entità e l'interfaccia di query per gestire le relazioni. NDB è un'interfaccia per Google Cloud Datastore. All'inizio l'utilizzo di NDB è andato benissimo, ma presto abbiamo riscontrato un problema con il modo in cui dovevamo utilizzarlo. La query è stata eseguita sulla versione "committata" del database (le scritture NDB sono spiegate in dettaglio in questo articolo dettagliato) che può avere un ritardo di diversi secondi. Le entità stesse, invece, non hanno avuto questo ritardo perché rispondono direttamente dalla cache. Sarebbe 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 di unità, abbiamo potuto vedere chiaramente il problema e abbiamo abbandonato le query per mantenere le relazioni in un elenco separato da virgole in memcache. Sembra un po' un hack, ma ha funzionato e memcache di AppEngine ha un sistema simile alle transazioni per le chiavi che utilizza l'eccellente funzionalità "confronta e imposta", quindi ora i test sono stati superati di nuovo.

Purtroppo memcache non è perfetto, ma presenta alcuni limiti, i più importanti dei quali sono le dimensioni del valore di 1 MB (non è possibile avere troppe stanze correlate a un campo di battaglia) e la scadenza della chiave, o come spiegato nella documentazione:

Abbiamo preso in considerazione l'utilizzo di un altro ottimo archivio chiave-valore, Redis. Tuttavia, all'epoca la configurazione di un cluster scalabile era un po' scoraggiante e, poiché preferivamo concentrarci sulla creazione dell'esperienza piuttosto che sulla manutenzione dei server, non abbiamo intrapreso questa strada. D'altra parte, la piattaforma Google Cloud ha rilasciato di recente una semplice funzionalità Click-to-deploy, una delle opzioni è un cluster Redis, quindi sarebbe stata un'opzione molto interessante.

Alla fine abbiamo trovato Google Cloud SQL e abbiamo spostato le relazioni in MySQL. È stato fatto molto lavoro, ma alla fine ha funzionato molto bene, gli aggiornamenti ora sono completamente atomici e i test sono ancora stati superati. Inoltre, ha reso molto più affidabile l'implementazione del matchmaking e del conteggio dei punti.

Nel tempo, la maggior parte dei dati è stata spostata lentamente da NDB e memcache a SQL, ma in generale le entità giocatore, campo di battaglia e stanza sono ancora archiviate in NDB, mentre le sessioni e le relazioni tra tutte sono archiviate in SQL.

Inoltre, dovevamo tenere traccia di chi giocava con chi e abbinare i giocatori tra loro utilizzando un meccanismo di accoppiamento che teneva conto del livello di abilità e dell'esperienza dei giocatori. Abbiamo basato la creazione di corrispondenze sulla libreria open source Glicko2.

Poiché si tratta di un gioco multi-player, vogliamo informare gli altri giocatori presenti nella stanza su eventi come "chi è entrato o se n'è andato", "chi ha vinto o perso" e se esiste 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 consentire ai due peer abbinati di comunicare tra loro e per avviare una connessione tra peer.

Esistono diverse librerie di terze parti che puoi utilizzare per il servizio di segnalazione e che semplificano anche la configurazione di WebRTC. Sono disponibili alcune opzioni: PeerJS, SimpleWebRTC e SDK WebRTC di PubNub. PubNub utilizza una soluzione di server ospitato 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 avremmo anche dovuto assicurarci che potessero gestire migliaia di utenti contemporaneamente, una funzionalità che già sapevamo essere supportata dall'API Channel.

Uno dei principali vantaggi dell'utilizzo della piattaforma Google Cloud in questo caso è la scalabilità. Il ridimensionamento delle risorse necessarie per un progetto App Engine è facilmente gestibile tramite Google Developers Console e non è necessario alcun intervento aggiuntivo per scalare il servizio di segnalazione quando si utilizza l'API Channels.

Eravamo preoccupati per la latenza e l'affidabilità dell'API Channels, ma l'avevamo già utilizzata per il progetto CubeSlam e aveva dimostrato di funzionare per milioni di utenti in quel progetto, quindi abbiamo deciso di utilizzarla di nuovo.

Poiché non abbiamo scelto di utilizzare una libreria di terze parti per aiutarci con WebRTC, abbiamo dovuto crearne una 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 viene impostata su "attiva" e 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 di server STUN e TURN per facilitare la connessione e gestire NAT e firewall. Scopri di più sulla configurazione di WebRTC nell'articolo HTML5 Rocks WebRTC nel mondo reale: STUN, TURN e signaling.

Anche il numero di server TURN utilizzati deve essere scalabile a seconda del traffico. Per gestire questo problema, 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 le nostre finalità ha funzionato perfettamente. Per il server TURN utilizziamo coturn, un'implementazione di STUN/TURN molto veloce, efficiente e apparentemente affidabile.

L'API Channel

L'API Channel viene utilizzata per inviare tutte le comunicazioni da e verso la stanza di gioco lato client. La nostra API di gestione dei giocatori utilizza l'API Channel per le notifiche relative agli eventi di gioco.

L'utilizzo dell'API Channels ha avuto alcuni problemi. Un esempio è che, poiché i messaggi possono essere non ordinati, abbiamo dovuto racchiuderli tutti in un oggetto e ordinarli. Ecco un codice di esempio che mostra 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. Purtroppo, dopo aver fatto funzionare tutto in fase di sviluppo, abbiamo notato che l'API Channel non funziona con i moduli in produzione. Siamo invece passati all'utilizzo di istanze GAE separate e abbiamo riscontrato problemi CORS che ci hanno costretto a utilizzare un bridge postMessage di iframe.

Motore di gioco

Per rendere il motore di gioco il più dinamico possibile, abbiamo creato l'applicazione front-end utilizzando l'approccio entity-component-system (ECS). Quando abbiamo iniziato lo sviluppo, i wireframe e le specifiche funzionali non erano impostati, quindi è stato molto utile poter aggiungere funzionalità e logica man mano che lo sviluppo progrediva. Ad esempio, il primo prototipo utilizzava un semplice sistema di rendering della tela per visualizzare le entità in una griglia. Un paio di iterazioni dopo, è stato aggiunto un sistema per le collisioni e un altro per i giocatori controllati dall'IA. Nel bel mezzo del progetto potremmo passare a un sistema di rendering 3D senza modificare il resto del codice. Quando le parti di rete erano attive, il sistema AI poteva essere modificato per utilizzare i comandi remoti.

Pertanto, la logica di base del multiplayer è inviare la configurazione del comando di azione all'altro peer tramite DataChannels e lasciare che la simulazione agisca come se fosse un giocatore IA. Inoltre, è presente una logica per decidere di quale turno si tratta, se il giocatore preme i pulsanti di passaggio/attacco, mette in coda i comandi se vengono inviati mentre il giocatore sta ancora guardando l'animazione precedente e così via.

Se si trattasse solo di due utenti che si scambiano il turno, entrambi i peer potrebbero condividere la responsabilità di passare il turno all'avversario al termine del proprio turno, ma è coinvolto un terzo giocatore. Il sistema di IA è tornato utile (non solo per i test) quando abbiamo dovuto aggiungere nemici come ragni e troll. Per adattarli al flusso a turni, dovevano essere generati ed eseguiti esattamente allo stesso modo su entrambi i lati. Il problema si è risolto consentendo a un compagno di controllare il sistema di svolta e di inviare lo stato attuale al peer remoto. Quando è il turno dei ragni, il gestore dei turni consente al sistema AI di creare un comando che viene inviato all'utente remoto. Poiché il motore di gioco agisce solo su comandi e ID entità, il gioco verrà simulato allo stesso modo su entrambi i lati. Tutte le unità possono anche avere il componente ai che consente test automatici semplici.

All'inizio dello sviluppo era ottimale avere un renderer della tela più semplice, concentrandosi sulla logica del gioco. Ma il vero divertimento è iniziato con l'implementazione della versione 3D e le scene sono state animate con ambienti e animazioni. Utilizziamo three.js come motore 3D ed è stato facile raggiungere uno stato di giocabilità grazie all'architettura.

La posizione del mouse viene inviata più di frequente all'utente remoto e un'indicazione 3D discreta indica dove si trova il cursore in quel momento.