Video Game Design and Coding

Week 23 packet

Student instructions, tasks, and checks. The answer key is not in this packet. Links stay as text. This page does not embed a clip or run a file.

# Week 23 — Reliable local persistence

## VGC-L111 — Separate project files from saved progress

**Objective:** Explain res://, user:// and why saves use plain data.

**Time:** 55 minutes

### Instruction
res:// names project resources, which are not a portable writable save location after export. user:// is Godot’s per-user data area. Save plain values such as an integer best score or a fixed checkpoint ID, not a live node reference. JSON can represent dictionaries, arrays, strings, numbers, Booleans and null; Vector2 must be decomposed or represented another way. This course uses approved checkpoint IDs instead of loading arbitrary scene paths from a save file.

### 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.

### Assignment
1. Create a save schema table for version, seed, water and checkpoint.
2. Mark types, allowed ranges and default values.

### Acceptance checks
- No field contains a real name or executable script.
- Checkpoint is a fixed allowed ID, not an arbitrary file path.

**Submit:** Save schema and res/user path explanation.

**Question:** Why not save a Node object directly as JSON?

**Support:** 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 extension:** 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.

**Optional free reading:** [Godot step by step: nodes, scenes, GDScript, input, signals](https://docs.godotengine.org/en/stable/getting_started/step_by_step/index.html), [Saving games: FileAccess and JSON](https://docs.godotengine.org/en/stable/tutorials/io/saving_games.html)

---

## VGC-L112 — Write a small best-score save

**Objective:** Use FileAccess and report failure honestly.

**Time:** 55 minutes

### Instruction
C-SAVE writes a tiny dictionary through JSON.stringify to a course-specific user:// file. FileAccess.open can fail, so check for null and return false rather than showing a false success message. Clamp a score before writing. Call write_best only after a valid round result or an explicit Save action, not every rendered frame. The baseline read prints a value but does not modify the file. Desktop persistence is the required path; browser storage has additional restrictions.

### 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())
```

### Assignment
1. Run the C-SAVE lab and call write_best with 42 using a temporary _ready test.
2. Stop, remove the write call and relaunch to read the saved value.

### Acceptance checks
- Relaunch reads 42 without rewriting it.
- The caller checks the returned success Boolean.

**Submit:** Best-score lab source and stop/relaunch evidence.

**Question:** Why remove the test write before verifying a load?

**Support:** 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 extension:** 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.

**Optional free reading:** [Godot step by step: nodes, scenes, GDScript, input, signals](https://docs.godotengine.org/en/stable/getting_started/step_by_step/index.html), [Saving games: FileAccess and JSON](https://docs.godotengine.org/en/stable/tutorials/io/saving_games.html)

---

## VGC-L113 — Validate before trusting loaded values

**Objective:** Apply type, version, range and allowed-ID checks.

**Time:** 55 minutes

### Instruction
A save file can be missing, truncated, edited or from another version. C-STATE-SAVE validates the outer Dictionary, version, numeric counts and allowed checkpoint IDs. It clamps counts to zero through ninety-nine and falls back to start for unknown locations. It never loads a script or scene path supplied by the file. Validation is part of program correctness even for a local game: accidental corruption is enough reason to defend the boundary.

### 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-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)
```

### Assignment
1. Run validate on a correct dictionary, an array, a string count, negative counts and an unknown checkpoint.
2. Record the cleaned result for each.

### Acceptance checks
- Invalid outer data returns defaults.
- Unknown checkpoint never becomes a file path or node load request.

**Submit:** Validation lab and five-case input/output table.

**Question:** Why is JSON parsing success not the same as schema validity?

**Support:** 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 extension:** 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.

**Optional free reading:** [Godot step by step: nodes, scenes, GDScript, input, signals](https://docs.godotengine.org/en/stable/getting_started/step_by_step/index.html), [Saving games: FileAccess and JSON](https://docs.godotengine.org/en/stable/tutorials/io/saving_games.html)

---

## VGC-L114 — Restore a safe checkpoint and inventory

**Objective:** Map validated data to known scene objects.

**Time:** 55 minutes

### Instruction
Use C-WORLD-DATA as the integrated extension after the scalar-save lab. Give pickups fixed IDs s1,s2,s3,w1,w2 and beds b1,b2. The manager records collected and planted IDs; the validator derives remaining inventory from those IDs. On load, hide collected pickups, mark planted beds and map checkpoint to known Marker2D nodes. Clear player velocity. This avoids restoring quantities while respawning the same resources. Version-two world saves use their own course filename; report a fresh-state fallback for unsupported data instead of loading paths from it.

### 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-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)
```

Scaffold C-CHECKPOINT — A safe checkpoint respawn
Setup: Add this pattern to a separate Node2D level root with child Player using C-PLATFORM. Call set_checkpoint with a safe global position when a player reaches a checkpoint Area2D; call respawn when entering a fall zone. This code does not connect Area2D automatically; select the area body_entered signal in editor and call these methods from its handler after checking the player group. Respawn is deferred because a physics callback may be active.
```gdscript
extends Node2D
@onready var player: CharacterBody2D = $Player
var checkpoint: Vector2
func _ready() -> void:
    checkpoint = player.global_position
func set_checkpoint(safe_position: Vector2) -> void:
    checkpoint = safe_position
func respawn() -> void:
    _apply_respawn.call_deferred()
func _apply_respawn() -> void:
    player.global_position = checkpoint
    player.velocity = Vector2.ZERO
```

Scaffold C-INVENTORY — Dictionary inventory with guarded spending
Setup: Attach to Node InventoryLab for standalone testing. In a real level put the same functions on its inventory node and update UI after successful calls. Item IDs are short fixed strings, never a player’s name. A key represents one item type and its integer value is the quantity.
```gdscript
extends Node
var items: Dictionary = {"seed": 0, "water": 0}
func add_item(id: String, amount: int = 1) -> bool:
    if not items.has(id) or amount <= 0:
        return false
    items[id] = int(items[id]) + amount
    return true
func spend(id: String, amount: int = 1) -> bool:
    if not items.has(id) or amount <= 0 or int(items[id]) < amount:
        return false
    items[id] = int(items[id]) - amount
    return true
func _ready() -> void:
    assert(add_item("seed", 2))
    assert(spend("seed", 1))
    assert(not spend("seed", 2))
    assert(items["seed"] == 1)
```

### Assignment
1. Integrate C-WORLD-DATA and restore collected pickup IDs, planted bed IDs, derived inventory and the approved checkpoint marker.
2. Collect s1,s2,w1, plant b1, save, stop and relaunch. Verify seed1/water0, b1 planted, and the three collected pickups absent.

### Acceptance checks
- After the specified relaunch: seed1, water0, b1 planted, and s1/s2/w1 absent.
- A remaining pickup awards once; reloading cannot award an already-picked ID again.

**Submit:** Integrated save checkpoint, schema amendment if needed, and relaunch test.

**Question:** Why can restoring inventory alone duplicate resources?

**Support:** 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 extension:** 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.

**Optional free reading:** [Godot step by step: nodes, scenes, GDScript, input, signals](https://docs.godotengine.org/en/stable/getting_started/step_by_step/index.html), [Saving games: FileAccess and JSON](https://docs.godotengine.org/en/stable/tutorials/io/saving_games.html)

---

## VGC-L115 — Test missing and damaged saves safely

**Objective:** Use dedicated test files and prove fallback behavior.

**Time:** 55 minutes

### Instruction
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)
```

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

### Acceptance checks
- No case crashes or loads an arbitrary resource.
- The game communicates fallback versus successful restore.

**Submit:** Save robustness log and repaired normal-save checkpoint.

**Question:** Why test the normal save again after fault cases?

**Support:** 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 extension:** 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.

**Optional free reading:** [Godot step by step: nodes, scenes, GDScript, input, signals](https://docs.godotengine.org/en/stable/getting_started/step_by_step/index.html), [Saving games: FileAccess and JSON](https://docs.godotengine.org/en/stable/tutorials/io/saving_games.html)

---