ブラウザのタブを閉じただけで、4時間の進捗が消えてしまうべきではありません。当たり前のことのように思えますが、多くのブラウザゲームでは、ローカルストレージの扱いが後回しにされています。プレイヤーがハイスコアを更新し、設定を調整して、翌日戻ってくると、何も残っていない。さらに悪いことに、パッチ適用後に戻ってきた際、プレイヤーの端末にあるセーブファイルが、配信したばかりのコードと一致しないためにゲームがエラーを吐く、といったことも起こります。Phaser 4でサバイバー系シューティングを構築する場合、絶え間なく押し寄せる敵の波に対処する必要がありますが、真の長期的な脅威は、あなた自身の将来のアップデートなのです。
ほとんどの開発者は、オブジェクトを取得して JSON.stringify を通し、それを localStorage に放り込むという方法で最初のセーブシステムを構築します。ロード時にはそれをパースし、生の状態でゲームに渡します。それは初日は機能します。しかし、新しい設定やアンロックフラグ、あるいは3層目のネストされた設定を追加した瞬間に壊れます。復帰したプレイヤーが vignette プロパティのない古いセーブファイルを持っている場合、新しいコードがその存在を期待していると、Boolean(真偽値)を期待していた場所に undefined が現れます。これを十数個の新機能に対して繰り返すと、最も忠実なプレイヤーを最初に直撃する、デバッグの悪夢へと発展します。
生のオブジェクトではなく、コントラクトから始める
localStorage に触れる前に、コードベース内でデフォルトのセーブスキーマを定義してください。それは、5分前に作成されたものであれ5ヶ月前に作成されたものであれ、すべてのセーブファイルが遵守しなければならない「コントラクト(契約)」だと考えてください。明確な出発点は、以下のようになります。
const defaultSave = {
highScore: 0,
settings: {
screenShake: true,
vignette: true
}
};
このオブジェクトはソースコード内に存在します。ゲームが起動するとき、常にこの形状が利用可能な状態になります。これによりベースラインが得られます。また、シリアライズを行う前に構造について考えることを強制してくれます。このステップを飛ばして、その時々に都合の良いステートオブジェクトを単に保存してしまうと、キーの不一致、フィールドの欠落、そして古いセーブが期待値から乖離した際のサイレントな失敗を招くことになります。
Try/Catch による防御的なロード
ローカルストレージはデータベースではありません。ブラウザ内の「文字列の物置」であり、何でも入り込む可能性があります。ユーザーが値を手動で編集したかもしれないし、書き込み操作が途中で中断されたかもしれないし、ブラウザ拡張機能が指定したキーにゴミデータを放り込んだかもしれません。その文字列を取り出して JSON.parse に渡すと、たった一つの破損した文字があるだけで、致命的な例外が発生します。Phaserのゲームにおいて、その未処理のエラーは起動シーケンスをフリーズさせたり、プレイヤーを真っ白な画面に戻したりする可能性があります。
読み込みとパースのロジックは、常に try/catch ブロックで囲んでください。失敗した場合は、デフォルトのスキーマにフォールバックします。目標は単純です。セーブファイルが読み取れない場合は、セッション全体をクラッシュさせるのではなく、プレイヤーを新規ユーザーとして扱うことです。この一つの習慣が、ホビープロジェクトとプロダクション級のビルドを分けるのです。実装コストはほとんどかからず、再現不可能な謎のバグ報告からあなたを守ってくれます。
古いデータをデフォルトとマージする
パースに成功したからといって、安全だとは限りません。パースした結果でデフォルトのオブジェクトを丸ごと置き換えてはいけません。その古いセーブファイルには、最新の設定が含まれていない可能性があります。screenShake は保存されていても、vignette は含まれていないかもしれません。最新のアップデートで同梱されたからといって、ゲームロジックが vignette の存在を前提としていると、再び undefined エラーの追跡に戻ることになります。
代わりに、ロードしたデータをデフォルトとマージしてください。Object.assign を使用して、ベースラインのスキーマの上に保存された値を重ね合わせます。デフォルト値が、欠落しているすべての隙間を自動的に埋めてくれます。バージョン2で追加した新しいプロパティには、デフォルトオブジェクトから初期値が割り当てられます。プレイヤーが実際に変更した既存のプロパティは、保存された設定で上書きされます。これなら全員がハッピーです。復帰したプレイヤーはハイスコアを維持でき、ゲームは昨日追加したばかりの新しいトグル設定にも、クラッシュすることなくアクセスできるようになります。
Object.assign は浅いマージ(shallow merge)を行うことに注意してください。設定オブジェクトが時間の経過とともに深くネストされるようになった場合は、それらの内部オブジェクトをもう少し慎重に扱う必要があるかもしれません。それでも、原則は変わりません。プレイヤーのデータはデフォルトを「装飾」すべきであり、完全に「置き換える」べきではないのです。
キーにバージョンを付ける
ブラウザは古いローカルストレージのエントリを自動的に削除しません。データ構造を劇的に変更する場合は、古いフォーマットを破棄するためのクリーンな方法が必要です。ストレージのキーには、バージョン接尾辞を付けて名前を付けましょう。bitSurvivorsSave_v1 とすれば明示的です。どのスキーマがそのファイルを書き込んだのかを正確に示せます。後で進行状況を刷新したり、完全なインベントリシステムを追加したりする場合は、bitSurvivorsSave_v2 に移行してください。
This gives you two practical benefits. First, you never accidentally parse a v1 blob with v2 logic. Second, you can write migration code if you choose. On boot, check for v1. If it exists and v2 does not, migrate the old data into the new structure, write it to the new key, and move on. If you do not want to migrate, at least the old key sits harmlessly in storage while your new code ignores it. Either way, versioning prevents silent corruption.
Make Saving Invisible
Persistence should feel like breathing. The player should never have to think about it. Do not add an Apply button in your settings menu. Apply buttons create friction and train users to worry about whether their choices actually stuck. They also invite data loss when a player toggles three options, misses Apply, and closes the tab.
Save the moment the interaction happens. When the player clicks a checkbox to disable screen shake, call your write function immediately. When the run ends and the final score tallies up, write the new high score before the game over screen finishes animating. Event-driven saving keeps your architecture predictable because the save always lives right next to the action that changed the data. You never have to hunt down a central batching function or worry about stale state.
This approach also simplifies your mental model. You know exactly where persistence happens: in the callback that handles the toggle, and in the function that handles death. There are no mystery writes scattered across the codebase.
Build a Reset Button for Yourself
You will corrupt your own saves during development. You will write bad data, test edge cases, and need to return to a clean state quickly. Build a reset button into a debug menu or a hidden key combination. Make that reset button do two things in this exact order: reset your in-memory state to the default schema, then immediately call the same save function that writes to local storage.
If you only clear the local variable and skip the write step, you have accomplished nothing. The next page refresh pulls the old data back out of the browser and resurrects it. A reset that forgets to persist is the kind of bug that wastes an afternoon. Nail the sequence once, and your testing loop stays fast for the rest of the project.
The Real Takeaway
Saving is not a feature you bolt on at the end. It is infrastructure that defines whether your game feels durable and respectful of the player's time. A Phaser 4 survivor shooter lives or dies on repeated runs. If the browser tab is a loaded gun pointed at the player's progress, they will eventually stop coming back. Write a schema, defend against bad data, merge instead of replacing, version your keys, and save on every meaningful event. Your future self, and every player who returns after your next update, will thank you.
