公開したゲームで、いちばん心が痛む報告はこれです——「10時間遊んだセーブデータが消えました」。原因は珍しいものではありません。セーブの書き込み中にアプリが落ちた。ファイルが壊れて 読めなくなった。アップデートでデータ構造を変えたら、旧バージョンのセーブが読めなくなった。
JSONでセーブ・ロードを作る記事 で「保存できる」ようにはなりました。この記事はその発展編です。 「書けた」と「壊れない」は別物——体験版・早期アクセス・製品版へ進む前に、プレイヤーの進行データを守る仕組みを組み込みましょう。
この記事でわかること
- セーブデータを襲う 3つの事故(書き込み中断・破損・仕様変更)
- 直接上書きせず 一時ファイル→入れ替え で書く(
File.Replace)- 入れ替えのついでに 前回分のバックアップ が残る仕組み
- try-catchと値の補正 による防御的な読み込み
- saveVersion とマイグレーションで旧セーブを引き継ぐ
- 実践:セーブをわざと壊して、バックアップからの復旧を見届ける
動作確認環境: Unity 2022.3 LTS / Unity 6
「書けた」と「壊れない」は別物
まず、敵を知りましょう。セーブデータを失わせる事故は、だいたい次の3つに分類できます。
- 書き込み中断: セーブの書き込み中にクラッシュ・強制終了・電源断が起きる。ファイルが「前半だけ新しく、後半が欠けた」中途半端な状態になり、 元のデータごと 読めなくなる
- 破損・改変: ストレージの不調や、プレイヤー自身のファイル操作ミスで、JSONとして読めない内容になっている
- 仕様変更: アップデートでフィールドを追加・改名したら、旧バージョンのセーブと形が合わなくなった
怖いのは、どれも 開発中のあなたの環境ではまず起きない ことです。自分のPCで数十回テストして「セーブは完璧」と思っていても、何百人のプレイヤーが何千回もセーブすれば、低確率の事故は必ず起きます。だから対策は「起きたら困る」ではなく「起きても壊れない」の側に置きます。
セーブデータにsaveVersionを持たせる
対策の土台として、セーブデータ自身に 「このデータはどの世代の形式か」 を名乗らせます。たった1つのintフィールドですが、これが後のマイグレーションの命綱になります。
using System.Collections.Generic;
[System.Serializable]
public class SaveData
{
public int saveVersion = 2; // このデータの形式バージョン
public string playerName = "";
public int level = 1;
public int hp = 100;
public int stamina = 100; // v2で追加したフィールド
// アイテムは「安定ID」の文字列で持つ
public List<string> itemIds = new List<string>();
}
もう1つの布石が 安定ID です。アイテムを「アイテムテーブルの3番目」のような 番号(インデックス)で保存すると、テーブルの並びを変えた瞬間に全プレイヤーの持ち物が化けます。"potion_small" のような、一度決めたら変えないID文字列で保存してください。ScriptableObjectでアイテムを定義している なら、そのアセットにIDフィールドを持たせる形が定番です。
直接上書きしない:一時ファイル→入れ替え
書き込み中断への対策は、手順を変えるだけです。 セーブファイルへ直接書かず、一時ファイルに完全に書き終えてから、完成品と入れ替えます。

理屈はシンプルです。直接上書きだと「書いている途中」にファイルが壊れた状態が存在します。一時ファイル方式なら、書いている途中に落ちても壊れるのは一時ファイルだけで、 本物のセーブは最後の「入れ替え」の瞬間まで無傷 です。
using System.IO;
using UnityEngine;
public static class SaveManager
{
static string MainPath => Path.Combine(Application.persistentDataPath, "save.json");
static string BackupPath => MainPath + ".bak";
static string TempPath => MainPath + ".tmp";
public static void Save(SaveData data)
{
string json = JsonUtility.ToJson(data, true);
// 1. まず一時ファイルへ「全部」書き切る
File.WriteAllText(TempPath, json);
if (File.Exists(MainPath))
{
// 2. 完成した一時ファイルを本物と入れ替える
// このとき、直前までの本物が .bak として自動で残る
File.Replace(TempPath, MainPath, BackupPath);
}
else
{
// 初回セーブはまだ本物が無いので、そのまま昇格させる
File.Move(TempPath, MainPath);
}
}
}
注目してほしいのは File.Replace の3つ目の引数です。入れ替えで押し出される「直前までのセーブ」を、 .bak ファイルとして自動で残してくれます。つまりこの書き方にするだけで、書き込み中断対策と 1世代前のバックアップ が同時に手に入ります。
読み込みは疑ってかかる:フォールバックと補正
書き込みを固めたら、次は読み込み側です。方針は 「読めなかったら1段ずつ下がる」。メインが読めなければバックアップ、それもダメなら新規データ——ゲームが起動しなくなる事態だけは絶対に避けます。

public static SaveData Load()
{
// 1段目: メインのセーブ
SaveData data = TryLoad(MainPath);
// 2段目: 読めなければバックアップ
if (data == null)
{
Debug.LogWarning("メインのセーブが読めません。バックアップから復旧します");
data = TryLoad(BackupPath);
}
// 3段目: それでもダメなら新規データ(起動不能だけは避ける)
if (data == null)
{
Debug.LogWarning("バックアップも読めません。新規データで開始します");
return new SaveData();
}
Migrate(data); // 旧バージョンなら最新形式へ(次の節)
Validate(data); // 値のおかしさを補正
return data;
}
static SaveData TryLoad(string path)
{
try
{
if (!File.Exists(path)) return null;
string json = File.ReadAllText(path);
return JsonUtility.FromJson<SaveData>(json);
}
catch (System.Exception e)
{
// 壊れたJSONはここで例外になる。落とさずnullを返して次の段へ
Debug.LogWarning("セーブの読み込みに失敗: " + path + "\n" + e.Message);
return null;
}
}
さらにもう1枚、防御を重ねます。「JSONとしては読めたが、中身がおかしい」ケースです。フィールドが欠けていれば型の初期値(0やnull)になりますし、プレイヤーがファイルを書き換えてHPが-9999になっているかもしれません。 読めた後の値を信用せず、範囲内へ補正します。
static void Validate(SaveData data)
{
data.level = Mathf.Clamp(data.level, 1, 100);
data.hp = Mathf.Max(1, data.hp);
// 欠けたListはnullになるので、空リストへ補正
if (data.itemIds == null)
{
data.itemIds = new List<string>();
}
}
ゲーム更新に耐える:マイグレーション
最後の敵が仕様変更です。v1.1のアップデートで stamina フィールドを追加し、アイテムIDを整理したとします。何もしなければ、v1.0のプレイヤーのセーブは「スタミナ0・持ち物に知らないID」の壊れたデータとして読み込まれます。
ここで saveVersion が効いてきます。読み込んだデータのバージョンを見て、 古ければ「その差分を埋める変換」を順番に適用 します。これをマイグレーションと呼びます。

static void Migrate(SaveData data)
{
// v1 → v2: staminaが存在しない世代のデータ
if (data.saveVersion < 2)
{
// 新フィールドに初期値を与える(JsonUtilityは欠けたintを0にするため)
data.stamina = 100;
// 整理した旧IDを新IDへ置き換える
for (int i = 0; i < data.itemIds.Count; i++)
{
if (data.itemIds[i] == "potion_old")
{
data.itemIds[i] = "potion_small";
}
}
data.saveVersion = 2;
}
// 将来v3を作ったら、ここに if (data.saveVersion < 3) を足していく
}
この形のうまみは、 変換がバージョン順に積み重なっていく ことです。v1のセーブはv1→v2、(将来)v2→v3と順に通り、どの世代のプレイヤーが戻ってきても最新形式に揃います。ゲーム本体のコードは常に「最新のSaveData」だけを相手にすればよく、古い形式の知識はMigrateの中に閉じ込められます。
実践:セーブをわざと壊して、復旧を見届ける
仕組みは揃いました。でも、バックアップからの復旧を 本番で初めて動かす のは怖すぎます。RPGを題材に、自分の手で事故を起こして、復旧が働くところまで見届けましょう。

手順はこうです。
- ゲームを起動し、名前を付けてレベルを上げ、2回 セーブする(2回目のセーブで
.bakが生まれます) Application.persistentDataPathのフォルダを開く(Debug.Log(Application.persistentDataPath)で場所が分かります)save.jsonをテキストエディタで開き、 中身の後半を適当に削って保存する(破損の再現)- ゲームを再起動する
Consoleに「メインのセーブが読めません。バックアップから復旧します」の警告が出て、 1回前のセーブ地点からゲームが始まる はずです。最新のセーブとの差分(数分の進行)は失われますが、10時間が消えるのとは天と地の差です。
続けて、もう一段壊してみましょう。save.json.bak も同じように壊して再起動すると、今度は「新規データで開始します」まで落ちて、それでも ゲーム自体は正常に起動します。もしここでエラーが出て止まったら、TryLoad の外に裸の File.ReadAllText が残っていないか探してみてください——読み込みは必ずtry-catchの傘の下、が鉄則です。
ポイントは2つ。 書き込みは「一時ファイル→入れ替え」の一方通行にする(File.Replace が中断対策とバックアップを同時にくれる)。 読み込みは「main→bak→新規」の三段構えにする(どこかで必ず着地し、起動不能にはならない)。
おまけ:先に知っておくと良いこと
- オートセーブは「区切り」で: 毎フレームの保存はストレージにもパフォーマンスにも無意味な負荷です。チェックポイント通過・シーン遷移・メニューを閉じた時など、意味のある区切りで保存しま す。モバイルでは
OnApplicationPause(true)(ホームへ回った瞬間)も定番の保存タイミングです - 「保存中」の表示を出す: 保存の瞬間にプレイヤーがアプリを終了すると中断事故になります。小さなアイコンでも「今保存している」ことを見せるのは、事故予防とプレイヤーの安心の両方に効きます
- クラウドセーブは次の一手: 端末の故障・買い替えには、SteamのCloud SaveやモバイルのクラウドAPIのような外部保存が要ります。ローカルをこの記事の形で固めておけば、クラウド対応は「同じJSONをもう1カ所へ置く」だけの話になります
まとめ
- セーブの事故は 書き込み中断・破損・仕様変更 の3種類。どれも「起きても壊れない」設計で受ける
- 書き込みは 一時ファイルへ書き切ってから
File.Replaceで入れ替え。中断しても本物は無傷、しかも.bakが自動で残る - 読み込みは main→bak→新規の三段構え+try-catch+値の補正。起動不能だけは絶対に避ける
- データ構造を変えたら saveVersionを上げてマイグレーション。アイテムは並び番号でなく 安定ID で保存する
- 復旧は本番前に 自分で壊して 一度見届けておく
あなたのゲームのセーブ処理、いま File.WriteAllText 一発で上書きしていませんか? この記事のSaveManagerへの置き換えは30分もかかりません——プレイヤーの10時間を守る30分です。