マガジン

古いセーブを捨てない更新:互換性を守るために先に決めておくこと

遊びを届ける

執筆にClaudeを使用しています。

開いた箱から小さなゲームの模型を展示台に移す風景。
記事のテーマをもとに制作したAI生成イメージ。実際のゲーム画面ではありません。

ブラウザゲームを公開してしばらく経ち、新しい要素を足す更新をする場面を考えてみます。遊んでくれている人の端末には、前の形式で書かれた記録が残っています。更新後に開いたとき、進行が初期化されたり、設定が既定に戻ったり、最悪の場合は画面が真っ白になったりします。更新した側には「新しい版が動くこと」しか見えていないので、この種の不具合は公開後に起きやすくなります。 この記事の問いは、古いセーブを捨てずに更新するために、何を先に決めておけばよいかです。答えは次の3点に絞れます。セーブに形式の番号を入れること、読み込み口を一本にして番号に応じて一段ずつ新しい形に直すこと、変更の種類ごとに対応を分けておくことです。 最初の版から完璧に設計する必要はありません。ただし、番号がないセーブは、あとから「どの形式か」を推測するしかなくなります。以下では、最小のコードと確認手順を示し、アソバースの確認済み事項や公式作品の「version1から version2への移行」から読み取れることにも触れます。

この記事の目次
  1. 形式の番号を最初から入れておく
  2. 読み込み口を一本にして、一段ずつ直す
  3. 変更の種類ごとに、対応を決めておく
  4. アソバースの仕組みと公式作品から読み取れること
  5. 更新の前に、古いセーブの見本で確かめる
  6. 次の行動

形式の番号を最初から入れておく

セーブのデータが { "best": 120, "sound": false } だけだったとします。更新で音の設定を settings というまとまりに入れたくなったとき、読み込む側は、手元のデータが新旧どちらなのかを判断する必要があります。sound があれば旧形式と見なすこともできますが、そのうち項目が増えるたびに「この項目があれば旧、なければ新」という推測が積み重なります。

そこで最初から version を入れておきます。{ "version": 1, "best": 120, "sound": false } なら、読み込む側は番号を見て次のように決められます。

  • 番号が現在の版と同じなら、そのまま使います。
  • 番号が小さければ、一段ずつ新しい形に直してから使います。
  • 番号が現在の版より大きければ、更新前の版で開かれた可能性があります。読み込まず、上書き保存もしないでおきます。

三つ目は見落としやすい点です。別の端末や古いキャッシュで古い版が開かれ、新しい形式のデータを読んだ場合に、知らない項目を落として保存し直してしまうと、新しい版で積んだ進行が消えます。読めない形式は触らない、と決めておけば、この事故は防げます。

番号は「中身が変わったら上げる」という規則にします。見た目の更新や調整だけならデータの形は変わらないので、上げません。ゲームの版と、セーブの形式の番号は別のものとして扱います。

読み込み口を一本にして、一段ずつ直す

セーブを読む処理がゲームのあちこちに散らばっていると、形式を変えるたびに直す場所が増えます。そこで、文字列を受け取って現在の形式のデータを返す関数を一つ作り、ほかの部分はそこを通したデータだけを使うようにします。

次の例は、version1 の sound を version2 で settings の中へ移す、という仮の変更を扱うものです。ライブラリは使わず、そのまま実行できます。

js
const CURRENT = 2;

const migrations = {
  // version1 -> 2: sound を settings の中へ移す(仮の例)
  1: (d) => {
    const { sound, ...rest } = d;
    return { ...rest, version: 2, settings: { sound: sound ?? true } };
  },
};

function defaults() {
  return { version: CURRENT, best: 0, settings: { sound: true } };
}

// 戻り値: 現在の形式のデータ。読めない形式なら null
function load(text) {
  let data;
  try { data = JSON.parse(text); } catch { return defaults(); }
  if (!data || typeof data.version !== "number") return defaults();
  if (data.version > CURRENT) return null;
  while (data.version < CURRENT) {
    const step = migrations[data.version];
    if (!step) return defaults();
    data = step(data);
  }
  const base = defaults();
  return { ...base, ...data, settings: { ...base.settings, ...data.settings } };
}

// 確認用: 古い形式の見本を読ませる
console.log(load('{"version":1,"best":120,"sound":false}'));
// 期待: { version: 2, best: 120, settings: { sound: false } }

この書き方のポイントは三つあります。

一つ目は、移行を「一段ずつ」にしていることです。version1 から version3 へ直接変換する関数は作りません。1→2、2→3 と小さな関数を積み、while で順に通します。version1 のセーブを持つ人が version3 の版を初めて開いても、同じ道筋で直ります。

二つ目は、最後に既定値を重ねていることです。項目が足りなくても、既定値で補えます。項目を追加するだけの更新なら、移行関数を書かなくても、この処理で古いセーブを受け止められます。

三つ目は、壊れた文字列に対する扱いです。例では既定値を返していますが、実際のゲームでは、読めなかった元の文字列を別の保存場所に退避してから初期化するほうが安全です。黙って初期化すると、あとで原因の調べようがなくなります。

変更の種類ごとに、対応を決めておく

セーブの形式の変更は、壊しやすさが種類によって違います。更新の前に、自分の変更がどれに当たるかを分けておくと、移行関数が要るかどうかを判断できます。

  • 項目を追加する、壊れやすさ:低い、対応の目安:既定値で補います。番号を上げなくても動きます。
  • 項目の名前を変える、壊れやすさ:高い、対応の目安:移行関数で旧名から新名へ移し、番号を上げます。
  • 項目の意味や単位を変える(秒からミリ秒など)、壊れやすさ:最も高い、対応の目安:同じ名前のまま意味を変えると旧データを新しい意味で読みます。別名にして移行関数で換算します。
  • 項目の型を変える(数値から配列など)、壊れやすさ:高い、対応の目安:移行関数で変換し、番号を上げます。
  • 項目を削除する、壊れやすさ:中程度、対応の目安:読み込み時に無視します。保存のときに消えるので、すぐ削除せず、しばらく残す選択もあります。

特に注意したいのは、意味や単位の変更です。名前も型も同じで、中身の意味だけが変わると、読み込みはエラーにならずに通ります。たとえば「残り時間」を秒からミリ秒に変えたのに名前を変えなければ、旧データの 30 を 30 ミリ秒として読み、ほぼ時間切れの状態で再開します。落ちずに間違うので、気づくのが遅れます。意味を変えるときは名前も変える、と決めておくと安全です。

容量にも注意します。アソバースでは、クラウドセーブは作品ごとにJSONで128KiBまでです。互換性を守ろうとして旧形式と新形式の両方を保存し続けると、容量を余計に使います。移行したら旧形式の項目は残さず、新形式だけを保存する方針が基本です。

アソバースの仕組みと公式作品から読み取れること

アソバースの確認済みの仕組みのうち、セーブの更新に関わる点を整理します。

  • URL投稿のゲームは、作者がSDKを読み込むと、作品専用のプレイヤーIDとクラウドセーブが使えます。アップロードしたゲームにはSDKが自動で追加されます。
  • 未同期の端末セーブは、クラウドの古いデータより優先して再送されます。
  • ゲストの履歴や進行は端末内に保存され、ログイン後のアカウントへ自動では混ざりません。
  • アップロード作品内の localStorage と sessionStorage は、一時メモリーの互換処理です。恒久的な保存先として頼らない設計が前提になります。

ここから導ける注意が一つあります。端末側とクラウド側のどちらにも、更新前後の形式のセーブが存在しうるということです。未同期の端末セーブが優先して再送されるなら、更新後の版が、更新前の版で書かれたセーブを受け取る場面は十分に考えられます。そのため、読み込み口は端末とクラウドのどちらから来たデータも同じ関数に通し、版の番号で判断する形にしておきます。保存先ごとに別々の読み込み処理を書くと、片方だけ移行し忘れる原因になります。

公式作品からも、設計の意図を読み取れます。13作品のうち、保存があるのは「余白の庭」「WAVE / WAVE」「かたちのあいだ」の3作品で、他の10作品の記録や録音は画面を開いている間だけです。そして、古い保存(version1)は新しい形式(version2)へ移行する、と案内されています。

ここから言えるのは次の2点です。

  • 保存の有無は、最初に決める設計の一つです。保存しない作品には、移行の問題はそもそも生じません。
  • 保存する作品では、形式の番号を持ち、旧形式を新形式へ移すという構造を採っています。

version1 から version2 でどの項目がどう変わったかは、案内されていません。この部分は、各作品を開いて、保存した内容が更新後も引き継がれるかを遊んで確かめてください。

更新の前に、古いセーブの見本で確かめる

互換性は、新しいデータだけでテストしても確かめられません。更新前の版で作った本物のセーブを、更新後の版に読ませる必要があります。次の手順は、読者が自分で行えば答えが出る形にしています。

  1. 見本を残す。 更新する前に、現在の版でいくつかの状態のセーブ文字列を書き出して保存します。初期状態、少し進んだ状態、設定をすべて変更した状態の3種類を用意します。
  2. 更新後の版に読ませる。 見本の文字列を load のような読み込み口に渡し、結果を確認します。
  3. 次の点を見ます。
  • 進行や記録が残っているか。
  • 設定が既定値に戻っていないか。
  • 同じ文字列を2回読んでも結果が同じか。
  • 移行後のデータを保存し、再び読み込んでも同じ内容になるか。
  1. 壊れた入力も試す。 空文字、途中で切れたJSON、version のないデータ、現在より大きい version のデータを渡します。落ちずに、意図した扱いになるかを確認します。

結果の読み方は次のとおりです。設定だけが既定に戻るなら、移行関数が項目を移し忘れているか、既定値の重ね方が逆になっている可能性があります。2回読んで結果が変わるなら、読み込みの途中で元のデータを書き換えている可能性があります。現在より大きい version を読んで上書き保存してしまうなら、「読めない形式は触らない」の分岐が働いていません。

起こりやすい誤りは、見本を更新後の版で作り直してしまうことです。それでは新しい形式のデータを新しい版に読ませているだけで、互換性の確認になりません。見本は更新前の版から取り出した文字列そのものを、ファイルとして保管しておきます。形式を変えるたびに見本が増えていくので、古い見本も消さずに残しておくと、version1 から最新版までの経路を毎回確かめられます。

次の行動

自分の作品のセーブに version が入っているか確認してください。入っていなければ、次の更新で version: 1 を入れ、読み込み口を一本にまとめる作業から始めるのが現実的です。そのうえで、更新前の版のセーブ文字列を3種類書き出し、見本として保管してください。

保存の仕組みそのものを見たいときは、「余白の庭」「WAVE / WAVE」「かたちのあいだ」を開き、保存した内容が再読み込み後も残るかを確かめるところから始められます。