Starch

Starch is an architecture toolkit for Unity: it keeps modules decoupled and makes the project's architecture structure visible. This manual is the toolkit's reference: each chapter covers one part.

The architecture graphs

The architecture graphs visualize the dependency structure your code actually has; the Rules tab checks it against rules you define.

The ScriptableObject types

The ScriptableObject types are the assets systems share data through instead of referencing each other; the dashboard tabs show them working during play.

Getting started → · Discord · Website

Install

Requirements:

To install:

Getting Started

Open Tools ▸ Starch ▸ Architecture, select the Graph tab and press Rescan. Boxes are the folders of your project; an arrow from one to another means code in the first references code in the second. If scripts are missing or third-party code clutters the view, the Settings tab controls what the scan reads. Right-click an arrow and choose Forbid to make that dependency a rule violation from the next scan on.

Setup AI

Starch is built to make working with LLM coding agents such as Claude Code easier. An agent does not hold your whole game in context when it implements a feature, and what it cannot see it gets wrong. Variables, Events and the other ScriptableObject types are each module's input and output, so a feature needs the context of its own module and the assets it touches, not the whole project.

Because modules only meet through named assets, whether new code respects the architecture is a question the scanner can answer. Rules you encode in the Rules tab are checked on every scan, and the agent can call DependencyQuery.Violations() to catch its own breaks. With editor access through MCP for Unity, the agent queries the dependency structure instead of guessing. You verify the result in the dashboards during play instead of reading every line of wiring.

Paste this prompt into your agent's project instructions (CLAUDE.md, .cursorrules, or a system prompt):

This Unity project uses Starch, a ScriptableObject-based architecture toolkit. Default to these conventions when writing code. They are reliable heuristics, not absolutes — the project's hard constraints are whatever its team encoded in the Rules tab (DependencyQuery.Violations() reports breaks):

- Asset taxonomy: subclass Definition for authored data (what things ARE — items, enemies, effects; read-only at runtime); subclass RuntimeStateSO for live mutable state (the built-in Variables, StateSO<T> and RuntimeList<T> already do); GameEvent / GameEvent<T> are the message channels. FloatReference/IntReference/BoolReference/StringReference/Vector3Reference are serializable struct FIELDS for tunables, not assets.
- Prefer not to reference another system's scene objects directly. Communication defaults to ScriptableObject assets: GameEvent / GameEvent<T> channels for messages, Variables (FloatVariable, IntVariable, BoolVariable, StringVariable, Vector3Variable, RangedFloatVariable) for shared state, and RuntimeList<T> for populations of live objects.
- Components expose [SerializeField] fields for the SO channels they use, assigned in the inspector rather than found at runtime.
- Scene objects add themselves to RuntimeLists in OnEnable and remove themselves in OnDisable. Mark fields worth showing in the Lists table with [Fact]; implement IHasId when an entry's id must survive a save file.
- Raise events with eventAsset.Raise() or eventAsset.Raise(value). Listen with a GameEventListener component, or implement IGameEventListener<T> and Register/Unregister in OnEnable/OnDisable.
- Read and write shared values through variable.Value; subscribe to variable.OnChanged for reactions. Value resets to the authored initialValue every time play starts.
- Single-value "current X" state holders subclass StateSO<T>. Multi-field state containers subclass RuntimeStateSO, keep runtime fields [NonSerialized], call RecordStateChange(old, new) in mutations, and override ResetRuntimeState().
- New typed event payloads are generated via Tools > Starch > New Typed Event… (Event + UnityEvent + Listener trio).
- Prefer one observer component per data source: it reads the source and publishes SO Variables/Events; other systems read those SOs.
- Create new channel assets via Assets > Create > Starch > ...

Verify wiring with the Architecture window (Tools > Starch > Architecture): the Events tab shows event fires and listeners live, the State tab shows variable values live and lets you edit them during play, and the Rules tab reports architecture violations (also queryable from code via DependencyQuery.Violations()).

The query methods the agent can call:

Starch dependency queries — call from editor code, or via Unity MCP execute_code. All methods return plain strings.

using Starch.Deps.Editor;

DependencyQuery.Rescan();          // refresh the scan cache first
DependencyQuery.Summary("Name");   // dependency summary for one type/file
DependencyQuery.UsedBy("Name");    // who depends on this type/file
DependencyQuery.Uses("Name");      // what this type/file depends on
DependencyQuery.Refs("Name");      // both directions at once
DependencyQuery.Between("A","B");  // dependency paths from A to B
DependencyQuery.Resolve("Name");   // disambiguate a partial name
DependencyQuery.Violations();      // current rule violations

The window

The Architecture window (Tools ▸ Starch ▸ Architecture) shows how systems depend on each other, when events fire, and what shared state holds. It has nine tabs.

Graph

The Graph tab draws the reference structure of your code: the scanner reads the C# in Assets/ and draws the types it finds as boxes, nested the way your folders nest, with arrows between them. Expanding folders far enough reaches the individual scripts. The Rescan button in the footer re-reads the source after code changes.

The top-level scan: Assets folders and packages, edge mode All.
The top-level scan: Assets folders and packages, edge mode All.

An arrow from A to B means code in A references code in B; one arrow can bundle many references. Red arrows are rule violations. Dimmed boxes are vocabulary: types that depend on nothing outside their own folder, and are therefore safe for anything to use. Box colors encode the type kind (MonoBehaviour, ScriptableObject, interface, enum, plain class); the Legend toggle shows the key. Registered packages appear as sealed boxes when Scan packages is on in the Settings tab.

Folders expanded down to scripts; the box colors are type kinds, and the gold edges touch the selected Goap folder.
Folders expanded down to scripts; the box colors are type kinds, and the gold edges touch the selected Goap folder.

Working in it:

_Imsim1 expanded one level with the Saves folder selected; the red marker is a rule violation, the panel below lists each touching edge.
_Imsim1 expanded one level with the Saves folder selected; the red marker is a rule violation, the panel below lists each touching edge.

Force

The Force tab lays out the same scan as a physics simulation: connected scripts pull together, unconnected ones drift apart. Scripts group by their actual connections, independent of the folder they are in.

Folder hulls and clusters in the Force layout; red edges are violations.
Folder hulls and clusters in the Force layout; red edges are violations.

Three shapes are worth looking for. A node with edges fanning out in every direction is a hub: either a god class or a heavily used vocabulary type. A tight cluster that spans two folders is usually one feature split across them. A node with no edges at all is a dead-code candidate; the Orphans toggle shows and hides these. Red edges are rule violations, the same as in the Graph tab.

Working in it:

Rules

The Rules tab turns architecture decisions into constraints that every scan checks. Violations appear as a list that opens the offending line, and as red arrows in the Graph and Force views.

Rules with notes and live match counts; entries in the violation list open the offending line.
Rules with notes and live match counts; entries in the violation list open the offending line.

The rule types:

Working in it:

Events

The Events tab shows the project's GameEvent assets as cards, live during play. Each card shows whether its event fired and which listeners are registered.

Event cards during play, with fire counters, listener badges and the log in list mode.
Event cards during play, with fire counters, listener badges and the log in list mode.

Each card flashes when its event raises and counts the raises. The listener badge lists the current listeners; clicking it opens the list, and picking a listener pings its GameObject in the scene. An empty badge on a card that flashed means the reaction never registered. The log records recent raises with payload, frame and time, as a zoomable timeline or a newest-first list; selecting cards filters the log to them.

The tab can also drive the game. The Raise button on a card fires the event without any gameplay involved; typed events take their payload from an input field on the card. This tests the listening side in isolation. Mute (the eye icon) hides a frequently firing event from the log.

Clicking a card selects and pings the event asset. Cards can be dragged to arrange them, and positions are stored per user. Right-click assigns a tag, and Auto Layout arranges cards by tag. Search filters by name; the library chips filter by library.

After you exit play mode the tab freezes rather than clearing, so the last session's log stays readable until the next play.

State

The State tab shows the project's runtime-state assets (Variables, RuntimeLists, StateSO subclasses, custom containers) as live cards during play.

State cards during play; amber rows differ from the authored initial.
State cards during play; amber rows differ from the authored initial.

Cards show current values and flash with a change counter when they change. Rows turn amber when the live value differs from the authored initial. The subscriber badge counts what is subscribed to the value; clicking it lists the subscribers, and picking one pings its component. RuntimeList cards list their current members; clicking a member pings it in the scene. Selecting a card lists everything in scenes and prefabs that references the asset.

Kit Variables are editable in place: type a value and press Set, toggle a bool by clicking it, reset one variable with or everything with Reset All. Editing a value mid-play and watching the readers react tests the wiring directly: you can force near-death at 5 HP without taking damage.

Dragging, tagging and filtering cards works the same as on the Events tab. The tab freezes on play exit so final values stay inspectable.

Lists

The Lists tab shows each RuntimeList asset as a live table: one row per entry, an Id and a Name column, and one column per fact (the entry's own [Fact] members plus anything asserted onto it with SetFact).

One table per list; the columns are the facts this population carries.
One table per list; the columns are the facts this population carries.

Rows appear and disappear as entries are added and removed. Each table folds behind a header with the list's name, entry count and a ping button that selects the list asset; the header flashes when the list changes. Search matches names, ids and fact values across all lists, and the library chips filter here too. Lists are empty in edit mode by design; they fill as things register during play.

Definitions

The Definitions tab lists the project's Definition assets and flags two things: assets nothing references, and accidental duplicates.

One table per Definition class; rows are assets, columns are their serialized fields.
One table per Definition class; rows are assets, columns are their serialized fields.

Assets are grouped by Definition class: one table per class, one row per asset, the class's serialized fields as columns. Tables fold behind a header with the class name and asset count. A wide class shows a capped set of field columns, and the last column header notes how many more exist. A class's table is its content set in one view, so a missing entry or an odd value stands out against the rows around it.

The Refs column counts what references each asset. orphan marks assets referenced by nothing in the project: either dead content to delete or content you forgot to wire. dup marks two assets of the same class sharing a name, which is usually an accidental copy. Clicking a row selects and pings the asset; the selected row expands into its referrer list, and clicking a referrer pings it.

Reference data comes from an asynchronous project scan, so the Refs column reads until it finishes; the Rescan button refreshes after big changes. Search matches asset names, class names and field values. The library chips filter by library, the same as on the other boards.

Settings

The Settings tab controls what the scan reads, and the analysis and dashboard defaults.

Scan scope, analysis and dashboard settings, with diagnostics in the footer.
Scan scope, analysis and dashboard settings, with diagnostics in the footer.

Changing a scan setting marks the scan stale; the Rescan button in the footer applies it. The footer also shows scan diagnostics: file counts, timings, what the exclusions removed, and type names declared in more than one place, whose references produce no edges.

Variables

A Variable is one shared value (the player's health, the alert level, the score) as a ScriptableObject asset. The system that writes the value and the systems that read it reference the asset, not each other.

The types are FloatVariable, IntVariable, BoolVariable, StringVariable, Vector3Variable, ColorVariable, and RangedFloatVariable (a float with an authored min and max). For a value type not listed here, see Custom Variable types below.

Making one

Create the asset via Assets ▸ Create ▸ Starch ▸ Variables ▸ Float Variable, name it after the value it holds (for example PlayerHealth), and set its initial value in the Inspector. Each component that uses it declares a serialized field and gets the asset assigned in the Inspector:

[SerializeField] FloatVariable playerHealth;

void ApplyDamage(float amount) => playerHealth.Value -= amount;

void OnEnable()  => playerHealth.OnChanged += HandleHealthChanged;
void OnDisable() => playerHealth.OnChanged -= HandleHealthChanged;

Read and write through Value. Subscribe to OnChanged to react to changes instead of polling.

A display component bound to the PlayerHealth asset in the Inspector; the whole wiring is one object field.
A display component bound to the PlayerHealth asset in the Inspector; the whole wiring is one object field.

Behavior

Custom Variable types

Every Variable is a subclass of StateSO<T>, so a Variable for another value type is one class:

[CreateAssetMenu(menuName = "Game/Double Variable")]
public class DoubleVariable : StateSO<double> { }

Value, OnChanged, change recording and the play-mode reset are inherited. For state that is a container with several fields rather than one value, see Custom State.

Events

A GameEvent is a message channel as an asset: one system raises it, any number listen, and none of them reference each other. Adding a reaction later means adding a listener, not editing the raiser.

GameEvent carries no data; GameEvent<T> subclasses carry a typed payload (FloatEvent, IntEvent, BoolEvent, StringEvent, Vector2Event, Vector3Event, ColorEvent, TransformEvent, ColliderEvent, RigidbodyEvent).

Making one

Create the asset via Assets ▸ Create ▸ Starch ▸ Events ▸ Game Event, name it after the occurrence (for example OnPlayerDied), and assign it to the raising component:

[SerializeField] GameEvent onDoorOpened;
void Open() => onDoorOpened.Raise();

[SerializeField] FloatEvent onHealthChanged;
void Set(float hp) => onHealthChanged.Raise(hp);

There are two ways to listen:

The raising side has no-code options too: GameEventRaiser raises its event at a chosen lifecycle moment (Awake, OnEnable or Start), and ButtonGameEventRaiser raises it when its UI Button is clicked.

A listener wired to a FloatEvent: the landing impact drives a particle burst, and the raiser never knows dust exists.
A listener wired to a FloatEvent: the landing impact drives a particle burst, and the raiser never knows dust exists.

Custom event types

For a payload type with no built-in Event class, Tools ▸ Starch ▸ New Typed Event… generates the Event, UnityEvent and Listener classes for it. Subclassing GameEvent<T> directly also works:

[CreateAssetMenu(menuName = "Game/Damage Event")]
public class DamageEvent : GameEvent<DamageInfo> { }

The subclass alone gives a channel usable from code; the generator's extra classes are what make it wireable through a listener component in the Inspector.

RuntimeLists

A RuntimeList is a collection as an asset: entries add themselves while they exist, and any system reads the same asset. A Variable holds state that exists once; a RuntimeList holds state that exists many times (the live enemies, the items in the world, the spawn points).

Subclass RuntimeList<T> for the element type, create the asset, and let the members register themselves:

public class EnemyList : RuntimeList<Enemy> { }

[SerializeField] EnemyList activeEnemies;

void OnEnable()  => activeEnemies.Add(this);
void OnDisable() => activeEnemies.Remove(this);

A consumer iterates Items, reads Count, checks Contains, picks with GetRandom, and subscribes to OnAdded and OnRemoved. Because registration follows enable state, the list is always current. Like a Variable, a list empties when play starts and appears as a live card in the State tab. For plain scene objects there is a ready-made TransformRuntimeList with a TransformRuntimeListRegistrar drop-on component.

Identity

Every entry gets a stable id the moment it is added; the list generates one automatically. IdOf returns it, Find looks an entry up by it. A generated id lasts one session; when the id goes into a save file, the entry must own it: implement IHasId (one string property) and the list uses that id instead.

Facts

An entry describes itself by marking fields or properties with [Fact]; the Lists tab shows one column per marked member, read live:

public class Zombie : MonoBehaviour
{
    [Fact] ZombieState state;
    [Fact] float awareness;
    [Fact("HP")] float health;
}

Other systems attach notes an entry knows nothing about with SetFact (a container recording an item's location, for example) and read them back with FactsOf. OnFactChanged fires on a real change. Typed gameplay data stays in fields, read directly off the entry; facts are what the table shows and what outsiders assert.

For objects with no code of their own, the TrackedObject drop-on component carries a save-stable id and joins an ObjectList asset on enable.

References

A Reference is a serializable struct field that an Inspector toggle switches between an inline constant and a shared Variable asset. The component's code reads Value either way, so a tunable that starts as a plain number (a walk speed, a jump height) can become shared state later without changing the component.

[SerializeField] FloatReference walkSpeed;

void Move() => transform.position += direction * walkSpeed.Value * Time.deltaTime;

The types are FloatReference, IntReference, BoolReference, StringReference and Vector3Reference. Use a Reference for values that are private tunables today but might become shared state; use a Variable directly for values that are shared from the start.

Definitions

A Definition is one asset per kind of thing a game accumulates: items, enemies, effects, levels. The data describing what a sword is (name, icon, world prefab, stack limit) lives in the sword's Definition asset, and each system that needs it (the tooltip, the inventory cell, the pickup) references that one asset.

Live state stays out of it: where a particular sword is and how many are in a stack live on the instance object, in Variables, or in RuntimeLists. A Definition is read-only at runtime; nothing enforces this, it is the convention the split depends on.

[CreateAssetMenu(menuName = "Game/Item")]
public class ItemDefinition : Definition
{
    public string DisplayName;
    public Sprite Icon;
    public int StackLimit;
    public GameObject WorldPrefab;
}

Each item in the game is one ItemDefinition asset (Sword, Bandage, Key), created from the asset menu without new code. An inventory entry is then a pair: a reference to the definition plus the entry's own mutable fields.

public class ItemInstance
{
    public ItemDefinition Definition;
    public int Count;
}

The definition reference doubles as identity: two entries stack because they point at the same asset, and a save file records which asset an entry meant. Definitions also carry references to other assets (the prefab spawned in the world, the prefab shown in the hand, a list of effect assets run on use), so a definition is where a kind's data and its associated assets meet.

The Definition base class adds no behavior. Subclassing it declares an asset as authored content, and that declaration is what tooling reads: the Definitions tab lists the project's Definition assets, one table per Definition class, and flags the ones nothing references, which is where forgotten and dead content shows up.

Custom State

Some live state is a container with several fields rather than one value (a match state with a round number and a phase, a save-slot descriptor). A container like that should still reset between play sessions and show up in the State tab, which is what subclassing RuntimeStateSO gives it. For a single value of a custom type, subclass StateSO<T> instead; see Custom Variable types under Variables.

The subclass carries three obligations: mark runtime fields [NonSerialized] so Unity never persists them, call RecordStateChange(old, new) in each mutation so the State tab sees the change, and override ResetRuntimeState() to restore the starting state.

public class MatchState : RuntimeStateSO
{
    [NonSerialized] int _round;

    public int Round
    {
        get => _round;
        set { RecordStateChange(_round, value); _round = value; }
    }

    public override void ResetRuntimeState() => _round = 0;
}

Bring Your Own ScriptableObjects

The dashboards discover assets by interface, not by class. A project that already has its own ScriptableObject event or variable types can put them on the boards by implementing small contracts from the Starch namespace, without replacing anything.

A minimal implementation, an event asset that forwards to an existing bus:

public class MyBusEvent : ScriptableObject, Starch.IGameEvent
{
    public void Raise()
    {
#if UNITY_EDITOR
        Starch.GameEventDebug.Record(this, null);
#endif
        MyBus.Publish(name);
    }
    public void Register(Starch.IGameEventListener l) { }
    public void Unregister(Starch.IGameEventListener l) { }
}

The interface alone makes the asset visible. The live pulses (flashes, counters, log entries) come from two editor-only calls your implementation makes when something happens: GameEventDebug.Record(this, value) inside your Raise, and VariableDebug.Record(this, oldValue, newValue) inside your setters. Wrap both in #if UNITY_EDITOR; they are stripped from builds. Skipping them costs only the pulses.

Grouping into libraries requires no code: right-click any card and assign a Library. Assignments are stored in ProjectSettings/StarchLibraries.json.

Storage

Starch stores its data in two places, split by whether the data is a project decision or a personal preference. Knowing the split tells you what to commit.

With the standard Unity ignore templates this happens by default; the split only matters if your ignore file is custom.

Troubleshooting

For problems and questions, ask in the Starch Discord.