Portujemy grę wieloosobową z C++ na web z Cheerp, WebRTC i Firebase

Wprowadzenie

Nasza firma Leaning Technologies oferuje rozwiązania do portowania tradycyjnych aplikacji desktopowych do sieci. Nasz kompilator C++ Cheerp generuje połączenie WebAssembly i JavaScript, co zapewnia zarówno proste interakcje z przeglądarką, jak i wysoką wydajność.

Jako przykład jego zastosowania postanowiliśmy przenieść do sieci grę wieloosobową i wybraliśmy do tego Teeworlds. Teeworlds to wieloosobowa, dwuwymiarowa gra retro z niewielką, ale aktywną społecznością graczy (w tym mnie!). Jest mała zarówno pod względem pobieranych zasobów, jak i wymagań dotyczących CPU i GPU — idealny kandydat.

Portujemy grę wieloosobową z C++ na web z Cheerp, WebRTC i Firebase
Działająca w przeglądarce Teeworlds

Zdecydowaliśmy się wykorzystać ten projekt do eksperymentowania z ogólnymi rozwiązaniami do portowania kodu sieciowego do sieci. Zazwyczaj można to osiągnąć na kilka sposobów:

  • XMLHttpRequest/fetch, jeśli część sieciowa składa się wyłącznie z zapytań HTTP, lub
  • WebSockets.

Oba rozwiązania wymagają hostowania komponentu serwerowego po stronie serwera, i żadne z nich nie pozwala na użycie jako protokołu transportowego UDP. Jest to istotne dla aplikacji czasu rzeczywistego, takich jak oprogramowanie do wideokonferencji i gry, ponieważ gwarancje dostarczania i porządku pakietów protokołu TCP mogą zakłócać niskie opóźnienia.

Jest też trzeci sposób — użycie sieci z przeglądarki: WebRTC.

RTCDataChannel obsługuje zarówno niezawodne, jak i niezawodne przesyłanie (w drugim przypadku stara się wykorzystać jako protokół transportowy UDP), i może być stosowane zarówno z serwerem zdalnym, jak i między przeglądarkami. To oznacza, że możemy portować całe aplikację do przeglądarki, w tym komponent serwerowy!

Jednak wiąże się to z dodatkową trudnością: zanim dwa węzły WebRTC będą mogły wymieniać dane, muszą przejść przez stosunkowo skomplikowaną procedurę „uścisku dłoni” (handshake) w celu połączenia, co wymaga kilku zewnętrznych podmiotów (serwer sygnalizacyjny i jeden lub więcej serwerów STUN/TURN).

W idealnym przypadku chcielibyśmy stworzyć sieciowe API, które wewnętrznie wykorzystuje WebRTC, ale było jak najbardziej zbliżone do interfejsu gniazd UDP, które nie wymaga ustanawiania połączenia.

To pozwoli nam wykorzystać zalety WebRTC bez konieczności ujawniania skomplikowanych szczegółów kodu aplikacji (który w naszym projekcie chcieliśmy zmieniać tak mało, jak to możliwe).

Minimalny WebRTC

WebRTC to zestaw interfejsów API dostępnych w przeglądarkach, umożliwiający wymianę dźwięku, wideo i dowolnych danych w trybie peer-to-peer.

Połączenie między parami jest ustanawiane (nawet w przypadku istnienia NAT z jednej lub obu stron) przy użyciu serwerów STUN i/lub TURN przez mechanizm zwany ICE. Parzy wymieniają informacje ICE oraz parametry kanałów za pomocą oferty i odpowiedzi z protokołu SDP.

Wow! Ile akronimów na raz. Podsumujmy, co te pojęcia oznaczają:

  • Session Traversal Utilities for NAT (STUN) to protokół do omijania NAT i uzyskiwania pary (adres IP, port) do bezpośredniej wymiany danych z hostem. Jeśli uda mu się to zrobić, pary mogą samodzielnie wymieniać się danymi.
  • Traversal Using Relays around NAT (TURN) również jest używany do omijania NAT, ale realizuje to przez przekierowywanie danych przez proxy, widoczne dla obu par. Wprowadza to opóźnienie i jest bardziej kosztowne w wykonaniu niż STUN (ponieważ jest stosowane przez cały czas trwania sesji), ale czasami jest to jedyna możliwa opcja.
  • Interactive Connectivity Establishment (ICE) jest używany do wyboru najlepszego możliwego sposobu połączenia dwóch par w oparciu o informacje uzyskane podczas bezpośredniego połączenia par, jak również informacje uzyskane od dowolnej liczby serwerów STUN i TURN.
  • Session Description Protocol (SDP) to format opisu parametrów kanału połączenia, takich jak kandydaci ICE, kodeki multimedialne (w przypadku kanału audio/wideo) itd. Jeden z par wysyła ofertę SDP („ofertę”), a drugi odpowiada odpowiedzią SDP („odpowiedzią”). Po tym tworzy się kanał.

Aby stworzyć takie połączenie, pary muszą zebrać informacje uzyskane od serwerów STUN i TURN i wymienić się nimi.

Problem polega na tym, że wciąż nie mają możliwości wymiany danych bezpośrednio, dlatego musi istnieć zewnętrzny mechanizm do wymiany tych danych: serwer sygnalizacyjny.

Serwer sygnalizacyjny może być bardzo prosty, ponieważ jego jedynym zadaniem jest przekazywanie danych między parami na etapie „uściski dłoni” (jak pokazano na schemacie poniżej).

Portujemy grę wieloosobową z C++ na web z Cheerp, WebRTC i Firebase
Uproszczony schemat sekwencji „uściski dłoni” WebRTC

Przegląd modelu sieciowego Teeworlds

Architektura sieciowa Teeworlds jest bardzo prosta:

  • Składniki klienta i serwera to dwa różne programy.
  • Klienci wchodzą w grę, łącząc się z jednym z kilku serwerów, z których każdy jednocześnie hostuje tylko jedną grę.
  • Cała wymiana danych w grze odbywa się przez serwer.
  • Specjalny serwer masterowy służy do zbierania listy wszystkich publicznych serwerów, które są wyświetlane w kliencie gry.

Dzięki wykorzystaniu WebRTC do wymiany danych możemy przenieść komponent serwera gry do przeglądarki, w której jest klient. To daje nam doskonałą możliwość…

Rezygnacja z serwerów

Brak logiki serwerowej ma przyjemną zaletę: możemy wdrożyć całą aplikację jako statyczną zawartość na Github Pages lub na własnym sprzęcie za Cloudflare, zapewniając sobie w ten sposób darmowe szybkie ładowanie i wysoki czas pracy. W zasadzie można o nich zapomnieć, a jeśli będziemy mieli szczęście i gra stanie się popularna, nie będzie potrzeby modernizacji infrastruktury.

Jednak aby system działał, będziemy musieli korzystać z zewnętrznej architektury:

  • Jeden lub kilka serwerów STUN: mamy do wyboru kilka darmowych opcji.
  • Co najmniej jeden serwer TURN: tutaj nie ma darmowych opcji, więc możemy albo skonfigurować własny, albo płacić za usługę. Na szczęście, przez większość czasu połączenie można nawiązać przez serwery STUN (zapewniając prawdziwy p2p), ale TURN jest potrzebny jako opcja zapasowa.
  • Serwer sygnalizacyjny: w przeciwieństwie do dwóch innych aspektów, sygnalizacja nie jest standaryzowana. To, za co właściwie będzie odpowiedzialny serwer sygnalizacyjny, częściowo zależy od aplikacji. W naszym przypadku przed nawiązaniem połączenia konieczna jest wymiana niewielkiej ilości danych.
  • Serwer masterowy Teeworlds: jest używany przez inne serwery do powiadamiania o swojej obecności i przez klientów do wyszukiwania publicznych serwerów. Chociaż nie jest obowiązkowy (klienci zawsze mogą ręcznie połączyć się z znanym im serwerem), byłoby dobrze go mieć, aby gracze mogli brać udział w grach z przypadkowymi osobami.

Postanowiliśmy skorzystać z darmowych serwerów STUN firmy Google, a jeden serwer TURN wdrożyliśmy samodzielnie.

Do dwóch ostatnich punktów wykorzystaliśmy Firebase:

  • Serwer główny Teeworlds jest bardzo prosty w realizacji: to lista obiektów zawierających informacje (nazwa, IP, mapa, tryb, ...) każdego aktywnego serwera. Serwery publikują i aktualizują swoje własne obiekty, a klienci pobierają całą listę i wyświetlają ją graczowi. Dodatkowo wyświetlamy listę na stronie głównej jako HTML, aby gracze mogli po prostu kliknąć na serwer i bezpośrednio wejść do gry.
  • Sygnalizacja jest ściśle związana z naszą implementacją gniazd, opisaną w następnym rozdziale.

Portujemy grę wieloosobową z C++ na web z Cheerp, WebRTC i Firebase
Lista serwerów w grze i na stronie głównej

Implementacja gniazd

Chcemy stworzyć API jak najbliższe do gniazd Posix UDP, aby zminimalizować ilość wymaganych zmian.

Chcemy również zaimplementować minimalny zestaw funkcji wymaganych do najprostszej wymiany danych przez sieć.

Na przykład, nie potrzebujemy prawdziwego routingu: wszystkie węzły znajdują się w jednej "wirtualnej LAN", połączonej z konkretnym egzemplarzem bazy danych Firebase.

W związku z tym nie potrzebujemy unikalnych adresów IP: do unikalnej identyfikacji węzłów wystarczą unikalne wartości kluczy Firebase (analogicznie do nazw domen), a każdy węzeł lokalnie przypisuje "fałszywe" adresy IP do każdego klucza, który wymaga konwersji. Całkowicie eliminuje to potrzebę globalnego przypisywania adresów IP, co jest zadaniem niełatwym.

Oto minimalne API, które musimy zaimplementować:

// Create and destroy a socket
int socket();
int close(int fd);
// Bind a socket to a port, and publish it on Firebase
int bind(int fd, AddrInfo* addr);
// Send a packet. This lazily create a WebRTC connection to the 
// peer when necessary
int sendto(int fd, uint8_t* buf, int len, const AddrInfo* addr);
// Receive the packets destined to this socket
int recvfrom(int fd, uint8_t* buf, int len, AddrInfo* addr);
// Be notified when new packets arrived
int recvCallback(Callback cb);
// Obtain a local ip address for this peer key
uint32_t resolve(client::String* key);
// Get the peer key for this ip
String* reverseResolve(uint32_t addr);
// Get the local peer key
String* local_key();
// Initialize the library with the given Firebase database and 
// WebRTc connection options
void init(client::FirebaseConfig* fb, client::RTCConfiguration* ice);

API jest proste i podobne do API gniazd Posix, ale ma kilka istotnych różnic: rejestracja wywołań zwrotnych, przypisanie lokalnych adresów IP i „leniwe” łączenie.

Rejestracja wywołań zwrotnych

Nawet jeśli oryginalny program używa asynchronicznego wejścia-wyjścia, aby uruchomić go w przeglądarce, kod musi zostać zrefaktoryzowany.

Powodem tego jest to, że pętla zdarzeń w przeglądarce jest ukryta przed programem (czy to JavaScript, czy WebAssembly).

W środowisku natywnym możemy pisać kod w ten sposób

while(running) {
  select(...); // czekaj na zdarzenia I/O
  while(true) {
    int r = readfrom(...); // próbuj odczytać
    if (r < 0 && errno == EWOULDBLOCK) // nie ma więcej dostępnych danych
      break;
    ...
  }
  ...
}

Jeśli pętla zdarzeń jest dla nas ukryta, to musimy przekształcić ją w coś podobnego:

auto cb = []() { // to zostanie wywołane, gdy nowe dane będą dostępne
  while(true) {
    int r = readfrom(...); // próbuj odczytać
    if (r < 0 && errno == EWOULDBLOCK) // nie ma więcej dostępnych danych
      break;
    ...
  }
  ...
};
recvCallback(cb); // zarejestruj wywołanie zwrotne

Przypisanie lokalnych adresów IP

Identyfikatory węzłów w naszej "sieci" nie są adresami IP, lecz kluczami Firebase (są to ciągi, które wyglądają tak: -LmEC50PYZLCiCP-vqde ).

Jest to wygodne, ponieważ nie potrzebujemy mechanizmu do przydzielania adresów IP i sprawdzania ich unikalności (ani ich utylizacji po dezaktywacji klienta), ale często konieczne jest identyfikowanie peerów za pomocą wartości numerycznej.

Właśnie dlatego używa się funkcji resolve i reverseResolve: aplikacja w jakiś sposób uzyskuje ciągowy klucz (poprzez wprowadzenie przez użytkownika lub za pomocą serwera głównego) i może zamienić go na adres IP do użytku wewnętrznego. Pozostała część API również dla uproszczenia otrzymuje zamiast tego wartość jako ciąg.

Przypomina to zapytanie DNS, ale odbywa się lokalnie u klienta.

Oznacza to, że adresy IP nie mogą być współdzielone przez różnych klientów, a jeśli potrzebny jest jakiś globalny identyfikator, trzeba go wygenerować w inny sposób.

Leniwe połączenie

UDP nie wymaga połączenia, ale, jak widzieliśmy, przed rozpoczęciem przesyłania danych między dwoma peerami, WebRTC wymaga długiego procesu nawiązywania połączenia.

Jeśli chcemy zapewnić ten sam poziom abstrakcji, (sendto/recvfrom z dowolnymi peerami bez wcześniejszego połączenia), musimy wykonać „leniwe” (odłożone) połączenie wewnątrz API.

Oto, co się dzieje podczas zwykłej wymiany danych między „serwerem” a „klientem” w przypadku użycia UDP i co musi zrobić nasza biblioteka:

  • Serwer wywołuje bind(), aby poinformować system operacyjny, że chce otrzymywać pakiety na określonym porcie.

Zamiast tego opublikujemy otwarty port w Firebase pod kluczem serwera i będziemy nasłuchiwać zdarzeń w jego poddrzewie.

  • Serwer wywołuje recvfrom(), odbierając na ten port pakiety przychodzące z dowolnego hosta.

W naszym przypadku należy sprawdzać kolejkę przychodzących pakietów wysyłanych na ten port.

Każdy port ma swoją własną kolejkę, a my dodajemy na początku datagramów WebRTC porty źródłowy i docelowy, aby wiedzieć, do której kolejki przekierować nowy pakiet przy jego przybyciu.

Wywołanie jest nieblokujące, dlatego jeśli nie ma pakietów, po prostu zwracamy -1 i ustawiamy errno=EWOULDBLOCK.

  • Klient uzyskuje w jakiś sposób IP i port serwera z zewnętrznych źródeł i wywołuje sendto(). Równocześnie wykonywane jest wewnętrzne wywołanie bind(), dlatego późniejszy recvfrom() otrzyma odpowiedź bez jawnego wykonywania bind.

W naszym przypadku klient otrzymuje zewnętrzny klucz tekstowy i korzysta z funkcji resolve() w celu uzyskania adresu IP.

Na tym etapie rozpoczynamy „uścisk dłoni” WebRTC, jeśli dwa peer-y jeszcze się ze sobą nie połączyły. Połączenia na różnych portach jednego peer-a korzystają z tego samego DataChannel WebRTC.

Wykonujemy również pośredni bind(), aby serwer mógł przywrócić połączenie w następnym sendto() na wypadek, gdyby z jakiegoś powodu się ono zamknęło.

Serwer jest powiadamiany o połączeniu klienta, gdy klient zapisuje swoją ofertę SDP pod informacją o porcie serwera w Firebase, a serwer tam również odpowiada swoją odpowiedzią.

Na poniższym schemacie pokazano przykład ruchu wiadomości dla schematu socketów i przesyłania od klienta do serwera pierwszej wiadomości:

Portujemy grę wieloosobową z C++ na web z Cheerp, WebRTC i Firebase
Pełny schemat etapu połączenia między klientem a serwerem.

Podsumowanie

Jeśli dotarłeś do końca, zapewne jesteś ciekaw teorii w działaniu. Możesz zagrać w grę na teeworlds.leaningtech.com, spróbuj!


Przyjacielski mecz między kolegami.

Kod biblioteki sieciowej jest swobodnie dostępny na Podróż, którą przeszedłem, okazała się fascynującą wyprawą w przeszłość i cieszę się, że w końcu znalazłem rozwiązanie. Poza tym:. Dołącz do rozmowy na naszym kanale w Gitter!

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster