サーバサイドプログラムの必要性について

目次

  概要
  クライアントサイドプログラムによる、基本的なゲーム開発&実装について
  クライアントサイドプログラムの問題点(チートの温床となる部分)
  チート対策以外でも必要とされる、サーバサイドプログラムの利点


概要

なぜ「サーバサイドプログラム」が必要となるのか?

  ここからは、オンプレミス版 MUN サーバを用いた運用の最大の利点ともいうべき、「サーバサイドプログラム」の実装方法について触れていきますが、
  そもそも「なぜサーバサイドプログラムが必要なのか」について説明しなければなりません。

  MUN 1.0 がリリースされた段階から今日に至るまで、
  ゲームロジックを全てクライアントサイドに実装することで、マルチプレイヤーゲームの実装をより簡単に行なうことが出来る
  というのが、MUN(Monobit Unity Networking) の商品に対する開発コンセプトの1つでした。

  現在でもそのコンセプト自体は変わることはありません。
  ただし、「サーバサイドプログラム無しでは成立しない」運用を強いられるケースが多々あるのもまた事実です。

「サーバサイドプログラム」が絶対に必要になる、その最大の理由は 『チート対策』 である!

  サーバサイドプログラムが必要となる場面はいくつかあります。
  例えば 非匿名の認証モジュールを使用する ことも、その場面の1つではあります。
  他にも、サーバを自社内で(文字通りのオンプレミスサーバとして)管理運用するというのは、
  インフラコストや管理運用面のバランスを考えた場合に適切な場合もあるでしょう。

  ただし、これらはサーバサイド”プログラム”を必要とする場面ではなく、
  サーバシステムはほぼそのままの運用で、動作仕様(プログラムにしても、ごくごく一部)をカスタマイズしているだけに過ぎません。

  サーバサイドプログラムが絶対に必要になる、その殆どを占めるであろう最大の理由は 「チート対策」 にあります。

  このページでは、サーバサイドプログラムが必要になる最大理由である「チート対策」の見地から、
  なぜ「サーバサイドプログラム」が必要とされるのか、について説明します。


クライアントサイドプログラムによる、基本的なゲーム開発&実装について

クライアントサイドでゲームを実装する場合の、ごく基本的なテンプレート

  まずはクライアントサイドプログラムで、ゲーム開発を行ない、実装した場合について、簡単な事例を取り上げてみましょう。

  クライアントサイドでゲームを実装する場合、原則的に以下のセオリーに従って、ゲームプログラムの開発を行ないます。
     ・ 個々のクライアントが制御すべき内容については、MonobitEngine.MonobitView.isMine で分岐して制御を行なう。
     ・ ルーム内のゲームルールや判定・監視に即した内容については、MonobitEngine.MonobitNetowk.isHost で分岐して処理を行なう。

  典型的な雛形として示すのであれば、以下のような実装を採用することになるはずです。
  (以下のパネルをカーソルクリックしてください。)
/*
 * MUN によるクライアントサイドプログラミングを実装する場合の、ごくごく一般的なテンプレート.
 */
class Foo : MonobitEngine.MonobitNetwork
{
    /*
     * Update.
     */
    void Update()
    {
        // サーバ接続&ルーム入室していることが前提
        if (MonobitEngine.MonobitNetwork.isConnect && MonobitEngine.MonobitNetwork.inRoom)
        {
            // 自身がルームホストであるかどうか
            if (MonobitEngine.MonobitNetwork.isHost)
            {
                //------------------------------------------------------------------
                // TODO : ゲームルールの制御
                // TODO : 各種判定処理(衝突判定など)
                // TODO : 各種監視処理(タイマー監視など)
                //------------------------------------------------------------------
            }
            // 自身に所有権があるオブジェクトかどうか
            if (this.monobitView.isMine)
            {
                //------------------------------------------------------------------
                // TODO : 各種ユニークオブジェクト制御(キャラクタのキー操作など)
                //------------------------------------------------------------------
            }
        }
    }
}

この実装例に基づいたサンプルについて

  基本的に MUN 1.x 時代から公開している弊社サンプルも含め、多くの MUN 利用者は上記のようなテンプレートに沿って開発していると思われます。
  MUN2.1 以降からは、サーバサイドプログラムの比較対象例として RakeupGame (ClientSide) のサンプル を公開しています。

  RakeupGame (ClientSide) の上記の雛形に比較して抽出すると、以下のような実装を採用することになるはずです。
  (以下のパネルをカーソルクリックしてください。)
        /*
         * 更新関数.
         */
        void Update()
        {
            ...

            // ホストの場合の処理
            if (MonobitNetwork.isHost)
            {
                // アイテムに接近したら、アイテム取得処理を実行する
                if (gameItemRakeupCount < gameItemLimit)
                {
                    for (int index = itemObject.Count - 1; index >= 0; --index)
                    {
                        foreach( SD_Unitychan_PC playerObject in s_PlayerObject)
                        {
                            if ((playerObject.transform.position - itemObject[index].transform.position).magnitude < 1.0f)
                            {
                                // そのオブジェクトを削除する
                                MonobitNetwork.Destroy(itemObject[index]);
                                itemObject.Remove(itemObject[index]);
 
                                // 自身のスコア情報を加算するRPC処理を実行する
                                monobitView.RPC("OnUpdateScore", MonobitTargets.All, playerObject.GetPlayerID(), playerObject.MyScore + 100);
                                gameItemRakeupCount++;
                            }
                        }
                    }
                }
                else
                {
                    // ゲーム終了メッセージを送信
                    monobitView.RPC("OnGameEnd", MonobitTargets.All, null);
                }

                // 制限時間の減少
                if (gameTimeLimit > 0)
                {
                    gameTimeLimit--;
                }
                else
                {
                    // ゲーム終了メッセージを送信
                    monobitView.RPC("OnGameEnd", MonobitTargets.All, null);
                }

                // 個数制限&時間制限のタイミングで、ゲームシーン上にオブジェクトを配置
                if ((gameItemIsPut < gameItemLimit) && (gameTimeLimit % 10 == 0))
                {
                    // ある程度ランダムな位置・姿勢でプレイヤーを配置する
                    Vector3 position = Vector3.zero;
                    position.x = UnityEngine.Random.Range(-10.0f, 10.0f);
                    position.z = UnityEngine.Random.Range(-10.0f, 10.0f);
                    Quaternion rotation = Quaternion.AngleAxis(UnityEngine.Random.Range(-180.0f, 180.0f), Vector3.up);

                    // オブジェクトの配置(他クライアントにも同時にInstantiateする)
                    MonobitNetwork.InstantiateSceneObject("item", position, rotation, 0, null);

                    // ゲームオブジェクト出現個数を加算
                    gameItemIsPut++;
                }

                // 制限時間をRPCで送信
                monobitView.RPC("TickCount", MonobitTargets.Others, gameTimeLimit);
            }
        }

    // Update is called once per frame
    public void Update()
    {
		if (monobitView.isMine)
        {
            if (monobitView.isMine)
            {
                // キャラクタの移動&アニメーション切り替え
                if (currentAnimId == 3)
                {
                    if (!animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Jump") || animator.GetCurrentAnimatorStateInfo(0).normalizedTime <= 0.55)
                    {
                        gameObject.transform.position += jumpFixedSpeed;
                    }
                    else
                    {
                        jumpFixedSpeed = Vector3.zero * Time.deltaTime;
                        gameObject.transform.position += jumpFixedSpeed;
                        currentAnimId = 0;
                        animator.SetInteger(animId, currentAnimId);
                        animator.SetFloat(moveSpeed, jumpFixedSpeed.magnitude);
                    }
                }
                else if (currentAnimId == 4)
                {
                    if (animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Emotion") && animator.GetCurrentAnimatorStateInfo(0).normalizedTime >= 0.90)
                    {
                        jumpFixedSpeed = Vector3.zero * Time.deltaTime;
                        gameObject.transform.position += jumpFixedSpeed;
                        currentAnimId = 0;
                        animator.SetInteger(animId, currentAnimId);
                        animator.SetFloat(moveSpeed, jumpFixedSpeed.magnitude);
                    }
                }
                else if (Input.GetButtonDown("Jump"))
                {
                    if (!animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Jump") && !animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Emotion"))
                    {
                        gameObject.transform.position += jumpFixedSpeed;
                        currentAnimId = 3;
                        animator.SetInteger(animId, currentAnimId);
                        animator.SetFloat(moveSpeed, jumpFixedSpeed.magnitude);
                    }
                }
                else if (Input.GetKeyDown("z"))
                {
                    if (animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Stand"))
                    {
                        jumpFixedSpeed = Vector3.zero * Time.deltaTime;
                        gameObject.transform.position += jumpFixedSpeed;
                        currentAnimId = 4;
                        animator.SetInteger(animId, currentAnimId);
                        animator.SetFloat(moveSpeed, jumpFixedSpeed.magnitude);
                    }
                }
                else if (Input.GetKey("up"))
                {
                    if (!animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Jump") && !animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Emotion"))
                    {
                        if (animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Stand") || animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Walk"))
                        {
                            jumpFixedSpeed = gameObject.transform.forward * 1.5f * Time.deltaTime;
                        }
                        else
                        {
                            jumpFixedSpeed = gameObject.transform.forward * 3.0f * Time.deltaTime;
                        }
                        gameObject.transform.position += jumpFixedSpeed;
                        currentAnimId = 1;
                        animator.SetInteger(animId, currentAnimId);
                        animator.SetFloat(moveSpeed, jumpFixedSpeed.magnitude);
                    }
                }
                else if (Input.GetKey("down"))
                {
                    if (!animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Jump") && !animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Emotion"))
                    {
                        jumpFixedSpeed = gameObject.transform.forward * -0.1f * Time.deltaTime;
                        gameObject.transform.position += jumpFixedSpeed;
                        currentAnimId = 2;
                        animator.SetInteger(animId, currentAnimId);
                        animator.SetFloat(moveSpeed, jumpFixedSpeed.magnitude);
                        if (animator.GetCurrentAnimatorStateInfo(0).normalizedTime <= 0.0f)
                        {
                            animator.Play(Animator.StringToHash("Walking@loop"), 0, 1.0f);
                        }
                    }
                }
                else
                {
                    if (!animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Jump") && !animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Emotion"))
                    {
                        jumpFixedSpeed = Vector3.zero * Time.deltaTime;
                        gameObject.transform.position += jumpFixedSpeed;
                        currentAnimId = 0;
                        animator.SetInteger(animId, currentAnimId);
                        animator.SetFloat(moveSpeed, jumpFixedSpeed.magnitude);
                        ChangeFace("default@sd_generic");
                    }
                }
                if (Input.GetKey("right"))
                {
                    if (!animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Jump") && !animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Emotion"))
                    {
                        gameObject.transform.Rotate(0, 30.0f * Time.deltaTime, 0);
                    }
                }
                if (Input.GetKey("left"))
                {
                    if (!animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Jump") && !animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Emotion"))
                    {
                        gameObject.transform.Rotate(0, -30.0f * Time.deltaTime, 0);
                    }
                }
                if (Input.GetKeyDown("x"))
                {
                    if (!animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Jump") && !animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Emotion"))
                    {
                        MonobitNetwork.Instantiate("Cube", transform.position, transform.rotation, 0);
                    }
                }
                animator.SetLayerWeight(1, currentFace);
                monobitView.RPC("SetFaceID", MonobitTargets.OthersBuffered, currentAnimId, currentFace, currentFaceName);
            }
        }
    }



クライアントサイドプログラムの問題点(チートの温床となる部分)

上記のような実装において何が問題となるのか!?

  さて、上記で示した基本的な雛形、および実装例についてですが、チート対策の観点からすると、いくつかの致命的な問題点があります。
  何処が問題があるのかについて、1つずつ解説していきましょう。

問題点[1] : メモリ改ざん

  プログラムで管理される変数(フィールド、プロパティ)は、その殆どがオンメモリ上にあります(一部の ScriptableObject において例外はありますが)。
  自身が作成されるものはもちろんのこと、上記で挙げた MonobitNetwork.isHost も、MonobitView.isMine も、これらの中に含まれます。

  オンメモリ上のデータは、そのデータにアクセスする方法さえあれば、容易に解析することも、変更することも可能で、ひいてはゲームバランスを崩壊させます。
  それはコンシューマゲーム機が登場以来憚っている、いくつかの代表的な「チートツール」が証明しています。

  これは Unity のクライアントバイナリとて、例外ではありません。
     ・ プロセスが管理するリソースメモリが限定的であること
     ・ クライアントの状態や、プレイヤーの行為によって変化したパラメータを解析し、変更するのは案外容易であること
  は、どのプラットフォームを選択した場合であっても状況は変わりませんので、本格的なオンラインゲームを運用することを考えた場合、
  メモリ改ざんチートへの対策は必要です。
問題点[1] への、最も効果的な対抗策
  ・ ゲームの根幹部分を操作される要素は、出来るだけオンメモリには置かないようにする。
     → サーバサイドによるプログラミングが必須となる。

  ・ 完全な理想形としては、オンメモリに置くような情報は全てサーバ側で管理し、
    クライアントはサーバ側から必要な情報だけを送信して反映するだけの「ビューア」としての機能のみを実装すること。
    かつ、サーバで管理している情報を直接クライアント側から制御できない仕組みを作ること。

問題点[2] : 通信データの改ざん

  メモリ改ざんとともに考えなければならないのは、通信データの改ざんです。

  最低限必要な策として、通信内容に対して暗号化が必須 です。
  例えば、ClientSide 版の RakeupGame サンプルでは、例えば獲得スコアの値について MonobitView.RPC() を使って送受信していますが、
  このメソッドの場合、高速通信を実現するために暗号化処理を行ないません。
  適切な暗号化処理を行なうために、MonobitView.RpcSecure() を使い、暗号化(encrypt) について有効にした状態で送信するのが望ましいです。
    ※ 上記でも触れた通り、出来ることであれば、クライアントの情報として保持せず、サーバ側で保持するのが望ましいです。

  また意外なほどに脆弱性を含むのが プレイヤーカスタムパラメータ の存在です。
  プレイヤーカスタムパラメータは、ルーム内のクライアント間で「個々のクライアントの固有情報」を共有するための便利な情報ですが、
  このデータは(ゲームシステムに囚われない、より柔軟なゲームシステムに対応する、という明確な理由あってこそですが)、サーバ側で整合性チェックなどは行なっておりません。
  プレイヤーカスタムパラメータに対して通信データを改ざんするのは比較的容易で、他プレイヤーに被害を与えやすくしてしまいます。
問題点[2] への、最も効果的な対抗策
  ・ ゲームに大きく影響を及ぼす情報のやり取りについては、MonobitView.RPC() ではなく MonobitView.RpcSecure() を用い、
    かつ、 暗号化処理について有効にすること。
     ※ ゲームに大きく影響しない、例えばチャットワードなどの情報であれば、その限りではありません。

  ・ オブジェクトの同期情報については MonobitView コンポーネントの Enable Sync Encrypt Network を有効にして、
    同期データについて暗号化を適用する。

  ・ プレイヤーカスタムパラメータは使用しない
     → 出来るだけ MonobitView.RpcSecure() に置き換える。
        ※ これもゲームに大きく影響しない情報であれば、その限りではありません。

問題点[3] : プログラムコードの改ざん

  更に憂慮すべきは、プログラムコードそのものに対する改ざんです。
  Unity はコンパイラ(Mono)によって生成されるIL(中間言語)を実行ファイル内に内在していますが、
  このコードは逆コンパイラ/逆アセンブラにより、比較的容易に解析できてしまいます。
    ※ Unity のリバースエンジニアリングについて、弊社で検証してはいませんが、例えば LINE 様で公開している
       https://engineering.linecorp.com/ja/blog/detail/110
      を見る限り、Mono2.x / IL2CPP のコンパイル手法を問わず、リバースエンジニアリングは可能であるようです。

  クライアントサイドで実装しているプログラムコードが改ざんされた場合、上記のようなメモリや通信データの改ざん以上のゲームシステムの改変は容易となり、
  サービスを提供した当初では考えもつかないようなチートが横行してしまうことになります。

  Unity でコンパイルされた生成バイナリに対してプログラムコードの解析を防ぐ手段は、限られている上に実装コストが高いと考えられますので、
  解析されること自体は不可避と捉えた上で、かつ、他のユーザーに迷惑の掛からない方式でリリースするのが最善といえます。
問題点[3] への、最も効果的な対抗策
  ・ ゲームの根幹部分を操作される要素は、出来るだけクライアントのコード上に書かないようにする。
     → サーバサイドによるプログラミングが必須となる。

  ・ 完全な理想形としては、ゲームシステムに関するほぼ全てのコードをサーバ側で管理し、
    クライアントはサーバ側から必要な情報だけを送信して反映するだけの「ビューア」としての機能のみを実装すること。
    かつ、サーバで管理している情報を直接クライアント側から制御できない仕組みを作ること。


チート対策以外でも必要とされる、サーバサイドプログラムの利点

チート対策というだけでもメリットはありますが…

  上記に触れた通り、根本からチートに対して策を講じるならば、サーバサイドプログラミングは欠かせません。
  これだけでも実装のための考慮材料に値しますが、それ以外にも、サーバサイドプログラムを必要とする場面はいくつかあります。

チート対策以外の利点[1] : ルームホスト偏重型のシステムからの脱却

  クライアントサイドプログラムの場合、どうしても避けられないのが
  「ルームホストとなっているクライアントの負荷が、それ以外のクライアントに比べて比重が高くなる」点です。

  ゲームシステムの隅々の部分が、ルームホスト側に偏るため、
  ゲームの進行速度・反応速度がルームホストとなったクライアントのPC性能(デバイス性能)や通信環境に依存します。
  とりわけルームホストクライアント側のマシン性能や通信環境が劣る場合、他のクライアントにとってはストレスに繋がります。

  ルームホストクライアント側のマシン性能・通信環境が優れている場合でも、通信遅延はゼロではない以上、
  ルームホストのタイムラグが完全な「ゼロ」であることに対して、ルームホスト以外のクライアントは若干ながらもタイムラグの影響を受けますので、
  どういった仕組みでゲームを動かすにせよ、クライアントサイドで動かす以上、『ルームホスト優位』の状況から脱却することはできません。

  クライアントサイドプログラムではなく、サーバサイドプログラムによるオンラインシステムを提供するように仕組みを変更すれば、
  ルームホストに偏重したシステムではない、「すべてのユーザーに対し、公平で、かつ、自身の環境に見合った快適さ」を与えることが可能になります。

チート対策以外の利点[2] : MUN システム以外との連携

  非匿名の認証モジュールを使用する 方法として触れていることも含め、運用に必要とされる、MUN で提供されているサービス以外の
  各種サーバ(例えばデータベースや、認証サーバとは別種のWebサーバなど)や各種クライアントとの連携が必要となる局面も考えられます。
  認証システムも本質的にはこれに含まれますが、MUN とは別種で運用している、別の性質を持ったサーバとの連携については
  サーバ側のプログラムを改変する他にありません。

  もっと具体的な言い方をすれば、MUN サーバに内蔵されている MRS ライブラリでは、MUNがサポートしている TCP/UDP 通信の他に、
  (MUN2.1 以降) WebSocket 通信にも対応しており、MUN サーバから MRS ライブラリの命令を呼び出すことは容易です。
  これを使って、MUN とは別のサービス(例えば Node.js など)に対して、MUN サーバの情報を送信することが可能となります。

     ※ MRS ライブラリの命令コードについては、別途、下記に示す MRS 技術文書をご覧ください。
          https://monobitinc.github.io/mrs_doc/