Menutup tab browser seharusnya tidak menghapus kemajuan selama empat jam. Hal itu terdengar jelas, namun banyak game browser memperlakukan localStorage hanya sebagai pelengkap. Seorang pemain mencapai skor tinggi, menyesuaikan pengaturan, kembali besok, dan tidak menemukan apa pun. Lebih buruk lagi, mereka kembali setelah adanya patch dan game tersebut memunculkan error karena file simpanan di perangkat mereka tidak lagi cocok dengan kode yang baru saja Anda rilis. Membangun game shooter bergaya survivor di Phaser 4 berarti harus menghadapi gelombang musuh yang terus-menerus, tetapi ancaman jangka panjang yang sebenarnya adalah pembaruan Anda di masa mendatang.
Kebanyakan pengembang membangun sistem simpanan pertama mereka dengan mengambil sebuah objek, menjalankannya melalui JSON.stringify, dan memasukkannya ke dalam localStorage. Saat dimuat, mereka melakukan parse dan memberikannya kembali ke game secara mentah. Itu berhasil di hari pertama. Namun, hal itu akan rusak saat Anda menambahkan pengaturan baru, flag unlock baru, atau lapisan ketiga dari konfigurasi bersarang (nested configuration). Jika pemain yang kembali memiliki file simpanan lama yang tidak memiliki properti vignette, dan kode baru Anda mengharapkan properti tersebut ada, Anda akan mendapatkan undefined di tempat yang seharusnya berisi boolean. Kalikan itu dengan selusin fitur baru dan Anda akan menghadapi mimpi buruk debugging yang akan menghantam pemain paling setia Anda terlebih dahulu.
Mulailah dengan Kontrak, Bukan Objek Mentah
Sebelum Anda menyentuh localStorage, definisikan skema simpanan default di dalam codebase Anda. Anggaplah ini sebagai kontrak yang harus dipatuhi oleh setiap file simpanan, baik itu dibuat lima menit yang lalu atau lima bulan yang lalu. Titik awal yang jelas mungkin terlihat seperti ini:
const defaultSave = {
highScore: 0,
settings: {
screenShake: true,
vignette: true
}
};
Objek ini ada di dalam kode sumber Anda. Saat game dimulai, Anda selalu memiliki bentuk ini yang tersedia. Ini memberi Anda baseline. Ini juga memaksa Anda untuk memikirkan struktur sebelum melakukan serialisasi apa pun. Jika Anda melewatkan langkah ini dan hanya menyimpan objek state apa pun yang praktis saat itu, Anda akan berakhir dengan kunci yang tidak konsisten, field yang hilang, dan kegagalan tanpa peringatan (silent failures) ketika simpanan lama tidak lagi sinkron dengan ekspektasi Anda.
Loading Defensif dengan Try/Catch
localStorage bukanlah sebuah database. Ia hanyalah sebuah tempat penyimpanan string di browser, dan apa pun bisa berakhir di sana. Pengguna mungkin telah mengedit nilai secara manual, operasi penulisan yang setengah selesai terinterupsi, atau ekstensi browser memasukkan data sampah ke dalam key yang Anda gunakan. Saat Anda mengambil kembali string tersebut dan memberikannya ke JSON.parse, satu karakter yang korup akan memicu exception yang fatal. Dalam game Phaser, error yang tidak ditangani tersebut dapat membekukan urutan booting atau mengembalikan pemain ke layar kosong.
Selalu bungkus logika pembacaan dan parsing Anda dalam blok try/catch. Jika gagal, gunakan skema default Anda sebagai cadangan. Tujuannya sederhana: jika file simpanan tidak dapat dibaca, perlakukan pemain sebagai pengguna baru daripada membuat seluruh sesi crash. Kebiasaan ini membedakan proyek hobi dengan build kelas produksi. Implementasinya hampir tidak membutuhkan biaya, dan ini menyelamatkan Anda dari laporan bug misterius yang mustahil untuk direproduksi.
Gabungkan Data Lama dengan Default
Hasil parse yang berhasil bukan berarti Anda aman. Jangan pernah mengganti objek default Anda sepenuhnya dengan hasil parse. File simpanan lama tersebut mungkin tidak berisi pengaturan terbaru Anda. Ia mungkin menyimpan screenShake tetapi tidak vignette. Jika logika game Anda mengasumsikan vignette ada karena disertakan dalam pembaruan terbaru, Anda akan kembali terjebak dalam error undefined.
Sebaliknya, gabungkan data yang dimuat dengan nilai default Anda. Gunakan Object.assign untuk menumpuk nilai yang disimpan di atas skema baseline. Nilai default akan mengisi setiap celah yang kosong secara otomatis. Properti baru yang Anda tambahkan di versi dua akan mendapatkan nilai awal dari objek default. Properti yang sudah ada yang sebenarnya diubah oleh pemain akan ditimpa dengan preferensi yang mereka simpan. Semua orang diuntungkan. Pemain yang kembali tetap mempertahankan skor tingginya, dan game mendapatkan akses ke toggle baru yang Anda tambahkan kemarin tanpa mengalami error.
Perlu diingat bahwa Object.assign melakukan shallow merge. Jika objek pengaturan Anda menjadi sangat bersarang (deeply nested) seiring berjalannya waktu, Anda mungkin perlu menangani objek-objek dalam tersebut dengan sedikit lebih hati-hati. Namun, prinsipnya tetap sama: data pemain harus memperkaya nilai default Anda, bukan menggantikannya secara keseluruhan.
Berikan Versi pada Key Anda
Browser tidak menghapus entri localStorage lama secara otomatis. Jika Anda mengubah struktur data secara drastis, Anda memerlukan cara yang bersih untuk meninggalkan format lama. Beri nama key penyimpanan Anda dengan akhiran versi. bitSurvivorsSave_v1 sangatlah eksplisit. Ini memberi tahu Anda skema mana yang menulis file tersebut. Nantinya, saat Anda merombak progres atau menambahkan sistem inventaris lengkap, pindahlah ke 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.
