Amazon GameLift FlexMatchからマッチングステータスを受け取るアーキテクチャを組んでみた

2023.11.21

こんにちは、ゲームソリューション部の入井です。

Amazon GameLiftでは、FlexMatchという対戦型オンラインゲームなどにおけるプレイヤー同士のマッチングをサポートする機能が提供されています。

FlexMatchは、それ単体で複雑なマッチメイキングロジックを構築することができますが、現在マッチングがどのような状況であるかを確認するには専用のアーキテクチャを組む必要があります。今回は、実際にそのアーキテクチャを構築し、マッチングステータスを受け取る流れを書いていきます。

構築手順

今回のアーキテクチャは、以下のAWS公式ブログを元にしています。

FlexMatchマッチング状態を確認するためのサーバレスアプリケーションの実装例 | Amazon Web Services ブログ

上記の記事は、GameLift FlexMatchは実際には利用せず、周辺のアーキテクチャの動作確認までの内容となっています。今回は、この内容をベースに以下の点で変化をつけて検証をしてみました。

  • 実際にGameLift FlexMatchを使ってマッチメイキング結果を出力させる
  • LambdaのコードはNode.jsランタイム用のものに変更
    • 実際にFlexMatchを使用する都合で一部処理の流れも微調整

FlexMatchマッチング状態を確認するためのサーバレスアプリケーションの実装例 | Amazon Web Services ブログより引用

なお、FlexMatchには、Amazon GameLiftのキューと連携してマッチング結果を元に自動的にゲームセッションやプレイヤーセッションの作成・配置を行うWITH_QUEUEモードと、セッション関係の処理は自前で作成する必要があるSTANDALONEモードがあります。今回の記事は、あくまでFlexMatchのマッチング機能のみに焦点を当てるため、STANDALONEモードで構築します。

なお、STANDALONEモードは、以下のような環境でマッチング関係の機能のみを使用するのに有用です。

  • 通常のGameLift(Managed Hosting)よりサーバー選択の自由度が高い FleetIQ環境
  • ゲームサーバーがAWS以外で稼働している環境

ただし、WITH_QUEUEモードではFlexMatchは無料で利用できますが、STANDALONEモードでは利用量に応じて費用が発生するのでご注意ください。

料金 - Amazon GameLift | AWS

マッチング結果通知SNSの設定

FlexMatchでは、ゲームクライアントからのマッチング要求を元にマッチメイキング処理を行う中で、マッチングのステータスが変化するごとにその内容をSNSやEventBridgeを使って外部へ通知することができます。

今回は、参考記事と同様にSNSにて通知を行うことにします。名前はFlexMatchTopicで、スタンダードトピックとして作成します。

SNSのアクセスポリシーは以下の通りで、GameLiftからのみパブリッシュを許可するようにしています。

{
  "Version": "2008-10-17",
  "Id": "__default_policy_ID",
  "Statement": [
    {
      "Sid": "EnableGameliftServicePrincipal",
      "Effect": "Allow",
      "Principal": {
        "Service": "gamelift.amazonaws.com"
      },
      "Action": "sns:Publish",
      "Resource": "arn:aws:sns:ap-northeast-1:[AWSアカウントID]:FlexMatchTopic"
    }
  ]
}

DynamoDBテーブルの作成

続いて、FlexMatchのマッチングステータス情報を保存するためにDynamoDBテーブルを作成します。

テーブル名はFlexMatchResults、パーティションキーはticketIdで設定しました。

FlexMatchでは、各クライアントからのマッチング要求をチケットという単位で管理します。パーティションキーをticketIdとすることで、任意のチケットの最新ステータスを常に参照できるようにしています。

マッチングステータスを保存するLambda関数の作成

ここまで作成したSNSトピックとDynamoDBテーブルを利用し、FlexMatchから届いたマッチングステータスをDynamoDBへ保存するFlexMatchEventHandlerというLambda関数を作成します。この関数は、SNSからの通知をトリガーに実行します。

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  BatchWriteCommand,
  DynamoDBDocumentClient,
} from "@aws-sdk/lib-dynamodb";

const tableName =  process.env.MATCHING_TABLE;

export const handler = async function (event, context) {
    const dynamoDbClient = new DynamoDBClient({});
    const dynamoDbDocClient = DynamoDBDocumentClient.from(dynamoDbClient);

    const message = JSON.parse(event['Records'][0]['Sns']['Message']);
    const body = message;

    console.info('Received event:', JSON.stringify(event, null, 2));

    if (!body.detail || !body.detail.type){
        console.error('Wrong notification, check whether your notification is from GameLift flexmatch');
        return message;
    }

    switch (body.detail.type) {
                // 特定のステータスの場合のみDynamoDBへ書き込み
        case "MatchmakingSearching":
        case "MatchmakingSucceeded":
        case "MatchmakingTimedOut":
                case "MatchmakingCancelled":
                case "MatchmakingFailed"
            const params = {
                RequestItems: {}
            };
                // チケット別に項目を分ける
            params.RequestItems[tableName] = [];
                body.detail.tickets.forEach(ticket => {
                params.RequestItems[tableName].push({
                    PutRequest: {
                        Item: {
                            ticketId: ticket.ticketId,
                            status: body.detail.type,
                            players: ticket.players,
                            time: body.time
                        }
                    }
                });
            });

            try {
                const command = new BatchWriteCommand(params);
                await dynamoDbDocClient.send(command);
            } catch (error) {
                console.error(error);
            }
            break;
        default:
            console.info(body.detail.type);
    }
    return message;
}

参考記事でPythonで書かれていたのをNode.jsランタイム用に書き換えました。また、基本的な処理の流れは同じですが、参考記事ではMatchMakingSucceededステータスのみDynamoDBへ保存していたのに対し、こちらのコードでは他のステータスも保存するようにしています。これは、どのステータスであってもAPI経由で確認できるようにしたかったからです。

なお、FlexMatchからのイベント通知は、今回コードに記載しているものの他にもプレイヤーによるマッチング承諾機能関係のものがありますが、今回はそれを使用していないので取り扱っていません。他にどのようなイベントがあるかについては、以下のドキュメントを参照してください。

FlexMatchマッチメイキングイベント - アマゾン GameLift

また、このLambda関数用のIAMロールにDynamoDBへのBatchWriteItemを許可するポリシーを付与しています。

マッチングステータスを確認するLambda関数の作成

ゲームクライアントからのリクエストを元に、指定されたチケットのマッチングステータスを取得するMatchingResultHandlerというLambda関数を作成します。こちらについても、処理の流れは参考記事のPythonコードと同様になっています。

import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";

const dynamoDbClient = new DynamoDBClient({});

export const handler = async (event, context) => {
  if (!event.queryStringParameters || !event.queryStringParameters.id) {
    return serverResponse(400, "Parameter Error");
  }

  const ticketId = event.queryStringParameters.id;
  const params = {
    TableName: process.env.MATCHING_TABLE,
    Key: marshall({ ticketId: ticketId })
  };

  try {
    const { Item: item } = await dynamoDbClient.send(new GetItemCommand(params));

    if (!item) {
      return serverResponse(200, 'Match not available');
    }

    return serverResponse(200, unmarshall(item));
  } catch (error) {
    console.error(error);
    return serverResponse(500, "Internal Server Error");
  }
};

const serverResponse = (statusCode, message) => {
  return {
    statusCode,
    headers: {
      "Access-Control-Allow-Headers": "Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token",
      "Access-Control-Allow-Methods": "DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT",
      "Access-Control-Allow-Origin": "*"
    },
    body: JSON.stringify(message)
  };
};

こちらのLambda関数用のIAMロールには、DynamoDBへのGetItemを許可するポリシーを付与しています。

API Gatewayの作成

ゲームクライアントからマッチングステータス確認のリクエストを受けるための窓口としてAPI Gatewayを作成します。設定としては以下の通りで、クエリパラメータ付きのGETリクエストの内容をMatchingResultHandlerへ渡します。

GameLift FlexMatchのマッチメーキングルールセットの作成

FlexMatchを利用するためには、どのようなルールでプレイヤー同士のマッチング処理を行うかを記載するマッチメーキングルールセットの作成と、FlexMatchをどのように運用するかの設定を行うマッチメーキング設定の作成が必要となります。

最初に、TestRuleSetという名前で以下のマッチメーキングルールセットを作成します。

{
    "name": "aliens_vs_cowboys",
    "ruleLanguageVersion": "1.0",
    "playerAttributes": [{
        "name": "skill",
        "type": "number",
        "default": 10
    }],
    "teams": [{
        "name": "cowboys",
        "maxPlayers": 1,
        "minPlayers": 1
    }, {
        "name": "aliens",
        "maxPlayers": 1,
        "minPlayers": 1
    }],
    "rules": [{
        "name": "FairTeamSkill",
        "description": "The average skill of players in each team is within 10 points from the average skill of all players in the match",
        "type": "distance",
        // get skill values for players in each team and average separately to produce list of two numbers
        "measurements": [ "avg(teams[*].players.attributes[skill])" ],
        // get skill values for players in each team, flatten into a single list, and average to produce an overall average
        "referenceValue": "avg(flatten(teams[*].players.attributes[skill]))",
        "maxDistance": 10 // minDistance would achieve the opposite result
    }, {
        "name": "EqualTeamSizes",
        "description": "Only launch a game when the number of players in each team matches, e.g. 4v4, 5v5, 6v6, 7v7, 8v8",
        "type": "comparison",
        "measurements": [ "count(teams[cowboys].players)" ],
        "referenceValue": "count(teams[aliens].players)",
        "operation": "=" // other operations: !=, <, <=, >, >=
    }],
    "expansions": [{
        "target": "rules[FairTeamSkill].maxDistance",
        "steps": [{
            "waitTimeSeconds": 5,
            "value": 50
        }, {
            "waitTimeSeconds": 15,
            "value": 100
        }]
    }]
}

上記の内容は、サンプルとして用意されているルールセットを元に作成しました。

プレイヤーのスキルレベルを元に、cowboysチームとaliensチームに均等にプレイヤーを配置するルールとなっています。今回の記事では具体的な記述内容の解説は行いませんが、teamsプロパティでチームの構成設定を書いており、rulesプロパティでマッチングのロジックを書いています。

サンプルでは、各チームのプレイヤー数は4~8に設定されていましたが、今回はマッチングをすぐに完了させるため1人チームに設定しています。

FlexMatchのマッチメーキング設定の作成

以下のような設定でTestMatchmakingConfigという設定を作成しました。

  • 設定名
    • TestMatchmakingConfig
  • ルールセット
    • TestRuleSet
  • FlexMatchモード
    • STANDALONE
  • リクエストのタイムアウト
    • 30秒
  • イベント通知設定
    • FlexMatchTopicを指定

マッチメーキング設定では、上記の他にもプレイヤーによるマッチング承諾機能などの設定を行うことが可能です。

マッチングテスト

設定が完了したのでFlexMatchを実際に動かしてみます。

今回は、マッチング要求とそのステータスの確認をAWS CLI経由で行いました。

実際のゲームで使用する場合は、ゲームクライアントから各APIへリクエストを送る形になります。

マッチング要求

aws gamelift start-matchmakingコマンドで、JSON形式のプレイヤー情報を送信します。

aws gamelift start-matchmaking --cli-input-json '
{
    "ConfigurationName": "TestMatchmakingConfig",
    "Players": [
        {
            "PlayerId": "User1",
            "PlayerAttributes": {
                "skill": {
                    "N": 15
                }
            }
        }
    ]
}
'

マッチング要求のリクエスト後にチケットの作成が完了すると、以下のようなレスポンスが得られます。これにより、今回のマッチング要求のチケットIDが把握できます。

{
    "MatchmakingTicket": {
        "TicketId": "8ad15a11-1beb-48ec-bf89-0baae1f6b2c6",
        "ConfigurationName": "TestMatchmakingConfig",
        "ConfigurationArn": "arn:aws:gamelift:ap-northeast-1:9999999999:matchmakingconfiguration/TestMatchmakingConfig",
        "Status": "QUEUED",
        "StartTime": "2023-11-18T07:06:00.499000+00:00",
        "Players": [
            {
                "PlayerId": "User1",
                "PlayerAttributes": {
                    "skill": {
                        "N": 15.0
                    }
                }
            }
        ]
    }
}

マッチングステータスの確認

受け取ったチケットID情報を使って、API Gatewayのエンドポイントを使って現状のマッチングステータスを確認します。

curl https://xxxxxxx.execute-api.ap-northeast-1.amazonaws.com/MatchingResult?id=8ad15a11-1beb-48ec-bf89-0baae1f6b2c6

以下のようなレスポンスが得られ、User1プレイヤーがcowboysチームに配置されていることと、現在のステータスはMatchmakingSearchingでまだマッチング処理の途中であることが分かります。

{
    "time":"2023-11-18T09:26:47.591Z",
    "players":[{"playerId":"User1","team":"cowboys"}],
    "ticketId":"8ad15a11-1beb-48ec-bf89-0baae1f6b2c6",
    "status":"MatchmakingSearching"
}

他のプレイヤーがいないとマッチングは完了しないので、追加で以下のUser2のマッチング要求を送ります。

aws gamelift start-matchmaking --cli-input-json '
{
    "ConfigurationName": "TestMatchmakingConfig",
    "Players": [
        {
            "PlayerId": "User2",
            "PlayerAttributes": {
                "skill": {
                    "N": 15
                }
            }
        }
    ]
}
'

その後、User1のチケットIDを使って再度マッチングステータスを確認すると、以下のようにMatchmakingSucceededというステータスに変わっており、マッチングが完了したことが分かります。

{
    "time":"2023-11-18T09:26:47.591Z",
    "players":[{"playerId":"User1","team":"cowboys"}],
    "ticketId":"8ad15a11-1beb-48ec-bf89-0baae1f6b2c6",
    "status":"MatchmakingSucceeded"
}

なお、今回は必要最低限の情報しかLambda経由でDynamoDBに保存していないので上記のようなレスポンスとなりましたが、例えばFlexMatchをGameLift Managed Hostingと連携させている場合、マッチング成功イベント通知の中にゲームセッションの情報も入ってくるので、そこからゲームクライアントへどのサーバーへアクセスすれば良いのかを伝えることができます。

具体的なデータ構造の例については、以下のリンク先サンプルのgameSessionInfoプロパティを見ると確認が可能です。

FlexMatch matchmaking events - Amazon GameLift

なお、MatchmakingSearchingステータスのままマッチメーキング設定で決めたタイムアウト時間を過ぎると、MatchmakingTimedOutイベントが送信され、以降はそのチケットはマッチングに使用されなくなります。

注意点

上記のFlexMatchの検証時、マッチングステータスが素早く切り変わった際にイベント通知が正しい順番で送信されないことがありました。利用時の条件にもよりますが、必要に応じてSNSはFIFOトピックを活用することも視野に入れた方が良いかもしれません。

まとめ

GameLift FlexMatchのマッチングステータスを確認するアーキテクチャを構築し、実際に動作させてみました。

オンラインゲームのマッチング処理は、場合によってはかなり複雑なロジックを組む必要性があり、自分で最初から開発していくのは大変です。そのため、FlexMatchのような簡単に使用できてルールの拡張性も高いサービスは、ゲーム開発の効率を上げるためにかなり有用なものと思います。