Hobbit 2014

Dodawanie rozgrywki WebRTC do Hobbit Experience

Daniel Isaksson
Daniel Isaksson

Z okazji premiery nowego filmu Hobbita „Hobbit: Bitwa Pięciu Armii” pracowaliśmy nad dodaniem nowych materiałów do zeszłorocznego eksperymentu w Chrome – Podróży przez Śródziemie. Tym razem głównym celem jest rozszerzenie zastosowania WebGL w Chrome i Firefox, ponieważ więcej przeglądarek i urządzeń może wyświetlać treści oraz umożliwić korzystanie z funkcji WebRTC. W tym rocznym eksperymencie mieliśmy 3 cele:

  • Rozgrywka P2P przy użyciu WebRTC i WebGL w Chrome na Androida
  • Stwórz prostą grę wieloosobową wykorzystującą wprowadzanie danych dotykowych.
  • Host w Google Cloud Platform

Definiowanie gry

Logika gry jest oparta na siatce, w której wojska poruszają się po planszy. Ułatwiło nam to testowanie rozgrywki na papierze podczas opracowywania zasad. Korzystanie z konfiguracji opartej na siatce pomaga też wykrywać kolizje w grze i utrzymać dobrą wydajność, ponieważ wystarczy sprawdzić, czy nie doszło do zderzenia z obiektami znajdującymi się na tych samych lub sąsiednich kafelkach. Od początku wiedzieliśmy, że nowa gra skupia się na walce między 4 głównymi siłami Śródziemia, ludzi, krasnoludów, elfów i orków. Eksperyment w Chrome musi być też na tyle swobodny, że nie wymaga zbyt wielu interakcji, aby można było się nauczyć. Zaczęliśmy od zdefiniowania 5 polów bitew na mapie Śródziemia, które pełnią funkcję pokoi gier, w których wielu graczy może rywalizować ze sobą w bitwach peer-to-peer. Wyświetlanie wielu graczy w pomieszczeniu na ekranie urządzenia mobilnego i umożliwienie użytkownikom wyboru wyzwania było już wyzwaniem. Aby ułatwić interakcję i scenę, zdecydowaliśmy, że aby rzucić wyzwanie i zaakceptować tylko jeden przycisk, w pomieszczeniu będzie można pokazywać wydarzenia i to, kto jest obecnie królem wzgórza. Zagadnienie to rozwiązało też kilka problemów z dopasowywaniem i pozwoliło nam wybrać najlepszych kandydatów do bitwy. W naszym poprzednim eksperymencie Cube Slam w Chrome dowiedzieliśmy się, że radzenie sobie z opóźnieniami w grze wieloosobowej wymaga dużo pracy, jeśli zależy to od wyniku gry. Ciągle trzeba zakładać, że w tej sytuacji ma być stan przeciwnika, a przeciwnik tak uważać za Ciebie, i synchronizować to z animacjami na różnych urządzeniach. Szczegółowo opisujemy te problemy w tym artykule. Dla ułatwienia zaprojektowaliśmy tę grę w trybie turowym.

Logika gry jest oparta na siatce, w której wojska poruszają się po planszy. Ułatwiło nam to testowanie rozgrywki na papierze podczas opracowywania zasad. Korzystanie z konfiguracji opartej na siatce pomaga też wykrywać kolizje w grze i utrzymać dobrą wydajność, ponieważ wystarczy sprawdzić, czy nie doszło do zderzenia z obiektami znajdującymi się na tych samych lub sąsiednich kafelkach.

Elementy gry

Aby stworzyć tę grę wieloosobową, musimy przygotować kilka kluczowych elementów:

  • Interfejs API zarządzania graczami po stronie serwera obsługuje użytkowników, dobieranie graczy, sesje i statystyki gier.
  • Serwery ułatwiające nawiązanie połączenia między graczami.
  • Interfejs API do obsługi sygnałów interfejsu API kanałów AppEngine używany do łączenia się ze wszystkimi graczami w pokojach gier i komunikacji z nimi.
  • Silnik gier JavaScript obsługujący synchronizację stanu i komunikatów RTC między dwoma graczami/grami.
  • Widok gry WebGL

Zarządzanie graczami

Z myślą o obsłudze dużej liczby graczy korzystamy z wielu równoległych pokojów gier na pole bitwy. Głównym powodem ograniczania liczby graczy w sali gier jest umożliwienie nowym graczom zajęcia się na szczycie tabeli wyników w rozsądnym czasie. Limit jest też powiązany z rozmiarem obiektu json opisującego pokój gier wysłany przez interfejs Channel API, który ma limit 32 KB. Musimy przechowywać informacje o graczach, pokojach, wynikach, sesjach i relacjach w grze. W tym celu najpierw zastosowaliśmy NDB dla encji i użyliśmy interfejsu zapytania do obsługi relacji. NDB to interfejs do Google Cloud Datastore. Na początku korzystanie z NDB sprawdzało się znakomicie, ale wkrótce napotkaliśmy problem z tym, jak potrzebujemy go. Zapytanie zostało wykonane przy użyciu „zatwierdzonej” wersji bazy danych (zapis NDB został obszernie wyjaśniony w tym szczegółowym artykule), którego opóźnienie może wynosić kilka sekund. Jednostki te nie miały jednak takiego opóźnienia, ponieważ odpowiadały bezpośrednio z pamięci podręcznej. Trochę łatwiej to wyjaśnić za pomocą przykładowego 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 widzieliśmy, na czym polega problem, i odeszliśmy od zapytań, aby zachować relacje w postaci listy rozdzielonej przecinkami w memcache. Wydawało się to sporą sztuczkę, ale zadziałało, a memcache AppEngine ma podobny do transakcji system dla kluczy korzystający z doskonałej funkcji „Porównaj i ustaw”, więc testy zostały zdane ponownie.

Memcache to nie tylko tęcza i jednorożce, ale mają kilka ograniczeń. Najważniejsze z nich to rozmiar 1 MB (nie można mieć zbyt wielu pokoi związanych z polem bitwy) i czas wygaśnięcia klucza lub, jak wyjaśniają dokumenty:

Rozważyliśmy użycie innego świetnego magazynu par klucz-wartość, Redis. Jednak w trakcie konfiguracji skalowalnego klastra było to trochę kłopotliwe, a ponieważ wolelibyśmy skupić się na budowaniu wygody, a nie na utrzymaniu serwerów, nie podążaliśmy tą ścieżką. Z drugiej strony w Google Cloud Platform pojawiła się niedawno prosta funkcja Kliknij, aby wdrożyć. Jedną z opcji jest klaster Redis, więc może to być bardzo interesujące rozwiązanie.

Na koniec znaleźliśmy Google Cloud SQL i przenieśliśmy relacje do MySQL. Wymagało to dużo pracy, ale w końcu zadziałało, aktualizacje są teraz w pełni absorbujące, a testy są nadal zaliczone. Poza tym stosowanie dopasowywania i utrzymywania wyników znacznie zwiększyło wiarygodność procesu.

Z czasem coraz więcej danych przechodzi z NDB i memcache na SQL, ale jednostki graczy, pola bitew i pomieszczeń wciąż są przechowywane w NDB, a sesje i relacje między nimi są przechowywane w SQL.

Musieliśmy też śledzić, kto z kim gra, i łączyć graczy ze sobą za pomocą mechanizmu dopasowywania, który uwzględniał poziom umiejętności i doświadczenie graczy. Dopasowywanie filmów oparto na bibliotece open source Glicko2.

Jest to gra wieloosobowa, dlatego chcemy informować innych graczy w pokoju o wydarzeniach takich jak „kto uczestniczył lub opuszczał”, „kto wygrał lub przegrał” oraz czy pojawi się wyzwanie do zaakceptowania. W tym celu wbudowaliśmy możliwość odbierania powiadomień przez interfejs Player Management API.

Konfigurowanie WebRTC

Gdy 2 graczy zmierzy się ze sobą w walce, system sygnalizacyjny wykorzystuje usługę sygnalową, aby porozumieć się ze sobą rówieśników i pomóc im w nawiązaniu połączenia.

Istnieje kilka bibliotek zewnętrznych, których możesz używać na potrzeby usługi sygnalizacji, a także upraszczają one konfigurację WebRTC. Dostępne opcje to PeerJS, SimpleWebRTC i PubNub WebRTC SDK. PubNub korzysta z hostowanego serwera, a ten projekt miał być hostowany w Google Cloud Platform. Pozostałe 2 biblioteki używają serwerów node.js, które mogliśmy zainstalować w Google Compute Engine, ale musielibyśmy też upewnić się, że będą w stanie obsługiwać tysiące równoczesnych użytkowników. Wiedzieliśmy już, że potrafi to zrobić interfejs Channel API.

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

Były pewne obawy związane z opóźnieniami i jego wydajnością, ale wcześniej korzystaliśmy z niego w projekcie CubeSlam i udowodniliśmy, że działa on dla milionów użytkowników, dlatego zdecydowaliśmy się użyć go ponownie.

Ponieważ nie zdecydowaliśmy się na użycie biblioteki zewnętrznej przy tworzeniu WebRTC, musieliśmy stworzyć własną bibliotekę. Na szczęście mogliśmy ponownie wykorzystać pracę wypracowaną w ramach projektu CubeSlam. Gdy obaj gracze dołączą do sesji, sesja jest ustawiona na „aktywna” i obaj gracze używają identyfikatora aktywnej sesji, by za pomocą interfejsu Channel API zainicjować połączenie peer-to-peer. Później cała komunikacja między tymi dwoma odtwarzaczami będzie realizowana za pomocą protokołu RTCDataChannel.

Potrzebujemy też serwerów STUN i TURN do nawiązania połączenia oraz w walce z NAT i zaporami sieciowymi. Więcej szczegółowych informacji o konfigurowaniu WebRTC znajdziesz w artykule HTML5 Rocks WebRTC w świecie rzeczywistym: STUN, TURN i sygnał.

Liczba używanych serwerów TURN również musi być skalowana w zależności od natężenia ruchu. W tym celu przetestowaliśmy menedżera wdrażania Google. Pozwala nam dynamicznie wdrażać zasoby w Google Compute Engine i instalować serwery TURN za pomocą szablonu. Jest ona nadal w wersji alfa, ale naszym zdaniem działa bez zarzutu. W przypadku serwera TURN używamy coturn, czyli bardzo szybkiej, wydajnej i pozornie niezawodnej implementacji STUN/TURN.

Channel API

Interfejs Channel API służy do wysyłania całej komunikacji do i z pokoju gier po stronie klienta. Nasz interfejs API do zarządzania odtwarzaczami używa interfejsu Channel API do przesyłania powiadomień o wydarzeniach związanych z grami.

Praca z interfejsem Channel API wiąże się z kilkoma opóźnieniami. Na przykład, ponieważ wiadomości mogą być nieuporządkowane, musieliśmy je pakować i sortować w obrębie obiektu. Oto przykładowy kod, który wyjaśnia, jak to działa:

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

Zależało nam również na tym, aby różne interfejsy API witryny były modułowe i oddzielone od hostingu witryny. Zaczęliśmy od modułów wbudowanych w GAE. Gdy wszystko działało w wersji deweloperskiej, okazało się, że interfejs Channel API nie działa z modułami w wersji produkcyjnej. Zamiast tego przeszli na osobne instancje GAE i napotkaliśmy problemy CORS, które zmusiły nas do użycia elementu iframe postMessage Bridge.

Silnik gry

Aby maksymalnie zwiększyć dynamikę silnika gry, stworzyliśmy aplikację interfejsu z wykorzystaniem metody entity-component-system (ECS). Gdy zaczynaliśmy programowanie, nie mieliśmy ustaleń dotyczących struktury i specyfikacji funkcji, więc bardzo przydatna była możliwość dodawania funkcji i mechanizmów logicznych w miarę postępów prac. Na przykład pierwszy prototyp wykorzystywał prosty system renderowania kanw, aby wyświetlić encje w siatce. Kilka kroków później dodano system kolizji i system dla odtwarzaczy sterowanych przez AI. W środku projektu mogliśmy przejść na system renderowania 3D bez zmiany reszty kodu. Gdy elementy sieciowe były już gotowe, system AI można było zmodyfikować tak, aby używał poleceń zdalnych.

Podstawową logiką w grze wieloosobowej jest więc wysłanie konfiguracji polecenia akcji do drugiego peera przez kanały DataChannels, a symulacja działa tak, jakby była grą AI. Trzeba też pamiętać o tym, która kolejka jest logiczna – jeśli gracz naciśnie przycisk przejścia lub ataku, polecenia w kolejce, jeśli pojawi się w kolejce, gdy wciąż patrzy na poprzednią animację itd.

Gdyby w rozgrywkach było tylko 2 użytkowników, obaj koledzy mogliby podzielić się obowiązkami przekazania swojej rundy przeciwnikowi, gdy to zrobią, ale w rozgrywce bierze udział trzeci gracz. System AI znów stał się przydatny (nie tylko do testowania), gdy trzeba było dodawać wrogów takich jak pająki i trolle. Aby pasowały do trybu turowego, trzeba było je utworzyć i wykonać dokładnie tak samo po obu stronach. Rozwiązano ten problem przez umożliwienie 1 peerowi sterowania systemem tur i wysyłanie aktualnego stanu do zdalnego peera. Gdy przyjdą pająki, menedżer zakrętu pozwala ai-systemowi utworzyć polecenie, które zostanie wysłane do użytkownika zdalnego. Ponieważ silnik gry obsługuje polecenia i identyfikator encji, gra będzie symulowana tak samo po obu stronach. Wszystkie jednostki mogą mieć również komponent ai, co ułatwia automatyczne testowanie.

Optymalne było to, aby na początku tworzenia aplikacji mieć prostszy mechanizm renderowania canvas, skupiając się na logice gry. Prawdziwa zabawa zaczęła się jednak po wdrożeniu wersji 3D i ożywieniu scen dzięki ożywaniom i animacji. Używamy three.js jako silnika 3D, a ze względu na architekturę łatwo było uzyskać stan gry.

Informacje o pozycji myszy są częściej wysyłane do użytkownika zdalnego, a w świetle 3D pojawiają się subtelne informacje o tym, gdzie w danej chwili znajduje się kursor.