Pokemon GO - jak to jest zrobione post
2016-07-24
Niedawno świat opanowała bardzo dziwna moda, miliony ludzi odeszło nagle od komputerów w celu łapania nieistniejących stworków za pomocą swoich telefonów. Rozgrywka jest o tyle ciekawa, że jej akcja odbywa się w zasadzie wszędzie dookoła nas. Mapa gry to nic innego jak bardzo sprytne wykorzystanie Google maps oraz pozycji GPS odczytywanej z telefonu gracza. W tym wpisie przedstawię mniej więcej w jaki sposób działa klient gry przez co nietrudno będzie się domyślić sposobu działania serwera.

Wiedza potrzeba do napisania tego wpisu to jedynie wynik analizy kodu dostępnego na serwisie Github. Nie zajmowałem się analizą którejkolwiek wersji klienta gry.
Na temat samej rozgrywki można napisać wiele jednak na potrzeby tego wpisu wystarczy, zrozumieć że mapa na którą patrzymy to nic innego jak Google Maps. Postać przedstawia naszą aktualną pozycję. Warto też zwrócić uwagę na fakt, że widoczna przestrzeń jest dla gracza ograniczona.
Aktualnie gra dostępne jest zarówno dla systemu Android jak i iOS. Jak nietrudno było się spodziewać klient gry na Androida został bardzo szybko rozłożony na czynniki pierwsze. Więcej na ten temat można przeczytać chociażby tutaj.
Czy więc mając takie informacje dałoby się napisać własnego klienta gry? Oczywiście, powstało już nawet kilka bibliotek tego typu w różnych językach. Pokusiłem się o próbę sportowania jednej z nich do PHP. Poniżej opiszę problemy na jakie natrafiłem w trakcie i czego się przy okazji nauczyłem.
Efekt mojej weekendowej przygody z pokemonami dostępny jest tutaj: https://github.com/norzechowicz/pkg-api-php
Kod biblioteki napisanej w pythonie, na której się wzorowałem znajdziecie tutaj: https://github.com/tejado/pgoapi
Na podstawie zależności klienta gry łatwo wywnioskować, że komunikacja oparta jest o GRPC. Ponieważ GRPC opiera się o Protocol Buffers, w pierwszej kolejności musiałem poszukać odpowiednich narzędzi do obsługi tego protokołu. Okazuje się, że pomimo braku wsparcia od samego Googla powstał bardzo udany port Protobuf dla PHP.
Czym jest Protobuf? W skrócie jest to mechanizm pozwalający w niezależny od wybranego języka sposób stworzyć definicję protokołu komunikacji. Przykładowo możemy stworzyć serwer GRPC w C++, zdefiniować protokół komunikacji za pomocą Protobuf a następnie wykorzystać definicję protokołu w kliencie napisanym w dowolnym języku.
Poniżej przykładowa definicja tokenu który klient musi pozyskać od serwera w procesie autoryzacji i przy użyciu którego uwierzytelniany jest każdy request.
Lista najbardziej aktualnych definicji protokołu Pokemon GO
# JWT.proto
syntax = "proto2";
package PkgClient.Protocol.Communication;
message JWT {
required string contents = 1;
required int32 unknown13 = 2;
}
W PHP ta definicja może zostać skompilowana do czegoś w tym stylu:
<?php
/**
* Generated by Protobuf protoc plugin.
*
* File descriptor : Communication.proto
*/
namespace PkgClient\Protocol\Communication\Request\AuthInfo;
/**
* Protobuf message : PkgClient.Protocol.Communication.Request.AuthInfo.JWT
*/
class JWT extends \Protobuf\AbstractMessage
{
/**
* @var \Protobuf\UnknownFieldSet
*/
protected $unknownFieldSet = null;
/**
* @var \Protobuf\Extension\ExtensionFieldMap
*/
protected $extensions = null;
/**
* contents required string = 1
*
* @var string
*/
protected $contents = null;
/**
* unknown13 required int32 = 2
*
* @var int
*/
protected $unknown13 = null;
/**
* Check if 'contents' has a value
*
* @return bool
*/
public function hasContents()
{
return $this->contents !== null;
}
/**
* Get 'contents' value
*
* @return string
*/
public function getContents()
{
return $this->contents;
}
/**
* Set 'contents' value
*
* @param string $value
*/
public function setContents($value)
{
$this->contents = $value;
}
/**
* Check if 'unknown13' has a value
*
* @return bool
*/
public function hasUnknown13()
{
return $this->unknown13 !== null;
}
/**
* Get 'unknown13' value
*
* @return int
*/
public function getUnknown13()
{
return $this->unknown13;
}
/**
* Set 'unknown13' value
*
* @param int $value
*/
public function setUnknown13($value)
{
$this->unknown13 = $value;
}
/**
* {@inheritdoc}
*/
public function extensions()
{
// This code is generated, it was removed for readability
}
/**
* {@inheritdoc}
*/
public function unknownFieldSet()
{
return $this->unknownFieldSet;
}
public static function fromStream($stream, \Protobuf\Configuration $configuration = null)
{
// This code is generated, it was removed for readability
}
public static function fromArray(array $values)
{
// This code is generated, it was removed for readability
}
public static function descriptor()
{
// This code is generated, it was removed for readability
}
/**
* {@inheritdoc}
*/
public function toStream(\Protobuf\Configuration $configuration = null)
{
// This code is generated, it was removed for readability
}
public function writeTo(\Protobuf\WriteContext $context)
{
// This code is generated, it was removed for readability
}
public function readFrom(\Protobuf\ReadContext $context)
{
// This code is generated, it was removed for readability
}
public function serializedSize(\Protobuf\ComputeSizeContext $context)
{
// This code is generated, it was removed for readability
}
public function clear()
{
$this->contents = null;
$this->unknown13 = null;
}
public function merge(\Protobuf\Message $message)
{
// This code is generated, it was removed for readability
}
}
Natomiast wykorzystanie tokenu sprowadza się do poniższego kodu:
$token = Request\AuthInfo::fromArray([
'token' => Request\AuthInfo\JWT::fromArray([
'contents' => 'this_is_auth_token_string_value',
'unknown13' => 59
]),
'provider' => 'ptc'
]);
W tej postaci $token
może zostać zapisany jako (string) $token
i przesłany
w zwykłym requeście http.
Serwer za pomocą tej samej definicji (a raczej bardzo podobnej, ta wykorzystania przeze mnie jest wynikiem reverse engineeringu) obsłuży wiadomość. Skąd jednak pozyskać token? Są na to 2 sposoby. Jeden to skorzystanie z autoryzacji przy użyciu "Pokemon Club" i wymiana loginu/hasła użytkownika na token, drugi to pozyskanie tokenu poprzez API Googla i wymiana go na token potrzebny do autoryzacji w grze. Obydwie metody korzystają z protokołu oAuth i można je oglądnąć tutaj:
No dobra, mamy token ale co dalej? Jeżeli wierzyć autorom pythonowej biblioteki klient gry
po uzyskaniu tokenu próbuje pobrać unikalny url API, którego będzie używał do końca sesji.
Klient wysyła wiadomość heartbeat
- jest to wiadomość wysyłana cyklicznie do serwera w celu powiadomienia go, że klient wciąż jest podłączony.
Heartbeat słany jest na adres https://pgorelease.nianticlabs.com/plfe/rpc
uzyskując w ten sposób
unikalny adresu url, który wygląda mniej więcej tak: https://pgorelease.nianticlabs.com/plfe/136
W tym momencie klient ma już wszystkie informacje aby rozpocząć łapanie pokemonów!
Kolejnym krokiem jest przesłanie aktualnej pozycji gracza do serwera w celu uzyskania informacji na temat tego co dzieje się dookoła. Jak to jednak zrobić? Przesyłanie współrzędnych geograficznych mogłoby okazać się odrobinę niewydajne. Tutaj z pomocą autorom gry przyszła fenomenalna jednak mało znana biblioteka Googla o nazwie s2-geometry-library Bilioteka napisana jest w języku C i nie posiada kompletnie żadnej dokumentacji, więcej na jej temat można jednak poczytać na tym blogu. Podobno jest ona wykorzystywana w takich produktach jak Google Maps, MongoDB engine oraz Foursquare
No dobra, ale co owa biblioteka robi? W dużym skrócie każdy centymetr kwadratowy powierzchni na ziemi posiada swój unikalny identyfikator będący 64-bitowym integerem. Biblioteka S2 pozwala ten identyfikator wyliczyć na podstawie współrzędnych geograficznych latitude i longitude. Co więcej taki identyfikator może reprezentować powierzchnię różnej wielkości, w zależości od wybranego poziomu może to być obszar o rozmiarze 85,011,012 km2 (poziom 0) lub 0.48 cm2 (poziom 30). Klient Pokemon GO wykorzystuje poziom 15 czyli obszar o powierzchni mniej więcej 3 km2. Kolejną fenomenalną funkcją tej biblioteki jest możliwość pobrania sąsiednich obszarów.
Po zalogowaniu do gry klient pobiera aktualną pozycję gracza, oblicza za pomocą biblioteki S2 identyfikator pozycji a następnie pobiera 20 pobliskich identyfikatorów reprezentujących obszary na mapie or rozmiarze 3 km2 każdy. Identyfikatory te wysyłane są do serwera, który odpowiada listą elementów znajdujących się na tych obszarach. Dzięki temu klient wie, że niedaleko gracza znajduje się pokemon, pokespot lub gym. Jeżeli gracz podejdzie wystarczająco blisko a czas trwania spawnu jeszcze nie upłynie gracz może złapać pokemona. Efekt dopełnia jeszcze wykorzystanie aparatu w celu stworzenia tła na którym łapany stworek się znajduje. Zapewne po stronie serwera informację na temat dostępności różnych elementów generowana jest w czasie logowania się pierwszego gracza na danym obszarze. Obszar ten jest prawdopodobnie analizowany co pozwala dostosować do niego odpowiednie rodzaje stworków, ilość gymów czy pokespotów. Gymy i Pokespoty to nic innego jak miejsca oznaczone przez ludzie na mapach google.
Mniej więcej w tym miejscu utknąłem pisząc PHP’owego klienta API Pokemon GO. Okazuje się, że biblioteka S2 nie posiada żadnego sensownego portu do PHP. Prawdopodobnie najsensowniejszą opcją byłoby napisanie rozszerzenia do php korzystającego z oryginalnego źródła od Googla.
Podsumowując sama gra praktycznie w 100% bazuje na rozwiązaniach Googla, należą do nich między innymi: GRPC Protobuf S2 Geometry Library
Pokemony okazały się kolejnym wielkim sukcesem Nintendo, sama gra nie jest jednak aż tak skomplikowana jak mogłoby się wydawać na pierwszy rzut oka. Prawdopodobnie największym problemem w wypadku tej konkretnej produkcji okazała się skala, w końcu pokemony łapią miliony ludzi z całego świata. Technologia wykorzystana do stworzenia gry może mieć ogromny potencjał w branży marketingowej (o ile już nie istnieje coś podobnego). Klient przechodzący koło sklepu dostaje powiadomienie o promocji, możliwość rozmieszczania kodów rabatowych na produkty wybranej marki w różnych częściach miasta. Możliwości są nieograniczone.