ホビット体験 2014

Hobbit エクスペリエンスに WebRTC ゲームプレイを追加する

Daniel Isaksson 氏
Daniel Isaksson 氏

Google では、ホビットの新作映画「ホビット 五軍の合戦」のリリースに合わせて、昨年の Chrome 試験運用版「中つ国の旅」に新たなコンテンツを追加する取り組みを進めています。今度は、コンテンツを表示できるブラウザとデバイスが増え、Chrome と Firefox で WebRTC 機能を使用できるようにして、WebGL の使用を拡大することを主な目標としています。今年のテストでは、次の 3 つの目標を設定しました。

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

ゲームの定義

このゲームロジックは、部隊がゲームボード上で動くグリッドベースのセットアップで構築されています。これにより、ルールを定義する際に、紙上でゲームプレイを試すことが簡単になりました。グリッドベースのセットアップを使用すると、同じタイルまたは隣接するタイル内のオブジェクトとの衝突のみをチェックする必要があるため、ゲーム内の衝突検出にも役立ち、良好なパフォーマンスを維持できます。新しいゲームは当初から、中つ国、人間、ドワーフ、エルフ、オークの 4 つの主力戦闘を中心としたいと考えていました。また、Chrome Experiment の中でプレイできる程度のカジュアルな雰囲気で、何度も操作して学ぶことがあってはなりません。 まず、中つ国マップ上に、複数のプレーヤーがピアツーピアの戦いで対戦できるゲームルームとして 5 つの戦場を定義することから始めました。部屋にいる複数のプレーヤーをモバイル画面に表示します。また、チャレンジする相手を選択できるようにすることで、それ自体がチャレンジでした。やり取りとシーンをわかりやすくするために、挑戦と受け入れのボタンは 1 つのみにして、イベントを表示するためだけに部屋を使用し、誰が現在の王様なのかを示すことにしました。この指示により、マッチメイキング面の問題もいくつか解決され、バトルの最適な候補をマッチングできるようになりました。 Chrome の前回のテスト版である Cube Slam では、マルチプレーヤー型ゲームのレイテンシに依存するゲームのレイテンシを処理するには、多大な労力がかかることがわかりました。常に、相手の状態がどこにいるのかを推測し、プレーヤーが自分がいると考え、それをさまざまなデバイスのアニメーションと同期する必要があります。この記事では、これらの課題について詳しく説明しています。お手軽な方法として、このゲームをターン制にしました。

このゲームロジックは、部隊がゲームボード上で動くグリッドベースのセットアップで構築されています。これにより、ルールを定義する際に、紙上でゲームプレイを試すことが簡単になりました。グリッドベースのセットアップを使用すると、同じタイルまたは隣接するタイル内のオブジェクトとの衝突のみをチェックする必要があるため、ゲーム内の衝突検出にも役立ち、良好なパフォーマンスを維持できます。

ゲームの要素

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

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

プレーヤー管理

多くのプレーヤーをサポートするために、バトルグラウンドごとに多数の並列ゲームルームを使用しています。ゲームルームあたりのプレーヤー数を制限する主な理由は、新しいプレーヤーが妥当な時間内にリーダーボードのトップに到達できるようにすることです。上限は、Channel API 経由で送信されるゲームルームを記述する JSON オブジェクトのサイズにも関係します(上限は 32 KB)。 プレーヤー、ルーム、スコア、セッション、それらの関係をゲームに保存する必要があります。そのために、まずエンティティに NDB を使用し、クエリ インターフェースを使用してリレーションを処理しました。NDB は Google Cloud Datastore へのインターフェースです。NDB は、当初はうまく機能していましたが、すぐに使用方法に問題が生じました。クエリは「コミット済み」バージョンのデータベースに対して実行されました(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 で関係をカンマ区切りリストで保持することにしました。これは少しハックしているように見えましたが、うまく機能しました。また、App Engine Memcache には、優れた「比較して設定」機能を使用するトランザクションのようなキー用のシステムがあるため、テストは再び合格しました。

残念なことに Memcache は必ずしもすべてのレインボー / ユニコーンというわけではありませんが、いくつかの制限があります。最も顕著な制限は、値のサイズが 1 MB(戦場に関連するルームをあまり多く持てないこと)とキーの有効期限、またはドキュメントに説明されているとおりです。

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

最後に、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 つは、スケーリングです。App Engine プロジェクトに必要なリソースのスケーリングは、Google Developers Console で簡単に処理でき、Channel API を使用する際にシグナリング サービスのスケーリングに追加の作業は必要ありません。

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

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

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

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

Channel API

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

Channels API を使用すると、速度が上がりました。一例として、メッセージが順序付けられていない可能性があるため、すべてのメッセージを 1 つのオブジェクトにラップして並べ替える必要がありました。その仕組みについて、コードの例をいくつか示します。

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)のアプローチを使用してフロントエンド アプリケーションを構築しました。開発を開始した当初は、ワイヤーフレームや機能の仕様は設定されていなかったため、開発が進むにつれて機能とロジックを追加できることは非常に有用でした。たとえば、最初のプロトタイプでは、シンプルな canvas-render-system を使用してエンティティをグリッドに表示しました。数回のイテレーションの後、衝突用のシステムと、AI が制御するプレーヤー用のシステムが追加されました。プロジェクトの途中で、コードの残りの部分を変更することなく、3D レンダラ システムに切り替えることができました。ネットワーク部分が稼働したら、リモート コマンドを使用するように AI システムを変更できました。

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

2 人のユーザーがターンを切り替えただけの場合、両方のピアがターンを完了したら、ターンを相手に渡す責任を共有できますが、3 番目のプレーヤーが関与しています。AI システムは、クモやトロールなどの敵を追加する必要があったときに、(テストだけでなく)再び便利になりました。ターンベースのフローに適合させるには、両側でまったく同じように生成して実行する必要がありました。この問題は、一方のピアがターンシステムを制御し、現在のステータスをリモートピアに送信することで解決されました。スパイダーのターンになると、ターン マネージャーは AI システムにコマンドを作成し、リモート ユーザーに送信します。game-engine はコマンドと entity-id:s で動作するだけなので、ゲームは両側で同じようにシミュレートされます。すべてのユニットに、簡単な自動テストを可能にする ai-component を含めることもできます。

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

マウス位置がより頻繁にリモート ユーザーに送信され、カーソルが現在どこにいるかを示す 3D ライトの微妙なヒントが表示されます。