ボタンを押すだけで、改造されにくいセーブ・ロードができる

ゲーム開発において、プレイヤーが途中でゲームを終了し、次回に続きから再開できるセーブ機能は非常に重要です。
しかし、単なるセーブ・ロード機能だけでは不十分です。データ改造を防止するシステムも実装することで、開発者はゲームの秩序を保つ必要があります。
また、ノベルゲームには、クイックセーブ・ロード(Quick Save、Quick Load)という機能があります。セーブをしてロードすると、その場面に戻ります。
この記事でも言及していますが、ゲームを起動しているときはデータを記憶して進んだり戻ったりすることができます。
しかし、再生ボタンを押してゲームを終了してもう一度スタートすると、データは保存されずに初期化される問題があります。

本記事では、Unityで改造されにくいセーブ機能の作り方について解説します。
これから紹介する手法を用いれば、安全で効率的なセーブシステムを構築できます。

実際に本記事で紹介するバイナリ形式のセーブファイルをメモ帳やバイナリエディタで開き、数値を書き換えてロードできるかを検証しました。結果として、テキストエディタでの直接改造はできませんでした。無理に書き換えると、デシリアライズに失敗してファイルごと読み込めなくなることも確認しています。なお、「改造されにくい」というのは「絶対に解析されない」ことを意味しません。
- Unityでセーブ機能を初めて実装する開発者
- データ改造を防止したいと考えている方
- PlayerPrefs以外のセーブ手法を探している方
- 軽量かつ効率的なセーブシステムに興味がある方
- 安全なセーブ機能を用いたゲームを作りたい方
セーブ・ロード

ゲームを攻略するには長時間かかります。そのため、プレイヤーの進行状況を保存し、再開できるセーブとロードはコンピュータゲームに必要不可欠です。
また、ミスをしてゲームオーバーになった場合でも、セーブしたポイントからロードすれば、その状況からやり直せます。このように、プレイヤーにリトライの機会を与えることができます。
この性質があるため、RPGやアドベンチャーゲーム、シミュレーションゲームなど長時間のプレイが必要となるゲームで特に重要です。
特にノベルゲームのようなストーリー重視のゲームでは、プレイヤーの選択や決定がストーリーの進行に影響を与えることがあります。セーブとロードを使うことで、異なるストーリー展開を探索し、複数のエンディングを体験することができます。
このように、セーブとロードはゲームやアプリケーションのプレイ体験を向上させ、プレイヤーに良い制御と楽しみを提供する不可欠な機能です。
Unityにおけるセーブ機能

最近のゲームは自動でセーブされるので、プレイヤー自身がセーブすることはあまりありません。しかし開発者側に立つと、セーブ機能を開発・導入しなければなりません。
Unityでは、「PlayerPrefs」「JsonUtility」「Easy Save」といった代表的な3つのセーブ機能があります。
| 手法 | 向いている場面 |
|---|---|
| PlayerPrefs | シンプルなハイスコアや設定の保存に最適 |
| JsonUtility | データ構造が複雑なゲームのセーブに向いている |
| Easy Save | 暗号化機能が求められる場合や、アプリ全体でセーブデータを管理したいときに最適 |
以下で紹介するEasy SaveのセーブファイルはES3形式と呼ばれます。「es3 ファイル 改造」のように改造方法を検索する読者が一定数存在します。暗号化していない設定のままだと内容を読み取られる可能性があるため、暗号化キーを必ず設定した上で利用することをおすすめします。
PlayerPrefs
PlayerPrefsは、Unityゲームエンジンで提供されている機能です。
PlayerPrefsを使用すると、ゲーム内のデータを永続的に保存および読み込むことができます。
要するに、ゲームを終了してからもう一度再生しても、データが保存された状態になります。保存できる変数は、int型・float型・string型の3つです。オフラインでデータを管理でき、データベースを必要としません。
// データの保存
PlayerPrefs.SetInt("HighScore", 10000);
PlayerPrefs.SetFloat("Volume", 0.5f);
PlayerPrefs.SetString("PlayerName", "John");
// データの読み込み
int highScore = PlayerPrefs.GetInt("HighScore");
float volume = PlayerPrefs.GetFloat("Volume");
string playerName = PlayerPrefs.GetString("PlayerName");
PlayerPrefs.Save();
// データの削除
// 全てデータ
PlayerPrefs.DeleteAll();
// HighScoreというキーのデータを削除
PlayerPrefs.DeleteKey("HighScore");データの書き込みのタイミングは、アプリ終了時、あるいはPlayerPrefs.Save();の実行時です。一見便利ですが、個人的にはあまり使用をおすすめしません。
Windowsの場合、レジストリにデータが保存されます。

マイクロソフトサポートが警告をしているように、レジストリエディターをむやみに編集するととりかえしのつかない状態になるので、できるだけセーブデータをここに保存したくありません。
レジストリ エディターを使用する場合は、注意が必要です。レジストリを誤って編集すると、オペレーティング システムを完全に再インストールする必要がある重大な問題が発生し、データが失われる可能性があります。非公式のソースによって提案される編集は避ける。保護を強化するには、正式に公開されたMicrosoftドキュメントに基づいて編集を行う前に、レジストリをバックアップしてください。その後、問題が発生した場合に復元できます。詳細については、Windowsでレジストリをバックアップおよび復元する方法に関するページをご確認ください。
Windows 10 でレジストリ エディターを開く方法 – Microsoft サポート
レジストリ・エディタは、誰でも入ることができるので、ユーザーが数値を改ざんすると、バグやゲームの公平性が保てなくなります。オープンな状態なので、改造の対策はありません。

また、PlayerPrefsはデータが比較的小さなサイズであることを前提としています。セキュリティを必要とする場合や、大量のデータを保存する必要がある場合は、別のシステムを検討してください。
- メリット: 簡単に実装でき、設定の保存やシンプルなデータのセーブには最適。
- デメリット: セキュリティが低く、データ改造が容易に行われる可能性がある。
参考:「PlayerPrefs – Unity スクリプトリファレンス」
JsonUtility
JsonUtilityは、Unityエンジン内でJSONデータのシリアル化(オブジェクトからJSON文字列への変換)とデシリアル化(JSON文字列からオブジェクトへの変換)を行うための便利なクラスです。
JSON(JavaScript Object Notation)は、データをテキストベースで表現する軽量で一般的なフォーマットです。Unityプロジェクト内でのデータの保存、読み込み、交換に広く使用されています。
- スクリプトからJSON文字列への変換
[System.Serializable]
public class PlayerData
{
public string playerName;
public int playerScore;
}
// PlayerDataオブジェクトを作成
PlayerData player = new PlayerData();
player.playerName = "John";
player.playerScore = 10000;
string json = JsonUtility.ToJson(player);
- JSON文字列からスクリプトへの変換
PlayerData loadedPlayer = JsonUtility.FromJson<PlayerData>(json);JsonUtilityはUnityのシリアライズされたクラス([System.Serializable]属性が付いているクラス)に適用されます。
シリアライズされたクラスは、公開されているフィールドに基づいてJSONデータが作成または読み込まれます。ただし、Unityのコンポーネントクラスなど、一部のクラスはシリアライズできない場合があります。
JsonUtilityはシンプルで使いやすい方法でJSONデータを処理できます。ただし、高度なJSON操作や大規模なデータの処理には適していません。
- メリット: JSON形式でデータを保存でき、視認性が高く、柔軟性がある。
- デメリット: テキスト形式なので、データの改造や解析が比較的容易。
JSON形式は人が読める形式のままファイルに保存されます。そのため「json セーブデータ 改造」のように、テキストエディタで直接書き換えられてしまうリスクがあります。改造を防ぎたい場合は、後述のバイナリ形式や暗号化、もしくは値のハッシュチェック(改ざん検出)を組み合わせる必要があります。
参考:「JsonUtility – Unity スクリプトリファレンス」
Easy Save
有料アセットですが、Unityでセーブ・ロードをするならば、「Easy Save」一択です。
本アセットは、暗号化、圧縮、クラウドストレージ、スプレッドシート、バックアップなどの機能が搭載させていて、簡単にセーブデータを保存できます。
2011年からの販売実績があり、「PC、Mac、Linux、Windows Universal、iOS、tvOS、Android、Oculus、Steam、WebGL」と互換性が高いです。
セーブファイルは.es3という拡張子で保存され、これがES3形式と呼ばれる独自フォーマットです。暗号化を有効にしていない場合は、内容を読み取られる可能性があります。改造防止を重視するなら、暗号化キーの設定は必須と考えてください。
- メリット: 複雑なデータのセーブも簡単に行える。暗号化機能があり、セキュリティも高い。
- デメリット: 有料のアセットであるため、コストがかかる。
サーバーにセーブデータを保管する

最近のゲームは、ネットワークの要素は欠かせないものです。
- ユーザーごとのセーブデータ管理
- ガチャ・ログインボーナスの処理
- マルチプレイの実行
- 外部サービス(SNS)の連携
セーブデータは、スマホやPC内に保存されるのではありません。主に、ゲームを配信している企業が保有するサーバーのデータベース上に保存されます。
この時、ユーザーはPHPやJavaのような言語で作られたAPIサーバーを仲介して、MySQL(SQLベース)のようなDBサーバーにアクセスしています。
例えば、ユーザーがゲームを起動すると、ID:1234番のような識別子をAPIサーバーに送ります。APIサーバーはDBサーバーから1234番のデータを探し出し、ユーザーに渡すというイメージです。

本記事ではAPIサーバーは使用していませんが、UnityはPHPと連携できるため、DBサーバー上の情報を取得することも可能です。
セーブデータをサーバーに保管することは、ローカルデバイスに保存する方法よりも、データの安全性や同期性を高める重要な手段です。
特にオンラインゲームや、複数のデバイス間で進行状況を共有(クロスプラットフォーム)したい場合に有効です。
ローカルでのセーブ保存に対するメリットとデメリットを紹介します。
- セキュリティ: ローカルデバイスに保存する場合と比べ、データ改造のリスクが大幅に低減される。
- データの同期: プレイヤーが異なるデバイスでゲームをプレイする際、同じ進行状況を反映できる。
- バックアップ: ローカルデバイスの破損や紛失があっても、サーバーに保存されたデータを使って復元できる。
- コスト: サーバーの維持や通信のためのコストが発生する。また、クラウドサービスを利用する場合、料金プランに依存する。
- 通信遅延: サーバーとの通信が必要なため、ネットワークの状態によってはデータの保存やロードに時間がかかることがある。
- 実装の複雑さ: サーバーとクライアント間の通信を適切に設計・実装する必要があり、ローカルセーブよりも高度なスキルが要求される。
REST APIやWebSocketなどを使用し、クライアントとサーバー間でデータを送受信して、実装できます。
自作する暗号化するセーブ・ロード機能

ここまで、3つの方法とサーバーでセーブ・ロードする方法を紹介しました。
どれも機能としては充実しています。しかし改ざんされる可能性があり、個人開発者にとっては初期段階でお金がかかる点も使用を躊躇う理由になるでしょう。
ここからは、バイナリー形式を使用して改造されにくいセーブ・ロードを作成していきます。
バイナリ形式は「テキストエディタでは読めない・編集しづらい」だけであり、完全な改造防止を保証するものではありません。GitHub上には、セーブデータの構造を解析するOSS(オープンソース)ツールや汎用のセーブエディタが公開されています。本格的にチート対策をしたい場合は、暗号化やチェックサムによる改ざん検出と組み合わせるのが一般的です。あくまで「お手軽に改造の難易度を上げる」ための手法として位置づけてください。
ソースコード
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using UnityEngine;
[System.Serializable]
public class SaveData
{
public int MyInt;
}
public class SaveManager : MonoBehaviour
{
public static SaveManager instance;
public SaveData saveData1;
private string saveFileName1 = "Data.alicia";
void Start()
{
saveData1 = LoadDataFromFile(saveFileName1);
Debug.Log("saveData1.MyInt: " + saveData1.MyInt);
}
public void SaveData1()
{
saveData1.MyInt = csvcontroler.i;
Debug.Log("Button clicked. Saving " + saveData1.MyInt + " to file.");
SaveDataToFile(saveData1, saveFileName1);
}
public void LoadData1()
{
saveData1 = LoadDataFromFile(saveFileName1);
csvcontroler.i = saveData1.MyInt;
Debug.Log("saveData1.MyInt: " + saveData1.MyInt);
}
private void SaveDataToFile(SaveData data, string fileName)
{
string filePath = Application.persistentDataPath + "/" + fileName;
FileStream fileStream = new FileStream(filePath, FileMode.Create);
BinaryFormatter bf = new BinaryFormatter();
bf.Serialize(fileStream, data);
fileStream.Close();
// Debug.Log("Save data saved to " + filePath);
}
private SaveData LoadDataFromFile(string fileName)
{
string filePath = Application.persistentDataPath + "/" + fileName;
if (File.Exists(filePath))
{
FileStream fileStream = new FileStream(filePath, FileMode.Open);
BinaryFormatter bf = new BinaryFormatter();
SaveData data = (SaveData)bf.Deserialize(fileStream);
fileStream.Close();
// Debug.Log("Save data loaded from " + filePath);
return data;
}
else
{
Debug.Log("Save file not found.");
return new SaveData();
}
}
}解説
- 名前空間
「using System.IO;」は、ファイルとデータストリームの読み書きを可能にして、ファイルとディレクトリに対するサポートを提供します。
「using System.Runtime.Serialization.Formatters.Binary;」は、オブジェクト、または接続されているオブジェクトをバイナリ形式でシリアル化(直列化)および逆シリアル化(デシリアライズ)します。
BinaryFormatterでのデータを改造することはできませんが、
セキュリティに関して安全性を保障することはできません。
BinaryFormatter クラス
BinaryFormatterは安全ではなく、セキュリティで保護することはできません。 詳細については、「BinaryFormatter セキュリティ ガイド」を参照してください。
特に逆シリアル化の脆弱性は懸念するべきで、攻撃者が脆弱性を突くと、サービス拒否 (DoS)、情報漏えい、またはアプリを遠隔で操作する恐れが発生する可能性があります。
しかしながら、本製作はあくまでオフラインでも稼働できるセーブデータの管理です。自分のPC内で解決することなので、セキュリティ面の不安は過度に心配しなくても良いでしょう。配布や対戦要素のあるゲームに転用する場合は、必ず暗号化やサーバー連携と併用してください。
これらは、C#ライブラリで提供される名前空間「Using System;」の一種です。一般的なC#機能やクラスにアクセスできるようにします。
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using UnityEngine;- フィールド(メンバ変数)
SaveData クラスを作成します。
[System.Serializable] 属性でマークして、シリアライズ可能なデータを保持します。
本スクリプトでは、int型のMyInt メンバー変数を持っています。
[System.Serializable]
public class SaveData
{
public int MyInt;
}SaveManager クラスは、セーブとロードの機能を提供します。
instance はシングルトンパターンを実装するために使用します。これにより、他のスクリプトからこのクラスへアクセスできるようになります。
saveData1 はセーブデータを保持する変数です。saveFileName1 はprivateなstring型の変数で、セーブファイルの名前(Data.alicia)を指定します。セーブファイルの名前は自分の好きなように設定できます。もちろん、拡張子も自分の好きなように決められます。
public static SaveManager instance;
public SaveData saveData1;
private string saveFileName1 = "Data.alicia";- メソッド・関数
Startメソッドは、ゲームが開始されたときに呼び出され、セーブデータをファイルから読み込みます。
また、読み込んだデータの MyInt値をデバッグログに表示します。
void Start()
{
saveData1 = LoadDataFromFile(saveFileName1);
Debug.Log("saveData1.MyInt: " + saveData1.MyInt);
}SaveData1メソッドは、ボタンがクリックされたときに呼び出されます。csvcontrolerクラスのint型変数iを saveData1.MyInt に設定し、セーブデータをファイルに保存します。
LoadData1 メソッドは、SaveData1メソッドの逆の処理をします。ボタンがクリックされたときに呼び出され、セーブデータをファイルから読み込み、csvcontrolerクラスのiに読み込んだ値を設定します。最後に、読み込んだデータのMyInt値をデバッグログに表示します。
public void SaveData1()
{
saveData1.MyInt = csvcontroler.i;
Debug.Log("Button clicked. Saving " + saveData1.MyInt + " to file.");
SaveDataToFile(saveData1, saveFileName1);
}
public void LoadData1()
{
saveData1 = LoadDataFromFile(saveFileName1);
csvcontroler.i = saveData1.MyInt;
Debug.Log("saveData1.MyInt: " + saveData1.MyInt);
}SaveDataToFileメソッドは、SaveDataオブジェクトをバイナリ形式でファイルに保存する役割を果たします。
「Application.persistentDataPath」は、Unityアプリケーションが実行されているプラットフォームに依存するデータ保存用のパスを返します。このパスに指定した「fileName」を追加して、ファイルの保存先のフルパスを取得します。
指定されたファイルパスでファイルストリームを作成します。「FileStream」を用いることで、同期および非同期の読み取り操作と書き込み操作をサポートします。「FileMode.Create」は、ファイルが存在しない場合に新しいファイルを作成し、既存のファイルがある場合は上書きするモードです。
そして、バイナリ形式でデータをシリアライズ・デシリアライズするためのバイナリフォーマッターを作成します。指定されたデータオブジェクト(data)をファイルストリームにシリアライズし、バイナリデータとして保存します。最後に、ファイルストリームを閉じて保存が完了します。
private void SaveDataToFile(SaveData data, string fileName)
{
string filePath = Application.persistentDataPath + "/" + fileName;
FileStream fileStream = new FileStream(filePath, FileMode.Create);
BinaryFormatter bf = new BinaryFormatter();
bf.Serialize(fileStream, data);
fileStream.Close();
// Debug.Log("Save data saved to " + filePath);
}LoadDataFromFile メソッドは、指定されたファイルからセーブデータを読み込む役割を果たします。
まず、保存されたセーブデータが格納されているファイルのフルパスを取得します。指定されたファイルパスが存在するかどうかを確認し、ファイルが存在する場合はセーブデータを読み込みます。セーブデータが存在しない場合は、新しいSaveDataオブジェクトを作成して返します。
ファイルが存在する場合は、ファイルストリームを作成し、指定されたファイルを開きます。FileMode.Openは既存のファイルを読み込むモードです。
次に、バイナリフォーマッターを作成して、ファイルストリームからデータをデシリアライズし、SaveData オブジェクトとして読み込みます。ファイルストリームを閉じてファイルの読み込みが完了したら、読み込んだセーブデータを呼び出し元に返します。
private SaveData LoadDataFromFile(string fileName)
{
string filePath = Application.persistentDataPath + "/" + fileName;
if (File.Exists(filePath))
{
FileStream fileStream = new FileStream(filePath, FileMode.Open);
BinaryFormatter bf = new BinaryFormatter();
SaveData data = (SaveData)bf.Deserialize(fileStream);
fileStream.Close();
// Debug.Log("Save data loaded from " + filePath);
return data;
}
else
{
Debug.Log("Save file not found.");
return new SaveData();
}
}実演
スクリプトが完成したら、Hierarchyウィンドウにアタッチしてください。その後、セーブ・ロードするUIボタンを「onclick」にアタッチしてください。

アタッチが完了しましたら、再生ボタンを押してください。
冒頭で紹介した動作ができれば成功です。
データの改造を試してみる
出力したファイル「Data.alicia」をメモ帳で開くと、次のように出力されます。

MyIntは保存する変数で、SaveDataは保存するクラスです。
実際にこの状態のファイルをメモ帳で直接書き換えて保存し、再度ロードを試してみました。すると、テキストとしての整合性が取れなくなるため、デシリアライズに失敗し、正常な値として読み込めませんでした。つまり、一般的なテキストエディタでの数値改ざんは成立しません。
要するに、メモ帳レベルの操作では、データを削除することしかやりようがありません。
あくまで本記事の検証は、「メモ帳での直接改造」と「テキストエディタでの数値書き換え」を試した範囲での結果です。GitHub等で公開されているバイナリ解析ツールや、専用のセーブエディタを使えば、構造を解析して改造される可能性は残ります。「改造されにくい」ことと「改造が不可能」であることは異なる点に注意してください。
なお、他者が作成・配布したゲームのセーブデータを無断で改造・再配布する行為は、利用規約や著作権法に抵触する可能性があります。自作のゲームで自分用に検証する範囲を超えて、第三者のセーブデータ改造ツールを配布する場合は、対象タイトルの利用規約を必ず確認してください。
セーブデータの保存先
セーブデータがどこに保存されているか疑問に思われているでしょう。
公式ページにパスの場所は言及されています。
Windows Store Apps: Application.persistentDataPath points to %userprofile%\AppData\Local\Packages\<productname>\LocalState.
iOS: Application.persistentDataPath points to /var/mobile/Containers/Data/Application/<guid>/Documents.
Android: Application.persistentDataPath points to /storage/emulated/0/Android/data/<packagename>/files on most devices (some older phones might point to location on SD card if present), the path is resolved using android.content.Context.getExternalFilesDir.
Application-persistentDataPath – Unity スクリプトリファレンス
もしも、見つからない場合は、スクリプトでコメント文としていましたが、「filePath」がデータの変数から探してください。
Debug.Log()を使用すれば、consoleウィンドウにパス(所在地)を得ることができます。
Debug.Log(filePath);
まとめ
Unityでは、再生ボタンを押してゲームを終了し、もう一度スタートすると、データは保存されずに初期化されてしまいます。
セーブ機能はゲーム体験を支える重要な要素です。特に改造されにくいバイナリ形式を用いることで、ゲームの公平性を保ちやすくなります。
他にも、PlayerPrefs、JsonUtility、Easy Save(ES3形式)といった様々な手法が提供されており、目的に応じて選択することが重要です。改造防止の強度で比べると、次のような目安で検討すると選びやすくなります。
PlayerPrefs・JsonUtility(テキスト形式)< バイナリ形式(自作) < Easy Save(暗号化あり)
色々な方法でセーブ・ロードできますが、特にバイナリ形式で外部にデータを保存することをおすすめします。
しかし、BinaryFormatterのセキュリティに関して安全性を保障することはできません。オフラインでのセーブデータを作成する程度の規模であれば一切問題ありませんが、本格的な改造対策が必要な場合は、暗号化やチェックサムによる改ざん検出、もしくはサーバー側でのデータ管理も検討してください。
最近のゲームは、ネットワークを使ってサーバーにアクセスし、データベース上に保存することが多いです。膨大なセーブデータでセキュリティを懸念する場合は、DBサーバーを使ったセーブ・ロードを実装してください。
立ち絵:イラストや





