MIXI DEVELOPERS
🙌

Open Match の Backfill機能を試してみる

2023/12/11に公開

はじめに

  • 本記事は MIXI DEVELOPERS Advent Calendar 2023 の11日目の記事です。
  • Open Match のバージョンは1.8.0です。
  • 記事中のコードは、表示の都合上簡略化しているためそのままでは動きません。

概要

Open Matchはゲーム等のマッチング機能を提供するフレームワークで、 Kubernetes 上で動作させることができます。

今回はOpen MatchのBackfill機能を試すべく、三人のプレイヤーを要求するゲームにプレイヤーを追加していき、タイムアウトしたら足りない分をbotで補うというデモを書いてみました。

今回実装したコードはこちらにあるので、よかったら試してみてください!

Backfillとは

Backfillはバージョン1.2.0以降でBeta機能として提供されており、マッチングの空き枠を示すのに使うことができます。

Backfillのないマッチング方法では、Open Matchは参加者が完全に揃っていなければマッチングを成立させることができませんでしたが、Backfillを用いると空き枠がある状態でもマッチングを成立させることが可能になります。

これにより、徐々に参加者が増えるようなマッチングを実現したり、一度ゲームが終わった後に抜けたユーザー分だけ追加するといったことが可能になります。

また、ユーザーがマッチング待機中にGameServerに接続できるようになるため、マッチングが完全に成立せずとも情報のやり取りを行うことができるようになります。

Backfillについてのより詳細な内容はこちらを読んでください。

デモ

このBackfill機能を使うために、以下のようなマッチングシステムを構築します。

今回はクライアントとしてgrpcurlを用いるため、クライアントの実装は不要です。

さらにゲームサーバーを一台に限定するため、Agonesのようなゲームサーバを管理するシステムも必要ありません。

つまり実装するべきものはGame Frontend、Director、Match Function(mmf)、GameServerの4つとなります。

それぞれの役割についてはこちらを参照してください。

clone & setup

まずはこちらのprerequirements一覧をインストールします。

その後以下を実行します。

% git clone https://github.com/utah-KT/open-match-tutorial.git
% cd open-match-tutorial
% minikube start -p open-match-test # minikubeを使う場合
% eval $(minikube docker-env -p openmatch-test) # minikubeを使う場合
% make build
% make install-openmatch # Open Match がインストールされてない場合
% make apply # Open Matchが起きるより早いとエラーになります

これでデモの準備ができました!

実行

それではこのシステムを使って、以下のシナリオを実現してみましょう。

  1. UserA がマッチング要求
  2. ゲームサーバに UserAが割り当てられ、残り二枠はBackfillを用いて空き枠として募集をかける
  3. UserB がマッチング要求
  4. ゲームサーバに UserBが割り当てられ、空き枠が一枠になる
  5. マッチングがタイムアウトし、ゲームにbotを参加させる
  6. メンバーが揃ったのでゲーム開始

マッチングの要求は以下のようにして行うことができます。
gamefront service ipの欄は、minikube service list -p open-match-testから取得できるgamefrontのipに置き換えてください。

% TICKET_ID=`grpcurl -plaintext -d '{"name":"userA"}' (gemefront service ip):30021 gamefront.GameFrontService.EntryGame | jq .ticketId`

しばらくするとマッチングが完了し、TICKET_IDにチケットのIDが入るので次はこれを持ってゲームサーバに参加します。(gameserver service ip)は先程同様にして取得したゲームサーバのipに置き換えます。

grpcurl -plaintext -d "{\"ticket_id\":$TICKET_ID}" (gameserver service ip):30054 gameserver.GameServerService.Join

うまくいくときっと以下のような出力が得られるはずです。

{
  "members": [
    {
      "name": "userA",
      "ready": true
    }
  ]
}

これでUserAはゲームを待機している状態になったので、同様にしてUserBを参加させます。
するとUserAとUserBの両方の接続に以下のレスポンスが得られます。

{
  "members": [
    {
      "name": "userA",
      "ready": true
    },
    {
      "name": "userB",
      "ready": true
    }
  ]
}

この状態で暫く待機するとタイムアウトとなり、botが追加されてゲームの準備が整ったと通知されます。

このシナリオを図で表すと以下のようになります。

詳細なフロー

それでは、今のシナリオがOpen Matchでどのように実現されているのか、細かく分けて見ていきましょう。

それぞれのシーンでシーケンス図を用意しているので、必要に応じて見てください。

最初のプレイヤーのマッチング

はじめに、最初のプレイヤー(UserA)がゲームサーバに参加するまでの流れを見ていきましょう。

UserA がゲームへの参加表明をすると、ゲームフロントは Open Match にチケットの発行を依頼します。このとき、Extensionsというフィールドに任意の値をもたせることができるので、このデモではプレイヤー名を入れています。

発行されたチケットがマッチングしたかどうかはWatchTicketというAPI経由で判断することができます。

  t := &ompb.Ticket{
    SearchFields: &ompb.SearchFields{
      Tags: []string{defaultPoolTag},
    },
    Extensions: ext, // 任意の要素を持たせることができる
  }
  ticketReq := &ompb.CreateTicketRequest{Ticket: t}
  created, _ := gf.FrontendClient.CreateTicket(context.Background(), ticketReq) // チケット発行

  ticketID := created.Id
  var assignment *ompb.Assignment
  watchReq := &ompb.WatchAssignmentsRequest{TicketId: ticketID}
  assignmentsStream, err := gf.FrontendClient.WatchAssignments(context.Background(), watchReq)

  for assignment.GetConnection() == "" { // アサインされるとConnectionが値を持つのでそれを待つ
    assignmentsRes, _ := assignmentsStream.Recv()
    assignment = assignmentsRes.Assignment
  }

発行されたチケットはmmfによってマッチングされますが、この時、Match.AllocateGameServertrue にしておく必要があります。
これはこのマッチが新たにゲームサーバを必要とするかどうかをDirectorに伝えるためのパラメータで、true を指定するのは、UserAが既に存在するゲームサーバに参加するわけではないためです。
これに加えてまだ参加者が不足しているので、マッチにBackfillを加え、空き枠の情報を付与します。

// 以下のようなMatchを作る
&pb.Match{
  MatchId:       uniqueID,
  MatchProfile:  profile,
  MatchFunction: mmfName,
  Tickets:       tickets,
  Backfill: &pb.Backfill{
    SearchFields: searchFields, // 募集するticketと同じ情報を入れる
    Generation:   0,            // これはOpen Matchが使用する値なので変更しない
    CreateTime:   timestamppb.Now(),
  },
  AllocateGameserver: true, // 新規にゲームサーバが必要かどうか
}

このマッチを受け取ったDirectorは、ゲームサーバにUserAのチケットIDとBackfillのIDを伝えます。
Backfillが存在しないマッチであればここでDirectorがOpen Matchに対してチケットのアサインを要求しますが、今回はBackfillが存在するため、それは不要になります。

// 成立したマッチングを受け取る
stream, _ := d.BackendClient.FetchMatches(context.Background(), req)

var matches []*ompb.Match
for {
  resp, err := stream.Recv()
  if err == io.EOF {
    break
  }
  matches = append(match, resp.GetMatch())
}
for _, match := range matches {
  backfill := match.GetBackfill()
  if match.AllocateGameserver { // mmf で設定した AllocateGameserver を使う
    // 新たにゲームサーバを要求する
  }
  if backfill == nil {
    // AssignTickets APIを呼ぶ
  }
}

ゲームサーバはDirectorからチケットとBackfillを受け取ります。チケットの Extensions には発行したときの値が入っているので、そこからプレイヤー情報を取り出して保存するということができます。今回のデモの場合はこれを経由してプレイヤー名を渡しています。

一方Backfillは、ゲームサーバが定期的に呼び出すべき AcknowledgeBackfill というAPIに使用されます。このAPIによりBackfillの情報が更新されるのですが、このときにOpen Match内部でチケットのアサインが行われています。先程Directorがアサインする必要がなかったのはこのためです。

ここまでくるとゲームフロントにチケットのアサイン情報が渡るようになります。つまり、待ち続けていた WatchTicket APIの結果が返り、UserAにその結果を返せるようになるのです!

  for assignment.GetConnection() == "" {
    assignmentsRes, _ := assignmentsStream.Recv()
    assignment = assignmentsRes.Assignment
  }
  stream.Send(&pb.EntryGameResponse{
    TicketId: ticketID,
  })
  // ticketの削除を忘れずに
  gf.FrontendClient.DeleteTicket(context.Background(), &ompb.DeleteTicketRequest{TicketId: ticketID})

チケットを受け取ったUserAはゲームサーバに接続し、無事最初のマッチングが完了します。

シーケンス図

二人目のプレイヤーのマッチング

次に二人目のプレイヤー、UserBが参加するまでの流れを見ていきます。UserBから見た動きは一人目であるUserAのときと変わらないので省きます。先程との違いが発生するのは発行されたチケットがmmfによってマッチングされる箇所からです。

先程はBackfillが存在しなかったのでmmfはチケットのみでのマッチングを行いましたが、今回はBackfillが存在するため、そちらを優先して埋める必要があります。さらに、今回は既にゲームサーバが存在するため、Match.AllocateGameServerfalse にする必要があります。

そうすることで出来上がった Match を受け取ったDirectorは、Open Matchに対してはこれ以上することはなく、またGameServerを新たにAllocateする必要はないので、既にあるゲームサーバに対してUserBのチケットとBackfillを送ります。

ここから先のプレイヤーに今のマッチングの情報が送られるまでの処理は一人目のプレイヤーと同様なので省略します。

シーケンス図

Backfillのマッチング停止からゲームの開始まで

最後に、参加者が揃わないまま制限時間を迎えるまでの処理を確認します。

今回のデモでは time.Ticker を用いてゲームサーバに AcknowledgeBackfill を実行させています。ここについでにタイムアウトを管理するタイマーを追加した処理が以下です。

Backfillがマッチング対象にならないように削除し、空いた枠へbotを追加しています。このとき、タイミング悪くmmfによってBackfillに充てられていたticketが存在していても、このタイミングでOpen Matchがマッチング対象に戻してくれます。

for {
  select {
  case <-ticker.C:
    if gs.BackfillID != "" { // Backfillがある場合のみAcknowledgeBackfillを叩く
      req := &ompb.AcknowledgeBackfillRequest{
        BackfillId: gs.BackfillID,
        Assignment: &ompb.Assignment{
          Connection: "myEndpoint",
        },
      }
      res, _:= gs.FrontendClient.AcknowledgeBackfill(context.Background(), req)
      gs.BackfillID = res.Backfill.Id
      for _, ticket := range res.Tickets {
        name, _ := getName(ticket)
        gs.RoomState.Members[ticket.Id] = &pb.Member{Name: name, Ready: false}
      }
      // Backfillが更新されている場合のみ参加者に更新情報を通知
      if res.Backfill.Generation > gs.LastBackfillGeneration {
        gs.LastBackfillGeneration = res.Backfill.Generation
        gs.RoomCh <- struct{}{}
      }
    }
  case <-timer: // 時間切れ
    ticker.Stop()
    gs.deleteBackfill() // Backfillを削除し、空き枠の募集を停止する
    for i := 1; len(gs.RoomState.Members) < RequiredMemberNum; i++ {
      name := fmt.Sprintf("bot%d", i)
      bot := &pb.Member{
        Name:  name,
        Ready: true,
      }
      gs.RoomState.Members[name] = bot
    }
    gs.RoomState.Ready = true
    gs.RoomCh <- struct{}{}
  }
}

botを追加し参加者を揃えたらUserAとUserBに対し、ゲームが開始できる通知を行い、このシナリオにおけるマッチングの全工程が完了します。

シーケンス図

まとめ

今回はOpen MatchのBackfill機能を使ったマッチングを試しました。Backfillが加わることでより複雑な実装が必要になりますが、ルームに徐々にメンバーが集まるようなマッチングの実装や、今回のように足りない場合のみbotを追加するといったマッチングを行う際にとても便利だと思います。

またそれだけでなく、ゲームの参加員数を可変にしたり、ルーム内のメンバーに合わせてマッチングの条件を変えてるといった、より柔軟なマッチングシステムを組むことができると感じました。

次は今回の例では試せなかった、Evaluatorでマッチングの優先度を指定することや、同じルームを使いまわしてユーザーの途中退出が可能を可能にするマッチングについて考えてみたいなと思います。

今回の記事が参考になれば幸いです。ここまで読んでいただきありがとうございました。

MIXI DEVELOPERS
MIXI DEVELOPERS

Discussion