Skip to content

Game Tools

Unity ScriptableObjects for Data-Driven Game Design (Without the Mess)

Game Tools 6 min read

Why ScriptableObjects still matter

When you’re building an indie game, you quickly outgrow hard-coded values. You want designers (or your future self) to tweak numbers without rummaging through scripts, and you want content to be portable, versionable, and testable.

In Unity, ScriptableObjects are the standard “data asset” type. They’re great for tuning: weapons, skills, enemy stats, item effects, quest parameters, dialogue lines, crafting recipes—anything you’d like to edit in the inspector.

The catch is that ScriptableObjects can also become a messy junk drawer: circular references, duplicated assets, unclear ownership, and no validation until something breaks at runtime.

This post is about a pattern we’ve found reliable for keeping ScriptableObjects clean: treat them as immutable configuration, enforce stable references, and add a lightweight validation layer so bad data fails fast in the editor.

1) Decide what lives in an asset (and what doesn’t)

A good rule of thumb:

  • Put in ScriptableObjects: numbers, strings, enums, IDs, and references to other assets that describe configuration.
  • Keep out: runtime state (current cooldowns, progress, timers), caches that can be rebuilt, and anything that changes every frame.

ScriptableObjects are assets. If you store runtime state inside them, you’ll eventually run into cross-play contamination, unexpected shared state between objects, and hard-to-reproduce bugs.

Instead, copy configuration into plain C# objects or component fields at spawn time. Configuration stays read-only; behaviour stays in systems and components.

2) Prefer IDs and lookup over deep graphs

It’s tempting to reference everything directly: an “Enemy” asset points to an “Attack” asset points to a “StatusEffect” asset points to another asset, and so on. Direct references are convenient, but deep graphs make it harder to validate, easier to create accidental cycles, and annoying to migrate.

A practical alternative is to use stable IDs for cross-asset links:

  • Each asset has a unique ID (string, GUID, or an int you control).
  • Other assets store IDs (or lists of IDs).
  • A central resolver builds lookup tables at editor time or at startup.

You still get data-driven authoring, but you reduce coupling and make it easier to validate missing references.

In Unity terms: keep the authoring shape in ScriptableObjects, but resolve into runtime references using a registry.

3) Use composition via “effect descriptors”

For many games, assets end up representing abilities: “Fireball”, “Poison Dart”, “Dash”. Abilities often share patterns: they deal damage, apply status effects, spawn projectiles, play VFX/SFX, and apply modifiers.

A clean approach is to represent these as small effect descriptors that compose into a larger ability.

Example structure:

  • AbilityAsset: cooldown, mana cost, and a list of Effects.
  • EffectDescriptorAsset (or a few specialised variants): damage config, status config, knockback config.
  • StatusEffectAsset: name/ID, duration, stacking rules.

This keeps individual assets focused and reduces the “mega asset with 40 fields” problem.

4) Make assets explicit about versioning

If you’re using ScriptableObjects for tuning and balancing, you’ll eventually want to maintain compatibility for saved games, replays, or live operations.

Even if you don’t ship live updates, adding asset version fields is a small habit that pays off later. For example:

  • AbilityAsset.version
  • StatusEffectAsset.version

Then your save system can store IDs + versions, and your load step can detect when an asset changed and decide how to handle it.

If you’re already doing versioned save data, it pairs naturally with data-driven assets.

5) Add validation that runs in the editor

Most ScriptableObject pain is data quality: missing references, duplicate IDs, invalid ranges, or arrays that don’t match expected lengths.

Don’t wait until a playtest to find out.

Use Unity editor callbacks to run validation. The exact mechanism depends on your Unity version and setup, but the general pattern is:

  • Implement validation methods on your ScriptableObjects.
  • Run them from an editor entry point or from OnValidate.
  • Use Debug.LogError / Debug.LogWarning so issues are visible in the console.

Validation rules that are worth adding early:

  • IDs are unique across all loaded assets.
  • References resolve (if you use IDs, ensure IDs exist).
  • Numeric ranges are sensible (no negative durations unless explicitly allowed).
  • Lists aren’t empty where they’re required (e.g., Ability has at least one effect).

For uniqueness checks, consider a registry asset or editor script that scans the project for your base type.

6) Keep runtime code ignorant of asset types where possible

When you’re deep in gameplay code, you don’t want every system to know about every ScriptableObject subclass.

A better pattern is to convert assets into plain data structures early, then let gameplay systems operate on those structures.

For example:

  • At ability equip time: convert AbilityAsset into an AbilityDefinition (plain struct/class).
  • At combat start: resolve effect descriptors into a runtime list of effect instances.

This gives you two benefits:

  • Gameplay code becomes easier to test.
  • If you later migrate from ScriptableObjects to another approach, you only change the conversion layer.

7) Organisation: folders, naming, and “types of assets”

As your project grows, the fastest path to chaos is lack of conventions. You don’t need heavy process—just a few rules:

  • Separate authoring assets by type (e.g., Abilities, StatusEffects, Effects).
  • Use consistent naming: Verb_Target (e.g., PoisonEnemy_1s) or Ability_Fireball.
  • Document what each asset should not contain (runtime state, mutable collections, caches).

Also, resist creating ten near-identical assets when you mean “one asset with parameters.” Use parameterisation before proliferation.

8) A simple checklist for “good” ScriptableObject data

If you can answer these quickly for a new asset, you’re probably doing it right.

  1. Is this asset configuration-only? (No runtime timers, no mutable shared state.)
  2. Does it have a stable ID? (For references, validation, and future save compatibility.)
  3. Are cross-links resolved safely? (Direct references are shallow; ID lookups are validated.)
  4. Are ranges constrained? (Damage can’t be NaN; durations aren’t negative unless intentional.)
  5. Can it be validated in the editor? (Errors show up before play mode.)
  6. Is behaviour elsewhere? (Systems interpret data; assets don’t run gameplay.)

Common pitfalls (and how to avoid them)

  • Storing runtime state in assets: You’ll see weird behaviour after entering/exiting Play Mode or spawning multiple instances. Fix by copying config to runtime objects.
  • Using ScriptableObjects as “singletons”: They’re not automatically safe for runtime state. Prefer proper singletons/services for logic, and assets for data.
  • Overusing direct references: Circular dependency chains and migration pain. If references are meant to be stable, IDs + registry tend to scale better.
  • No validation: Bugs appear as null reference exceptions during playtesting. Add editor-time checks early.

Where to go next

Once your ScriptableObjects are disciplined, you can build on them:

  • Editor tooling to mass-edit fields safely.
  • Export/import workflows (e.g., from CSV/Google Sheets) if tuning gets heavy.
  • Automated tests that load all assets and run validation in CI.

If you’re starting today, the biggest wins are: treat assets as immutable config, use stable IDs for links, and add validation that fails fast.

Further reading:

← Back to blog