31 KiB
Puzzle System Save/Load Integration - Analysis & Proposal
Date: November 3, 2025
Status: ✅ IMPLEMENTED - Awaiting Testing
🔍 Current Puzzle System Analysis
Architecture Overview
Key Components:
-
PuzzleManager (Singleton MonoBehaviour)
- Manages all puzzle state for current scene/level
- Tracks completed steps:
HashSet<PuzzleStepSO> _completedSteps - Tracks unlocked steps:
HashSet<PuzzleStepSO> _unlockedSteps - Registers ObjectiveStepBehaviours
- Handles step dependencies and unlocking
-
PuzzleStepSO (ScriptableObject)
- Defines individual puzzle steps
- Has unique
stepId(string) - Contains dependency data (
unlockslist) - Cannot be instantiated per-scene (it's an asset)
-
PuzzleLevelDataSO (ScriptableObject)
- Container for all steps in a level
- Loaded via Addressables
- Has
levelIdandallStepslist
-
ObjectiveStepBehaviour (MonoBehaviour)
- In-scene representation of a step
- References PuzzleStepSO
- Hooks into InteractableBase
- Visual indicator management
Current State Management
Runtime Tracking:
private HashSet<PuzzleStepSO> _completedSteps = new HashSet<PuzzleStepSO>();
private HashSet<PuzzleStepSO> _unlockedSteps = new HashSet<PuzzleStepSO>();
Queries:
public bool IsPuzzleStepCompleted(string stepId)
{
return _completedSteps.Any(step => step.stepId == stepId); // O(n) lookup!
}
Current Problem
❌ No Persistence:
- All puzzle progress is lost on scene reload
- All puzzle progress is lost on game exit
- Players must re-complete puzzles every session
💡 Proposed Solution: Simplified Pending Registration Pattern ⭐
Approach: String-based tracking + pending registration queue for timing-independent save/load
Key Insight: Instead of complex timing logic, simply queue behaviors that register before data is restored, then update them once restoration completes.
Core Mechanics
- PuzzleManager implements ISaveParticipant
- Use
HashSet<string>for completed/unlocked steps (enables immediate restoration) - Track restoration state with
_isDataRestoredflag - Queue early registrations in
_pendingRegistrationslist - Process queue after RestoreState() completes
This elegantly handles all timing scenarios without complex branching logic.
💡 Alternative Approaches (Rejected)
Option A: Minimal Changes (Keep ScriptableObject Tracking)
Approach: Make PuzzleManager implement ISaveParticipant, convert SOs to stepIds for serialization
Implementation
1. Save Data Structure:
[Serializable]
public class PuzzleSaveData
{
public string levelId; // Which level this data is for
public List<string> completedStepIds;
public List<string> unlockedStepIds;
}
2. Refactor Core Data Structures:
// CHANGE FROM:
private HashSet<PuzzleStepSO> _completedSteps = new HashSet<PuzzleStepSO>();
private HashSet<PuzzleStepSO> _unlockedSteps = new HashSet<PuzzleStepSO>();
// CHANGE TO:
private HashSet<string> _completedSteps = new HashSet<string>(); // Now stores stepIds
private HashSet<string> _unlockedSteps = new HashSet<string>(); // Now stores stepIds
3. Add Pending Registration Pattern:
private bool _isDataRestored = false;
private List<ObjectiveStepBehaviour> _pendingRegistrations = new List<ObjectiveStepBehaviour>();
4. Implement ISaveParticipant (Simple & Clean!):
public class PuzzleManager : MonoBehaviour, ISaveParticipant
{
public string GetSaveId()
{
string sceneName = SceneManager.GetActiveScene().name;
return $"{sceneName}/PuzzleManager";
}
public string SerializeState()
{
if (_currentLevelData == null)
return "{}";
var saveData = new PuzzleSaveData
{
levelId = _currentLevelData.levelId,
completedStepIds = _completedSteps.ToList(), // Direct conversion!
unlockedStepIds = _unlockedSteps.ToList() // Direct conversion!
};
return JsonUtility.ToJson(saveData);
}
public void RestoreState(string data)
{
if (string.IsNullOrEmpty(data))
return;
var saveData = JsonUtility.FromJson<PuzzleSaveData>(data);
if (saveData == null)
return;
// ✅ No timing dependency - restore IDs immediately!
_completedSteps = new HashSet<string>(saveData.completedStepIds);
_unlockedSteps = new HashSet<string>(saveData.unlockedStepIds);
_isDataRestored = true;
// ✅ Update any behaviors that registered before RestoreState was called
foreach (var behaviour in _pendingRegistrations)
{
UpdateStepState(behaviour);
}
_pendingRegistrations.Clear();
Debug.Log($"[PuzzleManager] Restored {_completedSteps.Count} completed, {_unlockedSteps.Count} unlocked steps");
}
}
5. Update RegisterStepBehaviour:
public void RegisterStepBehaviour(ObjectiveStepBehaviour behaviour)
{
if (behaviour == null) return;
_registeredBehaviours.Add(behaviour);
if (_isDataRestored)
{
// Data already loaded - update immediately
UpdateStepState(behaviour);
}
else
{
// Data not loaded yet - queue for later
_pendingRegistrations.Add(behaviour);
}
}
6. Register with SaveLoadManager:
private void InitializePostBoot()
{
// ...existing code...
BootCompletionService.RegisterInitAction(() =>
{
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.RegisterParticipant(this);
}
});
}
void OnDestroy()
{
// ...existing code...
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.UnregisterParticipant(GetSaveId());
}
}
Pros & Cons
Pros:
- ✅ Simple & elegant - minimal state tracking
- ✅ No timing dependencies - works in any order
- ✅ Better performance - O(1) lookups instead of O(n)
- ✅ Cleaner code - no SO ↔ string conversions
- ✅ Easy to debug - clear registration flow
Cons:
- ⚠️ Requires refactoring existing PuzzleManager methods
- ⚠️ Need to update all code that adds/checks steps
Alternative: Keep ScriptableObject Tracking (Not Recommended)
Approach: Change internal tracking to use stepIds (strings) instead of ScriptableObject references
Implementation
1. Refactor Core Data Structures:
// CHANGE FROM:
private HashSet<PuzzleStepSO> _completedSteps = new HashSet<PuzzleStepSO>();
private HashSet<PuzzleStepSO> _unlockedSteps = new HashSet<PuzzleStepSO>();
// CHANGE TO:
private HashSet<string> _completedSteps = new HashSet<string>(); // Now stores stepIds
private HashSet<string> _unlockedSteps = new HashSet<string>(); // Now stores stepIds
2. Update Query Methods:
// CHANGE FROM:
public bool IsPuzzleStepCompleted(string stepId)
{
return _completedSteps.Any(step => step.stepId == stepId); // O(n)
}
// CHANGE TO:
public bool IsPuzzleStepCompleted(string stepId)
{
return _completedSteps.Contains(stepId); // O(1)!
}
3. Update Step Completion Logic:
// CHANGE FROM:
public void CompleteStep(PuzzleStepSO step)
{
if (step == null) return;
if (_completedSteps.Contains(step)) return;
_completedSteps.Add(step);
OnStepCompleted?.Invoke(step);
// Unlock dependencies
foreach (var unlockedStep in step.unlocks)
{
UnlockStep(unlockedStep);
}
}
// CHANGE TO:
public void CompleteStep(PuzzleStepSO step)
{
if (step == null) return;
if (_completedSteps.Contains(step.stepId)) return;
_completedSteps.Add(step.stepId); // Store ID
OnStepCompleted?.Invoke(step);
// Unlock dependencies
foreach (var unlockedStep in step.unlocks)
{
UnlockStep(unlockedStep);
}
}
// Also add overload for string-based completion:
public void CompleteStep(string stepId)
{
if (string.IsNullOrEmpty(stepId)) return;
if (_completedSteps.Contains(stepId)) return;
_completedSteps.Add(stepId);
// Find the SO to fire events and unlock dependencies
var step = _currentLevelData?.allSteps.Find(s => s.stepId == stepId);
if (step != null)
{
OnStepCompleted?.Invoke(step);
foreach (var unlockedStep in step.unlocks)
{
UnlockStep(unlockedStep);
}
}
}
4. Implement ISaveParticipant (Much Simpler!):
public class PuzzleManager : MonoBehaviour, ISaveParticipant
{
public bool HasBeenRestored { get; private set; }
public string GetSaveId()
{
string sceneName = SceneManager.GetActiveScene().name;
return $"{sceneName}/PuzzleManager";
}
public string SerializeState()
{
if (_currentLevelData == null)
return "{}";
var saveData = new PuzzleSaveData
{
levelId = _currentLevelData.levelId,
completedStepIds = _completedSteps.ToList(), // Direct conversion!
unlockedStepIds = _unlockedSteps.ToList() // Direct conversion!
};
return JsonUtility.ToJson(saveData);
}
public void RestoreState(string data)
{
if (string.IsNullOrEmpty(data))
return;
var saveData = JsonUtility.FromJson<PuzzleSaveData>(data);
if (saveData == null)
return;
// No timing dependency - we can restore IDs immediately!
_completedSteps.Clear();
_unlockedSteps.Clear();
// Direct assignment!
_completedSteps = new HashSet<string>(saveData.completedStepIds);
_unlockedSteps = new HashSet<string>(saveData.unlockedStepIds);
HasBeenRestored = true;
// Update visual state of registered behaviors
UpdateAllRegisteredBehaviors();
Debug.Log($"[PuzzleManager] Restored {_completedSteps.Count} completed steps, {_unlockedSteps.Count} unlocked steps");
}
}
5. Update UnlockInitialSteps:
private void UnlockInitialSteps()
{
if (_currentLevelData == null) return;
// Don't unlock if we've restored from save
if (HasBeenRestored) return;
// ...rest of existing logic, but add stepIds to HashSet...
}
6. Registration (Same as Option A):
private void InitializePostBoot()
{
// ...existing code...
BootCompletionService.RegisterInitAction(() =>
{
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.RegisterParticipant(this);
}
});
}
void OnDestroy()
{
// ...existing code...
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.UnregisterParticipant(GetSaveId());
}
}
Pros & Cons
Pros:
- ✅ Much simpler save/load - direct serialization
- ✅ No timing dependencies - can restore before level data loads
- ✅ Better performance - O(1) lookups instead of O(n)
- ✅ Cleaner code - no SO ↔ string conversions
- ✅ More maintainable - stepId is the canonical identifier
Cons:
- ⚠️ Requires refactoring existing PuzzleManager methods
- ⚠️ Need to update all code that adds/checks steps
⚠️ CRITICAL: Timing Analysis
The Initialization Order Problem
Question: What if RestoreState() runs before LoadPuzzleDataForCurrentScene()?
Answer: It WILL happen! Here's the actual boot sequence:
1. BootstrapScene loads
2. SaveLoadManager initializes
3. SaveLoadManager.LoadAsync() starts
4. Gameplay scene loads (e.g., Quarry)
5. PuzzleManager.Awake() runs
6. SaveLoadManager finishes loading save data
7. ✅ SaveLoadManager.RestoreAllParticipantStates() → PuzzleManager.RestoreState() called
8. BootCompletionService.NotifyBootComplete()
9. ✅ PuzzleManager.InitializePostBoot() → LoadPuzzleDataForCurrentScene() starts (addressable async!)
10. ObjectiveStepBehaviours.Start() → RegisterStepBehaviour() calls
11. Addressable load completes → _currentLevelData available
RestoreState() (step 7) runs BEFORE LoadPuzzleDataForCurrentScene() (step 9)!
How Each Option Handles This
Option A: REQUIRES Pending Data Pattern
public void RestoreState(string data)
{
var saveData = JsonUtility.FromJson<PuzzleSaveData>(data);
if (_isDataLoaded && _currentLevelData != null)
{
// Lucky case - data already loaded (unlikely)
ApplySavedState(saveData);
}
else
{
// EXPECTED case - data not loaded yet
_pendingRestoreData = saveData; // Store for later
}
}
// Later, in LoadPuzzleDataForCurrentScene completion:
if (_pendingRestoreData != null)
{
ApplySavedState(_pendingRestoreData); // NOW we can convert stepIds to SOs
_pendingRestoreData = null;
}
Why? Can't convert stepIds to PuzzleStepSO references without _currentLevelData!
Option B: Works Naturally ✅
public void RestoreState(string data)
{
var saveData = JsonUtility.FromJson<PuzzleSaveData>(data);
// No dependency on _currentLevelData needed!
_completedSteps = new HashSet<string>(saveData.completedStepIds);
_unlockedSteps = new HashSet<string>(saveData.unlockedStepIds);
HasBeenRestored = true;
// Update any behaviors that registered early (before RestoreState)
UpdateAllRegisteredBehaviors();
// Behaviors that register later will check the populated HashSets
}
Why it works:
- ✅ stepIds are valid immediately (no ScriptableObject lookup needed)
- ✅ Behaviors that registered early get updated
- ✅ Behaviors that register later check against populated HashSets
- ✅ UnlockInitialSteps() checks HasBeenRestored and skips
All Possible Timing Permutations
| Scenario | Option A | Option B |
|---|---|---|
| RestoreState → Register Behaviors → Load Level Data | Pending data, apply later | ✅ Works immediately |
| Register Behaviors → RestoreState → Load Level Data | Pending data, apply later | ✅ Updates registered behaviors |
| Load Level Data → RestoreState → Register Behaviors | Apply immediately | ✅ Works immediately |
| RestoreState → Load Level Data → Register Behaviors | Pending data, apply on load | ✅ Works immediately |
Option B handles ALL permutations without special logic!
Additional Behavior Registration Timing
Even within a single scenario, behaviors can register at different times:
- Some in Start() (early)
- Some when state machines activate them (late)
- Some when player proximity triggers them
Option B handles this gracefully:
public void RegisterStepBehaviour(ObjectiveStepBehaviour behaviour)
{
// ...registration logic...
// Update this behavior's state based on current HashSets
// (which may or may not be populated from RestoreState yet)
if (_isDataLoaded && _currentLevelData != null)
{
UpdateStepState(behaviour);
}
}
Conclusion: Option B's string-based approach is timing-independent and more robust.
How ObjectiveStepBehaviour Gets Updated
Critical Question: When RestoreState() populates the HashSets, how do the ObjectiveStepBehaviours actually know to update their visual state?
Answer: Through the registration callback system already in place!
Current Update Flow (Before Save/Load)
1. ObjectiveStepBehaviour.Start()
└─ PuzzleManager.RegisterStepBehaviour(this)
└─ PuzzleManager.UpdateStepState(behaviour)
├─ Checks: Is step completed?
├─ Checks: Is step unlocked?
└─ Calls: behaviour.UnlockStep() or behaviour.LockStep()
└─ ObjectiveStepBehaviour updates visual indicator
Option B Update Flow (With Save/Load)
Scenario 1: RestoreState BEFORE Behaviors Register
1. SaveLoadManager.RestoreState() called
└─ PuzzleManager.RestoreState(data)
├─ Sets: _completedSteps = {"step1", "step2"}
├─ Sets: _unlockedSteps = {"step3"}
├─ Sets: HasBeenRestored = true
└─ Calls: UpdateAllRegisteredBehaviors()
└─ No behaviors registered yet - does nothing
2. Later: ObjectiveStepBehaviour.Start()
└─ PuzzleManager.RegisterStepBehaviour(this)
└─ PuzzleManager.UpdateStepState(behaviour)
├─ Checks: _completedSteps.Contains(behaviour.stepData.stepId)? ✅
├─ If not completed, checks: _unlockedSteps.Contains(stepId)? ✅
└─ Calls: behaviour.UnlockStep() or behaviour.LockStep()
└─ Visual indicator updates correctly! ✅
Scenario 2: Behaviors Register BEFORE RestoreState
1. ObjectiveStepBehaviour.Start()
└─ PuzzleManager.RegisterStepBehaviour(this)
└─ PuzzleManager.UpdateStepState(behaviour)
├─ Checks: _completedSteps (empty) - not completed
├─ Checks: _unlockedSteps (empty) - not unlocked
└─ Calls: behaviour.LockStep()
└─ Behavior locked (temporarily wrong state)
2. Later: SaveLoadManager.RestoreState() called
└─ PuzzleManager.RestoreState(data)
├─ Sets: _completedSteps = {"step1", "step2"}
├─ Sets: _unlockedSteps = {"step3"}
├─ Sets: HasBeenRestored = true
└─ Calls: UpdateAllRegisteredBehaviors()
└─ For each registered behaviour:
└─ PuzzleManager.UpdateStepState(behaviour)
├─ Checks: _completedSteps.Contains(stepId)? ✅
├─ If not completed, checks: _unlockedSteps.Contains(stepId)? ✅
└─ Calls: behaviour.UnlockStep()
└─ Visual indicator updates correctly! ✅
The Magic: UpdateStepState Method
This existing method is the key - it works for BOTH scenarios:
private void UpdateStepState(ObjectiveStepBehaviour behaviour)
{
if (behaviour?.stepData == null) return;
// OPTION B: This becomes a simple Contains() check on strings
if (_completedSteps.Contains(behaviour.stepData.stepId))
return; // Already completed - no visual update needed
// Check if step should be unlocked
if (_unlockedSteps.Contains(behaviour.stepData.stepId))
{
behaviour.UnlockStep(); // Shows indicator, enables interaction
}
else
{
behaviour.LockStep(); // Hides indicator, disables interaction
}
}
What UnlockStep() and LockStep() Do
UnlockStep():
- Sets
_isUnlocked = true - Calls
OnShow()→ activates puzzle indicator GameObject - Updates indicator visual state (ShowClose/ShowFar based on player distance)
- Enables interaction via InteractableBase
LockStep():
- Sets
_isUnlocked = false - Calls
OnHide()→ deactivates puzzle indicator GameObject - Hides all visual prompts
- Disables interaction
Key Implementation Detail for Option B
Current code needs ONE small update:
// CURRENT (uses PuzzleStepSO):
if (_completedSteps.Contains(behaviour.stepData)) return;
if (_unlockedSteps.Contains(behaviour.stepData))
// CHANGE TO (uses stepId string):
if (_completedSteps.Contains(behaviour.stepData.stepId)) return;
if (_unlockedSteps.Contains(behaviour.stepData.stepId))
That's it! The rest of the update flow stays exactly the same.
Why This Works So Well
- UpdateAllRegisteredBehaviors() already exists - just call it after RestoreState
- UpdateStepState() already exists - just change
.Contains()to check stepIds - UnlockStep/LockStep() already exist - they handle all visual updates
- No timing dependencies - works whether behaviors register before or after restore
Visual State Update Flow Diagram
RestoreState() populates HashSets
↓
├─── UpdateAllRegisteredBehaviors()
│ └─ For each already-registered behavior:
│ └─ UpdateStepState(behaviour)
│ └─ behaviour.UnlockStep() or LockStep()
│ └─ Visual indicator updates
│
└─── (Future registrations)
When RegisterStepBehaviour() called:
└─ UpdateStepState(behaviour)
└─ behaviour.UnlockStep() or LockStep()
└─ Visual indicator updates
Result: All behaviors end up in the correct visual state, regardless of registration timing! ✅
Visual Timeline Diagram
TIME →
Boot Sequence:
├─ SaveLoadManager.LoadAsync() starts
├─ Scene loads
├─ PuzzleManager.Awake()
│
├─ [CRITICAL] SaveLoadManager.RestoreState() called ← No level data yet!
│ │
│ ├─ Option A: Must store pending data ⚠️
│ │ Cannot convert stepIds to SOs yet
│ │ Must wait for addressable load
│ │
│ └─ Option B: Works immediately ✅
│ Sets HashSet<string> directly
│ No conversion needed
│
├─ BootCompletionService.NotifyBootComplete()
│
├─ PuzzleManager.InitializePostBoot()
│ └─ LoadPuzzleDataForCurrentScene() starts async
│
├─ ObjectiveStepBehaviours.Start() → Register with manager
│ │
│ ├─ Option A: Still waiting for level data ⚠️
│ │ Can't determine state yet
│ │
│ └─ Option B: Checks HashSets ✅
│ Immediately gets correct state
│
└─ Addressable load completes → _currentLevelData available
│
├─ Option A: NOW applies pending data ⚠️
│ Finally converts stepIds to SOs
│ Updates all behaviors
│
└─ Option B: Already working ✅
No action needed
📊 Comparison Summary
| Aspect | Option A (Keep SOs) | Option B (Use Strings) ⭐ |
|---|---|---|
| Code Changes | Minimal | Moderate |
| Save/Load Complexity | High | Low |
| Timing Dependencies | Yes (addressables) | No |
| Performance | O(n) lookups | O(1) lookups |
| Maintainability | Lower | Higher |
| Future-Proof | Less | More |
🎯 Recommendation
I strongly recommend Option B (String-Based Tracking) for these reasons:
- Simpler Save/Load - No conversion logic, no timing dependencies
- Better Performance - Faster lookups benefit gameplay
- More Robust - Works regardless of addressable loading state
- Cleaner Architecture - stepId is already the canonical identifier
- Easier to Debug - String IDs in save files are human-readable
The refactoring effort is moderate but pays off immediately in code quality and future maintainability.
🛠️ Implementation Plan (Simplified Pending Registration Pattern) ✅
Phase 1: Data Structure Refactor ✅
- Change
_completedStepstoHashSet<string> - Change
_unlockedStepstoHashSet<string> - Add
_isDataRestoredflag - Add
_pendingRegistrationslist - Update
IsPuzzleStepCompleted()to use.Contains()- O(1) lookup! - Update
IsStepUnlocked()to use.Contains(stepId)
Summary: Converted internal state tracking from HashSet<PuzzleStepSO> to HashSet<string> for timing-independent save/load. Added pending registration queue pattern with _isDataRestored flag and _pendingRegistrations list.
Phase 2: Update Step Management Methods ✅
- Update
MarkPuzzleStepCompleted()to store stepId - Update
UnlockStep()to store stepId - Update
UnlockInitialSteps()to add stepIds and skip if_isDataRestored - Update
UpdateStepState()to check stepIds instead of SOs - Update
AreStepDependenciesMet()to use string-based checks - Update
CheckPuzzleCompletion()to manually iterate and check stepIds
Summary: Refactored all internal methods to work with string-based stepIds instead of ScriptableObject references. Simplified dependency checking from O(n²) to O(n) using HashSet.Contains(). Added restoration check to UnlockInitialSteps().
Phase 3: Implement ISaveParticipant ✅
- Add
ISaveParticipantto PuzzleManager class signature - Add
PuzzleSaveDatastructure - Implement
GetSaveId() - Implement
SerializeState() - Implement
RestoreState()with pending registration processing
Summary: Implemented ISaveParticipant interface with direct serialization of string HashSets. RestoreState() populates HashSets immediately (no timing dependency), then processes any pending registrations that occurred before restoration.
Phase 4: Register with SaveLoadManager ✅
- Register in
InitializePostBoot()via BootCompletionService - Unregister in
OnDestroy() - Update
RegisterStepBehaviour()to use pending registration pattern - Add using statement for
Core.SaveLoad
Summary: Integrated with SaveLoadManager lifecycle. Updated RegisterStepBehaviour() to implement pending registration pattern: if data restored, update immediately; otherwise, queue for later processing.
Phase 5: Testing 🔄
- Test step completion saves correctly
- Test step completion restores correctly
- Test unlocked steps save/restore
- Test scene changes preserve state
- Test game restart preserves state
- Test early vs late behavior registration timing
- Test RestoreState before vs after LoadPuzzleData
Next Steps: Ready for testing in Unity Editor!
📊 Implementation Summary
What Changed
Core Data Structures:
- Converted
HashSet<PuzzleStepSO> _completedSteps→HashSet<string> _completedSteps - Converted
HashSet<PuzzleStepSO> _unlockedSteps→HashSet<string> _unlockedSteps - Added
bool _isDataRestoredflag for save/load state tracking - Added
List<ObjectiveStepBehaviour> _pendingRegistrationsfor timing-independent behavior updates
ISaveParticipant Integration:
- Implemented
GetSaveId()- returns"{sceneName}/PuzzleManager" - Implemented
SerializeState()- directly serializes string HashSets to JSON - Implemented
RestoreState()- restores HashSets immediately, then processes pending registrations - Registered with SaveLoadManager in
InitializePostBoot()via BootCompletionService
Pending Registration Pattern:
// When a behavior registers:
if (_isDataRestored) {
UpdateStepState(behaviour); // Data ready - update immediately
} else {
_pendingRegistrations.Add(behaviour); // Queue for later
}
// When RestoreState() completes:
_isDataRestored = true;
foreach (var behaviour in _pendingRegistrations) {
UpdateStepState(behaviour); // Process queued behaviors
}
_pendingRegistrations.Clear();
Performance Improvements
Before:
IsPuzzleStepCompleted(stepId): O(n) LINQ query checking all completed stepsAreStepDependenciesMet(): O(n²) nested loops comparing stepIds
After:
IsPuzzleStepCompleted(stepId): O(1) HashSet.Contains()AreStepDependenciesMet(): O(n) single loop with O(1) lookups
Timing Independence
The system now handles all timing scenarios gracefully:
-
RestoreState BEFORE behaviors register ✅
- Data restored, flag set
- Behaviors register → update immediately
-
Behaviors register BEFORE RestoreState ✅
- Behaviors queued in
_pendingRegistrations - RestoreState() processes queue after restoration
- Behaviors queued in
-
Mixed (some before, some after) ✅
- Early registrations queued
- RestoreState() processes queue
- Late registrations update immediately
-
No save data exists ✅
_isDataRestoredset to trueUnlockInitialSteps()runs normally- Behaviors update on registration
🔍 Key Files Modified
-
PuzzleManager.cs - Core implementation
- Added
ISaveParticipantinterface - Refactored to string-based tracking
- Implemented pending registration pattern
- Added
-
puzzle_save_load_proposal.md - Documentation
- Updated with simplified approach
- Marked implementation phases complete
🧪 Testing Checklist
When testing in Unity Editor, verify the following scenarios:
Basic Functionality
- Complete a puzzle step → Save game → Restart → Load game → Step should still be completed
- Unlock a step → Save game → Restart → Load game → Step should still be unlocked
- Complete multiple steps in sequence → Verify dependencies unlock correctly after load
Timing Tests
- Load game BEFORE puzzle behaviors initialize → Steps should restore correctly
- Load game AFTER puzzle behaviors initialize → Steps should restore correctly
- Scene change → New scene's puzzle data loads → Previous scene's data doesn't leak
Edge Cases
- First-time player (no save file) → Initial steps unlock normally
- Save with completed steps → Delete save file → Load → Initial steps unlock
- Complete puzzle → Save → Load different scene → Load original scene → Completion persists
Console Verification
- No errors during save operation
- No errors during load operation
- Log messages show participant registration
- Log messages show state restoration
✅ Implementation Complete
Date Completed: November 3, 2025
Changes Made:
- ✅ Refactored PuzzleManager to use string-based HashSets
- ✅ Implemented ISaveParticipant interface
- ✅ Added pending registration pattern for timing independence
- ✅ Integrated with SaveLoadManager via BootCompletionService
- ✅ Performance improvements (O(n²) → O(n) for dependency checks)
Files Modified:
Assets/Scripts/PuzzleS/PuzzleManager.cs- Core implementationdocs/puzzle_save_load_proposal.md- Documentation and proposal
Ready for Testing: Yes ✅
The implementation is complete and ready for testing in Unity Editor. The system handles all timing scenarios gracefully with the simplified pending registration pattern.
📝 Additional Considerations
Cross-Scene Puzzle State
Question: Should puzzle state persist across different scenes?
Current Approach: Each scene has its own PuzzleManager with its own level data
- Save ID:
{sceneName}/PuzzleManager - Each scene's puzzle state is independent
Alternative: Global puzzle progress tracker
- Would need a persistent PuzzleProgressManager
- Tracks completion across all levels
- More complex but enables cross-level dependencies
Recommendation: Start with per-scene state (simpler), add global tracker later if needed.
Save Data Migration
If you ever need to change the save format:
- Add version number to
PuzzleSaveData - Implement migration logic in
RestoreState() - Example:
[Serializable]
public class PuzzleSaveData
{
public int version = 1; // Add version tracking
public string levelId;
public List<string> completedStepIds;
public List<string> unlockedStepIds;
}
Editor Utilities
Consider adding:
- Context menu on PuzzleManager: "Clear Saved Puzzle Progress"
- Editor window: View/edit saved puzzle state
- Cheat commands: Complete all steps, unlock all steps
✅ Ready to Implement
I'm ready to implement Option B (Recommended) or Option A based on your preference.
Which option would you like me to proceed with?
Option A: Minimal changes, keep ScriptableObject tracking
Option B: ⭐ Refactor to string-based tracking (recommended)
After you choose, I'll implement all the code changes systematically.