Hobbit 2014

Dodawanie rozgrywki WebRTC do Hobbit Experience

Daniel Isaksson
Daniel Isaksson

Z okazji premiery filmu „Hobbit: Bitwa Pięciu Armii” rozszerzyliśmy zeszłoroczny eksperyment w Chrome Podróż przez Śródziemie o nowe treści. Tym razem skupiliśmy się na zwiększeniu wykorzystania WebGL, ponieważ więcej przeglądarek i urządzeń może wyświetlać treści oraz korzystać z możliwości WebRTC w Chrome i Firefox. W tym eksperymencie mieliśmy 3 cele:

  • Rozgrywka P2P z użyciem WebRTC i WebGL w Chrome na Androida
  • Twórz łatwe w obsłudze gry wieloosobowe oparte na sterowaniu dotykowym
  • Hostowanie w Google Cloud Platform

Definiowanie gry

Logika gry opiera się na siatce, a wojska poruszają się po planszy. Dzięki temu mogliśmy łatwo przetestować rozgrywkę na papierze, gdy definiowaliśmy zasady. Użycie konfiguracji opartej na siatce ułatwia też wykrywanie kolizji w grze, co pozwala zachować dobrą wydajność, ponieważ wystarczy sprawdzać kolizje z obiektmi na tej samej lub sąsiedniej planszy. Od samego początku wiedzieliśmy, że nowa gra będzie się koncentrować na walce między 4 głównymi siłami Śródziemia: ludźmi, krasnoludami, elfami i orkami. Musiał też być wystarczająco prosty, aby można było go uruchomić w ramach eksperymentu w Chrome, i nie zawierać zbyt wielu interakcji, które trzeba by analizować. Na początek zdefiniowaliśmy 5 map bitewnych na mapie Śródziemia, które pełnią rolę sal gier, w których wielu graczy może rywalizować w bitwach jeden na jednego. Wyświetlanie na ekranie urządzenia mobilnego wielu graczy w pomieszczeniu i umożliwienie użytkownikom wybrania osoby, której chcą rzucić wyzwanie, było samo w sobie wyzwaniem. Aby ułatwić interakcję i układ, zdecydowaliśmy się użyć tylko jednego przycisku do rzucenia wyzwania i przyjęcia go oraz wykorzystać pokój tylko do wyświetlania zdarzeń i określania aktualnego króla wzgórza. Dzięki temu rozwiązaliśmy też kilka problemów związanych z dopasowywaniem i mogliśmy dobrać najlepszych kandydatów do walki. Z naszego poprzedniego eksperymentu z Chrome dotyczącego gry Cube Slam dowiedzieliśmy się, że zarządzanie opóźnieniami w grze wieloosobowej wymaga dużo pracy, jeśli zależy od tego wynik rozgrywki. Trzeba stale zakładać, w jakim stanie będzie przeciwnik, gdzie według niego jesteś, i synchronizować to z animowanymi elementami na różnych urządzeniach. W tym artykule znajdziesz więcej informacji o tych wyzwaniach. Aby ułatwić rozgrywkę, zdecydowaliśmy się na system turowy.

Logika gry opiera się na siatce, a wojska poruszają się po planszy. Dzięki temu mogliśmy łatwo przetestować rozgrywkę na papierze, gdy definiowaliśmy zasady. Użycie konfiguracji opartej na siatce ułatwia też wykrywanie kolizji w grze, co pozwala zachować dobrą wydajność, ponieważ wystarczy sprawdzać kolizje z obiektmi na tej samej lub sąsiedniej planszy.

Elementy gry

Aby stworzyć tę grę wieloosobową, musieliśmy stworzyć kilka kluczowych elementów:

  • Interfejs API do zarządzania graczami po stronie serwera obsługuje użytkowników, dobieranie graczy, sesje i statystyki gry.
  • serwery, które pomagają nawiązać połączenie między graczami.
  • Interfejs API do obsługi sygnalizacji interfejsu AppEngine Channels API służącej do łączenia się i komunikowania ze wszystkimi graczami w pokojach gier.
  • Silnik gier JavaScript, który obsługuje synchronizację stanu i przesyłanie wiadomości RTC między 2 graczami lub peerami.
  • Widok gry WebGL.

Zarządzanie odtwarzaczem

Aby obsłużyć dużą liczbę graczy, używamy wielu równoległych pomieszczeń gier na każdy Battleground. Głównym powodem ograniczenia liczby graczy w pomieszczeniu jest umożliwienie nowym graczom dotarcia na szczyt tabeli wyników w rozsądnym czasie. Limit jest też powiązany z rozmiarem obiektu JSON opisującego pokój gry wysyłany przez interfejs Channel API, który ma limit 32 KB. Musimy przechowywać w grze graczy, pokoje, wyniki, sesje i ich relacje. W tym celu najpierw użyliśmy NDB do obsługi typów danych, a następnie interfejsu zapytań do obsługi relacji. NDB to interfejs Google Cloud Datastore. Na początku korzystanie z NDB było bardzo dobre, ale szybko napotkaliśmy problem związany z tym, jak go używać. Zapytanie zostało wykonane w wersji bazy danych „zaakceptowanej” (zapisy NDB są szczegółowo opisane w tym obszernym artykule), która może mieć opóźnienie kilkusekundowe. Jednak same obiekty nie mają tego opóźnienia, ponieważ odpowiadają bezpośrednio z pamięci podręcznej. Łatwiej będzie to wyjaśnić na przykładzie kodu:

// 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,
    }

Po dodaniu testów jednostkowych wyraźnie widać było problem, więc zrezygnowaliśmy z zapytań, a zamiast tego przechowywaliśmy relacje na liście rozdzielanych przecinkami w memcache. To było trochę nietypowe rozwiązanie, ale zadziałało. Memcache AppEngine ma system kluczy oparty na transakcjach, który korzysta z wyśmienitej funkcji „porównaj i ustaw”, więc testy znów się powiodły.

Niestety memcache nie jest idealny, ale ma kilka ograniczeń. Najważniejsze z nich to rozmiar wartości 1 MB (nie można mieć zbyt wielu pomieszczeń związanych z polem bitwy) i wygaśniecie klucza. Jak wyjaśniają dokumenty:

Rozważaliśmy użycie innej świetnej pamięci klucz-wartość, Redis. W tamtym czasie skonfigurowanie skalowalnego klastra było dość trudne, a ponieważ chcieliśmy się skupić na tworzeniu aplikacji, a nie na zarządzaniu serwerami, nie zdecydowaliśmy się na to rozwiązanie. Z drugiej strony Google Cloud Platform niedawno wprowadziła prostą funkcję Kliknij, aby wdrożyć, w której jednym z opcji jest klaster Redis. Byłaby to bardzo interesująca opcja.

W końcu znaleźliśmy Google Cloud SQL i przeniosliśmy relacje do MySQL. To była spora praca, ale ostatecznie wszystko zadziałało świetnie. Aktualizacje są teraz w pełni atomowe, a testy nadal się sprawdzają. Dzięki temu implementacja dopasowywania i śledzenia wyników stała się znacznie bardziej niezawodna.

Z czasem coraz więcej danych zostało przeniesionych z NDB i memcache do bazy danych SQL, ale ogólnie elementy gracz, pole bitwy i sala są nadal przechowywane w NDB, a sesje i związki między nimi – w bazie danych SQL.

Musieliśmy też śledzić, kto z kim gra, i dopasowywać graczy do siebie nawzajem za pomocą mechanizmu dopasowywania, który uwzględniał ich poziom umiejętności i doświadczenie. Dopasowanie zostało oparte na bibliotece open source Glicko2.

Ponieważ jest to gra wieloosobowa, chcemy informować innych graczy w pokoju o wydarzeniach takich jak „kto wszedł lub wyszedł”, „kto wygrał lub przegrał” oraz czy jest wyzwanie do zaakceptowania. Aby rozwiązać ten problem, dodaliśmy do interfejsu Player Management API możliwość otrzymywania powiadomień.

Konfigurowanie WebRTC

Gdy 2 graczy zostaną dopasowani do walki, usługa sygnalizacyjna umożliwia im nawiązanie połączenia i rozmowę.

Do obsługi usługi sygnalizacji możesz użyć kilku bibliotek innych firm, które uproszczą też konfigurowanie WebRTC. Dostępne są m.in. PeerJS, SimpleWebRTCPubNub WebRTC SDK. PubNub używa serwera hostowanego, a w przypadku tego projektu chcieliśmy go hostować w Google Cloud Platform. Pozostałe 2 biblioteki korzystają z serwerów node.js, które można zainstalować w Google Compute Engine, ale trzeba też zadbać o to, aby serwery te mogły obsługiwać tysiące jednoczesnych użytkowników. Wiedzieliśmy już, że interfejs Channel API ma taką możliwość.

W tym przypadku jedną z głównych zalet korzystania z Google Cloud Platform jest skalowalność. Skalowanie zasobów potrzebnych do projektu App Engine można łatwo przeprowadzić w Google Developers Console. Nie trzeba też wykonywać żadnych dodatkowych czynności, aby skalować usługę sygnalizacji przy użyciu interfejsu Channels API.

Mieliśmy pewne obawy dotyczące opóźnień i niezawodności interfejsu Channels API, ale wcześniej używaliśmy go w projekcie CubeSlam i okazało się, że działa on dobrze w przypadku milionów użytkowników, więc zdecydowaliśmy się go ponownie wykorzystać.

Nie chcieliśmy używać biblioteki zewnętrznej do obsługi WebRTC, więc musieliśmy utworzyć własną. Na szczęście mogliśmy wykorzystać wiele elementów z projektu CubeSlam. Gdy obaj gracze dołączą do sesji, sesja zostanie ustawiona jako „aktywna”, a obaj gracze będą używać tego aktywnego identyfikatora sesji do inicjowania połączenia peer-to-peer za pomocą interfejsu Channel API. Następnie cała komunikacja między 2 urządzeniami będzie obsługiwana przez RTCDataChannel.

Potrzebujemy też serwerów STUN i TURN, które pomagają nawiązywać połączenia i radzić sobie z NAT-ami i zaporami sieciowymi. Więcej informacji o konfigurowaniu WebRTC znajdziesz w artykule WebRTC w praktyce: STUN, TURN i sygnalizacja na stronie HTML5 Rocks.

Liczba używanych serwerów TURN musi się też skalować w zależności od natężenia ruchu. Aby rozwiązać ten problem, przetestowaliśmy Menedżera wdrożeń Google. Umożliwia nam to dynamiczne wdrażanie zasobów w Google Compute Engine i instalowanie serwerów TURN za pomocą szablonu. Jest to wersja alfa, ale w naszych celach działa bez zarzutu. W przypadku serwera TURN używamy coturn, który jest bardzo szybką, wydajną i wygląda na to, że niezawodną implementacją STUN/TURN.

Channel API

Interfejs Channel API służy do wysyłania całej komunikacji do i z pokoju gry po stronie klienta. Nasze interfejsy API zarządzania graczami używają interfejsu Channel API do wysyłania powiadomień o zdarzeniach w grze.

Praca z interfejsem Channels API wiązała się z kilkoma problemami. Przykładem jest to, że wiadomości mogą być nieuporządkowane, więc musieliśmy umieścić je w obiekcie i posortować. Oto przykładowy kod:

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
    }
  }
}

Chcieliśmy też zachować modułową strukturę różnych interfejsów API witryny i oddzielić je od hostingu witryny. Na początku korzystaliśmy z modułów wbudowanych w GAE. Niestety po wprowadzeniu wszystkich zmian w wersji deweloperskiej okazało się, że interfejs Channel API nie działa z modułami w wersji produkcyjnej. Zamiast tego zaczęliśmy używać oddzielnych instancji GAE, ale napotkaliśmy problemy z CORS, które zmusiły nas do użycia mostu postMessage w ramce iframe.

Silnik gry

Aby silnik gry był jak najbardziej dynamiczny, stworzyliśmy aplikację front-endową, korzystając z podejścia entyczność-komponent-system (ECS). Gdy rozpoczęliśmy prace nad rozwojem, nie mieliśmy gotowych makiet ani specyfikacji funkcjonalnej, więc możliwość dodawania funkcji i logiki w trakcie rozwoju była bardzo przydatna. Na przykład pierwszy prototyp używał prostego systemu renderowania na płótnie do wyświetlania elementów w siatce. Po kilku iteracjach dodano system kolizji i system dla graczy sterowanych przez AI. W połowie projektu mogliśmy przejść na system renderowania 3D bez zmiany reszty kodu. Gdy elementy sieciowe były gotowe do użycia, system AI można było zmodyfikować, aby używać poleceń zdalnych.

Podstawowa logika gry wieloosobowej polega na wysyłaniu konfiguracji polecenia działania do drugiego urządzenia za pomocą kanałów danych i na zmuszeniu symulacji do działania tak, jakby była to sztuczna inteligencja. Oprócz tego jest logika, która określa, czy jest to tura gracza, czy gracz naciska przyciski „Przekaz” lub „Atak”, czy komendy są w kolejce, jeśli gracz nadal ogląda poprzednią animację itp.

Gdyby było tylko 2 użytkowników, którzy się przełączali, oboje mogliby podzielić się odpowiedzialnością za przekazanie tury przeciwnikowi, gdy skończyli, ale w tym przypadku jest jeszcze trzeci gracz. System AI okazał się przydatny (nie tylko do testowania), gdy musieliśmy dodać wrogów, takich jak pająki i trolle. Aby pasowały do rozgrywki turowej, musiały być generowane i wykonywane dokładnie tak samo po obu stronach. Rozwiązaniem było umożliwienie jednemu z urządzeń sterowania systemem kolejkowym i przesyłanie bieżącego stanu do urządzenia zdalnego. Gdy przychodzi kolej pająków, menedżer kolejki pozwala systemowi AI utworzyć polecenie, które jest wysyłane do użytkownika zdalnego. Ponieważ silnik gry działa tylko na podstawie poleceń i identyfikatorów obiektów, symulacja gry będzie taka sama po obu stronach. Wszystkie jednostki mogą też zawierać komponent AI, który umożliwia łatwe testowanie automatyczne.

Na początku rozwoju gry, gdy skupialiśmy się na logice gry, optymalnie było mieć prostszy mechanizm renderowania na płótnie. Prawdziwa zabawa zaczęła się jednak, gdy wdrożyliśmy wersję 3D i sceny ożyły dzięki środowiskom i animacjom. Jako silnika 3D używamy three.js. Dzięki architekturze łatwo było uzyskać stan gotowości do gry.

Pozycja myszy jest wysyłana do użytkownika zdalnego częściej, a wskaźnik świetlny 3D daje subtelne wskazówki dotyczące bieżącej pozycji kursora.