セーブとシリアライズ/バージョニング
アップデートで過去のセーブが壊れないゲームを作りたい人へ。シリアライズ形式・バージョニング・破損対策の原理を押さえれば、後方互換を保ちつつ安全にセーブデータを進化させられます。
- セーブデータはゲーム状態をバイト列へ落とす永続化。形式はテキスト(JSON等・可読で緩い)とバイナリ(Protobuf/FlatBuffers等・小さく速い)に大別され、可読性・サイズ・スキーマ進化耐性のトレードオフで選ぶ。
- 後方互換の鍵はバージョン番号とマイグレーション。ファイル先頭にスキーマ版を埋め、旧版を読むときは版ごとの変換を順に適用して現行構造へ引き上げる。未知フィールドの無視・欠損フィールドの既定値補完・tag番号の不変が破壊回避の基本原則。
- 破損対策はチェックサム(CRC32等)で改ざん・断片破損を検知し、アトミックな一時ファイル+rename書き込みで書き込み中クラッシュによる全損を防ぐ。CRCは誤り検知用で改ざん防止にはならない点に注意。
セーブとは何か:実行時状態を永続バイト列へ落とす
横にスクロール
ゲームのセーブは、メモリ上に散らばった実行時状態——プレイヤー座標、インベントリ、クエスト進行、ワールドの改変差分——を、プロセス終了後も残るバイト列へ変換してストレージに書き出す処理です。この「メモリ上の構造をバイト列に落とす」変換がシリアライズ(直列化)、逆にバイト列から構造を復元する処理がデシリアライズです。
本質的な難所は、ゲームの内部データ構造が生ポインタや参照で相互に絡み合っている点にあります。オブジェクトAがオブジェクトBを指すポインタは、次回起動時にはまったく別のアドレスへ配置されるため、そのまま書き出しても意味を持ちません。そこでシリアライズでは、ポインタを識別子(ID・インデックス)へ置き換え、復元時にIDから実体を引き直す(ポインタスワッピング)方式を取ります。参照グラフに循環がある場合は、訪問済みオブジェクトをID管理して二重書き込みと無限再帰を防ぐ必要があります。
すべてのメモリを丸ごと書き出すのは誤りです。復元可能な状態——ロード済みテクスチャ、コンパイル済みシェーダ、実行時キャッシュ、乱数生成器の一時バッファ——は保存せず、再生成します。保存すべきは「再現不能な、プレイヤー固有の進行」だけです。乱数は状態全体ではなくシードと必要なら消費カウンタを保存し、ロード時に同じ系列を再構築するのが定石です。この切り分けを誤るとセーブが肥大化し、内部実装の変更でセーブ互換が容易に壊れます。
シリアライズ形式:テキストとバイナリ、スキーマの有無
シリアライズ形式は大きくテキスト系とバイナリ系に分かれ、さらに「スキーマ(型定義)を持つか」で性質が変わります。
- テキスト形式(JSON・XML・YAML等): 人間が読め、差分(diff)やデバッグが容易。反面サイズが大きく、パースが遅く、浮動小数点は10進表記で丸め誤差や桁数の問題が出やすい。フィールド名を文字列キーで持つため、キー名を変えない限りフィールドの追加・削除に強い(未知キーを無視しやすい)。
- バイナリ形式(独自バイナリ・Protobuf・FlatBuffers・MessagePack等): 小さく高速。ただし生の独自バイナリは「何バイト目が何か」を暗黙のレイアウトで決めるため、フィールドを1つ挿入しただけで以降のオフセットが全部ずれ、旧セーブが読めなくなる(スキーマ進化に極端に弱い)。
- スキーマ付きバイナリ(Protobuf・FlatBuffers): 各フィールドにtag番号(フィールド番号)を振り、番号でフィールドを識別する。これによりフィールド順序に依存せず、未知の番号は読み飛ばせるため、後述する後方・前方互換を構造的に確保できる。FlatBuffersはさらにパース不要(ゼロコピー)でオフセット参照だけで読める設計で、大きなセーブの読み込み遅延を抑えられる。
| 形式 | サイズ | 速度 | スキーマ進化耐性 | 主な用途 |
|---|---|---|---|---|
| JSON/XML(テキスト) | 大 | 遅い | 中(キー名不変なら追加削除に強い) | 設定・小さいセーブ・デバッグ |
| 独自バイナリ(レイアウト固定) | 最小 | 最速 | 弱い(挿入でオフセット全ずれ) | 版管理を自前で徹底する場合 |
| MessagePack | 小 | 速い | 中(JSON相当・型付き) | JSONの軽量バイナリ代替 |
| Protobuf | 小 | 速い | 強い(tag番号で識別) | 長期運用・頻繁な更新 |
| FlatBuffers | 小 | 最速級(ゼロコピー) | 強い(tag+既定値) | 大きいセーブ・即時ロード |
数値のバイト順(エンディアン)も無視できません。独自バイナリをそのままメモリコピーで書くと、書き出した環境と読む環境でエンディアンが異なる場合に数値が壊れます。クロスプラットフォームで配布するなら、リトルエンディアン等に明示的に正規化して書くか、エンディアン非依存の形式を用います。テキスト形式や整数の可変長符号化(varint)はこの問題を回避できます。
バージョニングとマイグレーション:後方互換の設計原理
ゲームは更新のたびにセーブ構造が変わります。新武器スロット追加、ステータスの内部表現変更、クエストフラグの再編——これらを経ても古いセーブを読めること(後方互換)が、プレイヤー体験の生命線です。中核となる手法がバージョニングです。
ファイルの先頭(ヘッダ)にマジックナンバー(この形式であることの識別子)とスキーマバージョン番号を埋め込みます。ロード時はまずヘッダを読み、記録された版と現行版を比較します。版が古ければ、版ごとのマイグレーション(変換)関数を順に適用し、データ構造を現行版まで段階的に引き上げてから通常のデシリアライズへ渡します。
セーブファイルの典型レイアウト:
[ magic number ] 例 "TSAV"(形式識別)
[ format version ] 例 v7(スキーマ版)
[ flags/length ] 圧縮有無・ペイロード長など
[ payload ] 本体(シリアライズされた状態)
[ checksum ] payload に対する CRC32 等
ロード時のマイグレーション連鎖(例: 保存版 v3 を現行 v7 へ):
v3 の bytes
-> migrate_v3_to_v4 フィールド名変更・単位換算
-> migrate_v4_to_v5 新フィールド追加(既定値を補完)
-> migrate_v5_to_v6 enum 値の再割り当てを吸収
-> migrate_v6_to_v7 構造再編(配列 -> マップ 等)
-> 現行デシリアライザへ
版を1つずつ上げる連鎖マイグレーションには利点があります。N個の過去版があっても変換関数はN個で済み(各隣接版間に1つ)、「v3から直接v7へ」といった全組み合わせ(O(N^2))の変換を書かずに済みます。各変換は「1つ前の版の構造」だけを知ればよく、テストも局所化できます。
Protobuf等のスキーマ付き形式で最も多い破壊は、削除したフィールドのtag番号を別フィールドに使い回すことです。旧セーブに残る古い番号のデータを、新しい意味のフィールドとして誤読し、静かにデータが壊れます。削除フィールドの番号は「予約(reserved)」にして永久欠番とし、決して再利用しないのが鉄則です。同様に、既存フィールドの型を変える(int を string へ等)のも後方互換を壊します。意味を変えたいときは番号を変えず新フィールドを追加し、旧フィールドは読み取り専用として残すか予約化します。
破壊を避けるフィールド進化の原則
後方互換(新しいコードで古いセーブを読む)と前方互換(古いコードで新しいセーブを読む)を両立させるには、フィールドの変更に規律が要ります。
- 追加は安全、削除・改名は危険: フィールド追加は、旧セーブにその値が無いだけなので既定値で補完すれば読める。逆に削除・改名は、旧コードや旧セーブとの対応が崩れる。
- 未知フィールドは無視して保持: 古いコードが新しいセーブを読むとき、知らないフィールドはエラーにせず読み飛ばす。可能ならそのまま保持して書き戻す(passthrough)ことで、古いクライアントが再保存しても新フィールドが消えない。
- 欠損フィールドは既定値: 新しいコードが古いセーブを読むとき、存在しないフィールドは型の既定値(0・空文字・空配列)や意味のある初期値で埋める。「フィールドが在る/欠けている」を区別できる形式(optional・presence bit)だと、既定値と明示的なゼロを取り違えずに済む。
- enumは末尾追加のみ: 列挙値は既存値の番号を変えず末尾に足す。中間に挿入して番号を詰め直すと、旧セーブの数値が別の意味になる。
JSONで手書きパースすると、上記の互換ルールをすべて人力で守ることになり抜けが生じます。Protobufのようなスキーマ駆動の形式は「未知フィールドの読み飛ばし」「欠損フィールドの既定値」「tag番号による識別」が仕組みとして最初から組み込まれており、規約さえ守れば互換維持のコードを自前で書く量が激減します。長期運用・頻繁更新のタイトルほど、スキーマ駆動形式+明示的な版番号の併用が効きます。
チェックサムと破損対策:検知と、書き込みのアトミック性
ストレージは万能ではありません。ビット反転、書き込み途中の電源断、セーブ改ざんツール——これらでセーブは壊れます。壊れたデータをそのまま読み込むとクラッシュや進行不能を招くため、検知と予防の両輪が要ります。
検知にはチェックサムを使います。ペイロード全体からCRC32(巡回冗長検査)等の短い値を計算し、ファイル末尾に付けます。ロード時に再計算して一致しなければ破損とみなし、ロードを拒否してバックアップへフォールバックします。CRC32はランダムなビット化けや断片破損の検知に有効ですが、暗号学的なハッシュではなく、改ざん防止にはならない点が重要です。CRCの値はデータから機械的に決まるため、チートツールはデータを書き換えたうえでCRCも計算し直せます。改ざん検知が目的なら、秘密鍵を用いるHMAC(鍵付きハッシュ)でなければ意味がありません(ネットワーク/セキュリティの基礎 も参照)。
最も致命的なのは、既存セーブファイルを直接上書きしている最中のクラッシュや電源断です。書き込みが途中で止まると、旧データは既に壊れ、新データは未完という「どちらでもない」状態になり、セーブが完全に失われます。これを防ぐのがアトミック書き込みです。まず別名の一時ファイルへ全部書き切り、fsync等でストレージへの永続化を確実にしてから、OSのアトミックなrenameで本番ファイルへ差し替えます。renameは「成功して新ファイル」か「失敗して旧ファイルのまま」のどちらかにしかならないため、途中状態が観測されません。加えて世代バックアップ(直前のセーブを1つ残す)を併用すれば、最新が壊れても1つ前へ戻せます。
破損対策の設計は、次の3層で考えると整理できます。(1) 検知層——チェックサムで壊れを見つける。(2) 予防層——一時ファイル+renameで書き込み中の全損を防ぐ。(3) 回復層——世代バックアップやクラウド同期で、壊れた最新から過去の健全なセーブへ戻す。オートセーブとクイックセーブでスロットを分け、オートセーブが常に上書きされても手動セーブは残す設計も、実質的な回復層として機能します。
- シリアライズの核心: ポインタ/参照はIDへ置換して書き、復元時に引き直す。保存するのは再現不能な進行のみで、乱数はシード+消費数を保存する。
- 形式選定: 可読性重視ならJSON、サイズ・速度・長期互換ならProtobuf/FlatBuffers。独自レイアウト固定バイナリは高速だがフィールド挿入でオフセットが全ずれし進化に弱い。エンディアンは正規化する。
- バージョニング: ヘッダにマジックナンバーとスキーマ版を埋め、隣接版間のマイグレーションを連鎖適用して現行構造へ引き上げる(変換関数はN個で済む)。
- フィールド進化の鉄則: 追加は安全、削除・改名・型変更は危険。tag番号は再利用せず予約化。未知フィールドは無視して可能なら保持、欠損は既定値、enumは末尾追加のみ。
- 破損対策: CRC32は誤り検知用(改ざん防止にはHMACが必要)。書き込みは一時ファイル+fsync+renameでアトミックにし、世代バックアップで回復層を持つ。
まとめ
ゲームのセーブは、実行時のメモリ状態を永続バイト列へ落とすシリアライズであり、ポインタのID化と「再現不能な進行だけを保存する」切り分けが土台になります。形式はテキスト(可読・大きい・進化に中程度)とバイナリ(小さい・速い)に分かれ、Protobuf/FlatBuffersのようなtag番号ベースのスキーマ付き形式は、フィールド順序に依存せず未知フィールドを読み飛ばせるため後方・前方互換を構造的に確保できます。更新でセーブ構造が変わっても古いセーブを読み続けるには、ヘッダにスキーマ版を埋め、隣接版間のマイグレーションを連鎖適用して現行構造へ引き上げるのが定石で、tag番号の再利用禁止・追加は安全/削除改名は危険・未知フィールドの保持・欠損の既定値補完といった進化の規律が破壊を防ぎます。破損に対しては、CRC32等のチェックサムで検知しつつ(ただし改ざん防止にはHMACが要る)、一時ファイル+fsync+renameによるアトミック書き込みで書き込み中の全損を防ぎ、世代バックアップで回復層を用意します。検知・予防・回復の3層と、明示的な版番号+スキーマ駆動形式の組み合わせが、アップデートを重ねてもプレイヤーのセーブを壊さないための実務的な要諦です。
ゲーム開発の記事ガイド
セーブとシリアライズ/バージョニングを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
ゲーム開発
比較で見る軸
難易度: advanced / カテゴリ: ゲーム開発 / タグ数: 6
導入後に効く点
後方互換の鍵はバージョン番号とマイグレーション。ファイル先頭にスキーマ版を埋め、旧版を読むときは版ごとの変換を順に適用して現行構造へ引き上げる。未知フィールドの無視・欠損フィールドの既定値補完・tag番号の不変が破壊回避の基本原則。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- ゲーム開発
- タグ数
- 6
判断チェックリスト
- 自社の用途が「ゲーム開発 / シリアライズ」に近いか確認する。
- 強みである「セーブデータはゲーム状態をバイト列へ落とす永続化。形式はテキスト(JSON等・可読で緩い)とバイナリ(Protobuf/FlatBuffers等・小さく速い)に大別され、可読性・サイズ・スキーマ進化耐性のトレードオフで選ぶ。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。