DEV Community

GameDevToolLab
GameDevToolLab

Posted on

Unity Save Data Writing Tips: Make Local Saves Harder to Break and Easier to Recover

Introduction

If you only need to save a few small settings in Unity, PlayerPrefs is usually the natural first option.

For example, saving volume and vibration settings can be as simple as this:

PlayerPrefs.SetFloat("option.masterVolume", currentMasterVolume);
PlayerPrefs.SetInt("option.vibration", currentVibration ? 1 : 0);
PlayerPrefs.Save();

var loadedMasterVolume = PlayerPrefs.GetFloat("option.masterVolume", 1.0f);
var loadedVibration = PlayerPrefs.GetInt("option.vibration", 1) != 0;
Enter fullscreen mode Exit fullscreen mode

For prototypes, or for a small number of independent option values, this is often enough.

However, PlayerPrefs.Save() is not something you should call for every frequent input event, such as every OnValueChanged call from a slider.
It is better to call it when the value is confirmed: when the user presses OK, closes the options screen, or when the app is about to pause as a final safeguard.

First, decide whether PlayerPrefs is enough for the data you are saving.

In a released game, save data tends to grow over time.
What started as only a volume setting may later include language, display settings, tutorial progress, read flags, and other state.
Once that happens, PlayerPrefs becomes awkward for consistency, migration, and recovery.

The next common option is to write a JSON file under Application.persistentDataPath.

var json = JsonUtility.ToJson(saveData);
var path = Path.Combine(Application.persistentDataPath, "save.json");
File.WriteAllText(path, json);
Enter fullscreen mode Exit fullscreen mode

But "convert it to JSON and write it" is not the end of the story.
There are several quiet traps: quit-time saves may not run, the app may be killed during a write, failed loads may accidentally overwrite recoverable data with defaults, and multiple save paths may overwrite newer data with older data.

Save data is not flashy, but when it breaks, the user experience is awful.
Even small data, such as volume or language settings, becomes frustrating if it resets every time.

This article collects practical notes for writing local saves and option settings in Unity.

This is not an article about creating a save system that can never break.
The goal is to reduce the chance of save failure, and to make recovery easier when failure does happen.

The main target is mobile Unity projects, including Android.
That said, most of the ideas apply to PC builds as well.

Scope of this article

This article focuses on lightweight local data such as option settings, tutorial progress, simple local save data, and cache metadata.

It does not cover full anti-cheat design, server synchronization, guarantees for paid items or currency, cloud save conflict resolution, or large custom binary formats.

In this article, "safe" mainly means the following:

  • Make files less likely to break during writes
  • Avoid relying too much on app quit events
  • Make failed loads easier to recover from
  • Avoid accidental overwrites caused by implementation mistakes

Once data is stored locally, you should assume the user can access or modify it.
Important currency, paid items, rankings, and values that affect competitive play should not trust local saves as the source of truth.

Keep PlayerPrefs for small use cases

PlayerPrefs is not a bad API.
For small settings, it is convenient and often the right tool.

It can be fine for values such as master volume, vibration, graphics preset, last selected language, or a simple "tutorial already shown" flag.

The decision should be based on operational requirements, not only on the item name.
A single language setting can be fine in PlayerPrefs.
But if you need to manage language together with other settings, progress, read flags, migration, and recovery, it is safer to move that state into a JSON file.

PlayerPrefs stores values by key.
It is not a great fit when you want to treat multiple values as one coherent save file.
It also makes it harder to express requirements such as:

  • Migrate based on a save version
  • Recover from a .bak file
  • Avoid overwriting immediately when a load fails
  • Keep broken files for investigation

Also, this is not specific to PlayerPrefs: local data should be treated as user-editable.
Do not use PlayerPrefs alone as the source of truth for paid items, currency, rankings, or values that affect multiplayer or competitive results.

A practical split is:

  • Use PlayerPrefs for a small number of independent settings
  • Use JSON files for grouped state, migrated state, or data that should be recoverable

From here on, we will assume that the data has grown beyond what should live in PlayerPrefs, and that we are saving JSON files under persistentDataPath.
First, let us look at the simplest direct file write approach many projects start with.

Use persistentDataPath as the default location

When you need to save data created at runtime in Unity, Application.persistentDataPath is usually the first place to consider.
Unity describes it as a directory for data that should persist across app launches.

using System.IO;
using UnityEngine;

public static class SaveFiles
{
    public static string OptionsPath =>
        Path.Combine(Application.persistentDataPath, "options.json");

    public static string ProgressPath =>
        Path.Combine(Application.persistentDataPath, "progress.json");
}
Enter fullscreen mode Exit fullscreen mode

However, persistentDataPath does not mean "this data can never disappear".

With a normal app update, data is expected to remain as long as the bundle identifier stays the same.
But data may be lost if the bundle identifier changes, the app is uninstalled, the user clears app data, a backup is restored, or the storage state changes.

So loading code should treat "the file does not exist" as a normal case.

if (!File.Exists(path))
{
    return CreateDefaultOptions();
}
Enter fullscreen mode Exit fullscreen mode

In some cases, local code cannot tell whether this is the first launch or whether the user deleted the data.
For important data, you need to think about consistency with server-side data or cloud saves as well.

JSON is convenient, but use a save DTO

For lightweight data, JSON is easy to inspect and debug.
For small data such as option settings, Unity's JsonUtility is often enough.

For example, option settings can be represented like this:

using System;

[Serializable]
public sealed class OptionSaveData
{
    public int version = 1;
    public float masterVolume = 1.0f;
    public float bgmVolume = 1.0f;
    public float seVolume = 1.0f;
    public string language = "ja";
    public bool vibration = true;
}
Enter fullscreen mode Exit fullscreen mode
var json = JsonUtility.ToJson(optionData, prettyPrint: true);
File.WriteAllText(SaveFiles.OptionsPath, json);
Enter fullscreen mode Exit fullscreen mode

Because JsonUtility uses Unity's serializer, keep these practical constraints in mind:

  • Add [Serializable]
  • Prefer fields for saved values
  • Avoid property-centered save classes
  • Dictionaries, complex types, and top-level arrays are awkward
  • Prepare migration when deleting or renaming fields
  • Add a version field from the beginning

A property-centered class like this is not a good save DTO for JsonUtility:

[Serializable]
public sealed class BadOptionData
{
    public float MasterVolume { get; set; } = 1.0f;
    public float BgmVolume { get; set; } = 1.0f;
}
Enter fullscreen mode Exit fullscreen mode

The class you use inside the game and the DTO you serialize do not have to be the same class.
Trying to force them to be one class often makes future changes harder.

Do not write directly; write to a temp file and replace

File.WriteAllText is convenient, but direct overwriting is scary for existing save files.

File.WriteAllText(path, json);
Enter fullscreen mode Exit fullscreen mode

If the app crashes during the write, the device loses power, or the OS kills the process, a partially written file may remain.

For example, you may end up with broken JSON like this:

{
  "version": 1,
  "masterVolume": 0.8,
  "bgmVolume":
Enter fullscreen mode Exit fullscreen mode

On the next launch, loading fails.
If the failure path is careless, the game may write default data immediately and destroy any chance of recovery.

A common improvement is to write to a temporary file first, then replace the main file.

The basic flow is:

  1. Write the new contents to save.json.tmp
  2. Flush the written contents
  3. Move the existing save.json to save.json.bak
  4. Move save.json.tmp to save.json
  5. Delete .bak after success

Android has an AtomicFile API based on a similar idea: write first, then commit by renaming.
The Unity C# sample below does not provide the same guarantees, but the idea is useful.

The following is a minimal explanatory sample.
This is not an "atomic save" implementation.
It is a best-effort approach that improves recoverability.
In production, centralize the save entry point and serialize writes to the same path.
Fixed .tmp and .bak names are also vulnerable to leftover files from previous failures and concurrent saves.

using System;
using System.IO;
using System.Text;

public static class SafeFileWriter
{
    public static void WriteAllText(string path, string contents)
    {
        // Explanatory sample.
        // Do not call this concurrently for the same path.
        // The caller should serialize writes and combine this with startup recovery.
        var directory = Path.GetDirectoryName(path);
        if (string.IsNullOrEmpty(directory))
        {
            throw new ArgumentException("Invalid path.", nameof(path));
        }

        Directory.CreateDirectory(directory);

        var tempPath = path + ".tmp";
        var backupPath = path + ".bak";

        WriteTemp(tempPath, contents);

        try
        {
            if (File.Exists(backupPath))
            {
                File.Delete(backupPath);
            }

            if (File.Exists(path))
            {
                File.Move(path, backupPath);
            }

            File.Move(tempPath, path);

            if (File.Exists(backupPath))
            {
                File.Delete(backupPath);
            }
        }
        catch
        {
            TryRestore(path, backupPath);
            throw;
        }
    }

    private static void WriteTemp(string tempPath, string contents)
    {
        var bytes = Encoding.UTF8.GetBytes(contents);

        using var stream = new FileStream(
            tempPath,
            FileMode.Create,
            FileAccess.Write,
            FileShare.None);

        stream.Write(bytes, 0, bytes.Length);
        stream.Flush(flushToDisk: true);
    }

    private static void TryRestore(string path, string backupPath)
    {
        try
        {
            if (!File.Exists(path) && File.Exists(backupPath))
            {
                File.Move(backupPath, path);
            }
        }
        catch
        {
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This sample does not guarantee a fully atomic replace on every platform.
Because it uses fixed .tmp and .bak names, avoid parallel saves to the same file and use it together with a centralized save entry point or per-path serialization.
There is a moment during replacement where the main file may temporarily not exist, so startup recovery is also required.
Flush(flushToDisk: true) helps flush file contents, but it does not magically make directory entries after rename fully crash-proof.

If your target environment supports it, File.Replace(tempPath, path, backupPath) can be another option.
However, you should verify runtime support, OS behavior, and exception behavior on your actual Unity target platforms.

The important point is to create the temp file in the same directory as the main file.
If you cross drives, mounts, or directories, a rename may behave more like a copy, which can reduce safety.

Recover from tmp and bak on startup

If you use temp-file replacement, also prepare startup recovery.

If the previous save failed halfway, you may find files like these:

  • save.json
  • save.json.tmp
  • save.json.bak

A simple policy is enough for many projects:

  • If the main file can be read, use it
  • If the main file cannot be read but the backup can be read, use the backup
  • Basically delete the temp file
  • If recovery succeeds, rewrite the main file if possible

In this sample, .tmp is not treated as a committed save result.
The design prioritizes consistency over latestness.
If you want to recover from .tmp as a possible latest save, design extra metadata such as generation numbers, checksums, or commit markers.

public OptionSaveData Load()
{
    var path = SaveFiles.OptionsPath;
    var tempPath = path + ".tmp";
    var backupPath = path + ".bak";

    TryDelete(tempPath);

    if (TryLoad(path, out var data))
    {
        Normalize(data);
        return data;
    }

    if (TryLoad(backupPath, out data))
    {
        Normalize(data);
        TrySaveRecoveredData(data); // Best-effort rewrite
        return data;
    }

    return new OptionSaveData();
}

private void TrySaveRecoveredData(OptionSaveData data)
{
    try
    {
        Save(data);
    }
    catch (Exception e)
    {
        Debug.LogWarning($"Recovered save data, but failed to rewrite main file.\n{e}");
        // Treat the load itself as successful.
        // If needed, mark the data dirty and retry later.
    }
}
Enter fullscreen mode Exit fullscreen mode

The key point is: do not overwrite with default data the moment loading fails.
Returning defaults may be fine, but saving them immediately can overwrite a broken main file or backup and remove any chance of investigation or recovery.

If the backup can be loaded, start the game with that data first.
Rewriting the main file should be best effort.

So far, we have looked at where to save, what format to use, and how to write.
Next is when to save.

Do not trust app-quit saves too much

A common save design is to write everything when the app quits.

private void OnApplicationQuit()
{
    Save();
}
Enter fullscreen mode Exit fullscreen mode

This may be acceptable for small PC tools, but it should not be your main strategy on mobile.

Unity's Application.quitting documentation notes that Android does not detect quitting when the app is paused.
It recommends using OnApplicationFocus(bool) or OnApplicationPause(bool) instead for those cases.
The OnApplicationQuit documentation also notes that on iOS, applications are usually suspended instead of quit, and suggests using OnApplicationPause for suspension and cleanup.

In other words, "save when the app quits" is a weak assumption on mobile.

Users do not always explicitly quit the app.
They press Home, switch apps, receive phone calls, get killed by the OS, or hit a crash.

This kind of design is risky:

public void SetMasterVolume(float volume)
{
    _data.masterVolume = volume;
}

private void OnApplicationQuit()
{
    Save();
}
Enter fullscreen mode Exit fullscreen mode

The user changes the volume, but the value is only written when the app quits.
If the app is killed before that, or the quit event never arrives, the change is lost.

Treat quit-time saving as the last safety net, not the main save timing.

Save option settings at confirmation points

Option settings such as volume, language, vibration, and graphics quality have clear save timing.
Instead of saving every time a slider moves, update the UI immediately and save when the user presses OK, applies the change, or closes the screen.
This also makes the design easier to extend later if saves become asynchronous or tied to cloud sync.

public sealed class OptionController
{
    private readonly OptionSaveData _current;
    private readonly OptionSaveData _editing;
    private readonly OptionSaveService _saveService;

    public void SetMasterVolume(float value)
    {
        _editing.masterVolume = value;
        AudioListener.volume = value; // Apply immediately
    }

    public void Apply()
    {
        Copy(_editing, _current);
        _saveService.Save(_current);
    }

    public void Cancel()
    {
        Copy(_current, _editing);
        AudioListener.volume = _current.masterVolume;
    }
}
Enter fullscreen mode Exit fullscreen mode

When canceling, restore not only the visible UI or audio output, but also the editing data.
If _editing keeps the changed value, it may be saved later when the screen is opened again or when another option is applied.

With this structure, OK saves, Cancel discards, and leaving the screen can also be made into a clear save point.

If you choose to save immediately when a value changes, it is still often better to debounce the request.
For example, save after 0.5 seconds without further slider movement.

You do not have to make the first version complex.
Saving when the options screen closes, or when the user presses OK, is already much safer than relying on app quit.

Save progress at meaningful milestones

Game progress should also avoid relying only on app quit.

Choose save points based on where the player would be unhappy to be rolled back.

Examples:

Data Save timing example
Tutorial progress When each step is completed
Stage progress Clear, checkpoint
Inventory item When acquisition or consumption is confirmed
Shop purchase When purchase is confirmed
Quest reward When reward is received
Option setting OK, Apply, screen close

If the stage clear animation plays, the player quits, and the next launch treats the stage as uncleared, that feels terrible.
On the other hand, saving every enemy position and projectile state every frame is usually unnecessary.

Define how far rollback is acceptable, and save at that boundary.

Use OnApplicationPause as a safeguard

On mobile, it is common to save when the app moves to the background using OnApplicationPause(true) or OnApplicationFocus(false).

This is useful.
But it should not be the only save timing.

private void OnApplicationPause(bool pause)
{
    if (pause)
    {
        SaveIfDirty();
    }
}

private void OnApplicationFocus(bool focus)
{
    if (!focus)
    {
        SaveIfDirty();
    }
}
Enter fullscreen mode Exit fullscreen mode

Normally, save at user actions and game milestones first.
Then, when the app is about to move to the background, save any remaining dirty data.

Keep background-transition work as light as possible:

  • Save data that already exists in memory
  • Write small JSON files
  • Save only when dirty
  • Treat network sync separately

Doing huge JSON generation, compression, encryption, file writes, and cloud sync all at the last moment is risky.
It is usually better to treat cloud or server saves as a separate system from local saves.

Write loading code as if failure is normal

No matter how carefully you write saves, you cannot make corruption impossible.
Crashes during writes, low storage, user deletion, migration bugs, and manual editing can all break files.
So loading code should assume failure is normal.

public static bool TryLoad<T>(string path, out T data) where T : class
{
    data = null;

    if (!File.Exists(path))
    {
        return false;
    }

    try
    {
        var json = File.ReadAllText(path);
        if (string.IsNullOrWhiteSpace(json))
        {
            return false;
        }

        data = JsonUtility.FromJson<T>(json);
        return data != null;
    }
    catch (Exception e)
    {
        Debug.LogWarning($"Failed to load json: {path}\n{e}");
        return false;
    }
}
Enter fullscreen mode Exit fullscreen mode

This TryLoad only checks whether the file can be read as JSON.
In a real load flow, treat the following as one pipeline:

  1. Check whether the file exists
  2. Parse JSON
  3. Check version
  4. Migrate old versions
  5. Normalize and validate values
  6. Decide what to do with future versions or validation failures

Even successfully loaded data may not be valid.
A volume value could be -999, a language code could be empty, the version could be missing, or an array could be unexpectedly huge.
Normalize values after loading.

private static void Normalize(OptionSaveData data)
{
    data.version = Mathf.Max(1, data.version);
    data.masterVolume = Mathf.Clamp01(data.masterVolume);
    data.bgmVolume = Mathf.Clamp01(data.bgmVolume);
    data.seVolume = Mathf.Clamp01(data.seVolume);

    if (string.IsNullOrEmpty(data.language))
    {
        data.language = "ja";
    }
}
Enter fullscreen mode Exit fullscreen mode

It is safer to treat save files almost like external input, even if your own app created them.

Add a version field from the beginning

I recommend adding a version field to save data from the start.

At first it feels unnecessary.
But once the app is released and the save format changes, you will almost certainly want it.

For example, an early version might have only one volume field, and later you may want separate masterVolume, bgmVolume, and seVolume fields.

Without a version, you need to infer which format a file uses.
Inference is possible, but annoying and error-prone.

private const int CurrentSaveVersion = 2;

public static OptionSaveData Migrate(OptionSaveData data)
{
    if (data.version > CurrentSaveVersion)
    {
        throw new InvalidOperationException($"Unsupported save version: {data.version}");
    }

    if (data.version <= 0)
    {
        data.version = 1;
    }

    if (data.version == 1)
    {
        // v1 -> v2 migration
        data.version = 2;
    }

    return data;
}
Enter fullscreen mode Exit fullscreen mode

Also decide how to handle future versions.
For example, if a user saves data with a newer app and then rolls back to an older app, the old app will see an unknown format.
Immediately overwriting it with default data is dangerous.
Depending on the project, you may stop loading and show a warning, read only compatible fields, or ask the user to update the app.

Even if you throw an exception for future versions, connect that failure to backup handling, logging, user notification, or retry logic rather than blindly overwriting with defaults.

For more complex save data, you may prepare old DTOs and migrate step by step.
Even for option settings, having a version field from day one often helps later.

Split files by lifecycle

Save data should not always be one huge JSON file.

Option settings and game progress often have different lifecycles, so splitting them can make the system easier to manage.
If game progress breaks, you do not want volume or language settings to break with it.
If only options change frequently, you also avoid rewriting all progress data every time.

A useful guideline is to split by lifecycle.

File Contents Save frequency
options.json Volume, language, vibration When settings change
profile.json Name, avatar, basic profile settings When changed
progress.json Stage progress, unlock state At milestones
cache_meta.json Cache metadata When needed

But do not split too much.

If two pieces of data must be updated together, keep them in the same file or design transaction-like handling.
For example, if item consumption and stage unlock live in separate files and only one save succeeds, you may create inconsistent data.
That is a sign the design needs another look.

Do not let multiple places save the same file

A common save bug is allowing multiple classes to directly write the same file.

1. A saves newer data
2. B saves older data it had from earlier
3. A's change disappears
Enter fullscreen mode Exit fullscreen mode

This is not a file I/O problem.
It is a design problem.

The fix is to centralize the save entry point.

public sealed class SaveRepository
{
    private SaveData _current;

    public void Update(Action<SaveData> update)
    {
        update(_current);
        SaveInternal(_current);
    }
}
Enter fullscreen mode Exit fullscreen mode

In Unity, much of your logic may run on the main thread, so you may not always need a lock.
Still, centralizing "update save data and write it" is important.

If save logic is scattered everywhere, it becomes hard to know what data is saved and when.
This is often underestimated in small projects, but fixing it later is painful.

Use a dirty flag

A dirty flag is useful for controlling save frequency.

public sealed class OptionStore
{
    private OptionSaveData _data;
    private bool _dirty;

    public void SetMasterVolume(float value)
    {
        if (Mathf.Approximately(_data.masterVolume, value))
        {
            return;
        }

        _data.masterVolume = value;
        _dirty = true;
    }

    public void SaveIfDirty()
    {
        if (!_dirty)
        {
            return;
        }

        try
        {
            Save();
            _dirty = false;
        }
        catch (Exception e)
        {
            Debug.LogError(e);
            _dirty = true;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

With a dirty flag, callers can freely call SaveIfDirty() during screen transitions or OnApplicationPause.
If nothing changed, nothing happens.
The caller does not need to know the details.

This is a minimal synchronous-save example.
If you extend it to asynchronous saves, use an in-progress flag, revision number, or save queue so an old save completion does not clear a newer dirty state.

Depending on platform and Unity version, OnApplicationPause and OnApplicationFocus may both be called or may occur close together.
Make repeated save requests safe by combining dirty flags with centralized, serialized save entry points.

One important detail: do not clear dirty when saving fails.
If you clear it after a failure, memory may contain changed data while the file still contains old data.

Do not make saving too heavy

For small JSON files, saving on the main thread is often fine.

But as save data grows, the cost can become noticeable.

Potentially expensive steps include:

  • JSON string generation
  • String allocation
  • UTF-8 conversion
  • Compression
  • Encryption
  • File writing
  • Flush

If too many of these happen at once, you may cause frame drops or a short freeze.

Moving heavy work to a background thread can help.
However, code that touches UnityEngine.Object is generally expected to run on the main thread.
First copy the required data into a save DTO, then decide how much of the remaining work can run off the main thread.

The following is a minimal example that mainly moves file writing to a background thread.
JsonUtility.ToJson runs before Task.Run, so this sample does not avoid frame drops caused by heavy JSON generation.
If JSON generation itself is heavy, copy the data into a DTO that does not touch Unity APIs or UnityEngine.Object, then consider moving JSON generation and file writing into the same background operation.

// Minimal explanatory sample.
// In this example, JsonUtility.ToJson runs on the main thread.
// In production, serialize save requests to the same file,
// or control the system so only the latest snapshot is committed.
public async Task SaveAsync(OptionSaveData data)
{
    var snapshot = Clone(data); // Copy to a save DTO that does not touch UnityEngine.Object
    var json = JsonUtility.ToJson(snapshot, prettyPrint: false);
    var path = SaveFiles.OptionsPath;

    await Task.Run(() =>
    {
        SafeFileWriter.WriteAllText(path, json);
    });
}
Enter fullscreen mode Exit fullscreen mode

Once saving becomes asynchronous, completion order can reverse.
A newer save with volume 0.8 may finish first, then an older save with volume 0.5 may finish later and overwrite it.
Avoid this by serializing saves, or by designing the system so only the latest snapshot is written.

You do not have to make everything asynchronous from the start.
For option settings, synchronous saving is often enough.

Separate encryption and tamper detection from durability

When saving JSON, the contents are visible, so it is natural to consider encryption or tamper detection.
But local encryption only makes casual inspection or editing harder.
It is not complete anti-cheat.
Important currency, paid items, rankings, and values that affect competitive play should be validated on the server side.

Durability and anti-cheat are different goals.
First prioritize save timing, corruption handling, recovery, versions, and normalization.
Then add encryption or tamper detection if the project needs it.

Test cases to cover

Save systems often look fine during normal play.
You should intentionally break them during testing.

At minimum, test these cases:

  • First launch with no save file
  • Valid JSON can be loaded
  • Empty file does not crash the app
  • Broken JSON does not crash the app
  • Old-version JSON can be loaded
  • Strange values are normalized
  • App can start even if .tmp remains
  • A valid-looking .tmp is still discarded if that is the design
  • If the main file is valid and an old .bak remains, the main file is used
  • Recovery from .bak works
  • App can start even if .bak loads but rewriting the main file fails
  • Repeated or simultaneous saves do not overwrite newer data with older data
  • App can start after an exception during save
  • If possible, test near-low-storage conditions
  • On Android, test Home, app switching, and task kill
  • Confirm data remains after app update

Android in particular can behave differently depending on device and OS settings.
Do not assume that working in the Editor is enough.

Also decide what to do when saving fails.
User notification, retry, returning to title, or server resync are gameplay and product decisions, not only engineering details.

Summary

The first step of Unity save data is simple.
For small settings, PlayerPrefs works.
For grouped data, JsonUtility.ToJson plus persistentDataPath can also work.

But in a released game, issues appear: quit-time saves may not run, files may break during writes, failed loads may overwrite recoverable data with defaults, and old data may overwrite newer data.

On mobile especially, do not rely too much on saving only when the app quits.

A practical policy is:

  • Keep PlayerPrefs for small settings and simple flags
  • Call PlayerPrefs.Save() at confirmation points
  • Save options and game progress at points where rollback would feel bad
  • Avoid direct overwrite; replace via a temp file
  • Prepare recovery from .tmp and .bak
  • Do not overwrite immediately with defaults when loading fails
  • Use version and Normalize to absorb old data and strange values
  • Centralize save entry points and avoid parallel saves to the same file

Local saving is easy to overlook, but painful when it fails.
Separate the range where PlayerPrefs is enough from the range where JSON files are better, and design save timing, corruption handling, and recovery together.

References

Top comments (0)