Getting Started with the Playable Locations API

Overview

This getting started guide will help you get up and running with the Playable Locations API v3. As you read through it, you'll gain an understanding of the design rationale behind the use of core techniques like the use of the S2 Cell mechanism that the Playable Locations API uses to specify areas upon the Earth's surface. This guide is based on a simplified game application architecture that allows you to develop a solid understanding of the entire end-to-end flow of getting and using playable locations.

Support libraries

The following support libraries are used throughout this guide.

LibraryDescription
S2 Geometry Flexible support for spatial indexing.
Protocol Buffers A language-neutral, platform-neutral, extensible way of serializing structured data for use in communications protocols, data storage, and more.
gRPC A high-performance, open-source, Remote Procedure Call framework.

Ways to access the API

You can access the Playable Locations API using either of the following two methods.

Method 1

Access the Playable Locations API using the gRPC client library (this is the recommended method). To do so:

  1. Download the latest version of the gRPC client library.
  2. Set your API key, as demonstrated in the sample code at the end of this page.

Method 2

Build a JSON client-library for sending REST HTTP requests, and for parsing HTTP responses. Because of the overhead associated with building and maintaining such components, this guide focuses on method 1 (gRPC) for simplicity.

Concepts

Search request

Calls to retrieve playable locations are modelled after a simple SQL database query. Conceptually, you can think of calls as having the form:

select FIELDS_TO_RETURN
from PlayableLocations
where FILTER
order by RANKINGS

Games place specific game object types (for example, enemies, treasures, and power-ups) on playable locations. Each game object type is associated with a different Criterion. You can bundle all of your Criterion together into a criteria[] array in a SearchPlayableLocationsRequest. In your request, you must also specify an AreaFilter, which specifies the S2 Cell (which defines the area to search within).

Search response

The SearchPlayableLocationsResponse contains the following fields.

Locations per game object type
The locations_per_game_object_type field is a map that associates each game object type with its corresponding list of playable locations.
Time-to-live
The ttl field specifies the length of time that the playable location can be cached, after which you must refresh it by performing a new search.

The Playable Locations Explorer

The Playable Locations Explorer is a web app that allows you to construct and test search requests for the Playable Locations API. You can use it to experiment with filters to find the best collections of playable locations for your game.

Querying and caching interactions

The recommended logic flow between the game client, game server, game-state database, and the Playable Locations API is depicted in the following sequence diagram.

Sequence diagram

The typical work-flow

The following steps detail the work-flow for getting, and then using a set of playable locations that cover a particular S2 Cell.

  1. Send a search request for playable locations, specifying coverage for a particular S2 Cell.
  2. The response contains a set of playable locations for that S2 Cell, and it includes a time-to-live value for the set.
  3. Return this data to game clients, and also cache it in the game-state database.
  4. With playable locations for this S2 Cell cached, you can now serve future requests for playable locations for that S2 Cell directly from the cache.
  5. When the set of playable locations reaches its expiration, refresh it by sending a new request for playable locations that cover that particular S2 Cell.

This flow can then be divided into the following five scenarios for covering the circle defined by your desired interaction radius.

Scenario 1

No S2 Cells have been cached. In this case, you must issue up to four search requests—specifying the adjacent S2 Cells needed to cover the interaction area, as depicted in the following image.

No cached S2 Cells covering a circular area

Scenarios 2, 3, and 4

Either one, two, or three S2 Cells have been cached. In these cases, you must retrieve either three, two, or one adjacent S2 Cells, respectively.

S2 Cell coverage for a circular area

Scenario 5

All four S2 Cells have been cached. In this case, there's no need to request more playable locations for additional S2 Cell coverage.

All S2 Cells are cached

This is the typical usage scenario—when the cache is warm, and new players enter the same area, or when existing players just move around. In these cases, there is no need for you to issue search requests.

Refresh strategies and streaming results

When designing your game server, Google recommends that you stream results back to clients as quickly as possible, while refreshing the remaining cold S2 Cells in the background. Here are a few suggestions for patterns that you can use to refresh these S2 Cells.

Producer-consumer queue

Implement a producer-consumer queue in the game server to fetch data from Google. Carefully tune the queue parallelization factor to ensure that you get a good response time. This pattern has the advantage of automatically prioritizing requests necessary to fulfill pending clients.

Async refresh

Another approach is to go through a Least Recently Used (LRU) list of S2 Cells, and refresh them. This pattern is likely simpler than the producer-consumer queue, but a disadvantage is that it can be difficult to track newly requested S2 Cells.

Determining the S2 Cell size

Experiment with the gameplay to determine the optimum S2 Cell size to use—given the following trade-offs:

  • Too fine-grained, using a high-level S2 Cell covering a small area. You end up sending many more requests.
  • Too coarse-grained, using a low-level S2 Cell covering a large area. Although each request contains more data, the resulting playable locations tend to be too far away from the player to interact with.

The number of game object types and criteria

You must be mindful when determining the optimum number of game object types for your game, and when determining how to setup the Criteria for each of them.

You need to specify a maxLocationCount for each Criteria, because the default values are unlikely to be appropriate for all cases. The actual number of playable locations will range anywhere from zero to maxLocationCount, depending on filters and spacing. Note that spacing takes precedence over maxLocationCount. So for example, it’s impossible to have 100 playable locations within 1 km from each other in a level 16 S2 Cell (which has ≈150 m per edge).

Spacing options

You can space-out rare game objects nicely by configuring spacing. SpacingOptions define the spacing, not just between those of a particular game object type, but from all other game object types as well. This scheme defines an implicit hierarchy: the first game object type should be the rarest (with the largest spacing), while the last game object type should be the most common (with the smallest spacing).

Fields to return

The Criterion for each game object type contains its own set of fields to return.

Storage layer

Google recommends that you use BigQuery to store the game state associated with cached playable locations. To handle TTL expiration, Google recommends that you index by the same S2 Cell key.

Pre-warming caches

Google recommends that you refrain from pre-warming the cache. Instead, we recommend that you build-up your cache lazily—as players begin entering new areas. This helps to keep the database small and fresh.

Sample code

The following Java code demonstrates how to build a gRPC request, as well as how to attach your API key to it using a ClientInterceptor.

import com.google.maps.playablelocations.v3.AreaFilter;
import com.google.maps.playablelocations.v3.Criterion;
import com.google.maps.playablelocations.v3.PlayableLocationsGrpc;
import com.google.maps.playablelocations.v3.PlayableLocationsGrpc.PlayableLocationsBlockingStub;
import com.google.maps.playablelocations.v3.SearchPlayableLocationsRequest;
import com.google.maps.playablelocations.v3.SearchPlayableLocationsResponse;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientCall;
import io.grpc.ClientInterceptor;
import io.grpc.ClientInterceptors;
import io.grpc.ForwardingClientCall;
import io.grpc.ManagedChannelBuilder;
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;
import java.util.logging.Logger;

public class ApiKeyAuthSample {

  public static void main(String[] args) {

    final String API_KEY = "YOUR_API_KEY";    // Replace with your API key.

    // Creates a Channel to access the Playable Locations API.
    Channel c = ManagedChannelBuilder.forAddress("playablelocations.googleapis.com", 443).useTransportSecurity().build();

    // To use your API key with the requests, attach the Interceptor to the
    // channel.
    c = ClientInterceptors.intercept(c, new Interceptor(API_KEY));

    PlayableLocationsBlockingStub pls = PlayableLocationsGrpc.newBlockingStub(c);

    SearchPlayableLocationsResponse r = pls.searchPlayableLocations(SearchPlayableLocationsRequest.newBuilder().setAreaFilter(
            AreaFilter.newBuilder().setS2CellId(7715420650000613376L))
              .addCriteria(
                  Criterion.newBuilder().setGameObjectType(0).build()).build())
              .addCriteria(
                  Criterion.newBuilder().setGameObjectType(1).build()).build()));

    System.out.println("RESULT:" + r.getLocationsPerGameObjectTypeMap().get(0));
    System.out.println("RESULT:" + r.getLocationsPerGameObjectTypeMap().get(1));
  }

  /**
  * An Interceptor that attaches the API key to the gRPC requests.
  */
  private static final class Interceptor implements ClientInterceptor {

    private static Logger LOGGER = Logger.getLogger("InfoLogging");

    private static Metadata.Key<String> API_KEY_HEADER = Metadata.Key.of("x-goog-api-key", Metadata.ASCII_STRING_MARSHALLER);

    private final String apiKey;

    public Interceptor(String apiKey) {
      this.apiKey = apiKey;
    }

    @Override
    public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {

      LOGGER.info("Intercepted " + method.getFullMethodName());

      ClientCall<ReqT, RespT> call = next.newCall(method, callOptions);

      call = new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(call) {

        @Override
        public void start(Listener<RespT> responseListener, Metadata headers) {

          LOGGER.info("Attaching API Key: " + apiKey);

          headers.put(API_KEY_HEADER, apiKey);

          super.start(responseListener, headers);
        }
      };

      return call;

    }
  }
}