DEV Community

GameDevToolLab
GameDevToolLab

Posted on

Speed Up Game Tuning with ScriptableObject: Edit and Save Values During Play Mode in the Unity Editor

Introduction

ScriptableObject is often one of the first tools developers reach for when they need to manage data in a Unity project.

Typical examples include enemy parameters, weapon settings, camera shake, player movement speed, skill cooldowns, and per-stage tuning values.

Because a ScriptableObject exists as an asset in the Unity Editor, it is easy to inspect, edit, and reference independently from scenes and prefabs. For small to medium-sized configuration data, it is an extremely convenient option.

Unity's documentation describes ScriptableObject as a data container that exists independently from class instances. It can also reduce duplicated data by allowing multiple prefabs to reference the same asset.

However, the real value of ScriptableObject is not limited to storing data as an asset.

One of its most useful properties in day-to-day game development is that you can change values while the game is running in the Unity Editor and immediately apply those changes to gameplay.

Movement speed, jump force, enemy attack intervals, camera following, knockback, hit-stop, and input-buffer timing rarely have a single correct value from the beginning. You normally decide them by playing the game and checking how they interact with the screen, controls, enemy movement, level size, and animation timing.

That process becomes slow when every adjustment requires the following cycle:

  1. Change code.
  2. Compile.
  3. Enter Play Mode.
  4. Test the result.
  5. Exit Play Mode.
  6. Change the value again.

If tuning values are collected in a ScriptableObject and the game reads them during Play Mode, you can make adjustments from the Inspector without repeatedly restarting the game.

This article looks at ScriptableObject not only as a place to keep small master-data sets, but as a practical workflow for speeding up game tuning.

The word "save" in this article refers to development work performed inside the Unity Editor. The samples primarily target Unity 2022.3 LTS and later. For older Unity versions, check whether the Editor APIs used here are available.

This is not a workflow for modifying a ScriptableObject asset in a player build and using it as user save data. Runtime save data should normally use a separate destination such as JSON, a binary format, PlayerPrefs, cloud storage, or a project-specific database.

ScriptableObject Stores Data as a Project Asset

Unlike a MonoBehaviour, a ScriptableObject does not need to be attached to a GameObject. It can exist directly as an asset in the Project window.

For example, the following asset holds player-tuning values:

using UnityEngine;

[CreateAssetMenu(
    fileName = "PlayerTuning",
    menuName = "Game/Tuning/Player Tuning")]
public sealed class PlayerTuning : ScriptableObject
{
    [Header("Move")]
    [Min(0f)] public float moveSpeed = 5.0f;
    [Min(0f)] public float dashSpeed = 8.0f;
    [Min(0f)] public float acceleration = 20.0f;

    [Header("Jump")]
    [Min(0f)] public float jumpPower = 12.0f;
    [Min(0f)] public float gravityScale = 2.5f;

    [Header("Camera")]
    [Range(0f, 1f)] public float cameraFollowLerp = 0.12f;
}
Enter fullscreen mode Exit fullscreen mode

Create this asset and reference it from a component such as PlayerController.

using UnityEngine;

public sealed class PlayerController : MonoBehaviour
{
    [SerializeField] private PlayerTuning tuning;

    private Vector3 velocity;

    private void Awake()
    {
        if (tuning == null)
        {
            Debug.LogError("PlayerTuning is not assigned.", this);
            enabled = false;
        }
    }

    private void Update()
    {
        float inputX = Input.GetAxisRaw("Horizontal");
        float targetSpeed = inputX * tuning.moveSpeed;

        velocity.x = Mathf.MoveTowards(
            velocity.x,
            targetSpeed,
            tuning.acceleration * Time.deltaTime);

        transform.position += velocity * Time.deltaTime;
    }
}
Enter fullscreen mode Exit fullscreen mode

This separates the player's movement parameters from the controller implementation.

The sample disables the component when tuning is not assigned, preventing a null reference in Update.

The input code uses the old Input Manager's Input.GetAxisRaw only to keep the sample short. Replace it with the Input System or whatever input layer your project uses.

You no longer need to open PlayerController.cs just to change moveSpeed. Select the PlayerTuning asset in the Project window and edit the value in the Inspector.

When tuning values are scattered across source files, developers spend more time finding them. A ScriptableObject gives the team a clear location: "The tuning values for this feature are in this asset."

Shared References Are a Major Advantage

Because a ScriptableObject is an asset, multiple prefabs and components can reference the same instance.

Suppose a scene creates 100 enemy instances. You can store definitions such as "Slime base parameters" and "Goblin base parameters" in ScriptableObject assets instead of copying the same values into every prefab.

In this design, the enemy instance owns its current state while the ScriptableObject owns its definition.

  • Current HP, current position, status effects, and the current target are runtime state.
  • Maximum HP, attack power, movement speed, and sight range are definitions or tuning values.

Separating state from definition allows you to change shared parameters in one place.

That ability to share one asset across multiple systems is also what makes ScriptableObject useful for tuning during Play Mode.

Editing During Play Mode Speeds Up Development

The largest practical benefit is how easily ScriptableObject values can be changed and reflected while the game is running.

If PlayerController reads PlayerTuning every frame, changing moveSpeed in the Inspector changes the player's movement speed on the next frame.

This sounds simple, but it has a large impact on gameplay tuning.

A common adjustment loop looks like this:

  1. Change a value in code or on a prefab.
  2. Enter Play Mode.
  3. Test the game.
  4. Exit Play Mode.
  5. Change the value again.
  6. Enter Play Mode again.

When values can be changed and applied during Play Mode, the loop becomes:

  1. Enter Play Mode.
  2. Change values in the Inspector while playing.
  3. Immediately test the result.
  4. Save the values that feel right.

You can keep the current gameplay context instead of restarting it.

This is especially valuable when the situation is expensive to reproduce, such as a specific boss phase, a long encounter, or a point deep inside a stage.

Shorter tuning loops lead to more iterations. In game development, an environment where you can test many variations quickly is often more valuable than trying to guess the perfect value on the first attempt.

Design the Game to Apply Changes Immediately

Changing a ScriptableObject value during Play Mode does nothing unless the game reads or reapplies that value.

For example, the following component copies the value once in Awake.

public sealed class PlayerController : MonoBehaviour
{
    [SerializeField] private PlayerTuning tuning;

    private float moveSpeed;

    private void Awake()
    {
        moveSpeed = tuning.moveSpeed;
    }

    private void Update()
    {
        // Changing tuning.moveSpeed during Play Mode does not update this value.
        Move(moveSpeed);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is not inherently a bad design. Caching can be useful, and some settings should not be recalculated every frame.

However, when Play Mode tuning is important, values that should update immediately must either be read directly from the ScriptableObject or reapplied through a dedicated mechanism.

The simplest option is to read the asset directly:

private void Update()
{
    Move(tuning.moveSpeed);
}
Enter fullscreen mode Exit fullscreen mode

Another option is to cache the value once for builds but reapply it every frame in the Editor.

private void Awake()
{
    // Apply the initial value in both the Editor and player builds.
    ApplyTuning();
}

private void Update()
{
#if UNITY_EDITOR
    // Reapply Play Mode changes every frame in the Editor.
    ApplyTuning();
#endif

    Move(moveSpeed);
}

private void ApplyTuning()
{
    moveSpeed = tuning.moveSpeed;
}
Enter fullscreen mode Exit fullscreen mode

Possible policies include:

  • Read lightweight values every frame when immediate updates are useful.
  • Use an Apply button when a value requires expensive recalculation.
  • Put only the Editor-only reapplication logic behind #if UNITY_EDITOR.

Because this example calls ApplyTuning from Awake, moveSpeed is also initialized in a player build. In the Editor it is reapplied every frame, while a build continues using the cached value from startup.

The final behavior should still be verified under build-equivalent conditions. Editor-only reapplication can hide assumptions that do not exist in the player build.

ScriptableObject alone does not automatically make a system easy to tune. You must also decide when a changed value is applied to the running game.

Make the Save Policy Explicit

Unity's behavior during Play Mode can be confusing.

Values changed on scene GameObjects and MonoBehaviours usually return to their pre-Play Mode state when Play Mode stops.

You may find a good value in the Inspector, stop the game, and then discover that the value has reverted because you forgot to write it down.

ScriptableObject is different because it is an asset.

When you edit the actual ScriptableObject asset selected in the Project window, you are modifying that asset object even during Play Mode. This is not the same as modifying a runtime copy created with Instantiate, and you should not assume the value will automatically revert when Play Mode ends.

The details depend on what is being edited:

  • A MonoBehaviour on a scene object
  • The ScriptableObject asset in the Project window
  • A component referencing the original asset
  • A runtime copy created with Instantiate
  • A value modified through SerializedProperty
  • A field modified directly from code

These cases can look similar in the Inspector while having different persistence behavior.

The following discussion assumes normal Play Mode settings. If your project disables Domain Reload or Scene Reload through Enter Play Mode Options, initialization timing and value lifetime can change. Verify the behavior under your project's settings.

It is also important to distinguish between these two states:

  • The ScriptableObject asset object has changed in the Editor.
  • The change has been written to the .asset file on disk.

Editing a ScriptableObject in the Project window changes the Editor-side asset object. When the change is committed to disk depends on explicit save operations and the AssetDatabase workflow.

In a team environment, use an explicit save action and inspect the version-control diff instead of relying on whether the value appears to remain after stopping Play Mode.

A workflow such as "adjust runtime values, then press Save only when the result is approved" makes the intent much clearer.

Relying on an asset accidentally remaining dirty after Play Mode is less predictable than providing an explicit save path.

Save Runtime Values Back to a ScriptableObject Asset

The following example adjusts runtime values on a MonoBehaviour and writes the approved values back to a ScriptableObject asset.

This is Unity Editor-only functionality. Code using the UnityEditor namespace must be placed in an Editor folder, guarded by #if UNITY_EDITOR, or compiled in a separate Editor assembly.

The workflow is:

  1. Load initial values from the ScriptableObject asset.
  2. Adjust runtime values during Play Mode.
  3. Press a Save button.
  4. Copy the runtime values to the asset.
  5. Mark the asset dirty.
  6. Save the asset to disk.

EditorUtility.SetDirty marks an object as dirty. AssetDatabase.SaveAssetIfDirty saves a specific asset when it is dirty. Because this article targets Unity 2022.3 LTS and later, the main sample uses SaveAssetIfDirty so that it saves only the target asset.

There is one important difference between these APIs.

SaveAssetIfDirty directly saves the specified asset and does not invoke AssetModificationProcessor.OnWillSaveAssets. If your version-control integration or a custom save hook relies on OnWillSaveAssets, confirm that SaveAssetIfDirty is appropriate for the project.

SaveAssets writes all unsaved asset changes. If a material, prefab, ScriptableObject, or another settings asset is also dirty, it may be saved together with the tuning asset.

For this reason, it is useful to think of these as two separate operations:

  • Copy runtime values into the target asset.
  • Flush asset changes to disk.

The target in this example is an existing ScriptableObject asset in the Project window. It is not a temporary object created only through ScriptableObject.CreateInstance.

The save destination is the PlayerTuning asset defined earlier.

The runtime component keeps its editable values separate from the asset:

using UnityEngine;

public sealed class PlayerTuningRuntime : MonoBehaviour
{
    [SerializeField] private PlayerTuning tuningAsset;

    [Min(0f)] public float moveSpeed;
    [Min(0f)] public float dashSpeed;
    [Min(0f)] public float acceleration;
    [Min(0f)] public float jumpPower;
    [Min(0f)] public float gravityScale;
    [Range(0f, 1f)] public float cameraFollowLerp;

    public PlayerTuning TuningAsset => tuningAsset;

    private void Awake()
    {
        if (tuningAsset == null)
        {
            Debug.LogError("PlayerTuning asset is not assigned.", this);
            enabled = false;
            return;
        }

        LoadFromAsset();
    }

    public void LoadFromAsset()
    {
        if (tuningAsset == null)
        {
            Debug.LogWarning("PlayerTuning asset is not assigned.", this);
            return;
        }

        moveSpeed = tuningAsset.moveSpeed;
        dashSpeed = tuningAsset.dashSpeed;
        acceleration = tuningAsset.acceleration;
        jumpPower = tuningAsset.jumpPower;
        gravityScale = tuningAsset.gravityScale;
        cameraFollowLerp = tuningAsset.cameraFollowLerp;
    }
}
Enter fullscreen mode Exit fullscreen mode

The fields are public in this sample so they are easy to edit in the default Inspector.

In a production project, you can replace them with [SerializeField] private fields and properties, a dedicated tuning DTO, or a custom Inspector.

The game reads the runtime values rather than the asset values directly:

using UnityEngine;

public sealed class PlayerController : MonoBehaviour
{
    [SerializeField] private PlayerTuningRuntime tuning;

    private void Awake()
    {
        if (tuning == null || tuning.TuningAsset == null)
        {
            Debug.LogError(
                "PlayerTuningRuntime or its PlayerTuning asset is not assigned.",
                this);

            enabled = false;
        }
    }

    private void Update()
    {
        Move(tuning.moveSpeed);
    }

    private void Move(float speed)
    {
        // Add the actual movement logic here.
    }
}
Enter fullscreen mode Exit fullscreen mode

Now changing PlayerTuningRuntime.moveSpeed in the Inspector immediately affects the running game.

Place the custom Editor in a path such as Assets/Editor/PlayerTuningRuntimeEditor.cs.

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(PlayerTuningRuntime))]
public sealed class PlayerTuningRuntimeEditor : Editor
{
    public override void OnInspectorGUI()
    {
        DrawDefaultInspector();

        var runtime = (PlayerTuningRuntime)target;
        var asset = runtime.TuningAsset;

        EditorGUILayout.Space();

        using (new EditorGUI.DisabledScope(
            asset == null || !EditorApplication.isPlaying))
        {
            if (GUILayout.Button("Load From ScriptableObject"))
            {
                Undo.RecordObject(
                    runtime,
                    "Load Player Tuning From Asset");

                runtime.LoadFromAsset();
            }

            if (GUILayout.Button(
                "Save Runtime Values To ScriptableObject"))
            {
                SaveToAsset(runtime, asset);
            }
        }
    }

    private static void SaveToAsset(
        PlayerTuningRuntime runtime,
        PlayerTuning asset)
    {
        Undo.RecordObject(asset, "Save Player Tuning");

        asset.moveSpeed = Mathf.Max(0f, runtime.moveSpeed);
        asset.dashSpeed = Mathf.Max(0f, runtime.dashSpeed);
        asset.acceleration = Mathf.Max(0f, runtime.acceleration);
        asset.jumpPower = Mathf.Max(0f, runtime.jumpPower);
        asset.gravityScale = Mathf.Max(0f, runtime.gravityScale);
        asset.cameraFollowLerp =
            Mathf.Clamp01(runtime.cameraFollowLerp);

        EditorUtility.SetDirty(asset);
        AssetDatabase.SaveAssetIfDirty(asset);
    }
}
Enter fullscreen mode Exit fullscreen mode

The buttons are enabled only during Play Mode.

Pressing Save copies the runtime values to the PlayerTuning asset, marks the asset dirty, and saves that specific asset to disk through SaveAssetIfDirty.

After Play Mode stops, the saved values remain in the .asset file.

The sample also clamps values before saving them.

Attributes such as [Min] and [Range] are useful Inspector aids, but they do not guarantee that code or debug tooling cannot assign an invalid value.

A production validation layer should also consider relationships between values, units, and upper bounds. For example:

  • dashSpeed should be greater than or equal to moveSpeed.
  • jumpPower and gravityScale should produce a valid jump curve.
  • Camera interpolation values should stay within a project-defined range.
  • Values that affect physics should use consistent units.

If project-specific version-control integration or save hooks require AssetDatabase.SaveAssets, document that it can save every dirty asset, not only the tuning asset.

The Save timing is explicit, so you can experiment freely and commit only values that you have approved.

This sample intentionally targets Play Mode tuning. If the same custom Editor must also support Edit Mode or prefab-instance editing, account for SerializedObject, SerializedProperty, prefab overrides, scene dirty state, and the relevant Undo behavior.

There is another maintenance trade-off in this example.

The same field list appears in PlayerTuning, PlayerTuningRuntime, LoadFromAsset, and SaveToAsset. Adding a new parameter requires updating every related location.

Treat this synchronization as reviewable code, or consider a shared synchronization method, generic copying through SerializedObject, or source generation when the parameter set becomes large.

Undo.RecordObject during Play Mode should also be treated as a convenience for the current Play session. Do not assume that the Undo stack will remain useful after leaving Play Mode. Verify saved values through the version-control diff and revert them there when necessary.

Directly Editing the ScriptableObject Asset

The previous workflow keeps experimental values on a MonoBehaviour and copies them to the ScriptableObject only when Save is pressed.

A simpler alternative is to select the ScriptableObject asset and edit it directly during Play Mode.

public sealed class PlayerController : MonoBehaviour
{
    [SerializeField] private PlayerTuning tuning;

    private void Update()
    {
        Move(tuning.moveSpeed);
    }
}
Enter fullscreen mode Exit fullscreen mode

Select the PlayerTuning asset in the Project window and change moveSpeed while the game is running.

If PlayerController reads tuning.moveSpeed every frame, the new value is applied immediately.

This method is convenient, but you are editing the original project asset during Play Mode. Treat the Editor-side asset change and the on-disk save as separate concerns, and use explicit save or discard actions together with version-control diffs.

A runtime copy created with Instantiate does not write back to the original asset. Editing the original asset does.

That means an extreme debug value can remain in the project or accidentally appear in a commit.

A practical comparison looks like this:

Approach Good fit Main concern
Edit the ScriptableObject asset directly Small teams, local experimentation, simple settings You are modifying the original asset, so save explicitly and inspect the version-control diff
Tune runtime values and save them with a button Team development and safer experimentation Requires a small Editor extension
Create a runtime copy of the ScriptableObject Keep the original asset untouched during testing Requires an explicit copy-back step when saving

For gameplay feel, I generally prefer "edit runtime values, then press Save when the result is good."

As the team grows, explicit persistence usually causes fewer accidents than implicit asset changes.

How This Differs from Saving MonoBehaviour [SerializeField] Values

A MonoBehaviour can also contain [SerializeField] values that are edited during Play Mode and written somewhere through a Save button.

However, values on a scene MonoBehaviour normally revert when Play Mode stops.

To preserve them, you must copy the values before stopping into a persistent destination such as:

  • A prefab
  • A ScriptableObject
  • An external file
  • Another Editor-managed asset

This pattern is useful for scene-specific data such as stage gimmicks, Timeline-driven sequences, or camera choreography.

When the same values must be shared by multiple scenes or prefabs, keeping them on a MonoBehaviour can become difficult to manage.

Neither approach is universally better.

  • Use a MonoBehaviour for scene-specific values.
  • Use a ScriptableObject for definitions shared by multiple scenes, prefabs, or systems.

Player movement feel, weapon recoil, enemy-type parameters, and global difficulty multipliers are often natural ScriptableObject candidates.

A camera sequence unique to one stage may be more naturally stored on a MonoBehaviour or Timeline.

The useful distinction is responsibility, not which type is "superior."

Data That Fits ScriptableObject Well

ScriptableObject is especially useful for data that developers want to inspect and tune inside the Unity Editor.

Examples include:

  • Player controls
  • Camera following
  • Camera shake
  • Base parameters per enemy type
  • Small to medium weapon or skill data
  • UI animation settings
  • Difficulty multipliers
  • Debug balance settings

It is particularly strong when the data naturally references Unity assets.

Sprite, prefab, AudioClip, AnimationClip, AnimationCurve, and Gradient references can be assigned directly in the Inspector.

[CreateAssetMenu(
    fileName = "WeaponData",
    menuName = "Game/Master/Weapon Data")]
public sealed class WeaponData : ScriptableObject
{
    public int weaponId;
    public string displayName;
    public int attack;
    public float coolTime;

    public Sprite icon;
    public GameObject hitEffectPrefab;
    public AudioClip attackSe;
}
Enter fullscreen mode Exit fullscreen mode

With Excel or CSV, those references usually need to be represented through a path, GUID, Addressables key, or another project-specific identifier.

For settings that include gameplay feel and Unity-asset references, the clarity of ScriptableObject can directly improve development speed.

Do Not Force Every Data Set into ScriptableObject

ScriptableObject is useful, but it becomes awkward when every large table is represented as ScriptableObject assets.

Examples include:

  • Items
  • Skills
  • Quests
  • Rewards
  • Shops
  • Gacha data
  • Progression tables
  • Localization text

At some point, planners may want to edit thousands of rows in Excel or Google Sheets. The project may also need ID lookup, composite keys, range queries, reference validation, and CI checks.

At that scale, common problems include:

  • Too many assets
  • Poor overview of the full table
  • Noisy version-control diffs
  • Conflicts during simultaneous editing
  • Difficult bulk validation
  • Custom lookup dictionaries scattered through the project

Do not reduce the design to "ScriptableObject or Excel" or "ScriptableObject or MasterMemory."

A hybrid approach is often better.

For example:

  • Player movement, camera shake, hit-stop, and presentation values stay in ScriptableObject assets.
  • Thousands of item rows, localization records, progression tables, and reward tables are managed in Excel or Google Sheets and converted for runtime use.

ScriptableObject should not be treated as a temporary solution that only belongs at the beginning of a project.

As a gameplay-tuning layer, it can remain useful late into production.

Practical ScriptableObject Pitfalls

1. Do Not Put Too Much Runtime State in Shared Assets

A ScriptableObject is shared by reference.

That is useful for definitions, but dangerous for per-instance state.

For example, storing the enemy's current HP in a shared EnemyParameter asset causes every enemy referencing that asset to share the same current HP.

public sealed class EnemyParameter : ScriptableObject
{
    public int maxHp;

    // Dangerous: this is runtime state, not a shared definition.
    public int currentHp;
}
Enter fullscreen mode Exit fullscreen mode

Current HP, current position, status effects, and the active target belong on the enemy instance or in a dedicated runtime-state object.

ScriptableObject is safest when it contains definitions, tuning values, and shared settings.

2. Be Conscious of Dirtying Project Assets

Editing a ScriptableObject asset in the Editor marks the asset dirty.

Decide whether the team edits the original asset directly or works on runtime values and writes them back only through an explicit Save button.

The latter requires more tooling, but it makes the decision to persist a change clear.

3. Keep Editor Code Out of Player Builds

Code that uses UnityEditor cannot be included in a player build.

Place Editor extensions in an Editor folder or guard them with #if UNITY_EDITOR.

Assets/
  Scripts/
    Runtime/
      PlayerTuning.cs
      PlayerTuningRuntime.cs
      PlayerController.cs
    Editor/
      PlayerTuningRuntimeEditor.cs
Enter fullscreen mode Exit fullscreen mode

When using assembly definitions, separating the Runtime and Editor assemblies is even safer.

4. Make the Save Target Obvious

A Save button should clearly state what it saves and where the data goes.

A button named only Save could refer to a scene, prefab, ScriptableObject, or external file.

A longer label such as Save Runtime Values To ScriptableObject is safer because its purpose is explicit.

When MasterMemory Becomes a Better Fit

A different system becomes worth considering when the project needs:

  • Large tables edited in Excel or Google Sheets
  • Frequent ID or composite-key lookup
  • CI validation of references and data constraints
  • Lower parsing cost and fewer runtime allocations
  • A type-safe, read-only runtime database

This is not a competition between tools.

ScriptableObject is excellent for accelerating gameplay tuning. MasterMemory is designed for large, read-only master-data sets that need fast, type-safe queries.

In the next article, I will examine what MasterMemory solves, what it does not solve, and where it fits in a practical Unity data pipeline.

Conclusion

ScriptableObject is more than a container for static data.

Its practical strengths are that it is visible in the Unity Editor, can be shared as an asset, and can feed changes into a running game during Play Mode.

Movement speed, jump force, camera interpolation, enemy attack intervals, hit-stop, camera shake, and UI timing are often faster to tune while playing than through repeated code edits and restarts.

A small Editor extension can add an explicit Save button so that approved runtime values are written back to a ScriptableObject asset.

When implementing that workflow, use APIs such as Undo.RecordObject, EditorUtility.SetDirty, and AssetDatabase.SaveAssetIfDirty, and keep the Editor-only code out of player builds.

At the same time, do not force large tabular data, localization, composite-key queries, CI validation, and spreadsheet integration into ScriptableObject.

Use each tool for the job it handles best.

ScriptableObject is a powerful way to shorten the gameplay-tuning loop. Used in its natural role, it can make Unity data management and iteration considerably easier.

Top comments (0)