ホビット体験 2014

ホビット エクスペリエンスに WebRTC ゲームプレイを追加する

ホビットの新作映画「ホビット ~戦いの決着~」の公開に合わせて、昨年の Chrome 試験運用版「中つ国をめぐる旅」を拡張し、新しいコンテンツを追加しました。今回の主な目的は、より多くのブラウザとデバイスでコンテンツを表示できるように WebGL の使用を拡大し、Chrome と Firefox の WebRTC 機能を使用できるようにすることです。今年のテストには 3 つの目標がありました。

  • Chrome for Android で WebRTC と WebGL を使用した P2P ゲームプレイ
  • タップ入力に基づいて簡単にプレイできるマルチプレーヤー型ゲームを作成する
  • Google Cloud Platform でホストする

ゲームの定義

ゲームロジックは、ゲームボードの上で兵士が動くグリッドベースの設定に基づいています。ルールを定義する際に、紙上でゲームプレイを簡単に試すことができました。グリッドベースの設定を使用すると、同じタイルまたは隣接するタイルのオブジェクトとの衝突のみを確認する必要があるため、ゲーム内の衝突検出でパフォーマンスを維持できます。新しいゲームでは、中つ国の 4 つの主要な勢力(人間、ドワーフ、エルフ、オーク)の戦いに焦点を当てることを最初から決めていました。また、Chrome テスト内でプレイできるほどカジュアルで、学習に必要なインタラクションが多すぎないゲームであることも必要でした。まず、複数のプレーヤーがピアツーピア バトルで競い合うゲームルームとして機能する 5 つのバトルグラウンドを中つ国の地図上に定義しました。モバイル画面に部屋内の複数のプレーヤーを表示し、ユーザーが挑戦する相手を選択できるようにすることは、それ自体が課題でした。操作とシーンを簡単にするため、挑戦と承認のボタンを 1 つだけとし、部屋はイベントと現在のキング オブ ザ ヒルのみを表示することにしました。この方向性により、マッチメイキング側の問題もいくつか解決され、バトルに最適な候補をマッチングできるようになりました。 以前の Chrome テスト「Cube Slam」では、マルチプレイヤー ゲームの結果がレイテンシに依存している場合、レイテンシを処理するために多くの作業が必要になることがわかりました。常に、対戦相手の状態や、対戦相手が自分がいると思っている場所を推測し、それをさまざまなデバイスのアニメーションと同期する必要があります。これらの課題について詳しくは、こちらの記事をご覧ください。少しでも簡単にするため、このゲームはターン制にしました。

ゲームロジックは、ゲームボードの上で兵士が動くグリッドベースの設定に基づいています。これにより、ルールを定義する際に紙上でゲームプレイを簡単に試すことができました。グリッドベースの設定を使用すると、同じタイルまたは隣接タイル内のオブジェクトとの衝突をチェックするだけでよいため、ゲーム内の衝突検出でも優れたパフォーマンスを維持できます。

ゲームの一部

このマルチプレーヤー ゲームを作成するには、いくつかの重要な部分を構築する必要がありました。

  • サーバーサイドのプレーヤー管理 API は、ユーザー、マッチメイキング、セッション、ゲーム統計情報を処理します。
  • プレーヤー間の接続を確立するサーバー。
  • ゲームルームのすべてのプレーヤーとの接続と通信に使用される AppEngine Channels API シグナリングを処理する API。
  • 2 つのプレーヤー/ピア間の状態と RTC メッセージの同期を処理する JavaScript ゲームエンジン。
  • WebGL ゲームビュー。

プレーヤーの管理

多数のプレーヤーをサポートするため、Battleground ごとに多くの並列ゲームルームを使用しています。ゲームルームあたりのプレーヤー数を制限する主な理由は、新しいプレーヤーが妥当な時間内にリーダーボードのトップに到達できるようにすることです。この上限は、Channel API を介して送信されるゲームルームを記述する JSON オブジェクトのサイズにも関連しています。このオブジェクトのサイズの上限は 32 KB です。プレーヤー、部屋、スコア、セッション、それらの関連性をゲームに保存する必要があります。そのために、まずエンティティに NDB を使用し、クエリ インターフェースを使用して関係を処理しました。NDB は Google Cloud Datastore へのインターフェースです。NDB の使用は当初はうまく機能していましたが、すぐに使用方法に問題がありました。クエリはデータベースの「commit 済み」バージョンに対して実行されました(NDB 書き込みについては、こちらの詳細な記事で詳しく説明しています)。この場合、数秒の遅延が発生することがあります。ただし、エンティティ自体はキャッシュから直接応答するため、そのような遅延は発生しませんでした。サンプルコードをいくつか使用した方がわかりやすいかもしれません。

// example code to explain our issue with eventual consistency
def join_room(player_id, room_id):
    room = Room.get_by_id(room_id)
    
    player = Player.get_by_id(player_id)
    player.room = room.key
    player.put()
    
    // the player Entity is updated directly in the cache
    // so calling this will return the room key as expected
    player.room // = Key(Room, room_id)

    // Fetch all the players with room set to 'room.key'
    players_in_room = Player.query(Player.room == room.key).fetch()
    // = [] (an empty list of players)
    // even though the saved player above may be expected to be in the
    // list it may not be there because the query api is being run against the 
    // "committed" version and may still be empty for a few seconds

    return {
        room: room,
        players: players_in_room,
    }

単体テストを追加した後、問題が明確になり、クエリから移行して、memcache のカンマ区切りリストに関係を保持しました。これはちょっとしたコツのように見えますが、うまく機能しました。AppEngine Memcache には、優れた「比較と設定」機能を使用してキーに対してトランザクションに似たシステムがあるため、テストには再び合格しています。

残念ながら Memcache には万能ではありませんが、いくつかの制限があります。特に注目すべきは、1 MB の値サイズ(戦場に関連する部屋の数が多くなりすぎない)とキーの有効期限、またはドキュメントで説明されているとおりです。

別の優れた Key-Value ストアである Redis の使用も検討しました。当時、スケーラブルなクラスタをセットアップするのは少し大変でした。また、サーバーのメンテナンスよりもエクスペリエンスの構築に注力したかったため、その方向には進みませんでした。一方、Google Cloud Platform は最近、シンプルなクリックしてデプロイ機能をリリースしました。この機能のオプションの 1 つが Redis Cluster であるため、非常に興味深いオプションだったでしょう。

最終的に Google Cloud SQL を見つけ、関係を MySQL に移行しました。大変な作業でしたが、最終的にはうまく機能し、更新は完全にアトミックになり、テストも引き続き合格しています。また、マッチメイキングとスコア管理の実装の信頼性が大幅に向上しました。

時間の経過とともに、より多くのデータが NDB と Memcache から SQL にゆっくりと移ってきていますが、一般的には、プレーヤー、戦場、部屋のエンティティは NDB に保存され、セッションとリレーションシップはすべて SQL に保存されます。

また、誰が誰と対戦しているかを記録し、プレイヤーのスキルレベルと経験を考慮したマッチング メカニズムを使用してプレイヤーをペアにする必要がありました。マッチメイキングは、オープンソース ライブラリ Glicko2 に基づいています。

これはマルチプレーヤー ゲームであるため、「誰が参加または退出したか」、「誰が勝ったか負けたか」、承認するチャレンジがあるかどうかなどのイベントを、部屋内の他のプレーヤーに通知する必要があります。これに対処するために、Player Management API で通知を受け取る機能を組み込んでいます。

WebRTC の設定

2 人のプレーヤーがバトルでマッチングされると、シグナリング サービスを使用して、マッチングした 2 つのピアが相互に通信し、ピア接続を開始できるようにします。

シグナリング サービスに使用できるサードパーティ ライブラリはいくつかあり、WebRTC の設定も簡素化されます。オプションには、PeerJSSimpleWebRTCPubNub WebRTC SDK があります。PubNub はホスト型サーバー ソリューションを使用しており、このプロジェクトでは Google Cloud Platform 上でホストすることにしました。他の 2 つのライブラリでは、Google Compute Engine にインストールできた Node.js サーバーを使用していますが、数千人の同時接続ユーザーに対応できることを確認する必要があります。これはすでに Channel API でできることがわかっていました。

この場合、Google Cloud Platform を使用する主なメリットの 1 つはスケーリングです。AppEngine プロジェクトに必要なリソースのスケーリングは、Google 開発者コンソールから簡単に行えます。また、Channels API を使用する場合、シグナリング サービスをスケーリングするために追加の作業を行う必要はありません。

レイテンシと Channels API の堅牢性について懸念がありましたが、以前に CubeSlam プロジェクトで使用したことがあり、そのプロジェクトで数百万人のユーザーに機能することが実証されていたため、再度使用することにしました。

WebRTC にサードパーティ ライブラリを使用することは選択しなかったため、独自のライブラリを構築する必要がありました。幸い、CubeSlam プロジェクトで行った作業の多くを再利用できました。両方のプレーヤーがセッションに参加すると、セッションは「アクティブ」に設定されます。両方のプレーヤーは、そのアクティブなセッション ID を使用して、Channel API を介してピアツーピア接続を開始します。その後、2 人のプレーヤー間のすべての通信は RTCDataChannel を介して処理されます。

また、接続を確立し、NAT とファイアウォールに対応するために、STUN サーバーと TURN サーバーも必要です。WebRTC の設定について詳しくは、HTML5 Rocks の記事「WebRTC in the real world: STUN, TURN, and signaling」をご覧ください。

また、使用する TURN サーバーの数も、トラフィックに合わせてスケーリングできる必要があります。この問題に対処するため、Google Deployment Manager をテストしました。これにより、Google Compute Engine にリソースを動的にデプロイし、テンプレートを使用して TURN サーバーをインストールできます。まだアルファ版ですが、目的に沿って問題なく機能しています。TURN サーバーには coturn を使用します。これは非常に高速かつ効率的で、一見信頼性の高い STUN/TURN の実装です。

Channel API

Channel API は、クライアント側でゲームルームとの間のすべての通信を送信するために使用されます。Player Management API は、ゲームイベントに関する通知に Channel API を使用しています。

Channels API の使用にはいくつかの障害がありました。たとえば、メッセージは順序付けされていない可能性があるため、すべてのメッセージをオブジェクトにラップして並べ替える必要がありました。以下に、その仕組みを示すコードサンプルを示します。

var que = [];  // [seq, packet...]
var seq = 0;
var rcv = -1;

function send(message) {
  var packet = JSON.stringify({
    seq: seq++,
    msg: message
  });
  channel.send(packet);
}

function recv(packet) {
  var data = JSON.parse(packet);

  if (data.seq <= rcv) {
    // ignoring message, older or already received
  } else if (data.seq > rcv + 1) {
    // message from the future. queue it up.
    que.push(data.seq, packet);
  } else {
    // message in order! update the rcv index and emit the message
    rcv = data.seq;
    emit('message', data.message);

    // and now that we have updated the `rcv` index we 
    // will check the que for any other we can send
    setTimeout(flush, 10);
  }
}

function flush() {
  for (var i=0; i<que.length; i++) {
    var seq = que[i];
    var packet = que[i+1];
    if (data.seq == rcv + 1) {
      recv(packet);
      return; // wait for next flush
    }
  }
}

また、サイトのさまざまな API をモジュラーにして、サイトのホスティングから分離し、GAE に組み込まれているモジュールを使用して開始しました。残念ながら、すべての機能を開発環境で動作させると、製品版ではモジュールを組み込んでも Channel API が動作しないことに気づきました。代わりに、個別の GAE インスタンスを使用するようになりました。CORS の問題に直面し、iframe の postMessage ブリッジを使用する必要がありました。

ゲームエンジン

ゲームエンジンを可能な限り動的なものにするため、エンティティ コンポーネント システム(ECS)アプローチを使用してフロントエンド アプリケーションを構築しました。開発を開始した時点では、ワイヤーフレームと機能仕様が設定されていなかったため、開発の進展に応じて機能とロジックを追加できたことは非常に有益でした。たとえば、最初のプロトタイプでは、シンプルなキャンバス レンダリング システムを使用してエンティティをグリッドに表示していました。数回その後、衝突用のシステムが追加され、AI コントロールのプレーヤー用のシステムも追加されました。プロジェクトの途中で、残りのコードを変更せずに 3D レンダラ システムに切り替えることができます。ネットワーキング部分が稼働したら、リモート コマンドを使用するように AI システムを変更できます。

マルチプレーヤーの基本的なロジックは、DataChannels を介してアクション コマンドの構成を他のピアに送信し、シミュレーションを AI プレーヤーのように動作させることです。さらに、どのターンが何であるかを決定するロジックがあります。たとえば、プレーヤーが前のアニメーションを見ているときにパスボタンや攻撃ボタンを押した場合にコマンドがキューに入ってくるとキューに入れます。

ユーザーが 2 人だけターンを切り替えた場合、両方のピアがターンを終えたら相手に引き継ぐ責任を分担しますが、3 人目のプレーヤーが関与します。AI システムは、スパイダーやトロールなどの敵を追加する必要が生じたときに、(テストのためだけでなく)再び便利になりました。ターン制のフローに適合させるには、両側でまったく同じ方法でスポーンして実行する必要がありました。この問題は、一方のピアがターンシステムを制御し、現在のステータスをリモートピアに送信することで解決しました。スパイダーの番になると、ターン マネージャーは AI システムにコマンドを作成させ、そのコマンドをリモート ユーザーに送信します。ゲームエンジンはコマンドとエンティティ ID に対してのみ動作するため、両側でゲームが同じようにシミュレートされます。すべてのユニットに ai コンポーネントを含めることもできます。これにより、自動テストを簡単に行うことができます。

開発の初期段階では、ゲームロジックに集中しながら、よりシンプルなキャンバス レンダラを使用するのが最適でした。3D バージョンを実装し、シーンに環境とアニメーションを加えて、本当に楽しいものになりました。3D エンジンには three.js を使用しており、アーキテクチャのおかげでプレイ可能な状態に簡単に到達できました。

マウスの位置がリモート ユーザーに頻繁に送信され、カーソルの現在地を示す 3D ライトがさりげなく表示されます。