【Unity】「セーブが消えた」を防ぐ:安全な書き込み・バックアップ・バージョン移行

作成: 2026-07-16

公開したゲームで最も痛い報告が「セーブデータが消えました」。JSONで保存できるようになった次に必要なのは、壊れない仕組みです。書き込み中のクラッシュに耐える一時ファイル方式、File.Replaceで自動的に残る世代バックアップ、try-catchと値の補正による防御的な読み込み、そしてゲーム更新でデータ構造が変わってもセーブを引き継ぐsaveVersionとマイグレーションまで、進行データを守る定番パターンを解説します。

公開したゲームで、いちばん心が痛む報告はこれです——「10時間遊んだセーブデータが消えました」。原因は珍しいものではありません。セーブの書き込み中にアプリが落ちた。ファイルが壊れて読めなくなった。アップデートでデータ構造を変えたら、旧バージョンのセーブが読めなくなった。

JSONでセーブ・ロードを作る記事 で「保存できる」ようにはなりました。この記事はその発展編です。 「書けた」と「壊れない」は別物——体験版・早期アクセス・製品版へ進む前に、プレイヤーの進行データを守る仕組みを組み込みましょう。

セーブデータを何重にも守るイメージ

この記事でわかること

  • セーブデータを襲う 3つの事故(書き込み中断・破損・仕様変更)
  • 直接上書きせず 一時ファイル→入れ替え で書く(File.Replace
  • 入れ替えのついでに 前回分のバックアップ が残る仕組み
  • try-catchと値の補正 による防御的な読み込み
  • saveVersion とマイグレーションで旧セーブを引き継ぐ
  • 実践:セーブをわざと壊して、バックアップからの復旧を見届ける

動作確認環境: Unity 2022.3 LTS / Unity 6

Sponsored

「書けた」と「壊れない」は別物

まず、敵を知りましょう。セーブデータを失わせる事故は、だいたい次の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世代前のバックアップ が同時に手に入ります。

Sponsored

読み込みは疑ってかかる:フォールバックと補正

書き込みを固めたら、次は読み込み側です。方針は 「読めなかったら1段ずつ下がる」。メインが読めなければバックアップ、それもダメなら新規データ——ゲームが起動しなくなる事態だけは絶対に避けます。

セーブ読み込みのフォールバックの流れ図。まずsave.jsonを試し、読めなければsave.json.bakを試し、それも読めなければ新規データで開始する三段構え
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 が効いてきます。読み込んだデータのバージョンを見て、 古ければ「その差分を埋める変換」を順番に適用 します。これをマイグレーションと呼びます。

セーブデータのマイグレーションの図。v1のデータがv1→v2の変換(staminaの初期値を与える・旧アイテムIDを置き換える)を通って最新のv2形式になり、ゲーム本体は常に最新形式だけを扱えばよくなる
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を題材に、自分の手で事故を起こして、復旧が働くところまで見届けましょう。

セーブ復旧の場面図。壊れたsave.jsonに気づいたシステムが、隣に残っていたsave.json.bakを開いて進行データを取り戻す様子

手順はこうです。

  1. ゲームを起動し、名前を付けてレベルを上げ、2回セーブする(2回目のセーブで .bak が生まれます)
  2. Application.persistentDataPath のフォルダを開く(Debug.Log(Application.persistentDataPath) で場所が分かります)
  3. save.json をテキストエディタで開き、 中身の後半を適当に削って保存する(破損の再現)
  4. ゲームを再起動する

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分です。