Amazon GameLift FlexMatch でマッチメイキング“だけ”動かしてみる

[AWS & Game Advent Calendar 2020](https://qiita.com/advent-calendar/2020/aws-game)  の 12 日目の記事です。
本日は Amazon GameLift FlexMatch というマッチメイキングサービスを**単独で**動かしてみた話をしたいと思います。

# マッチメイキングとは？

そもそも「マッチメイキング」という言葉を聞いて、どのようなイメージを抱くでしょうか。そのまま日本語にしてみると「試合（Match）を作る（Making）」、つまり「対戦する相手を決める」という意味合いになりそうです。
対戦相手を決めるには、挑戦者が対戦相手を希望することもあれば、どのような対戦相手がふさわしいか、第三者が対戦相手の能力を鑑みて決めることも考えられそうです。そもそも試合に参加するには、熟練度の制限があるかもしれません。
試合の挑戦者や対戦相手は、個人でなくチームということも考えられます。試合内容によっては、挑戦者や対戦相手は 2 人や 2 組でなく、それ以上の数の対戦相手になることも考えられますね。

このような考察から、マッチメイキングには、複数人でプレイするゲームに適用する「特定の評価方法」と「チーム構造」を定義することで、ゲームに参加するプレイヤーをグループ化させる役割がある、ということを導き出すことができます。
なお、上記では主に「対戦相手」を決めることに焦点を当てましたが、共通の目的を達成するための「協力者」を集めることにも同様に扱うことができそうです。

# マッチメイキングシステムを作りたい！でも……

マルチプレイングゲームを開発する際、対戦相手や協力者をプレイヤー間で互いに引き合わせることを目的として、マッチメイキングを実現するためのシステム、すなわちマッチメイキングシステムの開発を伴う場合が多いと思います。
マッチメイキングシステムを作るために、どのようにマッチメイクするかルールを決めることはもちろんですが、マッチメイキングシステムの実行基盤をどのように構築するかも考察する必要があります。実行基盤を構築した後も、可用性やスケーラビリティの考慮、定期的なメンテナンス、保守や運用方法を含めた検討も必要でしょう。
そうしたマッチメイキングシステムの実行基盤に対して時間を使うよりも、マッチメイキングそのものの品質を高めて、よりプレイヤーに快適に遊んでもらいたい、という想いが先行するのではないでしょうか。少なくとも元ゲーム開発者の私はそう感じております……

# Amazon GameLift FlexMatch とは？
Amazon GameLift FlexMatch は、ゲームの仕様に合わせてカスタマイズが可能なマッチメイキングサービスです。JSON 形式で表現する **FlexMatch ルールセット** を作成し、それを元にした**マッチメイキング設定**（**マッチメーカー**とも呼びます）を作成することで利用でき、マッチメイキングを実行する基盤を用意する必要はありません。最大 200 人までのプレイヤーを、大規模なマッチメイキングとして実施することができます。

[2020 年 11 月のアップデート](https://aws.amazon.com/jp/blogs/gametech/gamelift-flexmatch-now-generally-available/)より、この GameLift FlexMatch を単独（Standalone）で利用することが可能になりました。アップデート前までは、 FlexMatch を使用してマッチメイキングされたプレイヤーは、GameLift 管理下のゲームサーバー上のゲームセッション（ルーム）に案内する利用が前提でした。今回のアップデートにより、マッチメイキングが成功した後の案内先を、開発者側で自由に選択することができます。既にゲームセッションをホスティングするゲームサーバーは所有しているために、マッチメイキングだけ GameLift FlexMatch を使用したい、といったユースケースに最適です。単独で使えるため、マッチメイキングの動作確認だけ実施したい場合にも、利用しやすくなったのではないでしょうか。


# 使ってみる
では、今回は単独で利用できるようになった GameLift FlexMatch の動作を確認しようと思います。簡単に確認するために、以下にシンプルな構成とルールを示します。

## 構成について
![スクリーンショット 0002-12-11 9.48.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/911060/7e82bd9b-71aa-e138-ae00-01324cf39b91.png)

GameLift FlexMatch には、マッチメイキングの状況が変化した際にイベントが発行される仕組みがあります。このイベントを Amazon Simple Notification Service(SNS) トピックに発行させます。この SNS トピックには、予め配信先として Lambda 関数をエンドポイントとして指定しておきます。Lambda 関数は、SNS から受信するメッセージペイロードの内容からどのような処理をするか判断します。特に、マッチメイキングが成功した際に、プレイヤー全員がプレイする同一の環境であるゲームセッション（ルーム）に案内してあげる処理が必要になります。今回は動作を確認する便宜上ログ出力だけ行い、CloudWatch Logs でマッチメイキングイベントの内容を確認してみます。

## ルールについて

マッチメイキングのルールに関しては、今回はこのようなものを考えてみました。

* 共通の強敵に挑むためにプレイヤー 4 人を引き合わせたい
* マッチメイクする際、互いに職業が異なるようにしたい
  * ここでは、戦士（`Warrior`）、魔法使い（`Wizard`）、僧侶（`Priest`）、アーチャー（`Archer`）という職業のいずれかを選択できるものとします。

シンプルですが、FlexMatch の動作を確認するには十分です。

## 構築してみる
構築の順番としては下記になります。構築は東京リージョン内で実施しました。

1. Lambda 関数を作成
2. SNS トピックとサブスクリプションを作成
3. GameLift FlexMatch マッチメイキングルールセットとマッチメイキング設定を作成

### 1. Lambda 関数を作成 
ランタイムに Node.js 12.x を指定して Lambda 関数を作成します。その後、コードの編集画面で、SNS から受信するメッセージペイロードの内容をログとして出力するのみ行う処理を実装します。

```js:index.js
exports.handler = async (event) => {
    var message = event.Records[0].Sns.Message;
    console.log(message);
    const response = {
        statusCode: 200,
        body: message,
    };
    return response;
};
```

### 2. SNS トピックとサブスクリプションを作成
#### 2.1. SNS トピックの作成
トピックの作成画面より、下記項目を埋めて作成します。

| 項目 | 設定値 |
|:-:|:-:|
| タイプ | スタンダード |
| 名前 | SampleEventNoticeTopic |

#### 2.2. SNS サブスクリプションの作成
サブスクリプションの作成画面より、下記項目を埋めて作成します。

| 項目 | 設定値 |
|:-:|:-:|
| トピック ARN | 今回作成したトピックを選択 |
| プロトコル  | AWS Lambda |
| エンドポイント  | 今回作成した Lambda 関数を選択 |

### 3. GameLift FlexMatch マッチメイキングルールセットとマッチメイキング設定を作成

この項目が肝です。

#### 3.1. FlexMatch マッチメイキングルールセットの作成

GameLift のマネジメントコンソール画面から [マッチメーキングルールセットの作成] を選択します。
 ![スクリーンショット 0002-12-08 10.24.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/911060/e1c60e47-7fff-e4ac-7017-851fe6448852.png)

マッチメーキングルールセットの作成画面で下記項目を埋めます。

| 項目 | 設定値 |
|:-:|:-:|
| ルールセット名 | SampleRuleSet |
| ルールセット  | 下記コードを貼付 |

```:sample-rule-set
{
    "name": "sample-rule-set",
    "ruleLanguageVersion": "1.0",
    "playerAttributes":[{ 
         "name": "characterRole",
         "type": "string"
    }, {
        "name": "targetOpponent",
        "type": "string"
    }],
    "teams": [{
        "name": "party",
        "maxPlayers": 4,
        "minPlayers": 4,
        "quantity": 1
    }],
    "rules": [{
        "type": "comparison",
        "name": "SameOpponent",
        "description": "Players challenge the same opponent.",
        "measurements": ["flatten(teams[*].players.attributes[targetOpponent])"],
        "operation": "="
        }, {
        "type": "comparison",
        "name": "DifferentRole",
        "description": "Characer roles are different each other.",
        "measurements": ["flatten(teams[*].players.attributes[characterRole])"],
        "operation": "!="
    }]
}
```

ここで、このルールセットの中身について解説したいと思います。

ルールセットには少なくとも、「チームの構造」を定義する `teams` プロパティと、マッチメイクの「評価方法」のコレクションを定義する `rules` プロパティを宣言する必要があります。ルールセットのスキーマの詳細については、こちらの[公式ドキュメント](https://docs.aws.amazon.com/ja_jp/gamelift/latest/developerguide/match-ruleset-schema.html)をご参照ください。

- `teams` プロパティについて
    - 今回は 4 人固定のパーティを 1 つ組むため、`teams` プロパティ内の `maxPlayers` と `minPlayers` を `4` に設定しています。
- `rules` プロパティについて
    - 今回はマッチメイキングをする際に 2 種類の条件が示されています。「挑む強敵が共通であること」と、「職業が全員異なること」です。そのため、`rules` プロパティには、2 種類の評価方法を定義する必要があります。`rules` で表現できる内容の詳細については、こちらの[公式ドキュメント](https://docs.aws.amazon.com/ja_jp/gamelift/latest/developerguide/match-rules-reference.html)をご参照ください。
    - 「挑む強敵が共通であること」を表現するには、プレイヤーが選択した強敵 `targetOpponent` の文字列をプレイヤー間で比較し、全て一致するようにできれば要件を満たせます。今回のルールセットでは `SameOpponent` ルールで表現しています。
    - 「職業が全員異なること」を表現するには、プレイヤーの職業 `characterRole` の文字列をプレイヤー間で比較し、プレイヤー間で全て不一致であれば要件を満たせます。今回のルールセットでは `DifferentRole` ルールで表現しています。
    - このように、多くの場合に `rules` プロパティ内でゲーム固有の情報を参照する必要があります。そのために、プレイヤー属性を定義する `playerAttributes` プロパティで、属性の名前と型を宣言しています。この属性は、クライアントがリクエストする `StartMatchmaking` API のパラメータとして含めます。

少し脱線しましたので戻ります。
項目を埋めたら、[ルールセットの検証] をクリックして問題がないことを確認し、[ルールセットの作成] をクリックして作成します。

　![スクリーンショット 0002-12-08 15.26.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/911060/2d306f6c-3eb5-bc5c-91be-8146cc8f480c.png)

#### 3.2. FlexMatch マッチメイキング設定の作成

FlexMatch ルールセットが作成できたら、最後はマッチメイキング設定を作成していきます。[マッチメーキング設定の作成] を選択します。

![スクリーンショット 0002-12-11 9.25.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/911060/3f17cd5d-8a75-455b-cc41-b35846b87c5c.png)

マッチメーキング設定の作成画面で、下記項目を埋めます。
ポイントとしては、FlexMatch モードに `STANDALONE` を選択することと、通知先を指定することです。`STANDALONE` としての利用時には、マッチメイキングの成功後に、開発者側でプレイヤーをゲームセッションに誘導する必要があります。ここで指定する通知先が、マッチングされたプレイヤーをゲームセッションに案内する役目を担います。今回はログを出力する Lambda 関数がサブスクライブしている SNS トピックを指定しています。

| 項目 | 設定値 |
|:-:|:-:|
| 名前 | SampleMatchMaker |
| FlexMatch モード  | STANDALONE |
| リクエストのタイムアウト | 120 |
| ルールセット名 | 今回作成したルールセット: SampleRuleSet |
| 通知先 | 今回作成した SNS トピック: SampleEventNoticeTopic の ARN |

![スクリーンショット 0002-12-11 9.27.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/911060/e7bf4b48-c46d-f2a6-201b-e7b9000a838c.png)

項目を埋めたら、[作成] をクリックします。

これで FlexMatch を利用する準備ができました。

## 確認してみる

本来は AWS SDK を組み込んだクライアントから、マッチメイキングを開始する `StartMatchmaking` API を呼び出すように実装します。今回は便宜上 AWS CLI を使って動作を確認してみます。
下記のようなコマンドを、表に記載のパラメータの組み合わせ毎に叩いてみます。

```:コマンド例
aws gamelift start-matchmaking --region ap-northeast-1 \
    --configuration-name SampleMatchMaker \
    --players '[{"PlayerId": "10001", "PlayerAttributes": {"targetOpponent": {"S": "Troll"}, "characterRole": {"S": "Warrior"}}, "Team": "party"}]'
```

| PlayerId | PlayerAttributes.targetOpponent | PlayerAttributes.characterRoll |
|:-:|:-:|:-:|
|10001| Troll | Warrior |
|10002| Troll | Wizard |
|10003| Troll | Priest |
|10004| Dragon | Archer |
|10005| Dragon | Wizard |
|10006| Troll | Archer |
|10007| Dragon | Warrior |
|10008| Dragon | Wizard | 

コマンドを叩いた後は、このようなレスポンスが表示されます。

```json:CLI結果
{
    "MatchmakingTicket": {
        "TicketId": "ac9e35fa-7014-4615-8f52-f480b788996b",
        "ConfigurationName": "SampleMatchMaker",
        "ConfigurationArn": "arn:aws:gamelift:ap-northeast-1:<account-id>:matchmakingconfiguration/SampleMatchMaker",
        "Status": "QUEUED",
        "StartTime": "2020-12-11T11:49:50.002000+09:00",
        "Players": [
            {
                "PlayerId": "10008",
                "PlayerAttributes": {
                    "characterRole": {
                        "S": "Wizard"
                    },
                    "targetOpponent": {
                        "S": "Dragon"
                    }
                },
                "Team": "party"
            }
        ],
    }
}
```


CloudWatch Logs に出力されたログを確認すると、`Troll` を倒しに行く `10001`, `10002`, `10003`, `10006` のプレイヤー達で、マッチメイキングが成功した旨のイベントが発行されたことが確認できます。

```json:マッチメイキング成功ログ
{
    "version": "0",
    "id": "9ca60e00-0e11-4fc5-8a7c-4fc934b4ac93",
    "detail-type": "GameLift Matchmaking Event",
    "source": "aws.gamelift",
    "account": "<account-id>",
    "time": "2020-12-11T03:00:54.627Z",
    "region": "ap-northeast-1",
    "resources": [
        "arn:aws:gamelift:ap-northeast-1:<account-id>:matchmakingconfiguration/SampleMatchMaker"
    ],
    "detail": {
        "tickets": [
            {
                "ticketId": "cd197a41-d43b-41ec-881f-a82124f890cb",
                "startTime": "2020-12-11T03:00:33.353Z",
                "players": [
                    {
                        "playerId": "10001",
                        "team": "party1"
                    }
                ]
            },
            {
                "ticketId": "f9a7b7c8-7919-4b10-965d-1d943aefeff3",
                "startTime": "2020-12-11T03:00:34.335Z",
                "players": [
                    {
                        "playerId": "10002",
                        "team": "party1"
                    }
                ]
            },
            {
                "ticketId": "3d902bc3-1f47-41c6-9326-09e91d7087ce",
                "startTime": "2020-12-11T03:00:41.249Z",
                "players": [
                    {
                        "playerId": "10003",
                        "team": "party1"
                    }
                ]
            },
            {
                "ticketId": "dde60330-4624-46f7-87af-22b512ccf0eb",
                "startTime": "2020-12-11T03:00:54.519Z",
                "players": [
                    {
                        "playerId": "10006",
                        "team": "party1"
                    }
                ]
            }
        ],
        "type": "MatchmakingSucceeded",
        "gameSessionInfo": {
            "players": [
                {
                    "playerId": "10001",
                    "team": "party1"
                },
                {
                    "playerId": "10002",
                    "team": "party1"
                },
                {
                    "playerId": "10003",
                    "team": "party1"
                },
                {
                    "playerId": "10006",
                    "team": "party1"
                }
            ]
        },
        "matchId": "1bc10959-c6d5-4856-a36b-7501f79aee6b"
    }
}
```

一方、`Dragon` を倒しに行くプレイヤー達は、残念ながら `10005` と `10008` のプレイヤー間で職業が被り、マッチメイキングは成立せず、120 秒後にタイムアウトされた旨のイベントが ```PlayerId``` 毎に発行されていました。

```json:PlayerId=10004のタイムアウトログ
{
    "version": "0",
    "id": "b0763d83-2c9b-44ef-aceb-c1a38b4de401",
    "detail-type": "GameLift Matchmaking Event",
    "source": "aws.gamelift",
    "account": "<account-id>",
    "time": "2020-12-11T03:02:42.205Z",
    "region": "ap-northeast-1",
    "resources": [
        "arn:aws:gamelift:ap-northeast-1:<account-id>:matchmakingconfiguration/SampleMatchMaker"
    ],
    "detail": {
        "reason": "TimedOut",
        "tickets": [
            {
                "ticketId": "f1b4d050-bbb8-4257-af57-446dbf29589b",
                "startTime": "2020-12-11T03:00:42.149Z",
                "players": [
                    {
                        "playerId": "10004",
                        "team": "party"
                    }
                ]
            }
        ],
        "ruleEvaluationMetrics": [
            {
                "ruleName": "SameOpponent",
                "passedCount": 6048,
                "failedCount": 0
            },
            {
                "ruleName": "DifferentRole",
                "passedCount": 5544,
                "failedCount": 504
            }
        ],
        "type": "MatchmakingTimedOut",
        "message": "Removed from matchmaking due to timing out.",
        "gameSessionInfo": {
            "players": [
                {
                    "playerId": "10004",
                    "team": "party"
                }
            ]
        }
    }
}
```

# まとめ
GameLift FlexMatch でマッチメイキングを行い、マッチメイキングの内容を Lambda 関数で確認するところまで構築してみました。マッチメイキングのルールやロジックを考察する時間は楽しいですね。
GameLift FlexMatch がマルチプレイングゲームを開発する方々の一助となれば幸いです。

(免責) 本記事の内容はあくまでも個人の意見であり、所属する企業や団体とは関係がございません。