← Back to devlog
Engineering · July 2026

Rage against the SOAP: ScriptableObjects are data, not architecture

There’s a pattern that most Unity developers have been exposed to that is commonly called the ScriptableObject Architecture Pattern. SOAP, if you want the acronym. Unity themselves showcased it with the Chop Chop open project. It demos beautifully.

It’s a horrifying, deadly, timewasting, trap.

Before anyone grabs a pitchfork: I love ScriptableObjects. I use them all over my framework and I’d fight you to keep them. This isn’t a “Ew, Scriptable Objects” post. It’s a “ScriptableObjects are being asked to do a job they were never built for” post. Those are two very different things, and the difference is the whole article, so I’m putting it up top:

ScriptableObjects as authored data? Excellent. ScriptableObjects as a runtime broker? Lord help us.

Hold that line and the rest follows.

What the pattern actually is

The SOAP pitch is decoupling. Instead of a system holding a reference to another system, everything talks through ScriptableObject assets sitting in the middle.

You’ve seen them in YouTube videos and Reddit posts:

  • Event-channel SOs. A GameEvent asset. Anyone raises it, anyone listens to it, nobody references anybody. Like a radio station only the assets can tune into.
  • Registry / service SOs. A “smart” SO that live objects register themselves into at runtime. The player spawns and writes itself into a PlayerReference asset. Everything else reads the player back out of the asset.
  • Broker SOs. A cutscene registers itself into a channel asset, and pickups call the cutscene through the asset without ever knowing it exists.

On the surface this is gorgeous. Zero hard references, everything modular, the dependency graph looks like a clean hub instead of spaghetti. It demos in a five-minute talk and you walk away convinced.

The problem is what an SO is underneath. An SO is an asset. It’s serialized data on disk. You’re taking the thing Unity built to hold your stat tables and asking it to be a switchboard for live objects.

Where it falls apart

An SO doesn’t behave like a normal runtime object. It doesn’t behave like a text file either. It behaves like a Frankenstein asset/prefab hybrid. And the stitches down the middle are the problem.

Editor versus build. An SO acts one way in play mode inside the editor and completely different in a build. The reference you registered “works” every time you hit play at your desk, then comes back null on a device, or holds a value it shouldn’t. Or gets duplicated in an Addressable group. Your test loop is lying to you about how your game actually works. And that’s without getting into the value changes during play mode nonsense I assume you know about already.

Domain reload and load state. An SO’s runtime fields depend entirely on whether the asset has been loaded. Until your SO instance enters the domain, it’s just YAML on disk. Your references are null. Your unserialized data is nonexistent. It’s just a GUID on a UnityEngine.Object somewhere until something loads it. So… how can you control that lifecycle? The truth is, you can’t. Each instance is being lazyloaded in whatever way your dependency’s consumer thinks it should be. You need more control than that for a scalable system. Even more than that if you want multiple scalable systems working together coherently.

Add those up and you get the worst class of bugs there are. Non-deterministic, platform-specific, device-only, race conditions. It works on your machine, in play mode, most of the time. Then it null-references on a player’s console two rooms into the dungeon and you can’t even reason about what is happening without squinting at IL2CPP stack traces. It’s a nightmare.

I almost did this myself

This isn’t hypothetical for me. My agents like to run straight for it before I explicitly disallowed it.

I had an item-get cutscene, the Zelda-style key grab where the player turns to the camera and hoists the item over their head. It was reached through a global singleton, ItemGetCutscene.Instance, and I was cleaning that up (because singletons are, in general, the bane of my existence). When you kill a singleton, the tempting “clean” replacement is a decoupled channel. My agent came in hot with the go-to solution from its training data. It gently slid an SO cutscene channel onto the table: the cutscene registers itself into the asset, pickups fire through the asset, nobody references anybody. Textbook SOAP. Elegant on the whiteboard.

I rejected it, for exactly the reasons above. That registered cutscene reference would be null-until-loaded and behave differently in a build. I’d be trading a singleton I at least could predict for a load-order headache I didn’t. The channel wanted to be a runtime broker, which is the one thing an SO isn’t.

What actually solved it: the pickup already knows who collected it. So resolve the cutscene from the collecting entity instead of from any global or any asset. No singleton, no channel, no live reference parked in an asset anywhere. The runtime relationship already carried everything I needed. The asset was never the right place to put it.

So what are ScriptableObjects for

This is the half that matters, because it’s the half people drop when they hear “SO architecture is a trap.”

ScriptableObjects are for authored data. Serialized, typed, tuned-by-hand data that lives as a named asset instead of a magic number buried in code. This is the job they’re perfect at:

  • Stat blocks and tuning curves a designer tweaks in the inspector
  • Typed keys and identifiers
  • Any authored config you want versioned in git and editable without a recompile

If it’s data somebody authors and code reads, an SO is a genuinely great home for it. Author it, reference it, read from it. That’s not the trap. That’s the point of the feature.

A little edit-time convenience logic is fine too: an auto-populate button, an OnValidate that derives a field. Keep the surface area small. The moment the authoring UX grows past a couple of conveniences, that’s a custom inspector, not a fatter asset. The logic an SO owns is logic about its own data at edit time. Nothing more.

The distinction is dead simple: authored data at rest, yes. Live references passing through at runtime, no.

When you actually need runtime wiring

The data still lives in the SO. It’s the live relationship that has to be brokered by something built for runtime. Three tools, and the choice is mechanical:

  • Genuinely one thing for the whole game (a scene-transition controller)? An internal singleton behind an encapsulated socket with a curated API surface. One global, honestly modeled as one global.
  • One per actor (per player, per entity)? Resolve it from the actor that triggered the action. This is the cutscene fix. It’s also correct for co-op and splitscreen for free, because you never baked in “there is exactly one player.”
  • Just a notification? A plain C# event or Action on a real runtime object. We had these before SOs existed and they still work.

None of these put a live reference inside an asset. That’s the whole trick.

The takeaway

The rage isn’t at ScriptableObjects. It’s at asking a filing cabinet to be a switchboard.

Assets belong to your project. Not your game.