Stop Duplicating Enemy Stats Across Prefabs — Use ScriptableObjects Instead
Stop copying health and damage values across twenty enemy prefabs. ScriptableObjects let you define data once, reference it everywhere, and balance from a single asset.
28 Sept 2026, 04:44 UTC

The Problem: Scattered Data You Can't Balance
You have twenty enemy prefabs. Each one carries its own serialized fields for health, speed, and damage. When the designer says "make all grunts 10% tougher," you open twenty prefabs, change twenty health values, and hope you didn't miss one. A week later, a new enemy type needs the same base stats plus a tweak — so you copy-paste a prefab and now there are twenty-one places to update.
This is the classic data-duplication trap. The fix isn't a new architecture; it's a Unity feature that's been stable for years: ScriptableObjects.
What ScriptableObjects Actually Do
A ScriptableObject is an asset that lives in your project folder, not in a scene. It holds data — plain C# fields — and nothing else. Multiple MonoBehaviours can reference the same asset, so one edit in the Inspector updates every consumer at once. No scene dependency, no prefab overrides, no serialization duplication.
Think of it as a shared configuration file that the editor understands natively. You create it once, reference it everywhere, and balance from a single source of truth.
Worked Example: EnemyConfig
Define the data container. The [CreateAssetMenu] attribute allows you to create these assets directly from the Unity Editor's right-click menu.
using UnityEngine;
[CreateAssetMenu(fileName = "EnemyConfig", menuName = "Game/Enemy Config")]
public class EnemyConfig : ScriptableObject
{
public int maxHealth = 100;
public float moveSpeed = 3.5f;
public int damage = 10;
}
Right-click in the Project window → Create → Game → Enemy Config. Name it "GruntsConfig". Set health to 100, speed to 3.5, damage to 10. Create a second asset "EliteConfig" with health 250, speed 2.8, damage 22.
Now the enemy behaviour reads from the assigned asset. Note how we copy the value into a local variable to avoid mutating the asset:
using UnityEngine;
public class Enemy : MonoBehaviour
{
[SerializeField] private EnemyConfig config;
private int currentHealth;
private void Awake()
{
// Copy value from asset to instance to prevent asset mutation
currentHealth = config.maxHealth;
}
public void TakeDamage(int amount)
{
currentHealth -= amount;
if (currentHealth <= 0) Die();
}
// ... movement uses config.moveSpeed, attacks use config.damage
}
Drag "GruntsConfig" onto every grunt prefab's config field. Drag "EliteConfig" onto the elite prefab. When the designer wants grunts tougher, they open one asset, change maxHealth to 110, and every grunt in every scene reflects the change instantly.
Beyond Stats: Event Channels and Shared State
ScriptableObjects aren't limited to static tables. A GameEvent ScriptableObject with a List<Action> listeners becomes a decoupled broadcast channel — UI, audio, and gameplay systems subscribe without referencing each other. A PlayerProgress ScriptableObject holds runtime state (currency, unlocked levels) that persists across scene loads without a singleton MonoBehaviour.
Both patterns reduce direct references, making scenes testable in isolation. Load the combat scene, assign a mock EnemyConfig, run — no GameManager required.
The Trap: Editor Play-Mode Persistence
Here is the critical caveat: changes to a ScriptableObject's fields during Play mode in the Editor persist after you exit Play mode. If your Enemy.TakeDamage accidentally writes config.maxHealth -= 10, the asset on disk is permanently modified. Next time you open the project, grunts start with 90 health.
In a built player, ScriptableObject assets are read-only; runtime mutations affect only the in-memory copy. The divergence between Editor and build behavior is a common source of bugs that surface only in QA.
Mitigation: Treat ScriptableObjects as immutable configuration. If you need per-instance mutable state (current health, cooldown timers), copy the values into the MonoBehaviour at Awake. For shared mutable state that should persist (player progress), serialize to JSON or a save system explicitly; don't rely on the asset file.
When Not to Use Them
- Per-instance variation: If every enemy needs a unique random health roll, don't stuff the roll into the shared config. Generate it at spawn and store it on the instance.
- Large binary blobs: Textures, audio clips, or massive lookup tables belong in Addressables or Resources, not ScriptableObjects.
- Cross-project sharing: ScriptableObjects are Unity-specific assets. For data that moves between engines or tools, use JSON, CSV, or ScriptableObject-to-JSON export pipelines.
Verify It Yourself
- Create a new project (Unity 2022.3 LTS or later).
- Add the
EnemyConfigandEnemyscripts provided above. - Create two EnemyConfig assets (GruntsConfig, EliteConfig).
- Make a simple Enemy prefab with the Enemy script; duplicate it twice.
- Assign GruntsConfig to the first prefab, EliteConfig to the second.
- Place both prefabs in a scene. Enter Play mode. Confirm each enemy logs its correct maxHealth.
- While in Play mode, select GruntsConfig in the Inspector and change maxHealth to 999. Observe both enemies in the scene update immediately.
- Exit Play mode. Inspect GruntsConfig — it still reads 999. This confirms the persistence caveat.
Closing: One Asset, One Source of Truth
ScriptableObjects solve the duplication problem without ceremony. They are a serialization primitive that the editor treats as a first-class asset. Start with one config type (enemy stats, weapon data, level parameters). Move the fields out of the prefab, reference the asset, and feel the iteration speed improve. The trade-off is discipline: treat them as read-only configuration, and you'll avoid the persistence trap that catches most teams.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.