Open Matchでスプラトゥーンのような4vs4マッチメイキングをラクラク動かして理解を深める

グレンジでサーバー・インフラを担当している[スプラシューターベッチュー](https://twitter.com/SplatoonJP/status/1040371548153044992)がお気に入りの村田です  &#x1f991; &#x1f419;
モノクロかっこいい

[グレンジ Advent Calendar 2018](https://qiita.com/advent-calendar/2018/grenge) 9日目の記事になります。
弊社で導入を検討しているOpen Matchをラクラク動かし、Open Matchを理解する足がかりになればといいな思っています。

※本記事は、[Open Match v0.1.0-alphaタグ](https://github.com/GoogleCloudPlatform/open-match/tree/v0.1.0-alpha) のバージョンに基づく内容になっています。
※ラクラク動かすことを目的としているのでコンポーネント等の詳細な説明を省いています。
※サミールさんが、先日[0.2.0ベースの資料](https://www.slideshare.net/HammoudiSamir/open-match-deep-dive)を展開してくれました。 コンポーネントなど深掘りするならこの資料もご確認ください。

# Open Match
UnityとGoogle Cloudの共同プロジェクト「Open Match」
ゲームエンジンに依存しないマッチメイキングのオープンソースプロジェクトです。
[ハロウィン2018 Doodle](https://www.google.com/doodles/halloween-2018) で使われていたのもこのOpen Match。
Kubernetesで動作してスケーラブルであり、開発者はマッチメイキングのロジックを作り込むだけで良いような構造になっています。

要は、スプラトゥーンみたいなイカした4vs4のマッチメイキングをガチで作れたりします。
プレイヤーがガチパワーをセットしてマッチングをリクエストすると、

![openmatch1.png](https://qiita-image-store.s3.amazonaws.com/0/18212/33cba9ce-1683-eb13-2c29-2bbe994dbec4.png)

ガチパワーの近いプレイヤーとマッチングを行い、チーム分けや接続するゲームサーバー名、ルーム番号を受け取れるような仕組みを作れます。

![openmatch2.png](https://qiita-image-store.s3.amazonaws.com/0/18212/3df83ded-471f-d8e0-610c-003171ff3036.png)

# ラクラク動かしてOpen Matchを理解する足がかりに

今回はガチで作りません。
サンプルプログラムをラクラク変更して簡単な4対4のマッチメイキングを実現します。
プレイヤーのマッチングリクエスト送信、マッチング結果取得まで確認できれば、Open Matchの全体像を掴みやすく理解する足がかりになると思います。

# マッチメイキング仕様

- 4vs4
- チーム参加無し、ソロ参加のみ
- ブキ判定無し
- 一定範囲のガチパワーのプレイヤーをマッチング
- Open Match側でゲームサーバー名とルーム番号を決定

# 開発者が主に作る部分について解説

- フロントクライアント
- バックエンドクライアント
- MMF（マッチメイキングファンクション）

この3つです。

## フロントクライアントとは

Unityのプログラムなど、ゲームアプリに該当します。

Open MatchのFront APIを実行して、マッチングリクエストを送信します。
Front APIはgRPCで提供されています。

## バックエンドクライアントとは

バックエンドのサーバーで動作させるプログラム。

Open MatchのBackend APIを実行して、プロファイル（MMFの設定値みたいなもの）をセットしてマッチメイキング開始の指示出しを行います。この指示出しにより、MMFが起動します。
Backend APIもgRPCで提供されます。

またバックエンドクライアントは、フロントエンドクライアントにマッチング結果を渡す役割も担います。
今回は、MMFが作成したチーム分けにゲームサーバー名とルーム番号を加え、それらをフロントクライアントに渡すことにします。

## MMF（マッチメイキングファンクション）とは

プレイヤーのマッチングを行うプログラムです。

フロントクライアントから渡されたパラメータと、バックエンドクライアントから渡されたプロファイルを元にマッチングさせます。
フロントのパラメータとバックエンドのプロファイルは共にRedisにセットされています。
マッチング結果となるチーム分けもRedisにセットすればOKです。

MMFはdockerのコンテナで作成します。
コンテナで動作するなら、プログラムはどんな言語でも書いても構いません。
v0.1.0-alphaではC#のサンプルもあります。

# サンプルプログラムをラクラク変更

## フロントクライアント

examples/frontendclient にあり、ターミナルで実行します。
しかし、バックエンドクライアントとMMFのサンプルと連携できておらず、このままだとマッチング結果を受け取ることができません。
main.goを数ヶ所変更して連携させます。

### ソロ参加に変更

4人1グループでマッチングリクエストを出していますが、1人1グループのソロにします。

```examples/frontendclient/main.go
 func main() {

        // determine number of players to generate per group
-       numPlayers := 4 // default if nothing provided
+       numPlayers := 1 // default if nothing provided
        var err error
```

### プレイヤーデータ変更

マッチングリクエストで送信しているプレイヤーデータをまるっと変更します。
ガチエリアに参加、ガチパワーは1500〜2000のランダムとします。

```examples/frontendclient/main.go
        // Generate players for the group and put them in
        for i := 0; i < numPlayers; i++ {
                playerID, playerData, debug := player.Generate()
+               playerData = make(map[string]int)
+               playerData["battletype"] = 1 // ガチエリア的な
+               playerData["gachipower"] = int(1500 + rand.Float32() * 500)
                groupPlayer(g, playerID, playerData)
```

### マッチング結果受信待ちのキーを変更

GetAssignmentはマッチング結果の受信待ちになるFront APIです。
このAPIは、グループID(ソロなのでプレイヤーIDと同意)のキーのconnstringフィールドにマッチング結果がセットされるのをポーリングしています。
サンプルプログラムでは、グループIDを仮の「bitters」とし、connstringフィールドをredis-cli等で手動で書きかえて受信できることを確認しているだけです。
受信待ちキーをリクエストで送信したグループID(プレイヤーID)に変更します。

```examples/frontendclient/main.go
        // wait for value to  be inserted that will be returned by Get ssignment
-       test := "bitters"
-       fmt.Println("Pausing: go put a value to return in Redis using HSET", test, "connstring <YOUR_TEST_STRING>")
-       fmt.Println("Hit Enter to test GetAssignment...")
-       reader := bufio.NewReader(os.Stdin)
-       _, _ = reader.ReadString('\n')
+       test := g.Id
        connstring, err := client.GetAssignment(ctx, &frontend.PlayerId{Id: test})
+       ppJSON(connstring.ConnectionString)
        pretty.PrettyPrint(connstring.ConnectionString)
```

またマッチング結果として受け取ったJSONを標準出力させます。以下の様なデータを受け取ることがゴールです。

```
2018/12/05 23:21:17 {
  "red": [
    "937bec2f2a2f443bbbd2aaba96609107",
    "99800ac7bc4343ad9f77d364b4943e85",
    "d8daa32ea226405aa2628b58a73a98b4",
    "d9b72c00007f4a2c99047b3fc0f57a1a"
  ],
  "blue": [
    "685c68acae1e4dc9affd14bae25cf87c",
    "693ca010afba4db48bdcbe768d9f12da",
    "7520c0c3517d4bc5bbdc8305ee7a8c64",
    "8e9b20e35017427caa855a783beac394"
  ],
  "gameServer": "example.com:12345",
  "roomId": 3520882902
}
```

## バックエンドクライアント

### GameRoom構造体の定義を追加

MMFが作成したチーム分けRed、BlueをJSONデコードするための構造体です。
Red、BlueはグループID(プレイヤーID)の配列です。
また、フロントクライアントに渡すためにゲームサーバーとルームIDのメンバも追加しています。

```examples/backendclient/main.go
+type GameRoom struct {
+       Red        []string `json:"red"`
+       Blue       []string `json:"blue"`
+       GameServer string   `json:"gameServer"`
+       RoomId     uint32   `json:"roomId"`
+}
```

### MMFのマッチング結果が無しでも終了させない

マッチング開始指示・取得をループしています。
しかし、マッチング結果が無しだとfatalで終了して再起動させるのが面倒なので変更します。

```examples/backendclient/main.go
                        match, err := stream.Recv()
                        if err == io.EOF {
                                break
                        }
                        if err != nil {
-                               log.Fatalf("Error reading stream for ListMatches(_) = _, %v", err)
+                               log.Printf("Error reading stream for ListMatches(_) = _, %v", err)
                                break
                        }
```

### マッチング結果として渡す情報変更

サンプルプログラムは、マッチング結果として接続すべきゲームサーバー "example.com:12345" を渡すだけですが、
先ほど定義したGameRoom構造体を渡すように変更します。

まずMMFが作成した赤青チーム分けのJSONをデコード。
ゲームサーバーは仮で"example.com:12345" とします。
受け入れ可能なゲームサーバーはセットするイメージです。

ルーム番号もチーム分けJSONからハッシュ値を求めてセットします。
バトル中のルーム番号がユニークになればOK。

```examples/backendclient/main.go
                        roster := &backend.Roster{PlayerIds: playerstr}
-                       ci := &backend.ConnectionInfo{ConnectionString: ""}
+                       var gr GameRoom
+                       // 赤青チーム分けJSONをデコード
+                       json.Unmarshal([]byte(result.Raw), &gr)
+                       gr.GameServer = "example.com:12345"
+
+                       // 赤青チーム分けJSONからハッシュ値作成
+                       h := crc32.NewIEEE()
+                       h.Write([]byte(result.Raw))
+                       gr.RoomId = h.Sum32()
+
+                       bytes, _ := json.Marshal(&gr)
+                       ci := &backend.ConnectionInfo{ConnectionString: string(bytes)}
```

### MMFに渡すプロファイラを変更

サンプルプログラムのMMFは、properties.playerPoolのキーで指定したパラメータからプレイヤーを絞ってマッチングします。
battletypeは1、gachipowerは1500〜2000に該当するプレイヤーをマッチングさせるよう変更します。

```examples/backendclient/profiles/testprofile.json
 {
    "name":"testprofile",
    "properties":{
       "playerPool":{
-         "region.europe-west1":"0-150",
-         "mmr.rating":"950-1650",
-         "mode.ctf":"0-9999999999"
+         "battletype":"1-1",
+         "gachipower":"1500-2000"
       },
       "roster":{
          "blue":4,
          "red":4
       },
    }
 }
```

## MMF

examples/functions/golang/simpleをそのまま使います。
変更しません。

# デプロイと実行

## Google Cloud Builderでビルド、Kubernetesデプロイ

frontapiは外部ネットワークから利用したいので、type LoadBalancerを指定してEXTERNAL IPを持たせます。

```deployments/k8s/frontendapi_service.json
 {
   "kind": "Service",
   "apiVersion": "v1",
   "metadata": {
     "name": "om-frontendapi"
   },
   "spec": {
     "selector": {
       "app": "openmatch",
       "component": "frontend"
     },
     "ports": [
       {
         "protocol": "TCP",
         "port": 50504,
         "targetPort": "grpc"
       }
-    ]
+    ],
+    "type": "LoadBalancer"
   }
 }
```


あとは [docs/development.md](https://github.com/GoogleCloudPlatform/open-match/blob/v0.1.0-alpha/docs/development.md) の下記4項目に従いビルドします。

- Example of building using Google Cloud Builder
- Example of starting a GKE cluster
- Configuration
- Running Open Match in a development environment

Open Matchのコアコンポーネントが実行され、サンプルプログラムのMMFコンテナイメージも登録されます。

※簡単に動作確認をするだけなので、GKE clusterはシングルゾーンにノード1つだけで構いません。

```
% gcloud container clusters create --machine-type n1-standard-4 open-match-dev-cluster --zone asia-east1-b --num-nodes=1
```

※optionalのOpenCensusやPrometheusのデプロイは不要です。

## バックエンドクライアントのビルドと実行

バックエンドクライアントはGKEのクラスターで実行します。

```
% cd examples/backendclient
% gcloud builds submit --config cloudbuild.yaml
% kubectl run backendclient --image-pull-policy=Always --image=gcr.io/colorful-development/openmatch-backendclient:dev
```

## フロントエンドクライアントの実行

フロントエンドクライアントはローカルのターミナルで実行します。om-frontendapiのEXTERNAL-IPを確認。

```
% kubectl get svc om-frontendapi
NAME             TYPE           CLUSTER-IP      EXTERNAL-IP      PORT(S)           AGE
om-frontendapi   LoadBalancer   10.55.253.150   35.194.249.159   50504:32071/TCP   2m
```

/etc/hostsに書き加えます。

```
sudo vi /etc/hosts
35.194.249.159  om-frontendapi
```

ターミナルを8つ開いて、フロントクライアントを8つ実行。

```
% cd examples/frontendclient
% go run main.go
```

画面上はフロントクライアント、画面下はバックエンドクライアントのログです。フロント側で、red、blueのチーム分けやgameServerやroomIdが確認できればマッチング成功です。

![openmatch3.gif](https://qiita-image-store.s3.amazonaws.com/0/18212/b18367fd-6cae-fa7b-7948-4bd5f41bd603.gif)

# おわりに

OpenMatchをラクラク？動かすことができました。
redis-sentinel serviceにEXTERNAL IPを持たせてmonitorしたり、他のpodのログも眺めるとOpen Matchを理解しやすくなると思います。
[サミールさんの資料](https://www.slideshare.net/HammoudiSamir/open-match-deep-dive)も必見です。