การปิดแท็บเบราว์เซอร์ไม่ควรทำให้ความคืบหน้าสี่ชั่วโมงหายไป ฟังดูเป็นเรื่องธรรมดา แต่เกมบนเบราว์เซอร์จำนวนมากกลับมองว่า local storage เป็นเพียงเรื่องรอง ผู้เล่นปลดล็อกคะแนนสูงสุด ปรับแต่งการตั้งค่า แล้วกลับมาในวันรุ่งขึ้นแต่กลับไม่พบอะไรเลย ที่แย่กว่านั้นคือ พวกเขากลับมาหลังจากมีการอัปเดตแพตช์ แล้วเกมก็เกิดข้อผิดพลาดเพราะไฟล์เซฟในเครื่องของพวกเขาไม่ตรงกับโค้ดที่คุณเพิ่งปล่อยออกมาอีกต่อไป การสร้างเกมแนว survivor-style shooter ใน Phaser 4 หมายถึงการต้องรับมือกับฝูงศัตรูที่ถาโถมเข้ามาไม่หยุดหย่อน แต่ภัยคุกคามที่แท้จริงในระยะยาวคือการอัปเดตของคุณเองในอนาคต

นักพัฒนาส่วนใหญ่สร้างระบบเซฟครั้งแรกด้วยการดึงออบเจกต์ออกมา รันผ่าน JSON.stringify แล้วโยนลงใน localStorage เมื่อโหลดเกม พวกเขาก็ parse มันแล้วส่งกลับไปให้เกมในรูปแบบดิบๆ วิธีนี้ใช้ได้ผลในวันแรก แต่มันจะพังทันทีที่คุณเพิ่มการตั้งค่าใหม่, flag การปลดล็อกใหม่ หรือการตั้งค่าแบบซ้อนกัน (nested configuration) ชั้นที่สาม หากผู้เล่นที่กลับมาเล่นมีไฟล์เซฟเก่าที่ไม่มีคุณสมบัติ vignette และโค้ดใหม่ของคุณคาดหวังว่ามันต้องมี คุณจะได้ค่า undefined ในจุดที่คุณคาดหวังว่าเป็นค่า boolean เมื่อคูณสิ่งนี้เข้ากับฟีเจอร์ใหม่ๆ อีกนับสิบ คุณก็จะได้ฝันร้ายในการดีบั๊กที่ส่งผลกระทบต่อผู้เล่นที่ซื่อสัตย์ที่สุดของคุณเป็นกลุ่มแรก

เริ่มต้นด้วย "สัญญา" ไม่ใช่แค่ออบเจกต์ดิบๆ

ก่อนที่คุณจะแตะต้อง localStorage ให้กำหนด default save schema ไว้ใน codebase ของคุณก่อน ให้คิดซะว่ามันคือ "สัญญา" ที่ไฟล์เซฟทุกไฟล์ต้องปฏิบัติตาม ไม่ว่าไฟล์นั้นจะถูกสร้างขึ้นเมื่อห้านาทีก่อนหรือห้าเดือนก่อนก็ตาม จุดเริ่มต้นที่ชัดเจนอาจมีหน้าตาประมาณนี้:

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

ออบเจกต์นี้จะอยู่ใน source code ของคุณ เมื่อเกมเริ่มทำงาน คุณจะมีโครงสร้างนี้พร้อมใช้งานเสมอ มันช่วยให้คุณมีค่าพื้นฐาน (baseline) และยังบังคับให้คุณต้องคิดถึงโครงสร้างก่อนที่จะทำการ serialize อะไรก็ตาม หากคุณข้ามขั้นตอนนี้และแค่เก็บ state object อะไรก็ได้ที่สะดวกในขณะนั้น คุณจะจบลงด้วย key ที่ไม่สอดคล้องกัน, ฟิลด์ที่ขาดหายไป และความล้มเหลวที่เงียบเชียบ (silent failures) เมื่อไฟล์เซฟเก่าเริ่มไม่ตรงกับสิ่งที่คุณคาดหวัง

การโหลดแบบป้องกันด้วย Try/Catch

Local storage ไม่ใช่ฐานข้อมูล มันเป็นเพียงตู้เก็บสตริงในเบราว์เซอร์ และอะไรก็อาจจะเข้าไปอยู่ในนั้นได้ ผู้ใช้อาจจะแก้ไขค่าด้วยตัวเอง, การเขียนข้อมูลที่ค้างอยู่ถูกขัดจังหวะ หรือส่วนขยายของเบราว์เซอร์ (browser extension) อาจจะโยนขยะลงใน key ที่คุณใช้งานอยู่ เมื่อคุณดึงสตริงนั้นออกมาแล้วส่งให้ JSON.parse ตัวอักษรที่เสียหายเพียงตัวเดียวก็สามารถทำให้เกิด hard exception ได้ ในเกม Phaser ข้อผิดพลาดที่ไม่ได้จัดการ (unhandled error) นั้นอาจทำให้ลำดับการบูตเกมค้าง หรือทำให้ผู้เล่นกลับไปเจอหน้าจอว่างเปล่า

ให้ครอบ logic การอ่านและ parse ข้อมูลของคุณด้วย block try/catch เสมอ หากเกิดข้อผิดพลาด ให้ถอยกลับไปใช้ default schema ของคุณ เป้าหมายนั้นง่ายมาก: หากไฟล์เซฟอ่านไม่ได้ ให้ปฏิบัติกับผู้เล่นเหมือนเป็นผู้ใช้ใหม่ แทนที่จะปล่อยให้เกมพังไปทั้งเซสชัน นิสัยเพียงอย่างเดียวนี้คือสิ่งที่แยกโปรเจกต์งานอดิเรกออกจากโปรเจกต์ระดับ production มันแทบไม่มีต้นทุนในการทำเลย และยังช่วยปกป้องคุณจากรายงานบั๊กปริศนาที่ไม่สามารถจำลองสถานการณ์เพื่อแก้ไขได้

รวมข้อมูลเก่าเข้ากับค่าเริ่มต้น

การ parse สำเร็จไม่ได้หมายความว่าคุณปลอดภัย อย่าแทนที่ออบเจกต์เริ่มต้นของคุณด้วยผลลัพธ์ที่ parse ได้ทั้งหมด ไฟล์เซฟเก่าอาจไม่มีการตั้งค่าล่าสุดของคุณ มันอาจจะเก็บ screenShake ไว้แต่ไม่มี vignette หาก logic ของเกมคุณสมมติว่า vignette ต้องมีอยู่เพราะมันมาพร้อมกับการอัปเดตล่าสุด คุณก็จะกลับไปไล่ตาม error undefined อีกครั้ง

แทนที่จะทำแบบนั้น ให้รวม (merge) ข้อมูลที่โหลดมาเข้ากับค่าเริ่มต้นของคุณ ใช้ Object.assign เพื่อวางค่าที่เซฟไว้ทับลงบน baseline schema ค่าเริ่มต้นจะช่วยเติมเต็มช่องว่างที่ขาดหายไปโดยอัตโนมัติ คุณสมบัติใหม่ที่คุณเพิ่มในเวอร์ชันสองจะได้รับค่าเริ่มต้นจากออบเจกต์ default ส่วนคุณสมบัติเดิมที่ผู้เล่นได้เปลี่ยนไปแล้วจะถูกเขียนทับด้วยค่าที่พวกเขาเลือกไว้ ทุกฝ่ายได้ประโยชน์ ผู้เล่นที่กลับมาเล่นยังคงรักษาคะแนนสูงสุดไว้ได้ และเกมก็สามารถเข้าถึงปุ่มเปิด/ปิด (toggle) ใหม่ที่คุณเพิ่งเพิ่มเมื่อวานได้โดยไม่พัง

พึงระลึกไว้ว่า Object.assign ทำการ merge แบบตื้น (shallow merge) หากออบเจกต์การตั้งค่าของคุณมีการซ้อนกันลึกขึ้นเรื่อยๆ เมื่อเวลาผ่านไป คุณอาจต้องจัดการกับออบเจกต์ภายในเหล่านั้นด้วยความระมัดระวังมากขึ้น อย่างไรก็ตาม หลักการยังคงเดิม: ข้อมูลของผู้เล่นควรทำหน้าที่ "ตกแต่ง" ค่าเริ่มต้นของคุณ ไม่ใช่เข้ามาแทนที่มันทั้งหมด

กำหนดเวอร์ชันให้กับ Key ของคุณ

เบราว์เซอร์จะไม่ลบข้อมูลเก่าใน local storage โดยอัตโนมัติ หากคุณเปลี่ยนโครงสร้างข้อมูลอย่างสิ้นเชิง คุณจำเป็นต้องมีวิธีที่ชัดเจนในการละทิ้งรูปแบบเก่า ตั้งชื่อ storage key ของคุณด้วย suffix บอกเวอร์ชัน เช่น bitSurvivorsSave_v1 นั้นชัดเจนมาก มันจะบอกคุณได้ทันทีว่า schema ไหนเป็นคนเขียนไฟล์นั้น และในภายหลัง เมื่อคุณยกเครื่องระบบความคืบหน้าหรือเพิ่มระบบช่องเก็บของ (inventory) แบบเต็มรูปแบบ ก็ให้เปลี่ยนไปใช้ 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.