Việc đóng một tab trình duyệt không nên làm mất đi bốn giờ chơi game. Điều đó có vẻ hiển nhiên, vậy mà rất nhiều trò chơi trên trình duyệt lại coi localStorage chỉ là một phần phụ. Người chơi đạt được điểm cao, điều chỉnh cài đặt, quay lại vào ngày mai và thấy mọi thứ biến mất. Tệ hơn, họ quay lại sau một bản vá và trò chơi báo lỗi vì tệp lưu (save file) trên máy họ không còn khớp với mã nguồn bạn vừa phát hành. Xây dựng một trò chơi bắn súng phong cách survivor trong Phaser 4 đồng nghĩa với việc phải đối phó với những đợt kẻ thù liên tục, nhưng mối đe dọa thực sự về lâu dài chính là các bản cập nhật trong tương lai của chính bạn.

Hầu hết các nhà phát triển xây dựng hệ thống lưu trữ đầu tiên bằng cách lấy một đối tượng (object), chạy qua JSON.stringify và đẩy nó vào localStorage. Khi tải, họ phân tích cú pháp (parse) nó và đưa nguyên bản trở lại trò chơi. Cách đó hoạt động tốt trong ngày đầu tiên. Nhưng nó sẽ hỏng ngay khi bạn thêm một cài đặt mới, một cờ mở khóa (unlock flag) mới, hoặc một lớp cấu hình lồng nhau thứ ba. Nếu một người chơi quay lại có một tệp lưu cũ thiếu thuộc tính vignette, và mã mới của bạn mong đợi nó tồn tại, bạn sẽ nhận được undefined thay vì một giá trị boolean như mong đợi. Nhân con số đó lên với hàng tá tính năng mới và bạn sẽ có một cơn ác mộng gỡ lỗi (debugging) nhắm thẳng vào những người chơi trung thành nhất của mình.

Bắt đầu với một Bản hợp đồng, không phải một Đối tượng thô

Trước khi chạm vào localStorage, hãy định nghĩa một schema lưu trữ mặc định trong mã nguồn của bạn. Hãy coi nó như một bản hợp đồng mà mọi tệp lưu phải tuân thủ, cho dù nó được tạo ra cách đây năm phút hay năm tháng. Một điểm bắt đầu rõ ràng có thể trông như thế này:

const defaultSave = {
  highScore: 0,
  settings: {
    screenShake: true,
    vignette: true
  }
};

Đối tượng này nằm trong mã nguồn của bạn. Khi trò chơi khởi động, bạn luôn có sẵn cấu trúc này. Nó cung cấp cho bạn một mức cơ sở (baseline). Nó cũng buộc bạn phải suy nghĩ về cấu trúc trước khi tuần tự hóa (serialize) bất cứ thứ gì. Nếu bạn bỏ qua bước này và chỉ lưu bất kỳ đối tượng trạng thái (state object) nào tiện lợi vào thời điểm đó, bạn sẽ kết thúc với các khóa (keys) không nhất quán, các trường (fields) bị thiếu và các lỗi âm thầm khi các bản lưu cũ không còn đồng bộ với kỳ vọng của bạn.

Tải dữ liệu một cách phòng vệ với Try/Catch

Local storage không phải là một cơ sở dữ liệu. Nó là một "tủ chứa chuỗi" trong trình duyệt, và bất cứ thứ gì cũng có thể nằm trong đó. Người dùng có thể đã chỉnh sửa thủ công một giá trị, một thao tác ghi đang viết dở bị gián đoạn, hoặc một tiện ích mở rộng trình duyệt đã đổ rác vào khóa mà bạn đang sử dụng. Khi bạn lấy chuỗi đó ra và đưa vào JSON.parse, chỉ một ký tự bị lỗi cũng sẽ gây ra một ngoại lệ (exception) nghiêm trọng. Trong một trò chơi Phaser, lỗi không được xử lý đó có thể làm đóng băng trình tự khởi động hoặc đẩy người chơi trở lại một màn hình trống rỗng.

Luôn bao bọc logic đọc và phân tích cú pháp của bạn trong một khối try/catch. Khi thất bại, hãy quay về schema mặc định của bạn. Mục tiêu rất đơn giản: nếu tệp lưu không thể đọc được, hãy coi người chơi như một người dùng mới thay vì làm sập toàn bộ phiên chơi. Thói quen này phân biệt giữa các dự án nghiệp dư và các bản build đạt chuẩn sản xuất. Nó gần như không tốn công thực hiện, nhưng giúp bạn tránh được những báo cáo lỗi bí ẩn không thể tái hiện.

Hợp nhất Dữ liệu cũ với Giá trị mặc định

Việc phân tích cú pháp thành công không có nghĩa là bạn đã an toàn. Đừng bao giờ thay thế hoàn toàn đối tượng mặc định của bạn bằng kết quả đã parse. Tệp lưu cũ đó có thể không chứa các cài đặt mới nhất. Nó có thể lưu screenShake nhưng không có vignette. Nếu logic trò chơi của bạn giả định rằng vignette tồn tại vì nó đi kèm với bản cập nhật mới nhất, bạn sẽ lại rơi vào cảnh phải đuổi theo các lỗi undefined.

Thay vào đó, hãy hợp nhất dữ liệu đã tải với các giá trị mặc định. Sử dụng Object.assign để xếp chồng các giá trị đã lưu lên trên schema cơ sở. Các giá trị mặc định sẽ tự động lấp đầy mọi khoảng trống còn thiếu. Các thuộc tính mới bạn thêm vào ở phiên bản hai sẽ nhận giá trị ban đầu từ đối tượng mặc định. Các thuộc tính hiện có mà người chơi thực sự đã thay đổi sẽ được ghi đè bằng các tùy chọn đã lưu của họ. Tất cả đều có lợi. Người chơi quay lại vẫn giữ được điểm cao, và trò chơi có thể truy cập vào nút gạt mới mà bạn vừa thêm hôm qua mà không bị lỗi.

Hãy lưu ý rằng Object.assign thực hiện hợp nhất nông (shallow merge). Nếu đối tượng cài đặt của bạn trở nên lồng nhau sâu hơn theo thời gian, bạn có thể cần xử lý các đối tượng bên trong đó một cách cẩn thận hơn một chút. Tuy nhiên, nguyên tắc vẫn không đổi: dữ liệu của người chơi nên bổ sung cho các giá trị mặc định của bạn, chứ không phải thay thế hoàn toàn chúng.

Đánh số Phiên bản cho các Khóa (Keys)

Trình duyệt không tự động xóa các mục local storage cũ. Nếu bạn thay đổi cấu trúc dữ liệu một cách đáng kể, bạn cần một cách sạch sẽ để từ bỏ định dạng cũ. Hãy đặt tên khóa lưu trữ của bạn với một hậu tố phiên bản. bitSurvivorsSave_v1 là một cái tên rõ ràng. Nó cho bạn biết chính xác schema nào đã ghi tệp đó. Sau này, khi bạn đại tu hệ thống thăng tiến hoặc thêm một hệ thống kho đồ đầy đủ, hãy chuyển sang 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.