Game Player Matchmaking（GPM）でチャット作った！①

# チャットを作るまでの経緯
ふと社内で Tencent Cloud を試す機会がやってきました・・！
Tencent Cloud では色々なプロダクトが提供されていますが、その中でも筆者が目を引いたのは<b>「[Game Player Matchmaking](https://intl.cloud.tencent.com/product/gpm)（以下GPM）」</b>！！！。

ゲーム運営に携わる中で、マルチ対戦のようなネットワーク機能にあまり触れなかったことが目を引いた要因です。
せっかく試す機会が訪れたので、「GPMを使って何か形のあるモノを作ろう！チャットなら出来るかも！」という想いでチャットを作ることにしました。

##### 本記事では2回に分けてチャットの制作と使用したサービスについて紹介し、ゲームへ適応することを考えていこうと思います！
- <b>1回目（本記事）ではGPMの主要な機能と最小構成で作るチャットについて紹介します。</b>
- 2回目<b>[「Game Player Matchmaking（GPM）でチャット作った！②」](https://qiita.com/mynet-eng/items/43b2ed5173c6ffa3248f)</b>では <b>Game Server Elastic-scaling（GSE）</b>という別のサービスとGPMを連携したチャットの完成系と、ゲームサービスへの適応について紹介します。</b>

# GPMとは？
####「ゲームプレイヤー同士のマッチング処理をサービス化」
したものと言えます。
例えば以下に挙げた<b>「マッチング処理」</b>の部分を担当してくれます。

 - 格闘ゲームのネットワーク対戦において対戦相手を決める<b>「マッチング処理」</b>
 - スマートフォンアプリのGvGで同ランク帯のギルドを対戦させる<b>「マッチング処理」</b>

仮にプログラムで実装したなら、複雑なソースコードを書くことになるはずです。
しかしGPMでは<b>「ゲームプレイヤーの情報を渡す」「ルールをjsonで定義する」</b>だけで簡単にマッチングを実現してくれます！


# 今回作ったチャットの全体像
詳細については2回の記事に分けて紹介します。
GPMを利用して作成したチャットの全体構成は下図となります。
![全体像.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2127401/e0690af8-7e8f-3b5f-6594-38037d169389.jpeg)


## GPMで出来ること
今回利用するGPMについて初めて耳にする方もいると思うので、もう少し詳細に紹介します！

### ルール
GPMにおけるルールとは<b>「プレイヤー同士がどのような条件でマッチングされるか」を json で定義したもの</b>です。
GPMはこのルール定義に従ってマッチングを行います。

GPMが提供するデフォルト（最低限必要な）ルールは以下となります。


```json:default_rule.json
{
	"version": "v1.0",
	"teams": [
		{
			"name": "red",
			"minPlayers": 1,
			"maxPlayers": 1
		},
		{
			"name": "white",
			"minPlayers": 1,
			"maxPlayers": 1
		}
	]
}
```

まだピンと来ない方もいると思うので、筆者なりの解釈を表にしました。

| キー名 | 意味 |
| --- |  --- |
| version | バージョン情報。執筆時点でv1.0以外は指定不可 |
| teams | マッチングの対象となるグループのような概念。 |
| name | チーム名 |
| minPlayers | チームを構成する最低プレイヤー数 |
| maxPlayers | チームを構成する最大プレイヤー数 |
<b>「teams」</b>というキーで定義する配列がマッチングの対象となる単位です。
デフォルトルールは「redチーム（構成プレイヤー1名）vs whiteチーム（構成プレイヤー数1名）」でマッチングを行います。
例えば格闘ゲームのネットワーク対戦は1プレイヤー vs 1プレイヤーなので上記のルールに当てはまります。

もう少し例を挙げます。

以下は「guildA（構成プレイヤー1~20名）vs guildB（構成プレイヤー1~20名）」のマッチングを行うルールです。
「guildA」「guildB」はゲームにおけるギルドを表します。
1プレイヤーでもギルドが成立する状況下では、ギルドの人数差があってもGPMはマッチングを行います。

```json:e.g.)20vs20
{
	"version": "v1.0",
	"teams": [
		{
			"name": "guildA",
			"minPlayers": 1,
			"maxPlayers": 20
		},
		{
			"name": "guildB",
			"minPlayers": 1,
			"maxPlayers": 20
		}
	]
}
```

以下は「50人のプレイヤーが参加するバトルロワイヤル」のマッチングを想定したルールです。
チームは50人の単独プレイヤーで構成され、自分以外は敵となります。

```json:e.g.)1vs1vs...vs1
{
	"version": "v1.0",
	"teams": [
		{
			"name": "player1",
			"minPlayers": 1,
			"maxPlayers": 1
		},
		{
			"name": "player2",
			"minPlayers": 1,
			"maxPlayers": 1
		},
		（中略）
		{
			"name": "player50",
			"minPlayers": 1,
			"maxPlayers": 1
		}
	]
}
```
このようにルールはゲームの要件に合わせてjsonで定義するだけで柔軟に設定可能です。
その他の特徴として<font color="Red"><b>「teams」配列で指定した全てのチームが揃って初めてマッチングが成立</b></font>します。
バトルロワイヤルの例では50名のプレイヤーが揃わない限りマッチングは行われません。

### マッチ
GPMのマッチは<b>マッチングの設定を行います。</b>
作成したルールの関連付けや、マッチングまでのタイムアウトなどの設定を行うことでマッチングが可能となります。

以下は Tencent Cloud のコンソールでマッチを作成する際のスクリーンショットです。
![スクリーンショット 2021-10-14 235624.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2127401/cb7f7892-f472-e5c7-96c3-850125f15f81.png)

ルールと同様に筆者の解釈を表にしました。

| キー名 | 意味 |
| --- | --- |
| Name| マッチの名前 |
| Description | マッチの説明 |
| Associate Rule | 関連付けるルール。このルールに則ってマッチングを行う。 |
| Timeout| マッチングの待ち時間 |
| Notification Address| イベント通知先のURL |

<b>「Associate Rule」</b>は作成したルールを関連付けます。
関連付けるとマッチはそのルールに則ってマッチングを行います。

<b>「Timeout」</b>はマッチングを開始してから何秒間待つかの設定です。
仮に30で設定すると、マッチングを開始して30秒以内に相手が見つからなければタイムアウトとなります。

<b>「Notification Address」</b>は開始したマッチングの状態を指定したURLに送信する設定です。
これは<font color=Red><b>GPMのイベント通知機能で、GPMは成立やタイムアウトなどマッチングの状態が変化する度にイベント通知</b></font>を行います。

以下はイベント通知のサンプルです。
マッチングが成立すると MatchTickets.Status の部分がCOMPLETEDに変化します。

```js:event_notification_sample
{ Time: '2021-10-15T13:54:22Z',
  Account: '200017500577',
  Region: 'ap-shanghai',
  Source: 'TencentCloud.GPM',
  Type: 'MatchmakingSearching',
  Id: '7lh3woX',
  MatchTickets:
   [ { Id: 'ekuO17j',
       MatchCode: 'match-xxxxxxxx',
       MatchResult: '',
       MatchType: 'GSE',
       Players: [Array],
       Status: 'SEARCHING',
       StatusMessage: '',
       StatusReason: '',
       StartTime: '2021-10-15T13:54:22Z',
       EndTime: '',
       TicketType: 0,
       GameServerSessionId: '' } ],
  CustomEventPushData: '',
  MatchTokens:
   [ 'cad4e2e6-2dbe-11ec-96f9-525400db36db',
     '17e05e67-2dbe-11ec-a00b-52540074594e' ] }
```

今回はこのイベント通知の仕組みを使ってマッチングの状態を取得し、チャットを作成します。

### Matchmaking Process APIs
ルールで条件を設定し、マッチで試合の設定が終わるとマッチングのリクエストが可能です。
GPMでは<b>「Matchmaking Process APIs」</b>と呼ばれるマッチングの操作を行うAPIが用意されているのでこれを利用します。
<font color=Red><b>※また Tencent Cloud では「API Explorer」（コンソール上でパラメータを指定し、リクエストを検証出来るサービス）が提供されているので、手軽にAPIを試すことが可能です。</b></font>

各種APIについて筆者の解釈を表にしました。

| API | 機能 |
| --- | --- |
| StartMatching | マッチングを開始する |
| StartMatchingBackfill | ※[「Game Player Matchmaking（GPM）でチャット作った！②」](https://qiita.com/mynet-eng/items/43b2ed5173c6ffa3248f)</b>で紹介します |
| ModifyToken | マッチングの認証を行うトークンを発行する |
| DescribeToken | 最後に発行されたマッチング認証トークンを取得する |
| DescribeMatchingProgress | マッチチケットIDで指定したマッチングのステータスを取得する |
| CancelMatching | マッチングをキャンセルする |

<b>「StartMatching」</b>はその名の通りマッチングをリクエストします。
マッチングのリクエストは全てチケットという概念で管理され、チケットが発行されるとそのIDが付与されます。

<b>「CancelMatching」</b>は「StartMatching」で開始したマッチングを途中でキャンセルします。

<b>「ModifyToken」</b>はイベント通知されたマッチング結果を検証するためのトークンを発行します。
トークンは上述したイベント通知の <b>MatchTokens</b> というキーに付与されます。
トークンを使用することで、GPMを偽装したイベント通知の検証が可能となり、セキュリティ対策として必須です。

<b>「DescribeToken」</b>は ModifyToken で最後に発行されたトークンを取得します。
DescribeToken から取得したトークンと、イベント通知の MatchTokens の値を検証することマッチング情報の偽装を排除します。

以上がGPMの主な機能です！それではさっそくチャットを構成していきます！

# 開発環境
始めに作成したチャットの開発環境から紹介します。

### ■クライアント（Unity）
必須なツールは各サーバとの通信で使用している<b>「WebSocketSharp」のみ</b>です。
その他のライブラリはお好みで！

| ツール | バージョン |
|---|---|
| Unity | 2020.3.19f1 |
| UniRx | 7.1.0 | 
| extenject | 9.2.0 |
| WebSocketSharp | [WebSocketSharpのリポジトリ](https://github.com/sta/websocket-sharp)から手動ビルド |

### ■マッチングサーバ（node.js）
クライアントから要求されたマッチングの管理を担当するサーバ。
GPMへマッチングをリクエストするため<b>「tencentcloud-sdk-nodejs」</b>を入れています。

| ツール | バージョン |
|---|---|
| TencentOS Server(CVM) | 3.1 |
| git(TencentOS-AppStream) | 2.27 |
| nodejs | 10.21.0|
| express | 4.17.1 |
| express-ws | 5.0.2 |
| node-cron | 3.0.0 |
| tencentcloud-sdk-nodejs | 4.0.212 |


### ■チャットサーバ（Unity）
マッチング成立後、マッチングされたプレイヤー間でチャットをするためのサーバです。
クライアントと同じ「WebSocketSharp」を使用してチャットメッセージをやり取りします。

| ツール | バージョン |
|---|---|
| Unity | 2020.3.19f1 |
| WebSocketSharp | [WebSocketSharpのリポジトリ](https://github.com/sta/websocket-sharp)から手動ビルド |
| grpc_unity_package | 2.42.0 |
| grpc-protoc_windows_x64 | 1.42.0 |


# 最小構成から作るチャット
チャットを作る際、一番最初に考えた最小構成から紹介します！
（※実際に最初はこのような構成でしたｗ）

以下が最初に考えたサーバ1台 + GPMで完結する構成です。
![最低限のチャット.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2127401/f81c82da-46a0-6b16-17ed-70f96ab50a99.jpeg)

#### 実装全てを紹介するのは難しいので各通信部分について紹介します。

#### ①WebSocket部分
クライアント⇔サーバ間をWebSocketで双方向通信します。
主な用途はチャットのブロードキャストとマッチングのリクエスト制御です。

クライアントはWebSocketSharp、サーバはexpress-wsを使ってコネクションを張ります。
コネクションを張った後は json 形式のメッセージでやり取ります。

```json:request_json
{
	"id": "595c644a-7519-4c5f-8205-8795bea7601d",
	"name": "名無しさん",
	"action": "MATCH",
	"message": ""			
}
```
各パラメータはUnityで以下のように実装しています。

| パラメータ | 内容 |
| --- | --- |
| id | プレイヤーを特定するID。<br>C#のGuid.NewGuid()で生成してPlayerPrefsに保持。 |
| name | チャットに表示するユーザー名（InputFieldで入力） |
| action | サーバに要求するアクション文字列。<br>マッチングなら"MATCH"、チャットなら"CHAT"のように指定 |
| message | チャットのメッセージ（マッチングの時はempty） |

サーバは受け取ったactionに応じて処理を切り替えます。

```js:process_request
    /**
     * メッセージ受信処理
     * @param webSocket クライアントのWebSocket
     * @param message メッセージ
     */
    onMessage(webSocket, message)
    {
        // 確認用ログ
        console.log(message);

        // パース
        const request = JSON.parse(message);
        switch (request.action)
        {
            case "MATCH":
                this.onRequestMatch(webSocket, request);
                break;

            // チャットやキャンセル処理など
        }
    }
```

#### ②マッチング要求部分
サーバからGPMへマッチングをリクエストする部分です。
<b>「secretId」</b> や <b>「secretKey」</b> には Tencent Cloud の Cloud Access Management で発行して設定します。
<b>「region」</b> はGPMのルールやマッチを作成したリージョンを指定します。
<font color=Red>※GPMへリクエストを送信するサンプルコードは「API Explorer」のCodeGeneratingで言語毎に確認可能です。</b>

```js:matching_request
    /**
     * クライアントがマッチングをリクエストした時の処理
     * @param webSocket クライアントのWebSocket
     * @param request クライアントからのリクエスト
     */
    onRequestMatch(webSocket, request) {

        // リクエストパラメータ作成
        const params = {
            "MatchCode": GpmSettings.getMatchCode(),
            "Players": [
                {
                    "Id": request.id,
                    "Name": request.name,
                }
            ]
        };

        // GPMにリクエスト
        GpmRepository.startMatching(params).then(data => {

            // チケットIDを関連付ける
            webSocket.MatchTicketId = data.MatchTicketId;
            clientSockets.push(webSocket);

            // 確認用ログ
            console.log(data);
        }, err => {

            // エラーログ
            console.error(err);

            // リクエスト失敗
            const response = JSON.stringify({
                action: "ERROR",
                message: err
            });
            webSocket.send(response);
        });
```

#### ③マッチング結果（イベント通知）部分
②でマッチングのリクエストを行った後、GPMはマッチで設定したURLにイベント通知を送ります。
イベント通知を受けとれるようにサーバを設定します。

イベント通知を受け取ったら DescribeToken API で最後に発行されたトークンを取得し、
偽装されたマッチング情報を排除します。

最後にクライアントへマッチングの状態をレスポンスします。
レスポンスの形式はリクエスト時と同様の json 形式です。

マッチングが完了状態になった時に、クライアントはチャット画面に遷移させます。

```js:event_notification
// request body の json を取得出来るようにする
app.use(express.json());

// GPMのイベント通知を受ける
app.post('/', (req, res) => {

    console.log(req.body);

    // GPMに最後に発行したトークンを取得
    const params = {"MatchCode": GpmSettings.matchCode};
    gpmClient.DescribeToken(params).then((data) => {

        console.log(data);

        // トークンチェック
        const authorized = req.body.MatchTokens.some(e => e == data.MatchToken);
        req.body.MatchTickets.forEach(matchTicket => {

            let message = "";            
            const ws = clientSockets.find(e => e.MatchTicketId == matchTicket.Id);

            if (authorized)
            {
                switch (matchTicket.Status) {
                    // マッチング完了
                    case "COMPLETED":
                       break;

                    case "SEARCHING":
                        message = "マッチング中です。しばらくおまちください。";
                        break;

                    case "CANCELLED":
                        message = "キャンセルしました。";
                        break;

                    case "TIMEDOUT":
                        message = "タイムアウトしました。"
                        break;
                }

                // メッセージを送る
                ws.send(JSON.stringify({action: matchTicket.Status, message: message}));
            }
            else
            {
                // エラーにする
                ws.send(JSON.stringify({action: "ERROR", message: "トークンの検証が出来ませんでした。"}));
            }
        });
    });
})
```

これでGPMのマッチングを利用した最小構成のチャットは出来るようになります。

# ルールを設定する
GPMのデフォルトルールは1チーム（プレイヤー数1）vs1チーム（プレイヤー数1）だけでそれ以外のルールはありません。
実際のゲームではもっと複雑なルールを使用したマッチングが必要になります。
そこでルールの追加を考えてみます。

### Distance Ruleの追加
Distance Rule は名の通り特定の値から一定の距離にある範囲内をマッチング対象とするルールです。
これを記事の序盤で紹介したデフォルトルールに追加してみます。
Tencent Cloud のGPMコンソールから Rule を選択し、作成画面で「Add Distance Rule」を選択します。
![スクリーンショット 2021-10-15 152542.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2127401/a265bc0e-a1fe-7323-caa6-4811f3ddbc13.png)

以下の項目が追加されます。
<font Color=red>（※分かりやすくするため一部値を変更しています）</font>

```json:追加された項目
	"playerAttributes": [
		{
			"name": "age",
			"type": "number"
		}
	],
	"rules": [
		{
			"name": "ageRule",
			"type": "distanceRule",
			"description": "This is a distanceRule example, the avg attributes1 of each team must equals to the avg attributes1 of the same game session",
			"measurements": [
				"flatten(teams[*].players.playerAttributes[age])"
			],
			"referenceValue": "avg(flatten(teams[*].players.playerAttributes[age]))",
			"maxDistance": 10
		}
	]
```

各パラメータについて筆者の解釈をまとめました。

| パラメータ | 子パラメータ | 内容 |
| --- | --- | --- |
| playerAttributes| | プレイヤーの属性。マッチングのルールに必要なプレイヤーの属性を配列で複数指定する。 |
| | name| 属性の名前 |
| | type| 属性のタイプ |
|rules | | ルール。相手を探す時の条件を配列で複数指定。 |
| | name| ルールの名前 |
| | type| ルールの種類 |
| | description| ルールの説明 |
| | measurements| distanceルールの場合、referenceValueと比較する値 |
| | referenceValue| distanceルールの場合、measurementsと比較する値 |
| | maxDistance | measurementsとreferenceValueの差の最大値制約 |

<b>「playerAttributes」</b>はチームに所属するプレイヤーの属性（実際はデータ）を設定します。
例えば格闘ゲームのネットワーク対戦を考えたとき、なるべく同レベルのプレイヤー同士をマッチングさせたいと考えますよね。
その際「勝利数」や「ランク」などのプレイヤーデータを基に近いプレイヤーをマッチングさせると思います。
この「勝利数」や「ランク」が属性です。
属性は必要に応じて複数設定可能です。

<b>「rules」</b>は属性を使ってマッチングの条件を設定します。
Distance Rule の場合は、<b>「refereneceValue」</b>が基準値、<b>「measurements」</b>が比較対象値、<b>「maxDistance」</b>が範囲を表します。

例えば「年齢」という属性でルールの作成を考えてみます。
「30歳を基準にて+-10歳」（＝なるべく年齢が近いプレイヤー）でマッチングしたい場合は以下のように設定します。
<b>「flatten」</b>は引数に指定した値を1つのリストにまとめる関数です。

```json:sample_distance_rule
    "playerAttributes": [
        {
            "name": "age",
            "type": "number"
        }
    ],
    "rules": [
        {
            "name": "ageRule",
            "type": "distanceRule",
            "description": "This is a distanceRule example, the avg attributes1 of each team must equals to the avg attributes1 of the same game session",
            "measurements": [
                "flatten(teams[*].players.playerAttributes[age])"
            ],
            "referenceValue": 30,
            "maxDistance": 10
        }
    ]
```


### Comparison Rule の追加
Comparision Rule も名の通り比較を行います。

```json:sample_comparison_rule
	"playerAttributes": [
		{
			"name": "gender",
			"type": "number"
		}
	],
	"rules": [
		{
			"name": "genderRule",
			"type": "comparisonRule",
			"description": "This is a complarisonRule example, players with the same attributes1 can be matched to a same game session",
			"measurements": [
				"flatten(teams[*].players.playerAttributes[gender])"
			],
			"operation": "="
		}
	]
```

Distance Rule との大きな違いは<b>「operation」</b>です。
operation は measurements に指定されたそれぞれの値を operation で指定した符号で比較します。
サンプルでは各プレイヤーの gender（性別）が等しい場合にのみマッチングが成立します。

チャットで女子会やる場合は上述のように設定し、お見合いをする場合は operation に "!=" を設定することで異性とマッチングが出来るようになります。

このようにGPMでは、コンソール上のスクリプトを編集するだけで簡単にルールを追加することが出来ます！


## ルールをチャットに反映する
作成した「性別」「年齢」に加えて「年収」という3つのルールをチャットに反映してみます。

Unityで各属性を入力するInputFieldを作成します。
![スクリーンショット 2021-10-16 003432.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2127401/1536319f-cf28-a951-5c88-9917b05f337d.png)

入力した情報をGPMに伝えるため、json に playerAttributes という項目で「年齢」「性別」「年収」のパラメータを追加します。

```json:playerAttributes
{
	"id": "595c644a-7519-4c5f-8205-8795bea7601d",
	"name": "名無しさん",
	"action": "MATCH",
	"message": "",
	"playerAttributes": {
		"gender": 0,
		"age": 10,
		"annualIncome": 0
	}
}
```

サーバではクライアントから受け取ったjsonを使ってGPMへリクエストを送信します。

```js:サーバ側でplayerAttributesを添えてリクエスト
    onRequestMatch(webSocket, request) {

        // リクエストパラメータ作成
        const params = {
            "MatchCode": GpmSettings.getMatchCode(),
            "Players": [
                {
                    "Id": request.id,
                    "Name": request.name,
                    "MatchAttributes": [
                        {
                            "Name": "gender",
                            "Type": 0,
                            "NumberValue": request.playerAttributes.gender
                        },
                        {
                            "Name": "age",
                            "Type": 0,
                            "NumberValue": request.playerAttributes.age
                        },
                        {
                            "Name": "annualIncome",
                            "Type": 0,
                            "NumberValue": request.playerAttributes.annualIncome
                        }
                    ]
                }
            ]
        };

        // リクエスト処理へ
```

これでGPMはリクエストに追加した属性を参照し、ルールに合ったプレイヤー同士のマッチングが可能になります。

## チャットの課題
ここまでの実装でお見合い or 女子会チャットくらいにはなりましたｗ
しかしチャットとしての機能にはまだまだ課題があります。

### 課題
#### ・チャットルームがない
最小限の構成ではルーム概念（変数なりクラスなり）を実装しない限り、
マッチングが成立したとしても接続した全てのクライアントにメッセージを送ってしまいます。

#### ・途中参加させたい</b>
2名のプレイヤーがマッチングするルールを作成したので、後からプレイヤーが参加出来ません。
チャットでは途中からどんどん人が参加し盛り上がっていくのがセオリーだと思います。

#### [「Game Player Matchmaking（GPM）でチャット作った！②」](https://qiita.com/mynet-eng/items/43b2ed5173c6ffa3248f)で上記課題を解決していきます！

# 終わりに
今回 Tencent Cloud に触れることで初めてGPMのようなサービスがあることを知りました。
新しいサービスを知り、そして実際に使用して理解するということは、今後もゲーム開発に携わる上で非常に大きなアドバンテージだと思います。
また自身のキャリアにも繋がるので読者の皆様も是非お試しください！

株式会社マイネットでは一緒に働く仲間を募集しています！
弊社では様々なゲームタイトルをより長く、安定して運営していくために、インフラ最適化にも積極的に取り組んでいます。興味のある方、ご応募お待ちしております！
https://mynet.co.jp/recruitment/