Save‐Load‐System - UQcsse3200/2026-studio-3 GitHub Wiki

Team 5 | Save/Load System — Core, Game State Snapshot, and UI implemented; Restore Logic implemented | Branch: Feature-5


Overview

This page describes how a player's run is saved to disk and restored later. The system is split into three layers, each with a clear owner and a narrow interface to the others:

  • Save/Load Core (Anran Zhang) — the plain-data DTOs, JSON persistence, slot metadata, and structured error results. Owns SaveGameData and everything that gets written to a file.
  • Game State Snapshot (Manpreet Singh) — reads the live game (player, deck, map) and converts it into SaveGameData. This is the only piece that touches real Entity/PlayerDeck/RunState objects on the save side.
  • Restore Logic & UI (Luqishu "William" Wu) — validates a loaded SaveGameData and applies it back to live game state, and the actual Save/Load screen players interact with.

Core rule for the whole system: nothing outside GameStateSnapshotProvider and SaveGameRestoreService should ever serialise or reconstruct a libGDX Entity, Component, Screen, or MapNode object directly. Every DTO in this system holds only primitives and stable IDs — see Why DTOs, Not Live Objects.

For the Bestiary (monster/god manual), see the separate Bestiary wiki page (to be added) — it shares the same save file format but is documented on its own.


Quick Start

Saving a run:

```java GameStateSnapshotProvider snapshotProvider = new GameStateSnapshotProvider(player, playerDeck, runState); SaveGameRepository repository = new JsonSaveGameRepository(); // writes to DECO2800Game/saves/slot-{id}.json SaveGameService saveGameService = new SaveGameService(repository, snapshotProvider);

SaveResult result = saveGameService.saveGame(slotId); if (!result.success()) { statusLabel.setText(SaveErrorMessages.forSave(result)); // e.g. "Couldn't read or write the save file." return; } ```

Loading a run:

```java LoadResult loadResult = saveGameService.loadGame(slotId); if (!loadResult.success()) { statusLabel.setText(SaveErrorMessages.forLoad(loadResult)); return; }

SaveGameRestoreService restoreService = new SaveGameRestoreService(player, playerDeck, runState); RestoreResult restoreResult = restoreService.restore(loadResult.data()); if (restoreResult.success()) { game.setScreen(GdxGame.ScreenType.valueOf(restoreResult.resumeScreen())); // currently always MAP } ```

Loading is deliberately two steps: SaveGameService.loadGame() only reads and validates the file — it never touches live state. Applying the result to the running game is SaveGameRestoreService's job, kept separate so a corrupted or invalid file can never partially mutate an in-progress run.


Architecture

```mermaid flowchart LR subgraph Live Game State Player[Player Entity] Deck[PlayerDeck] Map[RunState / MapGraph] end

subgraph Save Side
    Snapshot[GameStateSnapshotProvider]
    Service[SaveGameService]
    Repo[JsonSaveGameRepository]
end

subgraph Load Side
    Restore[SaveGameRestoreService]
end

File[(slot-N.json)]

Player --> Snapshot
Deck --> Snapshot
Map --> Snapshot
Snapshot -->|SaveGameData| Service
Service --> Repo
Repo -->|write| File
File -->|read| Repo
Repo --> Service
Service -->|SaveGameData| Restore
Restore -->|validated, applied| Player
Restore -->|validated, applied| Deck
Restore -->|validated, applied| Map

```

GameStateSnapshotProvider and SaveGameRestoreService are constructed with explicit references to the live player Entity, PlayerDeck, and RunState — there is no global "get the current game state" service. Whichever screen owns these objects (currently SaveLoadScreen, reached from the main menu) is responsible for constructing both classes with the correct live references.


The Save Flow

  1. SaveGameService.saveGame(slotId) calls snapshotProvider.capture().
  2. GameStateSnapshotProvider reads:
    • Player — CombatStatsComponent.getHealth()/getMaxHealth(), InventoryComponent.getGold().
    • Deck — PlayerDeck.getCardIds().
    • Map — every MapNode in RunState.getMapGraph().getNodes(), converted to MapNodeSaveData (see Why DTOs, Not Live Objects). Current position comes from MapGraph.getCurrentNode(), not RunState.getActiveNodeId() — see the note below.
    • Progress — see ProgressSaveData.
  3. The resulting SaveGameData is handed to JsonSaveGameRepository, which writes it to a temporary file, validates it round-trips correctly, then atomically replaces the destination file — so a crash mid-write can never corrupt an existing save.
  4. SaveResult is returned — success(SaveSlotMetadata) or failure(SaveError, message).

A note on currentNodeId vs. activeEncounterNodeId: these are easy to confuse and were mixed up during development, caught by the end-to-end test (see Testing). MapGraph.getCurrentNode() is the player's physical position on the map. RunState.getActiveNodeId() is a different concept — it tracks the node whose encounter is currently being resolved, and is null outside of an active encounter. MapSaveData stores both, separately, on purpose.


The Load Flow

  1. SaveGameService.loadGame(slotId) reads the file via JsonSaveGameRepository, deserialises it, and returns a LoadResult. This step never touches live game state — a malformed or missing file simply returns LoadResult.failure(...).
  2. If loadResult.success(), the caller passes loadResult.data() to SaveGameRestoreService.restore(data).
  3. SaveGameRestoreService validates every part of the data (player values in range, deck IDs known to CardService, map structure consistent) before mutating anything. If any part fails, nothing is applied — the current run is left exactly as it was.
  4. On success, live state is updated: CombatStatsComponent/InventoryComponent are set from PlayerSaveData, PlayerDeck is rebuilt from DeckSaveData, and the map graph is reconstructed from MapSaveData — including calling MapGraph.restoreCurrentNode(nodeId) and RunState.restoreRun(mapGraph, activeNodeId), which set position directly rather than going through the normal moveToNode() path (that path re-validates connectivity between the previous and new node, which doesn't apply when restoring — there is no "previous" node in a load).
  5. RestoreResult.resumeScreen() tells the caller which screen to show. This is currently always MAP — see Resume Rule.

Data Model

DTO Fields Notes
SaveGameData player, deck, map, progress Root object written to each save file.
PlayerSaveData currentHealth, maxHealth, gold, piety piety is confirmed unimplemented this sprint (Team 7) — always 0, kept in the schema for forward compatibility.
DeckSaveData cardIds: List<String> Matches PlayerDeck.getCardIds() exactly — no conversion needed.
MapSaveData nodes: List<MapNodeSaveData>, currentNodeId, activeEncounterNodeId See the note on the two node-ID fields above.
MapNodeSaveData nodeId, roomType, state, connectionIds: List<Integer> Connections are stored as IDs, not MapNode references — see below.
ProgressSaveData completedEncounterIds, pendingRewardId, resumeScreen See open item below.

Why DTOs, Not Live Objects

MapNode.getConnections() returns other MapNode objects, which themselves reference other nodes — serialising that graph directly would either fail (circular structure) or dump far more data than needed. MapNodeSaveData instead stores each connection as a plain nodeId. Reconstruction on load is two-pass: every node is created first, then connections are wired up by resolving IDs back to the real node objects — a connection can't be created before both endpoints exist.

The same principle applies everywhere else in this system: PlayerSaveData, DeckSaveData, and ProgressSaveData hold only primitives and ID lists, never references to Entity, Component, or Screen.


Error Handling

Every operation returns a result object rather than throwing — callers always get a clear success/failure, never an uncaught exception from a bad file.

Result type Returned by
SaveResult saveGame()
LoadResult loadGame()
DeleteSaveResult deleteSave()
SaveSlotListResult listSaveSlots()
RestoreResult SaveGameRestoreService.restore()

SaveError codes: INVALID_SLOT, NO_SAVE_DATA, CAPTURE_FAILED, SLOT_NOT_FOUND, MALFORMED_SAVE, UNSUPPORTED_VERSION, IO_ERROR, NONE. RestoreError codes: NO_SAVE_DATA, MISSING_PLAYER_DATA, MISSING_DECK_DATA, MISSING_MAP_DATA, INVALID_PLAYER_STATE, INVALID_DECK_STATE, INVALID_MAP_STATE, APPLY_FAILED, NONE.

SaveErrorMessages.forSave() / forLoad() / forDelete() translate any SaveError into a readable UI string, e.g. MALFORMED_SAVE → "That save file is corrupted and can't be loaded." A unit test asserts every non-NONE value has a message, so a future error code can't silently ship without one.


File Format

Saves are written via libGDX external storage to DECO2800Game/saves/slot-{id}.json, one file per slot. Example contents:

```json { "player": { "currentHealth": 65, "maxHealth": 100, "gold": 120, "piety": 0 }, "deck": { "cardIds": ["strike", "strike", "defend", "bandage"] }, "map": { "nodes": [ { "nodeId": 0, "roomType": "COMBAT", "state": "COMPLETED", "connectionIds": [1] }, { "nodeId": 1, "roomType": "SHOP", "state": "CURRENT", "connectionIds": [0] } ], "currentNodeId": 1, "activeEncounterNodeId": null }, "progress": { "completedEncounterIds": [], "pendingRewardId": "", "resumeScreen": "MAP" } } ```

Writes go to a temporary file first, are validated, then atomically replace the real slot file — a failed or interrupted write can never leave a slot half-written.


Reaching Save/Load In-Game

The main menu has a load button (MainMenuUi.json), wired to MainMenuActions.onLoad(), which opens GdxGame.ScreenType.SAVE_LOAD — a dedicated SaveLoadScreen wrapping SaveLoadPanel (slot list, Save/Load/Delete buttons per slot, status feedback). This was intentionally built as its own screen rather than folded into an existing menu, per agreement with Team 4 — see Cross-Team Notes.


Cross-Team Notes

Team 4 (map): MapGraph and RunState originally had no way to set the player's position directly — only startRun()/moveToNode(), both of which validate that the destination is connected to wherever the player currently is. That validation doesn't make sense for a restore (there's no "previous" node). Team 4 added MapGraph.restoreCurrentNode(nodeId) and RunState.restoreRun(mapGraph, activeNodeId) specifically for this.

Team 4 (menu): Save/Load's UI was deliberately built as its own screen rather than integrated into the pause menu this sprint, per discussion with Team 4 — pause menu integration is a possible Sprint 3 follow-up once the studio's menu skin is centralised.

Team 2 (encounters): ProgressSaveData.pendingRewardId is confirmed to always stay empty — Team 2's Chance and Shop outcomes apply immediately, so there's no "pending reward" state to persist. Separately, the resume rule below was agreed to avoid ever re-applying an outcome that already happened.

Resume Rule (agreed with Team 2)

A load always returns the player to the Map screen — it never attempts to resume mid-encounter. Since Chance/Shop outcomes are applied immediately and nothing is ever left "in progress," this is always safe: there is nothing that could be replayed or double-applied. ProgressSaveData.resumeScreen is therefore always "MAP" for this sprint.

ProgressSaveData (open item)

completedEncounterIds is currently typed List<String>, which doesn't match the Integer node-ID contract used everywhere else in this system. It's likely redundant rather than just mistyped: MapNodeSaveData.state already records per-node completion, so tracking it a second time risks the two values drifting apart. This is pending a schema decision from Anran (Save/Load Core owner) — either drop the field and derive completion from map state at load time, or correct its type and keep it as a stored value. GameStateSnapshotProvider.captureProgress() leaves it empty until this is settled.


Testing

  • Unit tests for GameStateSnapshotProvider — player and map capture correctness, including a test that specifically checks connections are captured as IDs, not object references.
  • An end-to-end test that exercises the real pipeline: constructs live Entity/PlayerDeck/RunState objects, calls SaveGameService.saveGame(), reads the resulting file back from disk via JsonSaveGameRepository, and asserts the values match. This test caught a real bug during development — currentNodeId was initially reading from RunState.getActiveNodeId() instead of MapGraph.getCurrentNode() (see the note in The Save Flow).
  • Unit tests for SaveGameRestoreService, covering successful restore and each RestoreError case.
  • Unit tests for SaveErrorMessages, including a test that every SaveError value maps to a non-blank message.

Not yet covered: a single test that exercises the whole loop — save, close, reopen, load, verify restoration — through both SaveGameService and SaveGameRestoreService together. Each half is tested independently; the full round trip isn't yet.


Related Pages

Members

  • Anran Zhang (@Amber-729) — Save/Load Core
  • Manpreet Singh (@manpreet0101) — Game State Snapshot
  • Luqishu Wu (@wuwilliam-uq) — Restore Logic & UI
⚠️ **GitHub.com Fallback** ⚠️