Diversity w polskim IT
Moriz Büsing
Moriz BüsingTech Director @ WILD

Tworzenie gry multiplayer w Deepstream

Dowiedz się, jak stworzyć przeglądarkową wieloosobową grę czasu rzeczywistego przy pomocy JavaScript i Deepstream.
21.10.201910 min
Tworzenie gry multiplayer w Deepstream

W 2016 roku mieliśmy za zadanie zaprezentować najnowszą wersję Chrome’a, która obsługiwała WebVR. WebVR to technologia, która umożliwia podłączenie urządzeń VR do przeglądarki i korzystanie z nich bezpośrednio na stronie internetowej. Postanowiliśmy stworzyć grę w ping ponga na multiplayera.

W gotową grę możesz zagrać pod tym linkiem. Nie mogę zagwarantować, że wszystko nadal działa tak samo, gdyż Google od momentu jej stworzenia zmienił swoje zasady dotyczące WebVR. Ten artykuł będzie jednak koncentrował się na części dotyczącej sieci. 

Zasady gry: gracz (nazwiemy go gospodarzem) może otworzyć stronę i otrzyma wtedy 4-cyfrowy kod. Następnie może wysłać ten kod znajomemu, który musi użyć tych cyfr do połączenia się z pokojem gospodarza. Gdy tylko obaj gracze znajdą się w pokoju, rozpocznie się gra.

Deepstream

Budowaliśmy grę za pomocą WebRTC, dopóki nie zdaliśmy sobie sprawy, że w tym czasie Safari na iOS nie obsługiwała WebRTC. Klops! Musieliśmy się przegrupować i wrócić do websocketów. Deepstream to serwer typu websocket, którego można używać do synchronizacji danych między urządzeniami, więc idealnie nadaje się dla wielu graczy.

Deepstream ma koncepcję rekordów, będących głównymi elementami składowymi, których użyjemy do zbudowania komunikacji między klientami. Rekord jest zasadniczo fragmentem danych przechowywanych w pamięci na serwerze. Każdy klient może zaktualizować rekord, a serwer zaktualizuje rekord na klientach, które go subskrybują. Dla naszej gry ustanowiliśmy takie rekordy: jeden dla pozycji i obrotu każdej paletki, jeden do synchronizacji statusu gry (uruchamanie gry / gra rozpoczęta / pauza / koniec gry), jeden dla uderzeń piłki (zapamiętuje ostatnią pozycję, w której piłka była uderzona, a także wektor 3d, który przechowuje aktualną prędkość piłki), jeden dla chybionych uderzeń i jeden dla pingów w celu zmierzenia opóźnienia. Więcej na ten temat później.

Możesz się zastanawiać, dlaczego nie przechowujemy pozycji piłki. Dlatego, że mamy informację o ostatniej pozycji, w którą piłka została uderzona i prędkość, z którą teraz leci. W takim wypadku możemy po prostu zainicjalizować piłkę z tą prędkością i pozycją, a silnik fizyczny (użyliśmy cannon.js) obliczy trasę w ten sam sposób na obydwu klientach, ponieważ jest to system deterministyczny.

Każdy rekord ma identyfikator tekstowy, za pomocą którego klienci mogą go subskrybować. Poprzedzamy ten identyfikator 4-cyfrowym kodem gry, aby tylko klienci znajdujący się w tym samym pokoju widzieli aktualizacje rekordów.

Konfiguracja

Cały skrypt po stronie serwera, którego używamy do komunikacji przez websocket, jest następujący:

const DeepstreamServer = require('deepstream.io');
const C = DeepstreamServer.constants;

const server = new DeepstreamServer({
  host: '127.0.0.1',
  port: 6020,
});

// start the server
server.start();


Ponieważ Google planowało rozreklamować ten eksperyment, skonfigurowaliśmy wiele serwerów, aby upewnić się, że jesteśmy w stanie wytrzymać spodziewane obciążenie. Przy uruchomieniu mieliśmy cztery instancje ec2 w różnych regionach AWS. Ograniczyliśmy to do tylko jednej instancji, ponieważ ruch spadł.

Teraz, z perspektywy klienta, mamy 4 serwery, musimy wymyślić sposób, aby użytkownicy najpierw znaleźli najbliższy (lub najszybszy) serwer, a następnie pozwolili swojemu przeciwnikowi połączyć się z tym samym serwerem. To może wcale nie być najbliższy serwer dla nich, ale dla uproszczenia założyliśmy, że większość ludzi grających ze sobą, znajduje się przynajmniej na tym samym kontynencie.

  chooseClosestServer() {
    // try connecting to every available server, choose the one that answers first
    return new Promise((resolve, reject) => {
      Promise.race(this.availableServers.map((server, index) => this.pingServer(index))).then(fastestServer => {
        if (fastestServer === 'timeout') {
          reject(fastestServer);
          return;
        }
        this.chosenServer = fastestServer;
        // eslint-disable-next-line
        return this.connectToServer(this.availableServers[fastestServer]);
      }).then(() => {
        resolve();
      }).catch(e => {
        console.warn(`error:  ${e}`);
        reject(e);
      });
    });
  }


Przydaje się tu Promise.race. Metoda pingServer spróbuje połączyć się z serwerem i rozwiąże Promise, gdy tylko uda się nawiązać połączenie. Teraz możemy połączyć się z serwerem, który znaleźliśmy:

 connectToServer(host) {
    // connect to the deepstream server
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        reject('timeout');
      }, 2000);
      this.client = deepstream(host, {
        mergeStrategy: deepstream.MERGE_STRATEGIES.REMOTE_WINS,
      });
      this.client.login();
      this.client.on('error', e => {
        reject(e);
      });
      this.client.on('connectionStateChanged', e => {
        if (e === deepstream.CONSTANTS.CONNECTION_STATE.OPEN) {
          resolve();
        }
        if (e === deepstream.CONSTANTS.CONNECTION_STATE.ERROR) {
          reject('error');
        }
      });
    });
  }

Negocjowanie pokoju

Po ustanowieniu połączenia, gospodarz stworzy pokój. Ta metoda jest wywoływana z zewnątrz po kliknięciu przez użytkownika przycisku „utwórz pokój”.

 openRoom() {
    this.isHost = true;
    // pick a random prefix which belongs to the available prefixes for this server
    const prefix = this.availablePrefixes[this.chosenServer][rand(0, this.availablePrefixes[this.chosenServer].length)];
    this.GAME_ID = prefix + randomstring.generate({
      length: 3,
      charset: availableChars,
    });
    this.setRecords();
    this.statusRecord.set('room-is-open', true);
    this.startListening();
    return this.GAME_ID;
  }


Widać, że pierwszy znak 4-cyfrowego kodu faktycznie informuje, z którym serwerem mamy do czynienia. Korzystamy z pierwszej ćwiartki alfabetu dla pierwszego serwera, drugiej dla drugiego itd. Wykonanie tego takim sposobem daje nam prawie całą entropię 4-cyfrowego kodu, a jednocześnie nadal jesteśmy w stanie zakodować te informacje - moglibyśmy teraz teoretycznie hostować 262144 pokoi na każdym serwerze. Usunęliśmy 1, I i l z dostępnych znaków, aby uniknąć dwuznaczności podczas czytania kodu. Po otrzymaniu kodu pokoju przeciwnik będzie próbował połączyć się z tym serwerem:

 tryConnecting(id) {
    // try to connect to a given room id. the first character is a code for
    // which server the opponent is connected to. in case of 2 servers, the
    // first half of the available characters is reserved for the first server,
    // the second half is reserved for the second server. this way we can still
    // have as many random combinations with 4 letters.
    this.isHost = false;
    return new Promise((resolve, reject) => {
      let serverIndex = -1;
      this.availablePrefixes.forEach((prefixes, index) => {
        if (prefixes.indexOf(id[0]) !== -1) {
          serverIndex = index;
        }
      });
      if (serverIndex === -1) {
        // impossible room code, there is no prefix like that
        reject('no room found');
      }
      this.connectToServer(this.availableServers[serverIndex]).then(() => {
        this.GAME_ID = id;
        this.isHost = false;
        this.setRecords();
        this.statusRecord.subscribe('room-is-open', value => {
          if (value) {
            this.startListening();
            this.statusRecord.set('player-2', {action: ACTION.CONNECT});
            this.isOpponentConnected = true;
            setTimeout(this.sendPings.bind(this), 1000);
            resolve();
          } else {
            reject('room already full');
          }
        });
        setTimeout(() => {
          reject('no room found');
        }, 2000);
      }).catch(e => {
        reject(e);
      });
    });
  }

Konfiguracja rekordów

Po podłączeniu 2 klientów do tego samego serwera i udostępnieniu identyfikatora pokoju możemy skonfigurować rekordy, których potrzebujemy do gry.

  setRecords() {
    this.statusRecord = this.client.record.getRecord(`${this.GAME_ID}-status`);
    this.paddle1Record = this.client.record.getRecord(`${this.GAME_ID}-paddle1`);
    this.paddle2Record = this.client.record.getRecord(`${this.GAME_ID}-paddle2`);
    this.hitRecord = this.client.record.getRecord(`${this.GAME_ID}-hit`);
    this.missRecord = this.client.record.getRecord(`${this.GAME_ID}-miss`);
    this.pingRecord = this.client.record.getRecord(`${this.GAME_ID}-ping`);
  }


Następnie możemy zacząć słuchać zdarzeń i stworzyć callbacki, aby komunikowały się z grą, gdy otrzymamy aktualizację statusu:

  startListening() {
    this.statusRecord.subscribe(`player-${this.isHost ? 2 : 1}`, value => {
      switch (value.action) {
        case ACTION.CONNECT:
          setTimeout(this.sendPings.bind(this), 1000);
          this.statusRecord.set('room-is-open', false);
          this.isOpponentConnected = true;
          this.emitter.emit(EVENT.OPPONENT_CONNECTED);
          break;
        case ACTION.DISCONNECT:
          this.isOpponentConnected = false;
          this.emitter.emit(EVENT.OPPONENT_DISCONNECTED);
          break;
        case ACTION.PAUSE:
          if (this.isOpponentConnected) {
            this.emitter.emit(EVENT.OPPONENT_PAUSED);
          }
          break;
        case ACTION.UNPAUSE:
          if (this.isOpponentConnected) {
            this.emitter.emit(EVENT.OPPONENT_UNPAUSED);
          }
          break;
        case ACTION.REQUEST_COUNTDOWN:
          this.callbacks.receivedRequestCountdown();
          break;
        case ACTION.RESTART_GAME:
          this.callbacks.receivedRestartGame();
          break;
        default:
          console.warn('unknown action');
      }
    });
    if (this.isHost) {
      this.paddle2Record.subscribe('position', value => {
        this.callbacks.receivedMove(value);
      });
    } else {
      this.paddle1Record.subscribe('position', value => {
        this.callbacks.receivedMove(value);
      });
    }
    this.hitRecord.subscribe(`player-${this.isHost ? 2 : 1}`, value => {
      this.callbacks.receivedHit(value);
    });
    this.missRecord.subscribe(`player-${this.isHost ? 2 : 1}`, value => {
      this.callbacks.receivedMiss(value);
    });
    for (let i = 0; i < 20; i += 1) {
      // make 20 ping records so the pings don't get mixed up
      this.pingRecord.subscribe(`player-${this.isHost ? 2 : 1}-ping-${i}`, value => {
        if (value.ping) {
          this.pingRecord.set(`player-${this.isHost ? 1 : 2}-ping-${value.index}`, {
            index: value.index,
            pong: true,
          });
        } else {
          this.receivedPong(value);
        }
      });
    }
  }


Rekord ping służy do pomiaru opóźnienia w aktualnie podłączonym serwerze.

  sendPings() {
    this.pingInterval = setInterval(() => {
      this.pings[this.pingNumber] = Date.now();
      this.pingRecord.set(`player-${this.isHost ? 1 : 2}-ping-${this.pingNumber}`, {
        index: this.pingNumber,
        ping: true,
      });
      this.pingNumber += 1;
      if (this.pingNumber >= 20) {
        clearInterval(this.pingInterval);
      }
    }, 1000);
  }

  receivedPong(data) {
    const rtt = Date.now() - this.pings[data.index];
    this.roundTripTimes.push(rtt);
    this.roundTripTimes.sort((a, b) => a - b);
    // get median of all received roundtrips, divide by 2 to get the one-way-latency
    this.latency = this.roundTripTimes[Math.floor(this.roundTripTimes.length / 2)] / 2;
  }


Ustawiamy 20 oddzielnych rekordów ping, po jednym na sekundę. Ponieważ nasłuchujemy również aktualizacji tego rekordu, możemy zmierzyć czas, jaki upłynął od ustawienia rekordu do momentu otrzymania aktualizacji. To będzie czas roundtripu.

Po zmierzeniu mediany zebranych roundtripów i podzieleniu jej przez 2 otrzymujemy bieżące opóźnienie w jedną stronę. Zauważ, że jest to opóźnienie do serwera, a nie end-to-end. Ponieważ opóźnienie będzie się zmieniać w czasie, jeszcze dokładniejszą metodą byłoby robienie tego tak długo, jak długo trwa gra i przechowywanie okna ostatnich czasów roundtripów, biorąc medianę z tego okna przy każdym pingowaniu. Ale wybrane przez nas podejście okazało się wystarczające dla naszej gry.

Należy również pamiętać o usunięciu rekordu po zakończeniu gry lub wyjściu gracza. W przeciwnym razie serwer zachowa rekordy w pamięci, co może spowodować wyciek pamięci.

$(window).on('beforeunload', () => {
      if (this.isOpponentConnected) {
        // tell opponent we disconnected
        this.statusRecord.set(`player-${this.isHost ? 1 : 2}`, {action: ACTION.DISCONNECT});
      }
      if (this.statusRecord) {
        // delete all records
        this.statusRecord.discard();
        this.statusRecord.delete();
        this.paddle1Record.discard();
        this.paddle1Record.delete();
        this.paddle2Record.discard();
        this.paddle2Record.delete();
        this.hitRecord.discard();
        this.hitRecord.delete();
        this.missRecord.discard();
        this.missRecord.delete();
        this.pingRecord.discard();
        this.pingRecord.delete();
      }
    });


To kończy podstawową konfigurację wszystkich niezbędnych rekordów. Pełny moduł można znaleźć tutaj. To wystarczy, aby gra zadziałała, jednak wrażenia nie będą należały do najbardziej przyjemnych. Piłka przeleci przez paletkę i magicznie teleportuje się w inne miejsca, paletka przeciwnika nie będzie poruszać się płynnie. Nie będzie to zbyt realistyczne.

Maskowanie opóźnienia

Każda gra realtime w multiplayerze musi radzić sobie z opóźnieniami. Jest faktem, że istnieje teoretyczny limit prędkości przesyłania informacji przez sieć. Bez względu na postęp technologiczny, to się nigdy nie zmieni. Rzeczywiste opóźnienie jest oczywiście znacznie wyższe niż to teoretyczne minimum. 

Powoduje to poważny problem: jeśli uderzysz piłkę, zobaczysz, jak leci w kierunku paletki przeciwnika. On następnie uderzy piłkę i wyśle Ci tę informację, ale zanim ją otrzymasz, piłka już przeleci za paletkę. Możliwym rozwiązaniem tego problemu jest wizualne spowolnienie piłki, która leci w kierunku przeciwnika, tak aby uderzenie wizualne miało miejsce mniej więcej w tym samym czasie, w którym otrzymano informację o trafieniu. Wywoła się ono po uderzeniu piłki:

  slowdownBall() {
    // if the ball is on the way to the opponent,
    // we slow it down so it will be on the opponents side
    // approximately at the time they actually hit it
    // NOTE that we still only receive the hit half a roundtriptime later
    if (this.physics.ball.velocity.z > 0) {
      return;
    }
    const velocity = this.physics.ball.velocity.length();
    const dist = new Vector3().subVectors(this.ball.position, this.paddleOpponent.position).length();
    const eta = dist / velocity;
    const desirableEta = eta + (this.communication.latency / 1000);
    this.physicsTimeStep = 1000 * (desirableEta / eta) * 1;
  }


Zadziałało to całkiem dobrze w praktyce i naprawiło problem i gdy opóźnienia są wysokie, piłka będzie latać wolno. Jednak z drugiej strony, jeśli opóźnienie jest zbyt duże, odbiera to radość z gry, jej dynamikę i w pewien sposób sens rozgrywki, bez względu na to, jaką technikę wybierze gracz.

Problem przy tym rozwiązaniu polega na tym, że ponieważ opóźnienie zawsze się trochę zmienia, często zdarzy się, że pomimo idealnego uderzenia w piłkę, jej pozycja, która została przekazana wraz z trafieniem, nie była do końca poprawna. Bezpośrednie ustawienie piłki w zaktualizowanej pozycji byłoby bardzo widoczne. Opracowaliśmy technikę interpolacji, która liniowo interpoluje wizualną pozycję piłki do pozycji piłki wysyłanej przez sieć podczas jej ruchu do drugiego gracza. Interpolacja ma miejsce w każdej klatce, tak długo, jak piłka leci do gracza:

// we interpolate between the actual (received) position and the position
// the user would expect. after 500ms both positions are the same.
const fauxPosition = new Vector3().lerpVectors(
  this.physics.ball.position,
  new Vector3().addVectors(
    this.physics.ball.position,
    this.ballPositionDifference
  ),
  this.ballInterpolationAlpha
);
this.ball.position.copy(fauxPosition);
this.ball.quaternion.copy(this.physics.ball.quaternion);


ballInterpolationAlpha to liczba, która po każdym uderzeniu liniowo się zmniejsza z 1 do 0, a ballPositionDifference to wektor, który przechowuje różnicę między wizualną pozycją piłki a tą, która została otrzymana przez sieć.

Dodatkowa lektura

Oto kilka źródeł, które pomogły mi znaleźć sposób na zakodowanie tej gry:

  • Cube Slamto gra o podobnej mechanice, zaimplementowana w webRTC. W tym czasie miał on również źródło online
  • Artykułna temat kompensacji opóźnień i predykcji po stronie klienta
  • Tekstyo networkingu w grach


Pełne repozytorium gry znajdziesz tutaj, ale miej na uwadze, że projekt ma już kilka lat i prawdopodobnie nie będzie się bezproblemowo kompilował. Jeśli napotkasz problemy, możesz wypróbować starszą wersję node, np. 4 lub 5.


Oryginał tekstu w języku angielskim przeczytasz tutaj.

<p>Loading...</p>