マガジン

セーブデータの容量を減らす:JSONの圧縮

遊びをつくる

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

スマートフォンとノートパソコンの枠を、赤いゲームの駒と雲のアーチがつなぐ切り絵
記事のテーマをもとに制作したAI生成イラスト。

ブラウザゲームのセーブデータをlocalStorageやクラウドに保存するとき、JSONをそのまま文字列化して保存しているケースは少なくありません。小さなゲームなら問題になりにくいのですが、ステージ数が増えたり、マップデータや装備リストを持つようになったりすると、1件のセーブデータが数十KBを超えることがあります。 localStorageにはブラウザごとに上限があり、一般的には5MB前後とされていますが、複数のゲームやキーが積み重なると思ったより早く圧迫されます。クラウドセーブAPIにも1エントリあたりのサイズ制限が設けられていることがあります。容量問題が起きてからデータ構造を変えると、既存セーブとの互換性を保つ移行処理が必要になり、コストが跳ね上がります。先に「JSON を小さくする習慣」を持っておくほうが、後の手間を大きく減らせます。 この記事では、外部ライブラリを使わない手作業の最適化と、LZ系アルゴリズムによる文字列圧縮の二段階で、セーブデータを軽量化する方法を整理します。

この記事の目次
  1. なぜJSONは大きくなりがちなのか
  2. 手作業でできる最適化:キーの短縮と型の選択
  3. LZ系アルゴリズムで文字列ごと圧縮する
  4. 圧縮を導入するときに失敗しやすい点
  5. どの手法を組み合わせるか:判断の目安
  6. 次の行動
  7. 参考資料

なぜJSONは大きくなりがちなのか

JSON.stringify が出力する文字列には、キー名がすべての要素に繰り返されます。たとえばアイテムを100個持つリストがあれば、"itemId"・"quantity"・"equipped" といったキーが100回ずつ現れます。開発中はキーを読みやすい名前にしたいので当然の選択ですが、そのまま保存すると冗長です。

また、Booleanを true/false で持つか 1/0 で持つかだけでも、大量のフラグが並ぶデータでは数百バイトの差になります。数値の精度も問題です。座標を 1.0000000000000002 のような浮動小数点で保存しているとき、ゲームに必要な精度が小数点以下2桁であれば、parseFloat(x.toFixed(2)) で丸めるだけでサイズを削れます。

手作業でできる最適化:キーの短縮と型の選択

最初に試すべきなのは、保存用に別のシリアライズ関数を用意することです。ゲーム内部のオブジェクトは可読性のある名前のまま使い、保存・読み込みの境界でだけ変換します。

// 内部表現
{ itemId: 12, quantity: 3, equipped: true }

// 保存用に変換
{ i: 12, q: 3, e: 1 }

この変換表(マッピング)をコード内に定数として持っておくと、後から見たときに何が何を表すかを追えます。変換表なしにキー名だけ短くすると、数ヶ月後に自分でも読めなくなるため注意が必要です。

配列の活用も有効です。毎回同じ順番でデータが並ぶなら、オブジェクトより配列で持つほうがキー分のバイトを節約できます。

// オブジェクト形式(キー名が繰り返される)
[{"i":12,"q":3,"e":1}, {"i":7,"q":10,"e":0}]

// 配列形式(順番をコードで管理)
[[12,3,1],[7,10,0]]

ただし配列形式は、後でフィールドを追加するときにインデックスの管理が必要になります。追加頻度が高い設計では、短縮キーのオブジェクト形式のほうが保守しやすい場面もあります。どちらを選ぶかは「このデータ構造はどの程度変更されるか」を基準にしてください。

LZ系アルゴリズムで文字列ごと圧縮する

手作業の最適化で削れる量には限界があります。データ構造の変更なしに大幅に圧縮したい場合は、文字列圧縮ライブラリを使うのが現実的です。

ブラウザJavaScriptで広く使われているのが LZ-String です。LocalStorage向けの compressToUTF16 やBase64出力の compressToBase64 など、保存先に合わせた関数が用意されており、CDNやnpmで取得できます。

使い方は次のような流れになります。

  1. JSON.stringify(saveData) でJSON文字列を作る
  2. LZString.compressToUTF16(jsonStr) で圧縮する
  3. localStorage.setItem(key, compressed) で保存する
  4. 読み込み時は LZString.decompressFromUTF16(raw) で展開し、JSON.parse に渡す

実際の圧縮率はデータの性質によって変わりますが、繰り返しが多いJSONであれば元の30〜60%程度のサイズになることがあります。マップデータや同一構造の配列が長く続くケースほど効果が出やすいです。

一方で、すでに手作業で短縮しきったキー名の短いJSONや、ほぼランダムな数値しか入っていないデータでは、圧縮後に逆に大きくなる場合もあります。圧縮を導入する前に、実際のセーブデータのサンプルで試してサイズを比較するのが確実です。

圧縮を導入するときに失敗しやすい点

既存セーブとの互換性が最初の落とし穴です。圧縮前のデータがすでにユーザーのブラウザに保存されていると、新しいコードで読み込んだときにエラーが起きます。対策は、読み込み時に圧縮済みかどうかを判定する処理を挟むことです。たとえばキーを save_v2 のように変えてバージョンを明示し、旧キーが存在すれば旧フォーマットで読んで新キーに書き直す、という移行処理を一度だけ走らせる方法がよく使われます。

デバッグのしにくさも考慮してください。圧縮後のデータはそのままでは読めないため、開発中はlocalStorageの中身を確認するときに手間がかかります。開発ビルドでは非圧縮のまま保存し、本番ビルドのみ圧縮するフラグを用意しておくと、開発効率を保ちやすいです。

圧縮・展開のコストは通常は無視できる範囲ですが、セーブ処理をフレームの途中で同期的に実行しているとき、大きなデータでは処理落ちの原因になることがあります。セーブのタイミングをゲームのポーズ中や画面遷移のタイミングに限定することで、体感への影響を避けられます。

どの手法を組み合わせるか:判断の目安

  • データが1KB未満で今後も増えない、推奨する手法:何もしなくてよい
  • データが数KB〜数十KB、構造変更が許容できる、推奨する手法:キー短縮+配列化
  • データが数十KB超、または構造変更が難しい、推奨する手法:LZ-Stringなど文字列圧縮
  • 両方の条件に当てはまる、推奨する手法:手作業最適化+文字列圧縮の組み合わせ

まず JSON.stringify(saveData).length を一度確認してみてください。文字数がそのままバイト数の近似値になります(ASCII範囲のJSONの場合)。そのうえで上の目安と照らし合わせると、どこまで手を入れるべきかが判断しやすくなります。

次の行動

手元のゲームで JSON.stringify(saveData).length を一度確認し、現在のサイズを把握することから始めてください。数十KB以上あれば、まずキー短縮で削れる量を試算し、それでも足りなければLZ-Stringのサンプルコードで圧縮後のサイズを比較してみてください。アソバースのSDKを使ってクラウドセーブに対応している場合は、APIの1エントリあたりのサイズ制限も公式ドキュメントで確認しておくと、設計の判断がしやすくなります。

参考資料

  • LZ-String 公式ドキュメント(pieroxy.net)
  • MDN Web Docs — Web Storage API(localStorageの仕様と制限)
  • ECMAScript 仕様 — JSON.stringify の動作