皆様たいへんお世話になっております。オルトプラスでCTOやっています有馬と申します。
本記事は オルトプラス Advent Calendar 2023 の12/7の記事です。
メタバースが流行り、サーバサイドについてはMMORPGで培われてきた同時接続の技術が注目を集めています。またPhotonやDiarkis、GameLiftなど同時接続環境の選択肢も増え、実際に取り組まれている方も多いことでしょう。
弊社ではNode.jsなどを中心に、こうした同時接続環境を開発、運用しています。また、有馬自身もこれまでにさまざまなオンラインゲームやツールを作ってまいりました。
本稿では「チャットアプリは作れるけれど、オンラインゲームとしてどう作ればいいのかわからない」、「キャラクタの座標の共有とか作ってみたけれど、どうもうまくいかない」という人を対象に、こうした同時接続環境での開発ノウハウの一端をご紹介いたします。オンラインゲーム、メタバース、オープンワールドゲームの仕組みについて、ご興味を持っていただければ幸いです。
■よくある問題
ゲームの内容にもよりけりですが、下記のところがよく問題になります。
・キャラがフリーズするなどの「回線切断」に基づく問題
・キャラがワープするなどの「通信遅延」に関する問題
ほかにもマッチングやインフラコストに関して、問題になることが多くあります。
本稿ではひとまず「回線切断」、「通信遅延」のふたつについて見ていきましょう。
■回線切断
「フリーズ」という現象があります。たとえば対戦相手が急に動かなくなったり、ロビーにいる相手がまったく動かないというときです。多発してしまうと遊ぶどころではなくなってしまいます。
これの原因は通信が遅延しているか、回線が切断されたことをうまく検知できていないことがほとんどです。
通信というものは必ず切れます。モバイルはわかりやすいのですが、PCで有線をつないでいても不意の停電や事故で切れるということは、それなりにあります。サーバ側に負荷がかかってフリーズしているというのもあるでしょう。一方で通信が切れているのか疑わしい場合があります。実際には回線が切れているのに、何かしらの理由でソケットから切断を通知してこない、なんてことがよくあるのです。
ゲームではこうした場合でも正しく処理し、現在遊んでいる人やユーザーデータなどに影響を及ぼさないようにしないといけません。どうしたらいいのでしょうか?
サーバからpingを送って回線切断を検知する
フリーズを解決するには、一刻も早く、回線が切断されていることをゲーム側が把握しないといけません。早くわかればわかるほど、プレイヤーにフリーズしていると思わせることがなくなります。
ここで「回線が切断されている」ということを「通信が送受信できなくなっている」と言い換えてみましょう。それなら「生きてたら返事ください」という通信を送って返事が来るかどうかで判断できそうです。この仕組みを、ここではネットワークエンジニアリングの慣例に従って「ping」という名前で呼んでみます。
たとえば、こんなふうに定期的にデータを送り、それに対する返答が指定時間内に来たら「回線は切断されていない」と判定します。もし返信が来なかったら回線が切断しているものとして、ソケットを閉じ、扱わないようにします。
ゲームによってはこの時点で、他のクライアントに「このプレイヤーの回線が切れた」という通知をすることになるでしょう。
送るデータはできるだけ小さいデータにして負荷を下げます。よくあるのはサーバ時刻の同期用のunixtimeだったりします(中身がないというのもよくある)。
クライアントから送るべきか、サーバから送るべきかは、ゲーム内容や負荷によって方針が変わります。サーバの負荷があまりなく、ゲーム進行のロジックなどがサーバ側にある場合は、サーバから送ることがよくあります。
回線切断からの復帰
回線が切れたのはわかったのですが、プレイヤーがゲームに復帰してきたときはどうしたらよいのでしょうか? クライアントだけではデータ不正の問題があるため、サーバ側で切れたタイミングでゲーム内の情報を保存するような作りにします。
おおざっぱに言えば、こんなクラス構成をよく取っています。
// WebSocketひとつあたりの接続に対するデータ
type NetworkData = {
uid: number; //UID。デバック等で使用
ws: WebSocket | null; //ソケットの実体
game: Game | null; //ゲーム管理クラスの実態
};
export class NetworkManager {
//現在繋がっているWebSocketのリスト
private wsList: Array<NetworkData> = [];
/**
* WebSocketの接続開始
*/
public set(ws: WebSocket) {
const date = new Date();
const unixTimestamp = date.getTime();
const game = new Game();
this.wsList.push({
uid: unixTimestamp,
ws: ws,
game: game,
});
}
//認証してユーザーを特定する
//ユーザーに応じてデータ保存されていたらそれをリストアする
//ゲームへ復帰
/**
* WebSocketの切断
*/
public close(ws: WebSocket) {
try {
const index = this.wsList.findIndex((v) => v.ws === ws);
if (index >= 0) {
this.wsList[index].game.backup(); //ゲーム系のデータ保存
this.wsList[index].game.close(); //ゲーム系の切断処理を呼び出し
this.wsList[index].game = null;
this.wsList.splice(index, 1);
}
} catch (e) {
logger.info('ERROR', e);
}
}
}
「ネットワークを管理するクラス」、「1接続あたりの情報を管理するクラス」、「1ユーザあたりのゲームを管理するクラス」があります。ソケットが作られたときに、「1接続あたりの情報を管理するクラス」をひとつ実体化し、そこに「1ユーザあたりのゲームを管理するクラス」を紐づけます。ゲーム中の情報は、「1ユーザあたりのゲームを管理するクラス」の中で、常に更新していきます。
回線が切断された場合は、「1ユーザあたりのゲームを管理するクラス」の中にあるデータをDBなどのデータストレージやキャッシュに保存して、次の接続に備えます。
もしゲームが終了していたり、永続して保持することが良くない場合は、サーバ側で時限処理を行います。クラス内に生成時間を置いといてログイン時にそれを元に判断したり、Redisなどのキャッシュであればデータ保持時間を設定して勝手に消えるようにしておきます。
■遅延対策
もうひとつ、よくあるのが「ワープ」です。他のプレイヤーが突然移動してしまう現象です。ワープしている間の座標データが送られてこないため、クライアント側で表示できず、このような形で見えてしまいます。FPSのように弾がプレイヤーに当たったかどうかがゲームの勝敗につながるものでは、この現象はとかくシビアな問題を生みます。どうしたらいいのでしょうか?
データはできるだけ小さく、送る頻度は少なく
これが鉄板の方針となります。
プレイヤーの座標を共有するような場合、毎フレームごとに座標値を送受信するとしたら、「30FPS×座標(x,y,z)×接続しているプレイヤーの人数」を各プレイヤーに送ることになります。仮に5万人同時接続を目指すとしたら膨大なデータを短い間に送らないといけません。それぞれの軽減方法を考えてみましょう。
通信頻度を減らす
各プレイヤーの挙動を実際に見たら、他のプレイヤーとおしゃべりしたりして、動きが止まっている人も多くいるはずです。としたら、何も正直に30FPSごとに同期するようなデータを送らなくて良く、そのプレイヤーが動いたときだけデータを送るようにすれば、見た目は変えずに通信頻度を下げられます。
同じ理由で「見えない人たちのデータは送らない」というのもあります。フィールドの山の向こうにいる見えないプレイヤーのデータは送られてきても使うことができません。3DではLoDという考えで、プレイヤーから見えないところはレンダリングしないという仕組みがあります。それと同じことになります。この方式を使うとしたらサーバ側でフィールド情報を持ち、クライアント側から視点の情報をもらい、それを元に判断していくことになります。
「通信データをまとめる」という考えも重要です。単純な座標データだけで表示に使うわけではなく、そのキャラクタの状態なども表示に必要なはずです。これをバラバラに送ると通信頻度が増えてしまいます。なるべく「処理ひとつに使うデータはひとつにまとめる」ようにして、通信の頻度を下げていきます。
データを減らす
座標データの共有を考えるとき、移動方向などのベクトルだけ送れば、あとはクライアント側で計算できます。モーションの動きはどのクライアントでも同じにすることが多いので、それならベクトルデータだけもらえたら全部のクライアントで同じく表示できます。
キャラクタの移動でこれを使うと、「ぴったり止まらない」、「次の通信が来るまでずっと歩き続ける」とかの問題が起き、それはそれで対策が必要なのですが、「矢のような軌道がわかる攻撃物体」、「単純な動きをするNPC」などは、これで充分です。
こんな考え方で、そもそものデータを減らしていきます。
先ほど紹介した「通信データをまとめる」と矛盾していそうですが、実際その通りです。送信データと通信頻度、どっちを落とすか、たえず天秤の関係です。
複数の回線でクライアントとサーバをつなぐ
1本の回線より、数本の回線をつないだほうが安定するのではないか、という富豪的解決方法です。どの回線を使うかは、先ほど紹介した「ping」で返ってくるデータの時間を見ることで、遅延が少ないかどうか判断して選ぶことができます。また、「座標共有用」、「バトル用」と用途を分けることで、1回線当たりの共有するデータ量や頻度を下げられます。
実装するときはクライアント側にはsocket通信クラスのインスタンスを複数持ち、違うIPか違うポート番号のサーバへ繋いでいくことになります。
企画内容で何とかする
技術的な対策方法はどこかで行き詰ります。企画内容の方で調整したほうが、まだ助かる道があります。
たとえば魔法で敵を攻撃するような場合、すぐ攻撃するのではなく、魔法発動のモーションを0.5秒ほど取り、そのあとに攻撃があるようにします。この「間」がとても助かります。0.3秒遅れでデータが届いた場合、残り0.2秒でモーションを早送りで再生させることで、さほど遅れているようには見えなくなります。
実際に遅れてデータが届いたかどうかは、各プレイヤーの共通の時計(サーバ時刻)を用意し、それと比較することで、遅延した時間を知ることができます。たとえばこうなります。
サーバ時刻を同期するには、ゲームが始まるときにサーバ側から時刻(timestamp)を送り、それを受け取った時間からどれぐらい経過したかで、クライアント側は現在のサーバ時刻を知ることができます。
サーバからデータを送る時には、このサーバ時刻を添付してあげます。そうすれば、受け取ったクライアントのほうで経過時間と比較することで、どれぐらい他のクライアントとズレているのかわかります。
このほかにルームやワールドという考え方も助けてくれます。200人まで、という上限を定めることで、無制限に通信データや通信頻度が増えることを防げます。一見地続きになっているオープンワールドでは、たとえば村や町など、何かで「見えない区切り」があり、その単位で管理している方法をよく見かけます。
■弊社の開発事例
弊社オルトプラスでは、下記の内容を基本として、各ゲームに合わせてカスタマイズしています。
内容 | 採用しているもの |
---|---|
環境と言語 | Node.js + TypeScript |
ライブラリ | Express等を使用 |
通信 | WSS (WebSocket over SSL/TLS) |
暗号化 | 適宜 |
データフォーマット | JSON |
サーバ | ECS/Fargate |
マッチング部分はゲームの企画内容に合わせて、都度自作しています。
クライアント側とサーバ側のAPI定義については、自作のツールを使ったり、規模感が小さいものでは普通に仕様書に書いて、修正あったら伝え合うというようにしています。弊社ではUnityをいじれるサーバエンジニアが何人かいまして、このあたりを一手に引き受ける、ということもあります。
■Tips
実際に同時接続環境を運用した際のTipsについて述べてみます。
AWSのALBでWebソケットを扱うときの対応方法
AWSのALBは、WSSに対応していて、ソケット通信を扱うサーバでもALBが提供するオートスケール等のメリットをHTTPサーバと同じように受けられます。
ただ、サーバ接続時にALBがどのサーバを選ぶかは、わりと偏りがあり、均等に各サーバへ割り振られるようなイメージにはなりませんでした。サーバからALBに対して何かしら制御できればよかったのですが、あまりそういうこともできず……。
「できるだけサーバの負荷、接続人数を偏らないようにしていく」、「急激に接続数が増えても空いたサーバに接続できるようにする」について対応するとしたら、いろいろな方法がある(ワールド制にしてユーザーが選択させることで負荷下げる、接続数を返すAPIでサーバを選ぶ、などなど……)のですが、弊社ではせっかくのALBを活かすようにしたくて、「ALBとそれにつながるサーバの組み合わせを複数用意する」、「DNSラウンドロビンで各ALBにクライアントの接続を割り振る」ということで、わりと良い結果を得られました。
Node.jsのチューニング
いろいろやった上で最も効果的だったのは、Clusterモジュールの導入でした。CPUに応じてNode.jsのタスクを割り当てるというものです。マルチCPUのサーバなら、サーバのリソースをフルで使えるようになります。初めからこれを入れることにして、コードを作るようにしたらよいかなと思います。
Node.jsのメモリリーク対策
よくあるのは回線切断されると、それに紐づいてないリソースが解放されず溜まってくという現象です。
回線切断の検知には、pingを使うなどして必ずわかるようにしたり、ソケットひとつあたりの処理にいろいろなものを紐づけるようにして、切断時には必ずメモリへの参照を外すようにしていくとよいです。
このほか、Node.jsのメモリ使用状況を出す各種ライブラリを併用して調べていきます。同時接続環境だと複数の接続により原因が特定しにくいことがよくあります。基本はログに出していく感じになります。ソケットがつながるたびにUIDを振り、「時刻」「ソケットUID」「処理内容」「メモリヒープ容量」などを1行で出し、メモリの増加具合を見ていきます。リークのタイミングがわかるまで、こうしてログを地道に追いかけて行くことになります。これは今も昔も変わらないですね……。
同時接続環境の負荷試験
弊社では負荷試験のツールに「k6」をよく使っています。ソケット通信に対応していて、HTTPSの通信でデバッグ用APIを叩きながら同期通信のデータを処理する、なんてこともシナリオに記述できます。
またクライアント側のコードを改造して複数繋ぎに行く、という方法もやっています。事情があり、k6 のシナリオを書くのがたいへんだったり(とくに認証周り)、クライアント側の挙動も見たいときはこうしています。
■終わりに
ほかにもサーバ間データ共有(RedisのPub/Subつかってよい成果がありました)、マッチングの仕組み、サーバ側でのゲームフィールドの保持方法など、いろいろ興味深い内容があるのですが、ひとまずここまでにさせていただきます。
弊社ではおかげさまでこうしたノウハウが溜まってきています。同業他社様からもよくご相談を受けるようになりました。
一方で技術的な躍進が見られています。弊社では Laravel Octane + Swooleによるソケット通信環境について研究しています。また、レンダリングをサーバ側で行い、各プレイヤーに画面共有させる方法も一時期ありましたが、そちらについても興味があります。AIなどの学習系も、何かしら転用できそうです。
この分野はこれからもどんどん新しいものが出てくることでしょう。今後も良いものが作れたらと思います。
本稿が同時開発環境に取り組みたいあなたの一助となれば幸いです。