save-systems

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Save systems

存档系统

A save file is a serialized snapshot of game state that survives restarts. The hard parts aren't writing bytes — they're choosing what to save, writing it so a crash mid-save can't corrupt it, and reading old saves after you ship a patch. Get those three right and the rest is plumbing.
存档文件是游戏状态的序列化快照,可在游戏重启后保留进度。 难点不在于写入字节,而在于选择要保存的内容、确保中途崩溃不会损坏存档,以及在发布补丁后仍能读取旧版存档。解决好这三个问题,剩下的就只是基础实现了。

When to use

适用场景

  • Use to persist progress: player stats, inventory, world flags, settings, positions — across sessions and game updates.
  • Use to design save slots, quicksave/autosave, and crash-safe writes.
  • Use when old save files break after a content/code change (versioning & migration).
When not to use: for Roblox cloud persistence specifics, use
roblox-datastores
. For the data model the save serializes (resources/SOs), use
godot-resources
/
unity-scriptableobjects
. For Godot's
FileAccess
/
ResourceSaver
and
user://
paths, defer to the Godot engine skill while applying the patterns here.
  • 用于持久化进度:玩家属性、物品栏、世界标记、设置、位置——跨游戏会话和版本更新。
  • 用于设计存档槽、快速存档/自动存档,以及崩溃安全写入机制。
  • 当内容/代码变更导致旧存档失效时(版本控制与迁移)。
不适用场景:针对Roblox云端持久化的具体实现,请使用
roblox-datastores
。针对存档序列化的数据模型(资源/SO),请使用
godot-resources
/
unity-scriptableobjects
。对于Godot的
FileAccess
/
ResourceSaver
user://
路径,在应用此处的设计模式时,请参考Godot引擎的相关技能。

Core workflow

核心工作流

  1. Decide what state is authoritative. Save the data (hp, position, seed, unlocked flags), not engine objects or scene nodes. You will reconstruct objects from data on load — never serialize live node references.
  2. Define a versioned schema. Every save embeds a
    version
    integer. This is the single most important field for a game you intend to patch.
  3. Pick a format. JSON/text for readability and debuggability; a binary format for size/speed or mild tamper-resistance. Start with JSON.
  4. Write atomically. Serialize to a temp file, flush, then rename over the real file. A crash leaves either the old save or the new one — never a half-written one.
  5. Load defensively. Read version → migrate up to current → validate → instantiate. Keep a backup of the last good save and fall back on parse error.
  6. Autosave on safe boundaries (level change, checkpoint), throttled, and to a separate slot so it can't clobber a manual save.
  7. Verify: save, fully quit, relaunch, load — and confirm by inspection that state matches. Test loading a save from the previous version.
  1. 确定权威状态:保存数据(生命值、位置、随机种子、解锁标记),而非引擎对象或场景节点。加载时需从数据重构对象——绝不要序列化实时节点引用。
  2. 定义带版本的schema:每个存档都嵌入一个
    version
    整数。对于计划发布补丁的游戏而言,这是最重要的字段。
  3. 选择文件格式:JSON/文本格式便于阅读和调试;二进制格式更节省空间、速度更快,或具备轻度防篡改能力。建议从JSON开始。
  4. 原子式写入:先序列化到临时文件,刷新到磁盘,再重命名为正式存档文件。崩溃时只会保留旧存档或新存档——绝不会出现半写入的损坏存档。
  5. 防御式加载:读取版本 → 迁移至当前版本 → 验证 → 实例化。保留最近一次有效存档的备份,解析出错时回退到备份。
  6. 在安全边界触发自动存档(关卡切换、检查点),并限制触发频率,且写入单独的存档槽,避免覆盖手动存档。
  7. 验证:保存存档、完全退出游戏、重新启动、加载存档——通过检查确认状态一致。测试加载旧版本的存档。

Patterns

设计模式

1. Serialize state as plain data (not engine objects)

1. 将状态序列化为纯数据(而非引擎对象)

gdscript
undefined
gdscript
undefined

Build a dictionary of pure data. Each savable object reports its own state.

构建纯数据字典。每个可存档对象自行上报其状态。

func capture_state() -> Dictionary: return { "version": SAVE_VERSION, # ALWAYS stamp the schema version "player": { "hp": player.hp, "pos": [player.position.x, player.position.y] }, "inventory": player.inventory.to_array(), # ids + counts, not Item nodes "flags": world.flags, # e.g. {"met_guard": true} "seed": world.seed, # regenerate procedural content }
func capture_state() -> Dictionary: return { "version": SAVE_VERSION, # 务必标记schema版本 "player": { "hp": player.hp, "pos": [player.position.x, player.position.y] }, "inventory": player.inventory.to_array(), # 物品ID + 数量,而非Item节点 "flags": world.flags, # 例如 {"met_guard": true} "seed": world.seed, # 用于生成程序化内容 }

On load, RECONSTRUCT objects from the data — do not expect live references back.

加载时,从数据重构对象——不要期望还原实时引用。

func apply_state(data: Dictionary) -> void: player.hp = data["player"]["hp"] player.position = Vector2(data["player"]["pos"][0], data["player"]["pos"][1]) player.inventory.from_array(data["inventory"]) world.flags = data["flags"]
undefined
func apply_state(data: Dictionary) -> void: player.hp = data["player"]["hp"] player.position = Vector2(data["player"]["pos"][0], data["player"]["pos"][1]) player.inventory.from_array(data["inventory"]) world.flags = data["flags"]
undefined

2. Atomic, crash-safe write (temp + rename)

2. 原子式、崩溃安全写入(临时文件 + 重命名)

gdscript
undefined
gdscript
undefined

RIGHT: write to a temp file, then atomically rename over the target.

正确做法:写入临时文件,再原子式重命名为目标文件。

func save_atomic(path: String, data: Dictionary) -> void: var tmp := path + ".tmp" var f := FileAccess.open(tmp, FileAccess.WRITE) f.store_string(JSON.stringify(data)) f.flush() # ensure bytes hit disk f.close() DirAccess.rename_absolute(tmp, path) # replaces the target; atomic on POSIX
func save_atomic(path: String, data: Dictionary) -> void: var tmp := path + ".tmp" var f := FileAccess.open(tmp, FileAccess.WRITE) f.store_string(JSON.stringify(data)) f.flush() # 确保字节写入磁盘 f.close() DirAccess.rename_absolute(tmp, path) # 替换目标文件;在POSIX系统上是原子操作

WRONG: opening
path
directly and writing in place — a crash mid-write leaves a

错误做法:直接打开
path
并原地写入——中途崩溃会留下截断的、无法加载的存档,导致玩家进度丢失。

truncated, unloadable save and destroys the player's progress.


Rename-over-target is atomic on POSIX (same volume); on Windows a replace-by-rename
isn't guaranteed atomic, so keep the previous file as `path + ".bak"` before the
rename — that backup is what actually guarantees you can recover from a bad write.

在POSIX系统(同一卷)上,覆盖式重命名是原子操作;在Windows系统上,通过重命名替换文件不保证原子性,因此在重命名前需将原文件备份为`path + ".bak"`——这个备份才是确保从错误写入中恢复的关键。

3. Versioned load with migration

3. 带版本迁移的加载机制

python
SAVE_VERSION = 3

def load_save(raw_bytes):
    data = parse(raw_bytes)                  # JSON/binary -> dict
    v = data.get("version", 0)
    if v > SAVE_VERSION:
        raise NewerSaveError(v)              # save is from a newer build; refuse
    while v < SAVE_VERSION:                   # apply migrations in order, v -> v+1
        data = MIGRATIONS[v](data)
        v += 1
        data["version"] = v
    validate(data)                            # check required keys / ranges
    return data
python
SAVE_VERSION = 3

def load_save(raw_bytes):
    data = parse(raw_bytes)                  # JSON/二进制 -> 字典
    v = data.get("version", 0)
    if v > SAVE_VERSION:
        raise NewerSaveError(v)              # 存档来自更新版本的构建;拒绝加载
    while v < SAVE_VERSION:                   # 按顺序应用迁移,从v到v+1
        data = MIGRATIONS[v](data)
        v += 1
        data["version"] = v
    validate(data)                            # 检查必填字段/取值范围
    return data

Each migration is a pure function from one version's shape to the next.

每个迁移都是纯函数,将旧版本的数据结构转换为新版本。

def migrate_1_to_2(d): d["flags"] = {k: True for k in d.pop("completed_quests", [])} # list -> set-map return d MIGRATIONS = {1: migrate_1_to_2, 2: migrate_2_to_3}
undefined
def migrate_1_to_2(d): d["flags"] = {k: True for k in d.pop("completed_quests", [])} # 列表 -> 集合映射 return d MIGRATIONS = {1: migrate_1_to_2, 2: migrate_2_to_3}
undefined

4. Save slots + throttled autosave

4. 存档槽 + 限频自动存档

gdscript
const SLOT_PATH := "user://save_%d.json"      # manual slots 0..N
const AUTOSAVE_PATH := "user://autosave.json"  # separate file: never clobbers a slot
var _autosave_cooldown := 0.0

func autosave_if_due(dt: float) -> void:
    _autosave_cooldown -= dt
    if _autosave_cooldown <= 0.0:
        save_atomic(AUTOSAVE_PATH, capture_state())
        _autosave_cooldown = 60.0             # throttle: at most once a minute
gdscript
const SLOT_PATH := "user://save_%d.json"      # 手动存档槽0..N
const AUTOSAVE_PATH := "user://autosave.json"  # 独立文件:绝不会覆盖手动存档槽
var _autosave_cooldown := 0.0

func autosave_if_due(dt: float) -> void:
    _autosave_cooldown -= dt
    if _autosave_cooldown <= 0.0:
        save_atomic(AUTOSAVE_PATH, capture_state())
        _autosave_cooldown = 60.0             # 限频:每分钟最多触发一次

Trigger an immediate autosave on checkpoints/level transitions, not mid-combat.

在检查点/关卡切换时触发即时自动存档,不要在战斗中触发。

undefined
undefined

Pitfalls

常见陷阱

  • Serializing engine objects/node paths ties saves to scene structure; renaming a node breaks every old save. Save data, rebuild objects on load.
  • No version field. The day you ship a patch, every existing save is a guessing game. Stamp
    version
    from version 1.
  • In-place writes corrupt saves on crash/power loss. Always temp-write then rename; keep a
    .bak
    .
  • Trusting the file blindly. Saves get truncated, hand-edited, or cloud-synced stale. Validate on load and fall back to backup on failure.
  • Floats and locale. Text serializers can drop precision or use comma decimal separators in some locales. Use a locale-invariant serializer.
  • Autosave clobbering manual saves, or firing mid-action and saving an inconsistent state. Use a dedicated autosave slot and save on safe boundaries.
  • Storing secrets or trusting client saves in multiplayer. A local save is player-controlled; never treat it as authoritative for online state. For cloud, handle the device's data limits and conflicts (
    roblox-datastores
    ).
  • 序列化引擎对象/节点路径会将存档与场景结构绑定;重命名节点会导致所有旧存档失效。应保存数据,加载时重构对象。
  • 缺少版本字段。发布补丁的当天,所有现有存档都会变成无法解析的谜题。从版本1开始就标记
    version
    字段。
  • 原地写入会在崩溃/断电时损坏存档。务必先写入临时文件再重命名;保留
    .bak
    备份。
  • 盲目信任存档文件。存档可能被截断、手动编辑,或因云同步而过时。加载时需验证,失败时回退到备份。
  • 浮点数与区域设置。文本序列化器可能丢失精度,或在部分区域设置中使用逗号作为小数点分隔符。请使用不受区域设置影响的序列化器。
  • 自动存档覆盖手动存档,或在动作执行中途触发并保存不一致状态。使用专门的自动存档槽,并在安全边界触发存档。
  • 在多人游戏中存储机密信息或信任客户端存档。本地存档由玩家控制;绝不能将其作为在线状态的权威来源。对于云端存档,请处理设备的数据限制和冲突(参考
    roblox-datastores
    )。

References

参考资料

  • references/versioning-and-migration.md
    — schema evolution strategies, the migration chain, backups/rollback, format trade-offs (JSON vs binary), and a load-time validation checklist.
  • references/versioning-and-migration.md
    —— schema演进策略、迁移链、备份/回滚、格式权衡(JSON vs二进制),以及加载时的验证清单。

Related skills

相关技能

  • roblox-datastores
    — cloud persistence, request limits, session locking.
  • godot-resources
    ,
    unity-scriptableobjects
    — the data model you serialize.
  • procedural-gen
    — store the seed to regenerate worlds instead of saving them.
  • rpg
    ,
    survival-crafting
    ,
    visual-novel
    — genres that compose this skill.
  • roblox-datastores
    —— 云端持久化、请求限制、会话锁定。
  • godot-resources
    ,
    unity-scriptableobjects
    —— 用于序列化的数据模型。
  • procedural-gen
    —— 存储随机种子以重新生成世界,而非直接保存世界数据。
  • rpg
    ,
    survival-crafting
    ,
    visual-novel
    —— 会用到本技能的游戏类型。