Cloud Spanner Getting Started with Games Development

1. Introduction

Google Cloud Spanner is a fully managed horizontally scalable, globally distributed, relational database service that provides ACID transactions and SQL semantics without giving up performance and high availability.

These features makes Spanner a great fit in the architecture of games that want to enable a global player base or are concerned about data consistency

In this lab, you will be creating two Go services that interact with a regional Spanner database to enable players to sign up and start playing.

413fdd57bb0b68bc.png

Next you'll generate data leveraging the Python load framework Locust.io to simulate players signing up and playing the game. And then you'll query Spanner to determine how many players are playing, and some statistics about players' games won vs games played.

Finally, you will clean up the resources that were created in this lab.

What you'll build

As part of this lab, you will:

  • Create a Spanner instance
  • Deploy a Profile service written in Go to handle player signup
  • Deploy a Matchmaking service written in Go to assign players to games, determine winners and update players' game statistics.

What you'll learn

  • How to setup a Cloud Spanner instance
  • How to create a game database and schema
  • How to deploy Go apps to work with Cloud Spanner
  • How to generate data using Locust
  • How to query data in Cloud Spanner to answer questions about games and players.

What you'll need

  • A Google Cloud project that is connected to a billing account.
  • A web browser, such as Chrome or Firefox.

2. Setup and requirements

Create a project

If you don't already have a Google Account (Gmail or Google Apps), you must create one. Sign-in to Google Cloud Platform console ( console.cloud.google.com) and create a new project.

If you already have a project, click on the project selection pull down menu in the upper left of the console:

6c9406d9b014760.png

and click the ‘NEW PROJECT' button in the resulting dialog to create a new project:

949d83c8a4ee17d9.png

If you don't already have a project, you should see a dialog like this to create your first one:

870a3cbd6541ee86.png

The subsequent project creation dialog allows you to enter the details of your new project:

6a92c57d3250a4b3.png

Remember the project ID, which is a unique name across all Google Cloud projects (the name above has already been taken and will not work for you, sorry!). It will be referred to later in this codelab as PROJECT_ID.

Next, if you haven't already done so, you'll need to enable billing in the Developers Console in order to use Google Cloud resources and enable the Cloud Spanner API.

15d0ef27a8fbab27.png

Running through this codelab shouldn't cost you more than a few dollars, but it could be more if you decide to use more resources or if you leave them running (see "cleanup" section at the end of this document). Google Cloud Spanner pricing is documented here.

New users of Google Cloud Platform are eligible for a $300 free trial, which should make this codelab entirely free of charge.

Google Cloud Shell Setup

While Google Cloud and Spanner can be operated remotely from your laptop, in this codelab we will be using Google Cloud Shell, a command line environment running in the Cloud.

This Debian-based virtual machine is loaded with all the development tools you'll need. It offers a persistent 5GB home directory and runs in Google Cloud, greatly enhancing network performance and authentication. This means that all you will need for this codelab is a browser (yes, it works on a Chromebook).

  1. To activate Cloud Shell from the Cloud Console, simply click Activate Cloud Shell gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A (it should only take a few moments to provision and connect to the environment).

JjEuRXGg0AYYIY6QZ8d-66gx_Mtc-_jDE9ijmbXLJSAXFvJt-qUpNtsBsYjNpv2W6BQSrDc1D-ARINNQ-1EkwUhz-iUK-FUCZhJ-NtjvIEx9pIkE-246DomWuCfiGHK78DgoeWkHRw

Screen Shot 2017-06-14 at 10.13.43 PM.png

Once connected to Cloud Shell, you should see that you are already authenticated and that the project is already set to your PROJECT_ID.

gcloud auth list

Command output

Credentialed accounts:
 - <myaccount>@<mydomain>.com (active)
gcloud config list project

Command output

[core]
project = <PROJECT_ID>

If, for some reason, the project is not set, simply issue the following command:

gcloud config set project <PROJECT_ID>

Looking for your PROJECT_ID? Check out what ID you used in the setup steps or look it up in the Cloud Console dashboard:

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

Cloud Shell also sets some environment variables by default, which may be useful as you run future commands.

echo $GOOGLE_CLOUD_PROJECT

Command output

<PROJECT_ID>

Download the code

In Cloud Shell, you can download the code for this lab:

git clone https://github.com/cloudspannerecosystem/spanner-gaming-sample.git
cd spanner-gaming-sample/

Command output

Cloning into 'spanner-gaming-sample'...
remote: Enumerating objects: 91, done.
remote: Counting objects: 100% (91/91), done.
remote: Compressing objects: 100% (46/46), done.
remote: Total 91 (delta 39), reused 91 (delta 39), pack-reused 0
Receiving objects: 100% (91/91), 97.12 KiB | 3.74 MiB/s, done.
Resolving deltas: 100% (39/39), done.

Setup Locust load generator

Locust is a Python load testing framework that is useful to test REST API endpoints. In this codelab, we have 2 different load tests in the ‘generators' directory that we will highlight:

  • authentication_server.py: contains tasks to create players, and to get a random player to imitate single point lookups.
  • match_server.py: contains tasks to create games and close games. Creating games will assign 100 random players that aren't currently playing games. Closing games will update games_played and games_won statistics, and allow those players to be assigned to a future game.

To get Locust running in Cloud Shell, you will need Python 3.7 or higher. Cloud Shell comes with Python 3.9, so there's nothing to do but validate the version:

python -V

Command output

Python 3.9.12

Now, you can install the requirements for Locust.

pip3 install -r requirements.txt

Command output

Collecting locust==2.11.1
*snip*
Successfully installed ConfigArgParse-1.5.3 Flask-BasicAuth-0.2.0 Flask-Cors-3.0.10 brotli-1.0.9 gevent-21.12.0 geventhttpclient-2.0.2 greenlet-1.1.3 locust-2.11.1 msgpack-1.0.4 psutil-5.9.2 pyzmq-22.3.0 roundrobin-0.0.4 zope.event-4.5.0 zope.interface-5.4.0

Now, update the PATH so that the newly installed locust binary can be found:

PATH=~/.local/bin":$PATH"
which locust

Command output

/home/<user>/.local/bin/locust

Summary

In this step you have set up your project if you didn't already have one, activated cloud shell, and downloaded the code for this lab.

Lastly, you set up Locust for load generation later in the lab.

Next up

Next, you will set up the Cloud Spanner instance and database.

3. Create a Spanner instance and database

Create the Spanner instance

In this step we set up our Spanner Instance for the codelab. Search for the Spanner entry 1a6580bd3d3e6783.pngin the left top Hamburger Menu 3129589f7bc9e5ce.png or search for Spanner by pressing "/" and type "Spanner"

36e52f8df8e13b99.png

Next, click on 95269e75bc8c3e4d.png and fill out the form by entering the instance name cloudspanner-gaming for your instance, choosing a configuration (select a regional instance such as us-central1), and set the number of nodes. For this codelab we will only need 500 processing units.

Last, but not least, click on "Create" and within seconds you have a Cloud Spanner instance at your disposal.

4457c324c94f93e6.png

Create the database and schema

Once your instance is running, you can create the database. Spanner allows for multiple databases on a single instance.

The database is where you define your schema. You can also control who has access to the database, set up custom encryption, configure the optimizer, and set the retention period.

On multi-regional instances, you can also configure the default leader. Read more about databases on Spanner.

For this code-lab, you will create the database with default options, and supply the schema at creation time.

This lab will create two tables: players and games.

77651ac12e47fe2a.png

Players can participate in many games over time, but only one game at a time. Players also have stats as a JSON data type to keep track of interesting statistics like games_played and games_won. Because other statistics might be added later, this is effectively a schemaless column for players.

Games keep track of the players that participated using Spanner's ARRAY data type. A game's winner and finished attributes are not populated until the game is closed out.

There is one foreign key to ensure the player's current_game is a valid game.

Now create the database by clicking on ‘Create Database' in the instance overview:

a820db6c4a4d6f2d.png

And then fill in the details. The important options are the database name and the dialect. In this example, we named the database sample-game and chose the Google Standard SQL dialect.

For the schema, copy and paste this DDL into the box:

CREATE TABLE games (
  gameUUID STRING(36) NOT NULL,
  players ARRAY<STRING(36)> NOT NULL,
  winner STRING(36),
  created TIMESTAMP,
  finished TIMESTAMP,
) PRIMARY KEY(gameUUID);

CREATE TABLE players (
  playerUUID STRING(36) NOT NULL,
  player_name STRING(64) NOT NULL,
  email STRING(MAX) NOT NULL,
  password_hash BYTES(60) NOT NULL,
  created TIMESTAMP,
  updated TIMESTAMP,
  stats JSON,
  account_balance NUMERIC NOT NULL DEFAULT (0.00),
  is_logged_in BOOL,
  last_login TIMESTAMP,
  valid_email BOOL,
  current_game STRING(36),
  FOREIGN KEY (current_game) REFERENCES games (gameUUID),
) PRIMARY KEY(playerUUID);

CREATE UNIQUE INDEX PlayerAuthentication ON players(email) STORING (password_hash);

CREATE INDEX PlayerGame ON players(current_game);

CREATE UNIQUE INDEX PlayerName ON players(player_name);

Then, click the create button and wait a few seconds for your database to be created.

The create database page should look like this:

d39d358dc7d32939.png

Now, you need to set some environment variables in Cloud Shell to be used later in the code lab. So take note of the instance-id, and set the INSTANCE_ID and DATABASE_ID it in Cloud Shell

f6f98848d3aea9c.png

export SPANNER_PROJECT_ID=$GOOGLE_CLOUD_PROJECT
export SPANNER_INSTANCE_ID=cloudspanner-gaming
export SPANNER_DATABASE_ID=sample-game

Summary

In this step you created a Spanner instance and the sample-game database. You have also defined the schema that this sample game uses.

Next up

Next, you will deploy the profile service to allow players to sign up to play the game!

4. Deploy the profile service

Service overview

The profile service is a REST API written in Go that leverages the gin framework.

4fce45ee6c858b3e.png

In this API, players can sign up to play games. This is created by a simple POST command that accepts a player name, email and password. The password is encrypted with bcrypt and the hash is stored in the database.

Email is treated as a unique identifier, while the player_name is used for display purposes for the game.

This API currently does not handle login, but implementing this can be left to you as an additional exercise.

The ./src/golang/profile-service/main.go file for the profile service exposes two primary endpoints as follows:

func main() {
   configuration, _ := config.NewConfig()

   router := gin.Default()
   router.SetTrustedProxies(nil)

   router.Use(setSpannerConnection(configuration))

   router.POST("/players", createPlayer)
   router.GET("/players", getPlayerUUIDs)
   router.GET("/players/:id", getPlayerByID)

   router.Run(configuration.Server.URL())
}

And the code for those endpoints will route to the player model.

func getPlayerByID(c *gin.Context) {
   var playerUUID = c.Param("id")

   ctx, client := getSpannerConnection(c)

   player, err := models.GetPlayerByUUID(ctx, client, playerUUID)
   if err != nil {
       c.IndentedJSON(http.StatusNotFound, gin.H{"message": "player not found"})
       return
   }

   c.IndentedJSON(http.StatusOK, player)
}

func createPlayer(c *gin.Context) {
   var player models.Player

   if err := c.BindJSON(&player); err != nil {
       c.AbortWithError(http.StatusBadRequest, err)
       return
   }

   ctx, client := getSpannerConnection(c)
   err := player.AddPlayer(ctx, client)
   if err != nil {
       c.AbortWithError(http.StatusBadRequest, err)
       return
   }

   c.IndentedJSON(http.StatusCreated, player.PlayerUUID)
}

One of the first things the service does is set the Spanner connection. This is implemented at the service level to create the session pool for the service.

func setSpannerConnection() gin.HandlerFunc {
   ctx := context.Background()
   client, err := spanner.NewClient(ctx, configuration.Spanner.URL())

   if err != nil {
       log.Fatal(err)
   }

   return func(c *gin.Context) {
       c.Set("spanner_client", *client)
       c.Set("spanner_context", ctx)
       c.Next()
   }
}

The Player and PlayerStats are structs defined as follows:

type Player struct {
   PlayerUUID      string `json:"playerUUID" validate:"omitempty,uuid4"`
   Player_name     string `json:"player_name" validate:"required_with=Password Email"`
   Email           string `json:"email" validate:"required_with=Player_name Password,email"`
   // not stored in DB
   Password        string `json:"password" validate:"required_with=Player_name Email"` 
   // stored in DB
   Password_hash   []byte `json:"password_hash"`                                       
   created         time.Time
   updated         time.Time
   Stats           spanner.NullJSON `json:"stats"`
   Account_balance big.Rat          `json:"account_balance"`
   last_login      time.Time
   is_logged_in    bool
   valid_email     bool
   Current_game    string `json:"current_game" validate:"omitempty,uuid4"`
}

type PlayerStats struct {
   Games_played spanner.NullInt64 `json:"games_played"`
   Games_won    spanner.NullInt64 `json:"games_won"`
}

The function to add the player leverages a DML insert inside a ReadWrite transaction, because adding players is a single statement rather than batch inserts. The function looks like this:

func (p *Player) AddPlayer(ctx context.Context, client spanner.Client) error {
   // Validate based on struct validation rules
   err := p.Validate()
   if err != nil {
       return err
   }

   // take supplied password+salt, hash. Store in user_password
   passHash, err := hashPassword(p.Password)

   if err != nil {
       return errors.New("Unable to hash password")
   }

   p.Password_hash = passHash

   // Generate UUIDv4
   p.PlayerUUID = generateUUID()

   // Initialize player stats
   emptyStats := spanner.NullJSON{Value: PlayerStats{
       Games_played: spanner.NullInt64{Int64: 0, Valid: true},
       Games_won:    spanner.NullInt64{Int64: 0, Valid: true},
   }, Valid: true}

   // insert into spanner
   _, err = client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
       stmt := spanner.Statement{
           SQL: `INSERT players (playerUUID, player_name, email, password_hash, created, stats) VALUES
                   (@playerUUID, @playerName, @email, @passwordHash, CURRENT_TIMESTAMP(), @pStats)
           `,
           Params: map[string]interface{}{
               "playerUUID":   p.PlayerUUID,
               "playerName":   p.Player_name,
               "email":        p.Email,
               "passwordHash": p.Password_hash,
               "pStats":       emptyStats,
           },
       }

       _, err := txn.Update(ctx, stmt)
       return err
   })
   if err != nil {
       return err
   }
   // return empty error on success
   return nil
}

To retrieve a player based on their UUID, a simple read is issued. This retrieves the player playerUUID, player_name, email, and stats.

func GetPlayerByUUID(ctx context.Context, client spanner.Client, uuid string) (Player, error) {
   row, err := client.Single().ReadRow(ctx, "players",
       spanner.Key{uuid}, []string{"playerUUID", "player_name", "email", "stats"})
   if err != nil {
       return Player{}, err
   }

   player := Player{}
   err = row.ToStruct(&player)

   if err != nil {
       return Player{}, err
   }
   return player, nil
}

By default, the service is configured using environment variables. See the relevant section of the ./src/golang/profile-service/config/config.go file.

func NewConfig() (Config, error) {
   *snip*
   // Server defaults
   viper.SetDefault("server.host", "localhost")
   viper.SetDefault("server.port", 8080)

   // Bind environment variable override
   viper.BindEnv("server.host", "SERVICE_HOST")
   viper.BindEnv("server.port", "SERVICE_PORT")
   viper.BindEnv("spanner.project_id", "SPANNER_PROJECT_ID")
   viper.BindEnv("spanner.instance_id", "SPANNER_INSTANCE_ID")
   viper.BindEnv("spanner.database_id", "SPANNER_DATABASE_ID")

   *snip*

   return c, nil
}

You can see that the default behavior is to run the service on localhost:8080.

With this information it is time to run the service.

Run the profile service

Run the service using the go command. This will download dependencies, and establish the service running on port 8080:

cd ~/spanner-gaming-sample/src/golang/profile-service
go run . &

Command output:

[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST   /players                  --> main.createPlayer (4 handlers)
[GIN-debug] GET    /players                  --> main.getPlayerUUIDs (4 handlers)
[GIN-debug] GET    /players/:id              --> main.getPlayerByID (4 handlers)
[GIN-debug] GET    /players/:id/stats        --> main.getPlayerStats (4 handlers)
[GIN-debug] Listening and serving HTTP on localhost:8080

Test the service by issuing a curl command:

curl http://localhost:8080/players \
    --include \
    --header "Content-Type: application/json" \
    --request "POST" \
    --data '{"email": "test@gmail.com","password": "s3cur3P@ss","player_name": "Test Player"}'

Command output:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: <date> 18:55:08 GMT
Content-Length: 38

"506a1ab6-ee5b-4882-9bb1-ef9159a72989"

Summary

In this step, you deployed the profile service that allows players to sign up to play your game, and you tested out the service by issuing a POST api call to create a new player.

Next Steps

In the next step, you will deploy the match-making service.

5. Deploy the match-making service

Service overview

The match-making service is a REST API written in Go that leverages the gin framework.

9aecd571df0dcd7c.png

In this API, games are created and closed. When a game is created, 10 players who are not currently playing a game are assigned to the game.

When a game is closed, a winner is randomly selected and each players' stats for games_played and games_won are adjusted. Also, each player is updated to indicate they are no longer playing and so are available to play future games.

The ./src/golang/matchmaking-service/main.go file for the matchmaking service follows a similar setup and code as the profile service, so it is not repeated here. This service exposes two primary endpoints as follows:

func main() {
   router := gin.Default()
   router.SetTrustedProxies(nil)

   router.Use(setSpannerConnection())

   router.POST("/games/create", createGame)
   router.PUT("/games/close", closeGame)

   router.Run(configuration.Server.URL())
}

This service provides a Game struct, as well as slimmed down Player and PlayerStats structs:

type Game struct {
   GameUUID string           `json:"gameUUID"`
   Players  []string         `json:"players"`
   Winner   string           `json:"winner"`
   Created  time.Time        `json:"created"`
   Finished spanner.NullTime `json:"finished"`
}

type Player struct {
   PlayerUUID   string           `json:"playerUUID"`
   Stats        spanner.NullJSON `json:"stats"`
   Current_game string           `json:"current_game"`
}

type PlayerStats struct {
   Games_played int `json:"games_played"`
   Games_won    int `json:"games_won"`
}

To create a game, the matchmaking service grabs a random selection of 100 players that are not currently playing a game.

Spanner mutations are chosen to create the game and assign the players, since mutations are more performant than DML for large changes.

// Create a new game and assign players
// Players that are not currently playing a game are eligble to be selected for the new game
// Current implementation allows for less than numPlayers to be placed in a game
func (g *Game) CreateGame(ctx context.Context, client spanner.Client) error {
   // Initialize game values
   g.GameUUID = generateUUID()

   numPlayers := 10

   // Create and assign
   _, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
       var m []*spanner.Mutation

       // get players
       query := fmt.Sprintf("SELECT playerUUID FROM (SELECT playerUUID FROM players WHERE current_game IS NULL LIMIT 10000) TABLESAMPLE RESERVOIR (%d ROWS)", numPlayers)
       stmt := spanner.Statement{SQL: query}
       iter := txn.Query(ctx, stmt)

       playerRows, err := readRows(iter)
       if err != nil {
           return err
       }

       var playerUUIDs []string

       for _, row := range playerRows {
           var pUUID string
           if err := row.Columns(&pUUID); err != nil {
               return err
           }

           playerUUIDs = append(playerUUIDs, pUUID)
       }

       // Create the game
       gCols := []string{"gameUUID", "players", "created"}
       m = append(m, spanner.Insert("games", gCols, []interface{}{g.GameUUID, playerUUIDs, time.Now()}))

       // Update players to lock into this game
       for _, p := range playerUUIDs {
           pCols := []string{"playerUUID", "current_game"}
           m = append(m, spanner.Update("players", pCols, []interface{}{p, g.GameUUID}))
       }

       txn.BufferWrite(m)

       return nil
   })

   if err != nil {
       return err
   }

   return nil
}

The random selection of players is done with SQL using the TABLESPACE RESERVOIR capability of GoogleSQL.

Closing a game is slightly more complicated. It involves choosing a random winner amongst the players of the game, marking the time the game is finished, and updating each players' stats for games_played and games_won.

Because of this complexity and the amount of changes, mutations are again chosen to close the game out.

func determineWinner(playerUUIDs []string) string {
   if len(playerUUIDs) == 0 {
       return ""
   }

   var winnerUUID string

   rand.Seed(time.Now().UnixNano())
   offset := rand.Intn(len(playerUUIDs))
   winnerUUID = playerUUIDs[offset]
   return winnerUUID
}

// Given a list of players and a winner's UUID, update players of a game
// Updating players involves closing out the game (current_game = NULL) and
// updating their game stats. Specifically, we are incrementing games_played.
// If the player is the determined winner, then their games_won stat is incremented.
func (g Game) updateGamePlayers(ctx context.Context, players []Player, txn *spanner.ReadWriteTransaction) error {
   for _, p := range players {
       // Modify stats
       var pStats PlayerStats
       json.Unmarshal([]byte(p.Stats.String()), &pStats)

       pStats.Games_played = pStats.Games_played + 1

       if p.PlayerUUID == g.Winner {
           pStats.Games_won = pStats.Games_won + 1
       }
       updatedStats, _ := json.Marshal(pStats)
       p.Stats.UnmarshalJSON(updatedStats)

       // Update player
       // If player's current game isn't the same as this game, that's an error
       if p.Current_game != g.GameUUID {
           errorMsg := fmt.Sprintf("Player '%s' doesn't belong to game '%s'.", p.PlayerUUID, g.GameUUID)
           return errors.New(errorMsg)
       }

       cols := []string{"playerUUID", "current_game", "stats"}
       newGame := spanner.NullString{
           StringVal: "",
           Valid:     false,
       }

       txn.BufferWrite([]*spanner.Mutation{
           spanner.Update("players", cols, []interface{}{p.PlayerUUID, newGame, p.Stats}),
       })
   }

   return nil
}

// Closing game. When provided a Game, choose a random winner and close out the game.
// A game is closed by setting the winner and finished time.
// Additionally all players' game stats are updated, and the current_game is set to null to allow
// them to be chosen for a new game.
func (g *Game) CloseGame(ctx context.Context, client spanner.Client) error {
   // Close game
   _, err := client.ReadWriteTransaction(ctx,
       func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
           // Get game players
           playerUUIDs, players, err := g.getGamePlayers(ctx, txn)

           if err != nil {
               return err
           }

           // Might be an issue if there are no players!
           if len(playerUUIDs) == 0 {
               errorMsg := fmt.Sprintf("No players found for game '%s'", g.GameUUID)
               return errors.New(errorMsg)
           }

           // Get random winner
           g.Winner = determineWinner(playerUUIDs)

           // Validate game finished time is null
           row, err := txn.ReadRow(ctx, "games", spanner.Key{g.GameUUID}, []string{"finished"})
           if err != nil {
               return err
           }

           if err := row.Column(0, &g.Finished); err != nil {
               return err
           }

           // If time is not null, then the game is already marked as finished. 
           // That's an error.
           if !g.Finished.IsNull() {
               errorMsg := fmt.Sprintf("Game '%s' is already finished.", g.GameUUID)
               return errors.New(errorMsg)
           }

           cols := []string{"gameUUID", "finished", "winner"}
           txn.BufferWrite([]*spanner.Mutation{
               spanner.Update("games", cols, []interface{}{g.GameUUID, time.Now(), g.Winner}),
           })

           // Update each player to increment stats.games_played 
           // (and stats.games_won if winner), and set current_game 
           // to null so they can be chosen for a new game
           playerErr := g.updateGamePlayers(ctx, players, txn)
           if playerErr != nil {
               return playerErr
           }

           return nil
       })

   if err != nil {
       return err
   }

   return nil
}

Configuration is again handled via environment variables as described in the ./src/golang/matchmaking-service/config/config.go for the service.

   // Server defaults
   viper.SetDefault("server.host", "localhost")
   viper.SetDefault("server.port", 8081)

   // Bind environment variable override
   viper.BindEnv("server.host", "SERVICE_HOST")
   viper.BindEnv("server.port", "SERVICE_PORT")
   viper.BindEnv("spanner.project_id", "SPANNER_PROJECT_ID")
   viper.BindEnv("spanner.instance_id", "SPANNER_INSTANCE_ID")
   viper.BindEnv("spanner.database_id", "SPANNER_DATABASE_ID")

To avoid conflicts with the profile-service, this service runs on localhost:8081 by default.

With this information, it is now time to run the matchmaking service.

Run the match-making service

Run the service using the go command. This will establish the service running on port 8082. This service has many of the same dependencies as the profile-service, so new dependencies will not be downloaded.

cd ~/spanner-gaming-sample/src/golang/matchmaking-service
go run . &

Command output:

[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST   /games/create             --> main.createGame (4 handlers)
[GIN-debug] PUT    /games/close              --> main.closeGame (4 handlers)
[GIN-debug] Listening and serving HTTP on localhost:8081

Create a game

Test the service to create a game. First, open a new terminal in the Cloud Shell:

90eceac76a6bb90b.png

Then, issue the following curl command:

curl http://localhost:8081/games/create \
    --include \
    --header "Content-Type: application/json" \
    --request "POST"

Command output:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: <date> 19:38:45 GMT
Content-Length: 38

"f45b0f7f-405b-4e67-a3b8-a624e990285d"

Close the game

curl http://localhost:8081/games/close \
    --include \
    --header "Content-Type: application/json" \
    --data '{"gameUUID": "f45b0f7f-405b-4e67-a3b8-a624e990285d"}' \
    --request "PUT"

Command output:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: <date> 19:43:58 GMT
Content-Length: 38

"506a1ab6-ee5b-4882-9bb1-ef9159a72989"

Summary

In this step, you deployed the matchmaking-service to handle creating games and assigning players to that game. This service also handles closing out a game, which picks a random winner and updates all the game players' stats for games_played and games_won.

Next Steps

Now that your services are running, it's time to get players signing up and playing games!

6. Start playing

Now that the profile and matchmaking services are running, you can generate load using provided locust generators.

Locust offers a web-interface for running the generators, but in this lab you will use the command line (–headless option).

Sign up players

First, you will want to generate players.

The python code to create players in the ./generators/authentication_server.py file looks like this:

class PlayerLoad(HttpUser):
   def on_start(self):
       global pUUIDs
       pUUIDs = []

   def generatePlayerName(self):
       return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))

   def generatePassword(self):
       return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))

   def generateEmail(self):
       return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32) + ['@'] +
           random.choices(['gmail', 'yahoo', 'microsoft']) + ['.com'])

   @task
   def createPlayer(self):
       headers = {"Content-Type": "application/json"}
       data = {"player_name": self.generatePlayerName(), "email": self.generateEmail(), "password": self.generatePassword()}

       with self.client.post("/players", data=json.dumps(data), headers=headers, catch_response=True) as response:
           try:
               pUUIDs.append(response.json())
           except json.JSONDecodeError:
               response.failure("Response could not be decoded as JSON")
           except KeyError:
               response.failure("Response did not contain expected key 'gameUUID'")

Player names, emails and passwords are randomly generated.

Players that are successfully signed up will be retrieved by a second task to generate read load.

   @task(5)
   def getPlayer(self):
       # No player UUIDs are in memory, reschedule task to run again later.
       if len(pUUIDs) == 0:
           raise RescheduleTask()

       # Get first player in our list, removing it to avoid contention from concurrent requests
       pUUID = pUUIDs[0]
       del pUUIDs[0]

       headers = {"Content-Type": "application/json"}

       self.client.get(f"/players/{pUUID}", headers=headers, name="/players/[playerUUID]")

The following command calls the ./generators/authentication_server.py file that will generate new players for 30s (t=30s) with a concurrency of two threads at a time (u=2):

cd ~/spanner-gaming-sample
locust -H http://127.0.0.1:8080 -f ./generators/authentication_server.py --headless -u=2 -r=2 -t=30s

Players join games

Now that you have players signed up, they want to start playing games!

The python code to create and close games in the ./generators/match_server.py file looks like this:

from locust import HttpUser, task
from locust.exception import RescheduleTask

import json

class GameMatch(HttpUser):
   def on_start(self):
       global openGames
       openGames = []

   @task(2)
   def createGame(self):
       headers = {"Content-Type": "application/json"}

       # Create the game, then store the response in memory of list of open games.
       with self.client.post("/games/create", headers=headers, catch_response=True) as response:
           try:
               openGames.append({"gameUUID": response.json()})
           except json.JSONDecodeError:
               response.failure("Response could not be decoded as JSON")
           except KeyError:
               response.failure("Response did not contain expected key 'gameUUID'")


   @task
   def closeGame(self):
       # No open games are in memory, reschedule task to run again later.
       if len(openGames) == 0:
           raise RescheduleTask()

       headers = {"Content-Type": "application/json"}

       # Close the first open game in our list, removing it to avoid 
       # contention from concurrent requests
       game = openGames[0]
       del openGames[0]

       data = {"gameUUID": game["gameUUID"]}
       self.client.put("/games/close", data=json.dumps(data), headers=headers)

When this generator is run, it will open and close games at a ratio 2:1 (open:close). This command will run the generator for 10 seconds (-t=10s):

locust -H http://127.0.0.1:8081 -f ./generators/match_server.py --headless -u=1 -r=1 -t=10s

Summary

In this step, you simulated players signing up to play games and then ran simulations for players to play games using the matchmaking service. These simulations leveraged the Locust Python framework to issue requests to our services' REST api.

Feel free to modify the time spent creating players and playing games, as well as the number of concurrent users (-u).

Next Steps

After the simulation, you will want to check on various statistics by querying Spanner.

7. Retrieve game statistics

Now that we have simulated players being able to sign up and play games, you should check on your statistics.

To do this, use Cloud Console to issue query requests to Spanner.

b5e3154c6f7cb0cf.png

Checking open vs closed games

A closed game is one that has the finished timestamp populated, while an open game will have finished being NULL. This value is set when the game is closed.

So this query will all you to check how many games are open and how many are closed:

SELECT Type, NumGames FROM
(SELECT "Open Games" as Type, count(*) as NumGames FROM games WHERE finished IS NULL
UNION ALL
SELECT "Closed Games" as Type, count(*) as NumGames FROM games WHERE finished IS NOT NULL
)

Result:

Type

NumGames

Open Games

0

Closed Games

175

Checking amount of players playing vs not playing

A player is playing a game if their current_game column is set. Otherwise, they are not currently playing a game.

So to compare how many players are currently playing and not playing, use this query:

SELECT Type, NumPlayers FROM
(SELECT "Playing" as Type, count(*) as NumPlayers FROM players WHERE current_game IS NOT NULL
UNION ALL
SELECT "Not Playing" as Type, count(*) as NumPlayers FROM players WHERE current_game IS NULL
)

Result:

Type

NumPlayers

Playing

0

Not Playing

310

Determine top winners

When a game is closed, one of the players is randomly selected to be the winner. That player's games_won statistic is incremented during closing out the game.

SELECT playerUUID, stats
FROM players
WHERE CAST(JSON_VALUE(stats, "$.games_won") AS INT64)>0
LIMIT 10;

Result:

playerUUID

stats

07e247c5-f88e-4bca-a7bc-12d2485f2f2b

{"games_played":49,"games_won":1}

09b72595-40af-4406-a000-2fb56c58fe92

{"games_played":56,"games_won":1}

1002385b-02a0-462b-a8e7-05c9b27223aa

{"games_played":66,"games_won":1}

13ec3770-7ae3-495f-9b53-6322d8e8d6c3

{"games_played":44,"games_won":1}

15513852-3f2a-494f-b437-fe7125d15f1b

{"games_played":49,"games_won":1}

17faec64-4f77-475c-8df8-6ab026cf6698

{"games_played":50,"games_won":1}

1abfcb27-037d-446d-bb7a-b5cd17b5733d

{"games_played":63,"games_won":1}

2109a33e-88bd-4e74-a35c-a7914d9e3bde

{"games_played":56,"games_won":2}

222e37d9-06b0-4674-865d-a0e5fb80121e

{"games_played":60,"games_won":1}

22ced15c-0da6-4fd9-8cb2-1ffd233b3c56

{"games_played":50,"games_won":1}

Summary

In this step, you reviewed various statistics of players and games by using the Cloud Console to query Spanner.

Next Steps

Next, it is time to clean up!

8. Cleaning up (optional)

To clean up, just go into the Cloud Spanner section of the Cloud Console and delete the ‘cloudspanner-gaming' instance we created in the codelab step named "Setup a Cloud Spanner Instance".

9. Congratulations!

Congratulations, you have successfully deployed a sample game on Spanner

What's next?

In this lab, you have been introduced to various topics of working with Spanner using the golang driver. It should give you a better foundation to understand critical concepts such as:

  • Schema design
  • DML vs Mutations
  • Working with Golang

Be sure to take a look at the Cloud Spanner Game Trading Post codelab for another example of working with Spanner as a backend for your game!