ADVENT CALENDAR

LiteNetLibとMessagePackで行うリアルタイム通信

By wraikny

こんにちは

これは AmusementCreators 2020 アドベントカレンダー の 15日目の記事です。

オンラインでリアルタイムに協力とか対戦するゲームをつくりたいというモチベーションから、この記事を書きます。

動いている様子

これは実際にサーバーのプログラムをAmusementCreatorsのVPSに設置し、手元のクライアントから接続して同期している様子です。 pingは18ms前後で、ほとんどラグを感じずに操作できています。

サーバーはしばらく動作させておきます。 実行してみたい場合は、以下のURLからダウンロードできます。 なお、macOSでは実行権限を付与した上でコマンドラインから実行する必要があります。

ACAC2020_15 - Google Drive

はじめに

ライブラリの一覧

  • LiteNetLib
    • RUDPを扱う純C#製ライブラリで、レイテンシシミュレーション、NAT超えのオプション、暗号化処理を入れる仕組みなどがあって便利。
  • MessagePack-CSharp
    • MessagePackのC#実装。処理が極めて高速で、LZ4での圧縮やセキュリティのオプションも可能。

描画にはゲームエンジンAltseed2を使います。

  • Altseed2(.NET)
    • AmusementCreators中心に開発中のOSSゲームエンジン。
  • MessagePack.Altseed2
    • MessagePackのAltseed2向け拡張(構造体をサポート)。

LiteNetLibのレイテンシシミュレーションはDEBUGビルドでのみ動くため、nugetではなくsubmoduleで追加します。

Altseed2はnuget経由ではなく最新のビルドを利用します。(GitHub Actionsから取得できます)

MessagePack.Altseed2はlocal-altseed2ブランチをsubmoduleで利用して、ローカルのAltseed2を参照させます。

サーバー側もAltseed2の構造体を共通で使いたかったので、Altseed2のdllとMessagePack.Altseed2を参照しています。

今回の実装を行ったリポジトリはこちらです。

wraikny/ACAC2020-15 - GitHub

前提条件とか

  • Reliable User Datagram Protocol: 信頼性をもたせたUDP。TCPではオーバーヘッドが大きすぎる場合に使われます。
  • MessagePack: 効率の良いバイナリ形式のオブジェクト・シリアライズフォーマットです。

LiteNetLibにも簡易的なバイナリシリアライザは乗っていますが、MessagePackのほうが様々な面で扱いやすいのでこちらを利用します。(APIの使いやすさや性能やUnion等)

リアルタイム通信・オンラインゲームの実装には大まかに以下の3種類があると思います。

種類 メリット デメリット
クライアントが直接通信(P2P) サーバーを介さないので速い チートしやすい, N^2の通信が発生, 整合性を取りづらい
通信の中継を行うサーバー 人数が増えても通信回数を抑えられる チートしやすい, 整合性を取りづらい
ゲームロジックを持つサーバー チート対策が比較的楽, 整合性が取れる サーバーの処理が大きい

今回は規模が小さいものを楽に作りたいので、サーバーサイドにゲームロジックを持たせることにします。

オンライン通信のあるゲームでは、遅延によって状態に整合性が取れない場合があります。

例えばほぼ同じ瞬間に2人のプレイヤーが同じアイテムを拾ったとき、それぞれにプレイヤーには相手がそのアイテムを拾ったという情報が伝わっていないので、単純な実装ではズレが発生してしまいます。 整合性を取るために状態を巻き戻したり、確認の処理を挟む必要が出てきます。

P2Pやリレーサーバでは各クライアントがゲームロジックを処理するため、それぞれのクライアントで処理結果が同じになるように遅延を考慮して整合性を保つ必要があります。

サーバーにゲームロジックがあればサーバーの状態が正しいと考えて良いので同期ズレを考慮する必要が少なくなるはずです。また、クライアントは操作のみを送るとすれば、不正なデータを送信するようなチートはしずらくなります(ゲーム内容によってはbot対策などはまた別途必要ですが)。

諸々の解説

MessagePackについて

  • classやpropertyのアクセシビリティは全部publicにしましょう、注意(分かりづらい例外が発生して死ぬ)
  • MessagePackAnalyzerを入れてVisualStudioを使うことで、Attributionのつけ忘れは警告してくれるようになります。
  • readonlyfieldsetterには、デシリアライズ用のコンストラクタを定義しましょう。(SerializationConstructor)
  • MessagePackはinterfaceを利用したUnionに対応していますが、その場合はMessagePackSerializer.Serialize<IHoge>(fuga)とinterfaceを指定してシリアライズを行いましょう(具体的なclassとしてシリアライズを行うと、Unionとしてのデシリアライズが失敗します)
  • No hash-resistant equality comparer available for type: Altseed2.Vector2I: Altseed2.Vector2IDictionarykeyにしてMessagePackに突っ込んだらなんか例外が出た、悲しい。

幸い有名でよく使われているライブラリなので、エラーメッセージでググるとGitHubのissueがヒットしてくれます。

LiteNetLibについて

DeliveryMethod

DeliveryMethodの種類としておおよそ以下のものがあります。

  • ReliableOrdered: 送信保証・順序保証あり。
  • ReliableSequence: 最新のデータのみ送信保証あり、遅れて来たデータは無視する。(最新情報のみ欲しい場合に使う)
  • ReliableUnordered: 送信保証のみあり。
  • 上記のReliableが外れたもの: 送信保証なし。

この辺の用語の説明は以下のサイトに詳しく書かれていました。(別のライブラリですが)

Reliability Types - RakNet

NetPeer.SendではDeliveryMethodに加えてchannnel: byteを指定できて、複数のDeliveryMethodを使い分けたい場合に便利です。ただし、NetManaber.ChannelsCountを事前に指定する必要があります。

注意点としては、送信するヘッダーサイズ+データサイズがMTUサイズを超える場合に、ReliableOrderedReliableUnorderedでは分割して送信してくれますがそれ以外では例外が発生するということです。LiteNetLib/LiteNetLib/NetPeer.cs#L533

また、全く制御せず単にUDPでおくるためのメソッド(NetManager.SendUnconnectedMessage)なども用意してあるようです(これはLiteNetLibをつかう意味は特にないですけど)

SimulateLatency, SimulatePacketLoss

LiteNetLibには、レイテンシシミュレーションを行うNetManager.SimulateLatency、パケットロスシミュレーションを行う NetManager.SimulatePacketLossのオプションがあります。これらはLiteNetLib自体のDEBUGビルドでのみ有効なので、利用したい場合はsubmoduleとして追加する必要があります。

NatPunchEnabled

LiteNetLibには標準でNAT超えのための機能があり、このオプションを有効にすることでいい感じになりそう(自分は未検証)。

通信の仕方

この辺はいろいろ最適化の余地もあると思いますが、とりあえず今回の実装の説明です。

  1. 各クライアントは操作のたびに、その操作を表すデータを送信します。
  2. サーバーはデータを受信するとゲームステートの更新を行い、差分が生じた場合は更新されたというフラグを立てます。
  3. サーバーは数msごとにゲームステートに更新があったかどうかを確認し、更新されていたら各クライアントに現在のゲームステートを送信します。
  4. クライアントがゲームステートを受信すると、それを画面に反映します。

気をつけるポイントは、サーバーが各クライアントにデータを送る場合に、クライアントの操作を受信するたびに送信はしないで、数msごとに全クライアントに送信するという点です。 人数が大きくなった際に通信回数がN^2で肥大する原因となるので、気をつけると良いらしいです。

これは以下の記事などを参考にしました。

本来はクライアント側の送信も数msごとにまとめて送信するほうが通信回数が減って良いかもしれませんが、その場合に発生する遅延のことを考慮するのが面倒だったので今回は即時に送信することにしました。

コードを探索する

ここからは実際のコードをいくつか追ってみます。

フォルダ構成

  • src/Shared 各プロジェクトで共通で利用するコードを配置したディレクトリ。共通の設定・メソッドの他、通信で使用するオブジェクトの定義などが含まれます。

  • src/ACAC2020_15.Client ゲームクライアントのディレクトリ。

  • src/ACAC2020_15.Server ゲームサーバーのディレクトリ。

LiteNetLib、MessagePackのオプションなど

Shared.Settings

LiteNetLibに関する設定をまとめて書きました。

Shared.Utils

MessagePackOptionを定義してあります。 クライアントとサーバーそれぞれのProgram.Mainの先頭でMessagePackSerializer.DefaultOptionsに設定しています。

なお、ここではMessagePack.Altseed2のResolverの追加、ネットワーク越しに送られてくるバイト列をデシリアライズする際の脆弱性を考慮したセキュリティの設定、LZ4を使用した圧縮を指定しています。

MessagePack-CSharp、本当に使いやすくて素晴らしいです

LiteNetLib関連

Server.Client

サーバー側でクライアント側の情報を管理するためのクラスです。

今回は利用しませんでしたが、各クライアントごとに平均のレイテンシを計算して保持しています。

Server.Server

サーバーのクラスです。 INetEventListenerを実装することで、NetManagerに登録してイベントが起こった際に処理を記述できます。

ClientにはId : ulongを持たせていて、Dictionary<int, Client>NetPeerId: intと対応させています。 これはLiteNetLibの内部で接続解除されたNetPeerのインスタンスが使い回されているようだったので、ServerではClient.Idを別で管理しています。 OnPeerConnectedで、クライアントにClient.Idを送信しています。

他は基本的にはLiteNetLibのExampleに従いました。

また、今回はサーバーに唯一のGameStateをもたせてOnPeerConnectedGameState.PlayerEnterを、OnPeerDisconnectedGameState.PlayerExitを呼んでいますが、 サーバーに複数のルームのようなものをもたせたい場合はメッセージのやり取りをした上で入退室を行うと良いです。

OnNetworkReceiveではメッセージを受け取った際の処理を書いています。 ここでMessagePackを利用したデシリアライズと型でのパターンマッチを行って、GameStateのメソッドを呼び出して更新します。 IClientMsgUnionに関しては後述しますが、これが簡単にできるのがMessagePackのいい点です。

UpdateメソッドはServer.Program.Mainからループの中で呼び出しています。 ここが前でも述べたポイントで、更新が発生したか確認してすべてのクライアントへ送信することで回数を抑える工夫です。

Client.NetworkNode

Server.Serverと同様に、INetEventListenerを実装しています。 OnNetworkReceiveでメッセージを受け取った処理を記述しているのも同様です。

一応Altseed2.Nodeを継承して実装しました。

Message

Shared.IServerMsg

サーバーからクライアントに送信するメッセージを表しています。 MessagePackのUnionという機能を使うことで、interfaceやabstract classを対象に事前に指定したクラスでシリアライズ・デシリアライズが可能になります。

クライアントに自身のIdを伝達するIServerMsg.ClientIdクラスと、現在のGameStateを送信するIServerMsg.SyncGameStateを定義しました。

なお、今回の実装ではGameStateを直接送信していますが(楽なので)、それによってデータサイズが肥大化することが懸念される場合には差分のデータのみを送信したり、ゲームの実装によってはプレイヤーの周囲外をカリングすることでデータサイズを削減できると思います。

Shared.IClientMsg

クライアントからサーバーに送信するメッセージを表しています。 実装によってはその他のメッセージが増えることも考えて、IPlayerActionという型で具体的な操作を定義しています。

Shared.IPlayerAction

プレイヤーがゲームで行う操作を表しています。

ここではMoveCreateBlockBreakBlockの3種類の操作があり、Moveでは列挙型のDirectionのみを持っているため、クライアントは不正に位置情報を書き換えるなどが難しくなっています。

ただし今回の実装では更新回数のバリデーションなどは行っていないので1フレームに何度も情報を送信できてしまいますが、実際はClientに送信間隔などの情報をもたせて ServerOnNetworkReceiveなどでバリデーションを行うと良いと思います。

Shared.GameState

ゲームの状態を管理します。 ゲームの内容が小さいので大したことはしていません。 GamePlayerGameBlockの管理を行っています。

MessagePackで送信するので実装はShared以下に置いていますが、実際の更新はサーバーでしか行わないのでpartialを使って更新のためのメソッドはServer以下に置いてもいいかもしれません。 ゲームの内容によっては、クライアントとサーバー両方で更新した後にサーバーから届いた情報を適宜反映させる必要もあるかもしれません。

クライアント側のIO(入力・表示)

Client.PlayerInputNode

プレイヤーの入力を管理するクラスです。 Network関連のメソッドへの参照をもたせるとコードが汚くなりそうだったので、eventを用意してIPlayerActionのインスタンスを流すようにしました。

いろいろ

  • BlockViewNode ブロック表示用のノード
  • PlayerViewNode プレイヤー表示用のノード
  • OtherPlayerNode 自分以外のプレイヤーを表すためのノード。今回はPlayerViewNodeのラップでしかないが、特定の処理を行いたい場合を考えて用意してある。
  • SelfPlayerNode 自身を表すためのノード。同様にPlayerViewNodePlayerInputNodeのラップ

Client.SceneNode

Client側の全体の処理を管理しているのノード。

OnPlayerInputで、プレイヤーが入力を行った際にメッセージをサーバーへ送信している。

OnReceiveGameStateで、サーバーから最新のGameStateが送られてきたとき他プレイヤーとブロックの追加・更新・削除を行っている。

以上のコードからわかるように、クライアント側ではGameStateに対する一体の更新処理を行わず、サーバーから受け取った情報のみから画面の更新を行っています。

しかし、動画のように実際にサーバーを介した通信を行ってみても、この程度の規模とゲーム性であればラグは感じずにプレイできていました。 もちろん、日本国内での通信のみ、かつ光回線で有線接続しているためレイテンシが少ない、というのもあるとは思いますが。

おわりに

考察など

今回はとにかく楽に動くものが作りたかったのでこんな感じのアプリケーションを作ってみましたが、例えば座標が連続的になるだけでラグや通信間隔が気になり始めるかもしれません。

人数がもっとに増えれば、当然通信回数をもっと抑える必要が出てくると思います。

ラグをごまかすための手法としては、アニメーションの初動を工夫したり、座標の線形補間を行ったり、データの送信時にゲーム開始時から現在の時刻を一緒に送信するなどの方法があるようです。

また、ゲーム性の面でもオンラインに向いているゲーム・向かないゲームというのはありますよね。

例えばシューティング系のゲームでは、弾の座標は速度とレイテンシをもとに補間しやすいです。

少人数の対戦パズルでお邪魔を送り合うようなゲームでは、ラグの補間はおじゃまのタイミング程度な気もしますし(比較的)楽に実装できそうです。

一方で、プレイヤー同士に当たり判定があったり押し合ったりするゲームでは、座標を適切に補間したり数フレーム前までの情報を保持し続けるなど、かなりの工夫が必要そうですね。

気持ち

LiteNetLibとMessagePackを使って、かなり手軽かつ快適にリアルタイム通信が実現できました。 オンラインゲームを作りたい方は今回のコードを参考にしたり、逆に通信回数の削減やラグをごまかす手法について記事を書いてもらえたら嬉しいなと思っています。

また、P2PのためのマッチングサーバーなどはREST APIベースで比較的楽に作れそうですし、年が明けたら着手の機運もあります。

ところで……

私が制作したゲーム RouteTiles はご存知でしょうか?

タイルをつなげて消すパズルゲームで、複雑につなげて一気に消す爽快感が楽しく、オンラインランキングで得点を競うのがかなりアツいです! UIにもこだわって作ったので、ぜひ遊んでみてください。

実は今回のLiteNetLibの調査は、このRouteTilesに対戦機能を入れたいというモチベーションから始めたものでした。 年明け後から本格的に着手する予定なので、応援していただけると嬉しいです!

DLはこちら: 夏休みゲームジャム成果発表!!新作大公開SP

SHARE THIS POST