Video Game Design and Coding · PLECK lesson

PLECK lesson 115 · suggested week 23, day 5

Test missing and damaged saves safely

Objective. Use dedicated test files and prove fallback behavior.

Godot Engine Standard desktop editor 4.7.2-stable, GDScript (Godot 4), Compatibility renderer. Headless Godot 4.7.2.stable.official.ed1daf0bf passed 106 assertions on the supplied scaffolds, including malformed JSON saves. Audio used the Dummy driver and a synthetic WAV, not a speaker. The visual editor, browser export, and signed-in submission were not tested. The desk still does not run a student's own upload.

About 55 minutes. The due date is chosen by a parent.

JSON, user://, schema validation and safe failure

Suggested pace: Recall and predict 5 min; Read instruction and scaffold 12 min; Build or design the assigned artifact 25 min; Run acceptance tests 8 min; Explain and checkpoint 5 min.

Materials: Godot Standard 4.7.2 desktop editor (GDScript, Compatibility renderer), Local course folder and prior checkpoint, No paid assets or public account required.

Comes after VGC-L114.

Only manipulate the course’s own test save, never unrelated user files. Create a backup of the valid course save, then test a missing file, malformed JSON, wrong version, wrong field type and out-of-range count. C-STATE-SAVE should return defaults or cleaned values without crashing. Restore the valid course save afterward. A safe fallback must also show a clear message if progress could not be loaded; do not silently claim a successful restore when a new game actually started.

Worked example

A validated save contains known scalar fields and a version. An array instead of a dictionary is syntactically valid JSON but should still be rejected by the schema. Scaffold C-SAVE — Small, validated local best-score save Setup: Attach to Node SaveLab. Only saves version and integer best score to user://course_best.json. No nodes, paths from a save file, private data or scripts are loaded. Functions return safe defaults for missing/invalid data. Test in desktop editor, using a course-only filename so existing game saves are untouched. Do not edit unrelated user:// files. ```gdscript extends Node const PATH: String = "user://course_best.json" func write_best(best: int) -> bool: var file := FileAccess.open(PATH, FileAccess.WRITE) if file == null: return false file.store_string(JSON.stringify({"version": 1, "best": clampi(best, 0, 999999)})) file.close() return true func read_best() -> int: if not FileAccess.file_exists(PATH): return 0 var file := FileAccess.open(PATH, FileAccess.READ) if file == null: return 0 var text: String = file.get_as_text() file.close() var parser := JSON.new() if parser.parse(text) != OK: return 0 var parsed: Variant = parser.data if not parsed is Dictionary: return 0 var data: Dictionary = parsed if data.get("version", 0) != 1: return 0 var best: Variant = data.get("best", 0) if not (best is int or best is float): return 0 return clampi(int(best), 0, 999999) func _ready() -> void: print("Saved best: ", read_best()) ``` Scaffold C-STATE-SAVE — Validated inventory and checkpoint data Setup: Attach to a Node named SaveData. This is a separate file from C-SAVE and only reads fixed known scalar fields. get_checkpoint_data returns plain data, not live node references. When applying a loaded checkpoint, the assignment maps checkpoint ID to a teacher-approved safe Marker2D, never to a filename supplied by the save. ```gdscript extends Node const PATH: String = "user://seed_station_v1.json" func defaults() -> Dictionary: return {"version": 1, "seed": 0, "water": 0, "checkpoint": "start"} func _bounded_count(value: Variant) -> int: if value is int or value is float: return clampi(int(value), 0, 99) return 0 func validate(raw: Variant) -> Dictionary: var clean := defaults() if not raw is Dictionary: return clean var data: Dictionary = raw if data.get("version", 0) != 1: return clean clean["seed"] = _bounded_count(data.get("seed", 0)) clean["water"] = _bounded_count(data.get("water", 0)) var checkpoint: Variant = data.get("checkpoint", "start") if checkpoint is String and checkpoint in ["start", "greenhouse"]: clean["checkpoint"] = checkpoint return clean func save_data(raw: Dictionary) -> bool: var file := FileAccess.open(PATH, FileAccess.WRITE) if file == null: return false file.store_string(JSON.stringify(validate(raw))) file.close() return true func load_data() -> Dictionary: if not FileAccess.file_exists(PATH): return defaults() var file := FileAccess.open(PATH, FileAccess.READ) if file == null: return defaults() var text: String = file.get_as_text() file.close() var parser := JSON.new() if parser.parse(text) != OK: return defaults() return validate(parser.data) ``` Scaffold C-WORLD-DATA — Consistent pickup/bed persistence with derived inventory Setup: Attach to a separate Node named WorldData. This is the default integrated Seed Station model after the scalar-save lab. Fixed pickup IDs are s1,s2,s3,w1,w2; fixed bed IDs are b1,b2. Manager records picked IDs only after a successful pickup and planted IDs only after validating both resource costs. Inventory is derived from these IDs, so reloading cannot keep quantities while respawning already-picked rewards. On load, hide/free picked objects, mark planted beds, set the known start/greenhouse marker, then refresh HUD. Version 1 scalar labs are intentionally not migrated: report a new-game fallback. ```gdscript extends Node const PATH: String = "user://seed_station_world_v2.json" func defaults() -> Dictionary: return {"version": 2, "picked": [], "planted": [], "seed": 0, "water": 0, "checkpoint": "start"} func _ids(raw: Variant, allowed: Array) -> Array[String]: var clean: Array[String] = [] if raw is Array: for value in raw: if value is String and value in allowed and value not in clean: clean.append(value) return clean func validate(raw: Variant) -> Dictionary: var result := defaults() if not raw is Dictionary: return result var data: Dictionary = raw if data.get("version", 0) != 2: return result var picked := _ids(data.get("picked", []), ["s1", "s2", "s3", "w1", "w2"]) var planted := _ids(data.get("planted", []), ["b1", "b2"]) var seed_total: int = 0 var water_total: int = 0 for id in picked: if id.begins_with("s"): seed_total += 1 else: water_total += 1 while planted.size() > mini(seed_total, water_total): planted.pop_back() result["picked"] = picked result["planted"] = planted result["seed"] = seed_total - planted.size() result["water"] = water_total - planted.size() result["checkpoint"] = "greenhouse" if "b1" in planted else "start" return result func save_data(raw: Dictionary) -> bool: var file := FileAccess.open(PATH, FileAccess.WRITE) if file == null: return false file.store_string(JSON.stringify(validate(raw))) file.close() return true func load_data() -> Dictionary: if not FileAccess.file_exists(PATH): return defaults() var file := FileAccess.open(PATH, FileAccess.READ) if file == null: return defaults() var text: String = file.get_as_text() file.close() var parser := JSON.new() if parser.parse(text) != OK: return defaults() return validate(parser.data) ```

Small, validated local best-score save

GDScript (Godot 4, not Scratch, Python, JavaScript or C#). Scaffold C-SAVE. Copy starts from this text. The desk does not run it.

Scene and node setup

Attach to Node SaveLab. Only saves version and integer best score to user://course_best.json. No nodes, paths from a save file, private data or scripts are loaded. Functions return safe defaults for missing/invalid data. Test in desktop editor, using a course-only filename so existing game saves are untouched. Do not edit unrelated user:// files.

extends Node
const PATH: String = "user://course_best.json"
func write_best(best: int) -> bool:
    var file := FileAccess.open(PATH, FileAccess.WRITE)
    if file == null:
        return false
    file.store_string(JSON.stringify({"version": 1, "best": clampi(best, 0, 999999)}))
    file.close()
    return true
func read_best() -> int:
    if not FileAccess.file_exists(PATH):
        return 0
    var file := FileAccess.open(PATH, FileAccess.READ)
    if file == null:
        return 0
    var text: String = file.get_as_text()
    file.close()
    var parser := JSON.new()
    if parser.parse(text) != OK:
        return 0
    var parsed: Variant = parser.data
    if not parsed is Dictionary:
        return 0
    var data: Dictionary = parsed
    if data.get("version", 0) != 1:
        return 0
    var best: Variant = data.get("best", 0)
    if not (best is int or best is float):
        return 0
    return clampi(int(best), 0, 999999)
func _ready() -> void:
    print("Saved best: ", read_best())

Checks to run in the editor

Native Godot 4.7.2.stable.official.ed1daf0bf headless compile and representative node-tree/runtime tests passed; see native-qa-results.json. Visual UI, audible output, exports and website submission still need separate checks.

Validated inventory and checkpoint data

GDScript (Godot 4, not Scratch, Python, JavaScript or C#). Scaffold C-STATE-SAVE. Copy starts from this text. The desk does not run it.

Scene and node setup

Attach to a Node named SaveData. This is a separate file from C-SAVE and only reads fixed known scalar fields. get_checkpoint_data returns plain data, not live node references. When applying a loaded checkpoint, the assignment maps checkpoint ID to a teacher-approved safe Marker2D, never to a filename supplied by the save.

extends Node
const PATH: String = "user://seed_station_v1.json"
func defaults() -> Dictionary:
    return {"version": 1, "seed": 0, "water": 0, "checkpoint": "start"}
func _bounded_count(value: Variant) -> int:
    if value is int or value is float:
        return clampi(int(value), 0, 99)
    return 0
func validate(raw: Variant) -> Dictionary:
    var clean := defaults()
    if not raw is Dictionary:
        return clean
    var data: Dictionary = raw
    if data.get("version", 0) != 1:
        return clean
    clean["seed"] = _bounded_count(data.get("seed", 0))
    clean["water"] = _bounded_count(data.get("water", 0))
    var checkpoint: Variant = data.get("checkpoint", "start")
    if checkpoint is String and checkpoint in ["start", "greenhouse"]:
        clean["checkpoint"] = checkpoint
    return clean
func save_data(raw: Dictionary) -> bool:
    var file := FileAccess.open(PATH, FileAccess.WRITE)
    if file == null:
        return false
    file.store_string(JSON.stringify(validate(raw)))
    file.close()
    return true
func load_data() -> Dictionary:
    if not FileAccess.file_exists(PATH):
        return defaults()
    var file := FileAccess.open(PATH, FileAccess.READ)
    if file == null:
        return defaults()
    var text: String = file.get_as_text()
    file.close()
    var parser := JSON.new()
    if parser.parse(text) != OK:
        return defaults()
    return validate(parser.data)

Checks to run in the editor

Native Godot 4.7.2.stable.official.ed1daf0bf headless compile and representative node-tree/runtime tests passed; see native-qa-results.json. Visual UI, audible output, exports and website submission still need separate checks.

Consistent pickup/bed persistence with derived inventory

GDScript (Godot 4, not Scratch, Python, JavaScript or C#). Scaffold C-WORLD-DATA. Copy starts from this text. The desk does not run it.

Scene and node setup

Attach to a separate Node named WorldData. This is the default integrated Seed Station model after the scalar-save lab. Fixed pickup IDs are s1,s2,s3,w1,w2; fixed bed IDs are b1,b2. Manager records picked IDs only after a successful pickup and planted IDs only after validating both resource costs. Inventory is derived from these IDs, so reloading cannot keep quantities while respawning already-picked rewards. On load, hide/free picked objects, mark planted beds, set the known start/greenhouse marker, then refresh HUD. Version 1 scalar labs are intentionally not migrated: report a new-game fallback.

extends Node
const PATH: String = "user://seed_station_world_v2.json"
func defaults() -> Dictionary:
    return {"version": 2, "picked": [], "planted": [], "seed": 0, "water": 0, "checkpoint": "start"}
func _ids(raw: Variant, allowed: Array) -> Array[String]:
    var clean: Array[String] = []
    if raw is Array:
        for value in raw:
            if value is String and value in allowed and value not in clean:
                clean.append(value)
    return clean
func validate(raw: Variant) -> Dictionary:
    var result := defaults()
    if not raw is Dictionary:
        return result
    var data: Dictionary = raw
    if data.get("version", 0) != 2:
        return result
    var picked := _ids(data.get("picked", []), ["s1", "s2", "s3", "w1", "w2"])
    var planted := _ids(data.get("planted", []), ["b1", "b2"])
    var seed_total: int = 0
    var water_total: int = 0
    for id in picked:
        if id.begins_with("s"):
            seed_total += 1
        else:
            water_total += 1
    while planted.size() > mini(seed_total, water_total):
        planted.pop_back()
    result["picked"] = picked
    result["planted"] = planted
    result["seed"] = seed_total - planted.size()
    result["water"] = water_total - planted.size()
    result["checkpoint"] = "greenhouse" if "b1" in planted else "start"
    return result
func save_data(raw: Dictionary) -> bool:
    var file := FileAccess.open(PATH, FileAccess.WRITE)
    if file == null:
        return false
    file.store_string(JSON.stringify(validate(raw)))
    file.close()
    return true
func load_data() -> Dictionary:
    if not FileAccess.file_exists(PATH):
        return defaults()
    var file := FileAccess.open(PATH, FileAccess.READ)
    if file == null:
        return defaults()
    var text: String = file.get_as_text()
    file.close()
    var parser := JSON.new()
    if parser.parse(text) != OK:
        return defaults()
    return validate(parser.data)

Checks to run in the editor

Native Godot 4.7.2.stable.official.ed1daf0bf headless compile and representative node-tree/runtime tests passed; see native-qa-results.json. Visual UI, audible output, exports and website submission still need separate checks.

Test missing and damaged saves safely — evidence task

  1. 1. Run five corrupted/missing-data cases using the course-only path.
  2. 2. Restore the valid file and verify the normal case again.

Deliverable. Save robustness log and repaired normal-save checkpoint.

Acceptance checks

Understanding question. Why test the normal save again after fault cases?

Include a downloadable project ZIP with project.godot, .tscn, .gd and original asset files when code changes. A private, teacher-accessible download link to that ZIP is equivalent. Screenshots or a playthrough do not replace editable source.

If this is still shaky. Use the course-specific save filenames in C-SAVE and C-STATE-SAVE. Never edit unrelated game saves or load scripts from save data. Start with the first assigned task and re-run its acceptance test before adding the remaining feature.

Optional extra. Add a new fixed scalar field with a safe default and explicit validation. Document one changed parameter, your prediction, and the observed effect; optional, not required for completion.

Name ____________________ Date ____________________