ハイスコアだけを保存していたゲームに、アップデートで「プレイ回数」を足した。ビルドして配布したあと、前のバージョンで遊んでいた人のセーブはどうなるのか。
これは、公開してアップデートを出す段になると必ず一度は考えることになる問題です。個人開発 でも、体験版から製品版へ、Early Accessの更新で、確実に踏みます。この記事では、UEが古いセーブをどう扱うのか、何をすると本当に壊れるのか、そしてバージョン番号を1つ持っておくだけで全部が楽になるという話をします。
この記事でわかること
- 変数を 足したとき 、UEは既定値で埋める
- 本当に危ないのは 型を変える・名前を変える
SaveVersionを最初から入れておく- ロード直後に 移行してから使う という型
- 🚨 移行したら 保存し直す (しないと毎回走り続ける)
- 実践: 旧セーブを壊さずに新しい変数を足す
何が起きるのか
セーブ&ロード の仕組みは単純です。Save Game クラスを作り、変数を持たせ、Save Game to Slot でディスクへ書き、Load Game from Slot で読み戻す。
問題は、書いたときのクラスと、読むときのクラスが違う場合です。

アップデートで変数を1つ足しただけで、この状況になります。
- ディスクの中のデータ:
BestScoreだけが入っている(v1で保存した) - 今のクラス:
BestScoreとPlayCountの2つを持っている(v2)
PlayCount は、ディスクのどこにも書かれていません。このとき何が起きるかを知らないまま配布すると、事故になります。
UEの既定の挙動
先に結論を言うと、UEはこの状況で落ちません。
古いセーブに存在しない変数は、クラスに書かれた既定値で埋められます。

PlayCount の既定値が 0 なら、v1のセーブを読んだあとの PlayCount は 0 になります。クラッシュもエラーも出ません。
これはかなり親切な挙動です。 単に変数を足すだけなら、何もしなくても動きます。
ただし、ここに落とし穴があります。「既定値で埋まった」のか「本当にその値だった」のかを、後から区別できないのです。
| 状況 | PlayCount の値 | 意味 |
|---|---|---|
| v1のセーブを読んだ | 0 | データが無いので既定値 |
| v2で1度も遊んでいない | 0 | 本当に0回 |
この2つが同じ値になります。「初めて起動した人だけにチュートリアルを出す」ような処理をこの値で判定していると、既存のプレイヤー全員にチュートリアルが出ます。
だからこそ、「このデータはどの世代のものか」を自分で持っておく必要があります。それが SaveVersion です。
本当に危ない3つの操作
変数を足すのは安全でした。危ないのは別の操作です。

| 操作 | 何が起きるか | 危険度 |
|---|---|---|
| 変数を追加する | 既定値で埋まる | 🟢 安全 |
| 変数を削除する | ディスクの値は無視されるだけ | 🟡 値は失われるが落ちない |
| 型を変える | 読めない。既定値になる | 🔴 危険 |
| 名前を変える | 削除+追加と同じ | 🔴 危険 |
いちばん見落とされるのが「名前を変える」です。 Score を BestScore に直したくなる場面は必ず来ますが、これは中身から見ると別の変数を作ったのと同じです。古い Score の値は行き場を失い、新しい BestScore は既定値で埋まります。プレイヤーのハイスコア が0に戻ります。
型の変更も同じです。Integer の Score を Float にすると、古い値は引き継がれません。
リファクタリングしたくなったら、移行処理とセットで。 名前や型を変えること自体は問題ありません。変えた上で、古い値を新しい変数へ移し替える処理を書けばいいだけです。次の節の
SaveVersionがあると、これが書けるようになります。
SaveVersionを最初から入れる
対策は、拍子抜けするほど単純です。SaveGameクラスに、整数の変数を1つ足しておく。
| 変数名 | 型 | 既定値 |
|---|---|---|
SaveVersion | Integer | 1 |
これだけです。そしてセーブするたびに、その時点の最新バージョン番号を書き込みます。

なぜこれが効くのか。ロードしたときに「このデータはどの世代か」が分かるからです。
SaveVersionが1→ 昔のデータ。PlayCountは既定値で埋まっているSaveVersionが2→ 今の世代。そのまま使える
前の節の「既定値で埋まったのか、本当に0だったのか分からない」問題が、これで解決します。
そして重要なのは、これを最初から入れておくことです。 後から足すこともできますが、その場合既に配布したセーブには SaveVersion が入っていません(読むと既定値の 1 になります)。結果として「バージョン番号がない世代=v1」と決め打ちする必要が出ます。動きはしますが、余計な考慮が1つ増えます。
セーブを作る最初の瞬間に、変数を1つ足しておくだけ。これが記事全体でいちばん効く一手です。
移行処理の型
SaveVersion があれば、移行処理は決まった形で書けます。ロードした直後、使う前に、最新版へ揃える。

流れはこうです。
Load Game from Slotでデータを読むSaveVersionを見る- 古ければ、足りない部分を埋めたり、値を移し替えたりする
SaveVersionを最新の番号に更新する- 🚨 移行した結果を、同じスロットへ保存し直す
- ここから先は、常に最新版として扱える
手順5を忘れると、移行が終わりません。 ここまでの操作はメモリ上のオブジェクトを書き換えただけで、ディスクの中身はまだv1のままです。保存し直さない限り、次に起動したときも同じ移行処理が走ります。

「毎回走るだけなら害はないのでは」と思うかもしれませんが、そうとは言い切れません。移行処理はその世代のデータを前提に書かれているので、世代が増えるほど「毎回、何世代ぶんも通す」ことになります。移行の中で値を計算し直しているなら、そのたびに同じ計算がやり直されます。移行が終わったら、その場で書き戻すのが正しい形です。
ポイントは、移行が終わったあとのコードは、バージョンを一切気にしなくていいことです。「v1のときはこう、v2のときはこう」という分岐がゲーム本編に散らばると、手に負えなくなります。入口で1回だけ揃えて、あとは知らんぷりができる形にします。
バージョンが3世代以上になったら、1つずつ順番に上げるのが定石です。
移行処理(Migrate)
if SaveVersion < 2:
// v1 → v2 の変換
PlayCount = 0
SaveVersion = 2
if SaveVersion < 3:
// v2 → v3 の変換(前の変換を通った結果に対して行う)
BestTimeSeconds = OldBestTimeMilliseconds / 1000.0
SaveVersion = 3
if を並列ではなく上から順に通すのが肝です。v1のデータは1つ目と2つ目の両方を通り、v2のデータは2つ目だけを通ります。世代がいくつ離れていても、これだけで最新まで上がります。
実践:旧セーブを壊さずに変数を足す
インディーゲームの体験版から製品版へ、ローグライクのアップデートで実績を追加、パズルゲームにベストタイムを追加。「配布した後で保存する項目が増える」 は必ず起きます。ここでは実際に壊れる場面を作ってから直します。
題材
初めてのゲーム制作 で作った BP_CoinSave を使います。手元に無ければ、同じものを作ってください。
| 用意するもの | 内容 |
|---|---|
BP_CoinSave | 親クラス Save Game 。変数 BestScore(Integer / 既定値 0) |
| 保存処理 | Save Game to Slot(Slot Name = CoinHighScore) |
| 表示 | ゲーム開始時に Load Game from Slot で読み、Print String でハイスコアを出す |

手順
① v1のセーブを作る
まず現状のまま Play して、ハイスコアを実際に保存してください。BestScore に 50 などが入った状態で終了します。これが「配布済みのセーブ」の役です。
② 変数を足す(ここで壊れる場面を見る)
BP_CoinSave に変数を2つ追加します。
| 変数名 | 型 | 既定値 |
|---|---|---|
SaveVersion | Integer | 1 |
PlayCount | Integer | 0 |
そして、ロード後に Print String で SaveVersion と PlayCount も出すようにして、もう一度 Play してください。
BestScore は 50 のまま読めています。SaveVersion は 1、PlayCount は 0 です。 落ちません。ここがUEの親切なところです。
ただし、PlayCount の 0 は 「既定値で埋まった0」 であって、「本当に0回遊んだ」わけではありません。
③ 移行処理を書く
BP_CoinGameState( またはロードしている場所)で、Load Game from Slot の直後に処理を挟みます。
Cast To BP_CoinSaveで受け取るBranchでSaveVersion < 2を判定- True の場合(v1のデータ)→
PlayCountを1に設定(「少なくとも1回は遊んだ人だ」と解釈する)→SaveVersionを2に設定 →Save Game to Slotで同じスロットへ保存し直す - どちらの枝からも、同じ変数(
SaveObject)へ入れて先へ進む
手順3の最後の「保存し直す」を必ず入れてください。 ここを飛ばすと、ディスクの中身はv1のままなので、次回起動でもまた移行処理が走ります。
④ 保存側も更新する
Save High Score の中でも、書き込む前に SaveVersion を 2 に設定します。移行のときだけでなく、通常の保存でも常に最新の番号を書くのが正しい形です。

BP_CoinGameState(イベントグラフ)
Load High Score
→ Does Save Game Exist(Slot = "CoinHighScore")
→ (存在する)Load Game from Slot → Cast To BP_CoinSave
// ── 移行処理:使う前に最新版へ揃える ──
→ Branch(Condition: SaveVersion < 2)
True → Set PlayCount = 1 // v1にはPlayCountが無かった
Set SaveVersion = 2
Save Game to Slot(SaveObject, "CoinHighScore") // ← ディスクへ書き戻す
False → (何もしない)
→ Set SaveObject(どちらの枝もここへ合流)
// ── ここから先はバージョンを気にしない ──
→ Print String("Best: " + BestScore + " / Plays: " + PlayCount)
確かめる
Playすると、Best: 50 / Plays: 1 と出ます。ハイスコアは引き継がれ、PlayCount は「既定値の0」ではなく移行処理が入れた1になっています。
そのままもう一度起動してください。今度は SaveVersion が 2 でディスクに書かれているので、移行処理は走りません。PlayCount はゲーム側の加算だけで増えていきます。
ここで、移行直後の保存をわざと外してもう一度試すと違いが見えます。ハイスコアも PlayCount も正しく表示されるのに、起動するたびに移行処理が走り続けます。ディスクの中身が変わっていないからです。
うまくいかないときの切り分けです。
BestScoreが0に戻った → 変数の名前か型を変えてしまっています。追加以外の変更は移行処理が要ります- 毎回移行処理が走る → 移行した結果を保存し直していません。移行の最後に
Save Game to Slotを入れてください(メモ リ上で直しただけでは、ディスクは変わりません) Cast To BP_CoinSaveが失敗する → セーブしたクラスと読むクラスが違います。スロット名の打ち間違いも定番です
ポイントは2つです。
- 移行したら、その場で保存し直す: メモリ上で直しただけでは、ディスクはまだ古い世代です。移行の最後に
Save Game to Slotを入れて、次回起動では走らない状態にします - 既定値には「意味のある値」を入れる:
PlayCountの既定値が0だから移行処理が要りました。もし「未設定」を表す-1を既定値にしていれば、移行処理なしでも区別が付きます。既定値の選び方も設計のうちです
保存する項目が増えて変数が散らかってきたら、構造体でまとめる と管理しやすくなります。
おまけ:先に知っておくと良いこと
設定はセーブデータに混ぜない。 音量・解像度・キーコンフィグといった設定は、ゲームの進行状況とは寿命が違います。UEには専用の GameUserSettings があるので、そちらへ分けてください。混ぜると「セーブデータを消したら設定も飛ぶ」という嫌な挙動になります。
壊れたデータへの備え。 ディスクの破損や、途中で電源が落ちた場合、Load Game from Slot は None を返します。Is Valid で必ず確認し、駄目なら新規データとして始める道を用意してください。Does Save Game Exist で存在確認をしていても、読めるとは限りません。
スロットを複数持つときも同じ。 セーブスロットが3つあるなら、3つそれぞれが違う世代である可能性があります。移行処理は「ロードのたびに1回」なので、スロットごとに自然と適用されます。特別なことは要りません。
バージョン番号は「増やすだけ」。 一度使った番号は再利用しないでください。移行処理が積み上がっていくのは正常です。3世代前のデータを読む人がいる限り、その分岐は消せません。古いプレイヤーを切り捨てる判断をしたときだけ、まとめて消せます。
まとめ
- 変数を 足すだけなら安全 。UEは既定値で埋める
- 危ないのは 型の変更と名前の変更 。どちらも「削除+追加」と同じ扱いになる
SaveVersion(Integer)を最初から入れておく 。これが全部の出発点- 移行は ロード直後に1回だけ 。
if version < Nを上から順に通して最新まで上げる - 🚨 移行したら、同じスロットへ保存し直す 。しないと毎回走り続ける
- 保存側でも
SaveVersionを更新する のを忘れない
やることは変数1つと Branch 1つです。最初に入れておけば、アップデートのたびに悩まずに済みます。あなたのセーブデータには、次に何を足したくなりそうですか。