マガジン

保存データが壊れて起動しない:読み込み時の防御

遊びをつくる

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

同じ目のサイコロと操作の跡が二本の平行な小道に並ぶ、再現手順の模型。
記事のテーマをもとに制作したAI生成イラスト。

自作のブラウザゲームを更新して公開したあと、「前は遊べたのに、開いても真っ白のままです」という連絡が届く場面を考えてみます。ゲーム本体のコードは変えていません。変わったのは保存データの形だけです。調べると、遊んだ人の端末に残っていた古い形の保存を、新しいコードがそのまま読み込み、存在しない項目を参照して止まっていました。 この種の不具合が厄介なのは、再読み込みしても直らない点です。壊れた保存が毎回の起動で読まれるので、遊ぶ側には打つ手がなく、作る側も自分の端末では再現できません。この記事では、読み込みの入口にどんな関門を置けば「起動しない」を「前の続きは失われたが遊べる」に変えられるかを、形式の検証と復帰の設計という二つの面から整理します。

この記事の目次
  1. 壊れ方は数種類に分けられる
  2. 読み込みの関門を順番に並べる
  3. 公式作品の保存から読み取れること
  4. クラウドセーブと端末セーブの関係
  5. 最小の読み込み関数と、壊して試す手順
  6. 次の行動

壊れ方は数種類に分けられる

保存データが原因で起動できなくなる経路は、大きく次のように分けられます。どれが起きるかで、防御の置き場所が変わります。

  • 文字列として壊れている、起きる場面の例:書き込み途中で中断された、手で編集して括弧が欠けた、読み込み時の症状:JSONの解析が例外で止まる
  • 形が違う、起きる場面の例:項目名の変更、配列だったものがオブジェクトになった、読み込み時の症状:存在しない項目の参照で止まる
  • 版が古い・新しい、起きる場面の例:更新前後で保存の構造が変わった、読み込み時の症状:新旧の取り違えで値が不正になる
  • 値が範囲外、起きる場面の例:負の手数、重複した数字、上限を超える個数、読み込み時の症状:起動はするが、描画や判定が壊れる
  • 保存先が使えない、起きる場面の例:保存領域へのアクセスが拒否される環境、読み込み時の症状:読み出し自体が例外になる

特に見落とされやすいのは4行目です。解析も項目の存在確認も通るのに、中身の値がゲームのルールに反している状態で、起動時ではなく数手遊んだあとに壊れるため、原因が保存データだと気づきにくくなります。

読み込みの関門を順番に並べる

防御は一か所に集めず、段階に分けると考えやすくなります。読み込みの流れを次の順で置いてみます。

  1. 取得:保存先から文字列を読む。読めない、または何もなければ初期状態で始める。
  2. 解析:JSONとして読む。失敗したら壊れた保存として扱う。
  3. 版の判定:保存に付けた版番号を見る。古ければ移行へ、未知の新しい版なら採用しない。
  4. 移行:旧形式を現行形式に変換する。
  5. 検証:型、範囲、ゲーム固有の条件(不変条件)を確かめる。
  6. 採用または破棄:通ったものだけを状態に反映し、通らなければ初期状態に戻す。

ここで大事なのは、検証の前にゲームの状態へ一文字も反映しないことです。解析した結果を直接ゲームの変数に代入してから検証すると、検証に落ちた時点で状態が半分だけ書き換わった中途半端な状態になります。

もう一つの要点は、破棄するときの扱いです。壊れた保存を黙って上書きすると、原因調査の手がかりが消えます。別のキーに退避してから初期状態に戻す設計にしておくと、後から中身を見て、どの関門で落ちたのかを確かめられます。

「不変条件」とは、そのゲームで常に成り立つはずの条件のことです。数字を並べ替えるスライドパズルなら、盤面は「0から8までの数が一つずつ並ぶ配列」であるはずです。この条件は、保存形式がどう変わっても使える強い検証になります。

公式作品の保存から読み取れること

アソバースの公式作品13本のうち、保存を持つのは「余白の庭」「WAVE / WAVE」「かたちのあいだ」の3本です。他の10作品の記録や録音は、画面を開いている間だけ残ります。この線引きは、設計から次のように読み取れます。保存を持たない作品には、読み込み時の壊れ方がそもそも起こりません。保存を持つ作品だけが、この記事の関門を必要とします。

共通の事項として、古い保存(version1)は新しい形式(version2)へ移行されることが確認されています。つまり保存に版番号があり、旧版を読んで新版へ変換する経路が用意されています。前節の「版の判定」と「移行」に当たる設計です。

3作品それぞれの検証対象を、確認済みの機能から推測すると次のようになります。

  • 余白の庭:置けるのは最大80個です。保存された庭にこの上限を超える個数が入っていた場合をどう扱うかは、検証の観点になります。
  • かたちのあいだ:数字を順にそろえるスライドパズルで、3×3と4×4があります。盤面が数の重複や欠けのない配置になっているかは、不変条件の候補です。
  • WAVE / WAVE:4つの音を16ステップに並べます。ステップ数や音の種類が想定の範囲に収まるかは、検証の観点になります。

ただし、実際にどの項目をどう検証し、壊れていたときにどう復帰するかは、確認済みの情報にはありません。この部分は、作品を開いて自分で確かめる領域です。方法は後の節で示します。

クラウドセーブと端末セーブの関係

アソバースには、作品ごとにJSONで128KiBまでのクラウドセーブがあります。アップロードした作品にはSDKが自動で追加され、URLで投稿した作品でも、作者がSDKを読み込めば使えます。

ここで押さえたいのは、未同期の端末セーブはクラウドの古いデータより優先して再送される、という仕様です。端末側に新しい進行があるなら、それを失わないための設計と読み取れます。一方で作る側から見ると、端末セーブが壊れていた場合に、その壊れたデータがクラウドへ送られうるという注意点も生まれます。

この問題への対策は、読み込み時の検証を「端末から読んだとき」と「クラウドから読んだとき」の両方に通すことです。検証を通ったものだけを採用し、通らなければもう一方を試す設計にすれば、片方が壊れても復帰できます。これは公式の実装の説明ではなく、読者が自作ゲームで取れる判断基準です。

もう一つ、ゲストの履歴や進行は端末内に保存され、ログイン後のアカウントへ自動では混ざらないことも確認されています。ログイン前後で保存の出どころが変わりうるので、どちらから読んでも同じ検証を通す形にしておくと、取り違えが起きにくくなります。

また、アップロード作品内のlocalStorageとsessionStorageは、一時メモリーの互換処理として扱われます。ブラウザ標準の保存と同じ永続性を前提にせず、永続させたい進行はSDKのクラウドセーブに載せる前提で設計してください。永続性を前提にしない場合でも、読み込みの関門は変わらず必要です。

最小の読み込み関数と、壊して試す手順

次のコードは、スライドパズルを想定した自作の例です。公式作品の保存形式ではありません。旧形式の項目名も仮定です。HTMLファイルとして保存し、ローカルで開けば単独で動きます。

html
<script>
const KEY = "demo-save";
const VERSION = 2;

function defaultState() {
  return { version: VERSION, board: [1,2,3,4,5,6,7,8,0], moves: 0 };
}

function isPermutation(board, size) {
  if (!Array.isArray(board) || board.length !== size) return false;
  const seen = new Set(board);
  if (seen.size !== size) return false;
  for (let i = 0; i < size; i++) if (!seen.has(i)) return false;
  return true;
}

function migrate(data) {
  if (data.version === 1) {
    // 仮定: 旧形式は手数を count という名前で持っていた
    return { version: 2, board: data.board, moves: data.count };
  }
  return data;
}

function validate(d) {
  return d.version === VERSION && isPermutation(d.board, 9)
    && Number.isInteger(d.moves) && d.moves >= 0;
}

function load() {
  let raw;
  try { raw = localStorage.getItem(KEY); } catch (e) { return defaultState(); }
  if (raw === null) return defaultState();
  try {
    const data = migrate(JSON.parse(raw));
    if (validate(data)) return data;
  } catch (e) { /* 解析や参照の失敗は破棄扱い */ }
  try { localStorage.setItem(KEY + "-broken", raw); } catch (e) {}
  return defaultState();
}

const state = load();
console.log(state);
</script>

読み取りの要点は三つです。保存領域へのアクセス自体も例外になりうるので、取得を try で囲んでいます。解析の失敗と、移行中の項目参照の失敗を、同じ catch で破棄扱いにしています。そして検証を通ったものだけを返し、落ちたものは -broken のキーへ退避してから初期状態を返しています。

この関数が本当に壊れたデータを防げるかは、読者が自分で壊して確かめられます。手順は次のとおりです。

  1. 上のHTMLをローカルのブラウザで開き、開発者ツールのコンソールで localStorage.setItem("demo-save", ...) を使って保存値を書き込みます。
  2. 次の入力を一つずつ試します。途中で切れたJSON文字列、null、版が1で count を持つ旧形式、盤面に同じ数字が二つ入った配列、moves が負数や文字列のもの。
  3. 入力ごとにページを再読み込みし、コンソールの出力が初期状態に戻るか、移行されて採用されるかを記録します。同時に demo-save-broken に元の文字列が残っているかも確認します。
  4. 記録は、入力の種類、どの関門で落ちたか、復帰後の状態の3項目を表にします。想定と違う行があれば、その関門の条件を見直します。

見直しの目安は次のとおりです。起動で例外が出るなら解析か移行の囲みが足りません。起動はするが盤面が変なら、検証の不変条件が足りません。

自分のゲームで試すときは、この手順の入力を「実際に保存している項目」に置き換えます。公式の3作品については、遊んで保存された状態を確認し、更新後も続きから始められるかを見ることで、移行の挙動を観察できます。壊れた保存に対する挙動は、公開情報にないので、自作ゲームで試す範囲にとどめてください。

次の行動

自作ゲームの読み込み処理を開き、解析・版の判定・検証・退避のどれが欠けているかを、上の関門の一覧と照らして確認してみてください。保存の構造を変えるときは、必ず版番号を上げ、旧版からの移行関数を足してください。そのうえで、壊したデータを5種類ほど用意して、起動と復帰を自分で確かめるところまでが一つの作業です。