ホビット エクスペリエンスに WebRTC ゲームプレイを追加する
ホビットの新作映画「ホビット ~戦いの決着~」の公開に合わせて、昨年の Chrome 試験運用版「中つ国をめぐる旅」を拡張し、新しいコンテンツを追加しました。今回の主な目的は、より多くのブラウザとデバイスでコンテンツを表示できるように WebGL の使用を拡大し、Chrome と Firefox の WebRTC 機能を使用できるようにすることです。今年のテストには 3 つの目標がありました。
- Android 版 Chrome で 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 の設定も簡素化されます。たとえば、PeerJS、SimpleWebRTC、PubNub WebRTC SDK などがあります。PubNub はホスト型サーバー ソリューションを使用しており、このプロジェクトでは Google Cloud Platform でホストすることを希望していました。他の 2 つのライブラリは Node.js サーバーを Google Compute Engine にインストールできますが、数千人の同時ユーザーを処理できることも確認する必要があります。これは、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 システムが(テストだけでなく)再び役立ちました。ターン制のフローに適合させるには、両側でまったく同じ方法でスポーンして実行する必要がありました。この問題は、1 つのピアにターンシステムを制御させ、現在のステータスをリモート ピアに送信することで解決されました。スパイダーの番になると、ターン マネージャーは AI システムにコマンドを作成させ、そのコマンドをリモート ユーザーに送信します。ゲームエンジンはコマンドとエンティティ ID に対してのみ動作するため、両側でゲームが同じようにシミュレートされます。すべてのユニットに ai-component を追加して、自動テストを簡単に行うこともできます。
開発の初期段階では、ゲームロジックに集中しながら、よりシンプルなキャンバス レンダラを使用するのが最適でした。3D バージョンを実装し、環境とアニメーションでシーンを生き生きとさせ、本格的な楽しみが始まりました。3D エンジンには three.js を使用しており、アーキテクチャのおかげでプレイ可能な状態に簡単に到達できました。
マウスの位置がリモート ユーザーに頻繁に送信され、カーソルの現在地を示す 3D ライトがさりげなく表示されます。