セーブデータの互換性:更新で壊さない設計
遊びをつくる執筆にClaudeを使用しています。

ゲームをリリースしてから新しい要素を追加したとき、既存のプレイヤーのセーブデータが読み込めなくなってしまった——そのような経験をした、または恐れている作者は少なくないはずです。セーブデータの互換性問題は、ゲームの更新が進むほど深刻になりやすく、対処が遅れると「古いセーブを捨てて最初からやり直し」という最悪の状況につながります。 この記事では、セーブデータの互換性をどのように設計すれば更新のたびに壊れにくくなるのか、その考え方と具体的な手順を整理します。JavaScriptとJSONを使ったブラウザゲームを主な対象にしていますが、考え方はほかの構成にも共通して使えます。
この記事の目次
なぜ更新でセーブが壊れるのか
セーブデータの互換性問題は、大きく二種類に分けられます。一つは「読み込みエラー」で、保存されたデータの構造とコードが期待する構造が一致せず、処理が止まるケースです。もう一つは「サイレントな壊れ方」で、エラーは出ないものの、値がundefinedになったり意図しないデフォルト値が使われたりして、プレイヤーの進行状況がおかしくなるケースです。後者のほうが発見が遅れるため、より注意が必要です。
典型的な原因は次のようなものです。新しいステージを追加してデータ構造に新しいフィールドを加えたとき、古いセーブにはそのフィールドが存在しません。逆に、仕様変更でフィールドを削除したり、名前を変えたりすると、古いデータを読んだときにそのキーが行き先を失います。さらに、フィールドの型や意味を変えた場合(たとえば、スコアを整数から小数に変えた、配列だったものをオブジェクトにしたなど)は、値が存在していても正しく処理できません。
最初に入れておくべき「バージョンフィールド」
互換性管理の出発点は、セーブデータにバージョン番号を持たせることです。たとえば{ "saveVersion": 1, "score": 300, ... }のように、データ構造のバージョンを示すフィールドを一つ用意します。
このバージョン番号は、ゲーム自体のバージョンではなく「セーブデータの構造バージョン」として独立して管理するのが原則です。ゲームのバグ修正や見た目の変更ではバージョンを上げる必要はなく、データの構造が変わったときだけ番号を増やします。
読み込み時には、まずこの番号を確認します。もしsaveVersionが存在しない、または古い番号だった場合は、後述するマイグレーション処理を走らせてから使います。もし番号が想定よりも新しかった場合(ユーザーが古いバージョンのゲームを開いているケース)は、安全に処理できないと判断してエラーメッセージを表示するか、機能を限定して動かすかを選択します。
前方互換と後方互換の違いを理解する
セーブデータの互換性には方向性があります。
後方互換(backward compatibility)とは、新しいバージョンのゲームが古いセーブデータを読める状態を指します。プレイヤーが過去に保存したデータを失わずに済むために必要な互換性で、ほとんどの場面でこちらを優先します。
前方互換(forward compatibility)とは、古いバージョンのゲームが新しいセーブデータを読める状態です。複数のバージョンが同時に存在するケース(たとえばサーバー側と端末側の更新タイミングがずれるケース)で問題になりますが、ブラウザゲームではゲームファイルを自分でホストしている場合が多く、前方互換が必要になる場面は後方互換ほど頻繁ではありません。
ブラウザゲームの作者が普段意識すべきは主に後方互換です。「今日のアップデートが、半年前にセーブしたプレイヤーのデータを壊さないか」を更新のたびに確認する習慣が大切です。
マイグレーション処理の実装パターン
古いバージョンのデータを現在の構造に変換する処理を「マイグレーション」と呼びます。バージョンごとに変換関数を用意して順番に適用していく方式が、管理しやすいやり方です。
考え方としては次のような手順です。
- セーブデータを読み込んだら、まずsaveVersionを確認します。
- 現在のバージョンより小さければ、そのバージョンから次のバージョンへの変換処理を順番に呼び出します。たとえばバージョン1のデータがある場合、「1→2の変換」「2→3の変換」と順次適用して最新の状態にします。
- すべての変換が終わったら、最新のsaveVersionを書き込んで保存し直します。
各変換関数の中では、追加されたフィールドにデフォルト値を設定する、削除されたフィールドを取り除く、名前変更や型変換を行うといった操作を行います。このとき、変換前のデータを直接書き換えるのではなく、元のオブジェクトをコピーしてから変換する方が安全です。万一変換中にエラーが起きても元データを保護できます。
失敗しやすい点として、変換処理の中で「このフィールドは必ずあるはず」と決めつけてしまうことが挙げられます。古いバージョンのデータには想定外のフィールドが欠けていることがあるため、値を参照する前に存在確認を入れる、またはデフォルト値を持つアクセス方法(data.gold ?? 0のようなNull合体演算子など)を使うと堅牢になります。
設計段階でやっておくと後が楽なこと
すでにリリース済みのゲームでも後から対処はできますが、設計段階から意識しておくといくつかの点で楽になります。
フィールド名を変えない文化を持つ:いったん使い始めたフィールド名は、よほどの理由がない限り変えない運用が互換性を守りやすくします。意味が変わった場合は古いフィールドを残したまま新しいフィールドを追加し、移行期間を設けてから古いほうを読み込み処理だけに使い、書き込みでは使わないようにする流れが現実的です。
ネストを深くしすぎない:データ構造の階層が深いほど、途中のオブジェクトが存在しない場合にエラーになりやすくなります。セーブデータはできるだけフラット(階層が浅い)な構造にしておくと、マイグレーションの記述も読み込みの防御処理も書きやすくなります。
テストデータを残しておく:過去の各バージョンのセーブデータのサンプルをファイルとして保管しておくと、新しいバージョンをリリースする前にそれらを読み込んで確認するテストができます。特に、バージョン1のサンプルは最も古い形を残すものとして大切にします。
次の行動
まずは自分のゲームのセーブデータにsaveVersionフィールドが含まれているかを確認してください。含まれていない場合はsaveVersion: 1を追加し、読み込み時にバージョンを確認する分岐を一つ入れることを次の更新の最初の作業にすることをおすすめします。アソバースでZIPまたはHTMLを公開している場合も、セーブデータの設計はゲームファイル内の実装に依存するため、この記事の考え方をそのまま活用できます。
参考資料
- MDN Web Docs(Web Storage API、JSON、Null合体演算子)
- semver.org(セマンティックバージョニングの考え方)

