agentleFS
Nice-Wolf-StudioNice-Wolf-Studio/unity-claude-skills/skills/unity-game-architecture/SKILL.md

unity-game-architecture

Skill32 starsChanged 59 days agoMITView on GitHub

Unity game architecture decision patterns. Service Locator vs Singleton vs DI, Event Bus vs ScriptableObject channels, MonoBehaviour vs plain C#, component composition, manager bootstrap sequences. DECISION format: WHEN/DECISION/SCAFFOLD/GOTCHA. Based on Unity 6.3 LTS.

The file

We split this skill into the rules it gives an agent

5 rules. About 0 are common, 5 are distinctive, and 0 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈5

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 0

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    WHEN: Systems need to find other systems at runtime (AudioManager, InputManager, SaveManager, etc.)

    line 22
  2. Distinctive:

    WHEN: Deciding whether a new class should inherit from MonoBehaviour

    line 76
  3. Distinctive:

    WHEN: Multiple GameObjects share some behavior but differ in specifics (enemies with different attacks, interactable objects)

    line 145
  4. Distinctive:

    WHEN: Two or more systems need to communicate without direct references

    line 196
  5. Distinctive:

    WHEN: Multiple manager systems depend on each other at startup

    line 255

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Game Systems Architecture -- Decision Patterns

Prerequisite skills: unity-scripting (MonoBehaviour, ScriptableObjects, events), unity-lifecycle (RuntimeInitializeOnLoadMethod, DontDestroyOnLoad, SubsystemRegistration), unity-foundations (components, GameObjects)

These patterns address the most common architectural failure: Claude defaults to fat MonoBehaviours with direct references and singletons everywhere. This works for prototypes but collapses at system scale.


PATTERN: Global Service Access

WHEN: Systems need to find other systems at runtime (AudioManager, InputManager, SaveManager, etc.)

DECISION:

  • Lazy Singleton -- Tiny project, 1-3 managers, no testing needed. static Instance + DontDestroyOnLoad. Fast to write, impossible to mock.
  • Service Locator -- Medium project, want to swap implementations for testing (mock audio, stub save). Central registry with Register<T>/Get<T>. Dependencies are implicit but swappable.
  • Constructor/Method DI -- Large project or library code, maximum testability. Pass dependencies explicitly. Use VContainer or Zenject for MonoBehaviour injection.

SCAFFOLD (Service Locator):

public static class Services
{
    private static readonly Dictionary<Type, object> _services = new();

    public static void Register<T>(T service) where T : class
    {
        _services[typeof(T)] = service;
    }

    public static T Get<T>() where T : class
    {
        if (_services.TryGetValue(typeof(T), out var service))
            return (T)service;
        throw new InvalidOperationException($"Service {typeof(T).Name} not registered");
    }

    public static bool TryGet<T>(out T service) where T : class
    {
        if (_services.TryGetValue(typeof(T), out var obj))
        {
            service = (T)obj;
            return true;
        }
        service = null;
        return false;
    }

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    static void Reset() => _services.Clear();
}

// Registration (in a bootstrap MonoBehaviour or RuntimeInitializeOnLoadMethod):
Services.Register<IAudioService>(new AudioService());
Services.Register<ISaveService>(new SaveService());

// Usage (anywhere):
Services.Get<IAudioService>().PlaySFX("explosion");

GOTCHA: Service Locator hides dependencies -- you cannot see what a class needs by looking at its constructor. Use it for true infrastructure services only (audio, save, analytics), not for gameplay dependencies. Always back services with interfaces (IAudioService, not AudioManager) so tests can register mocks. The SubsystemRegistration reset is critical for Enter Play Mode Options with domain reload disabled.


PATTERN: MonoBehaviour vs Plain C# Class

WHEN: Deciding whether a new class should inherit from MonoBehaviour

DECISION:

  • MonoBehaviour -- Needs any of: Inspector serialization via [SerializeField], Unity callbacks (Update, OnTriggerEnter), Transform/GameObject access, coroutines/Awaitable with destroyCancellationToken, or being a component on a GameObject.
  • Plain C# class -- Pure logic: state machines, pathfinding algorithms, data processing, inventory logic, damage calculation, save/load DTOs. Owned and driven by a MonoBehaviour.

SCAFFOLD (Plain C# class owned by MonoBehaviour):

// Pure logic class -- testable without Unity
public class HealthSystem
{
    public int Current { get; private set; }
    public int Max { get; }
    public bool IsDead => Current <= 0;
    public event Action OnDied;
    public event Action<int, int> OnChanged; // current, max

    public HealthSystem(int maxHealth)
    {
        Max = maxHealth;
        Current = maxHealth;
    }

    public void TakeDamage(int amount)
    {
        if (IsDead) return;
        Current = Mathf.Max(0, Current - amount);
        OnChanged?.Invoke(Current, Max);
        if (IsDead) OnDied?.Invoke();
    }

    public void Heal(int amount)
    {
        if (IsDead) return;
        Current = Mathf.Min(Max, Current + amount);
        OnChanged?.Invoke(Current, Max);
    }
}

// Thin MonoBehaviour wrapper -- bridges Unity and logic
public class HealthComponent : MonoBehaviour
{
    [SerializeField] private int maxHealth = 100;

    public HealthSystem Health { get; private set; }

    void Awake()
    {
        Health = new HealthSystem(maxHealth);
    }

    // Optional: expose events for Unity-side wiring
    void OnEnable() => Health.OnDied += HandleDeath;
    void OnDisable() => Health.OnDied -= HandleDeath;

    void HandleDeath()
    {
        // Unity-specific: play VFX, disable collider, etc.
        Destroy(gameObject, 2f);
    }
}

GOTCHA: Plain C# classes cannot use [SerializeField]. Use [System.Serializable] for nested display in the Inspector. They have no destroyCancellationToken -- pass one from the owning MonoBehaviour if they do async work. Use Mathf (not System.Math) for Unity-compatible math in plain C# classes that reference UnityEngine.


PATTERN: Component Composition vs Inheritance

WHEN: Multiple GameObjects share some behavior but differ in specifics (enemies with different attacks, interactable objects)

DECISION:

  • Composition with interfaces (default) -- Separate capabilities into focused components (Health, Mover, DamageDealer). Consumers query via GetComponent<IDamageable>(). Maximum flexibility, easy to mix-and-match.
  • Abstract base class -- Only when there is genuine IS-A relationship with shared STATE and shared IMPLEMENTATION (not just shared interface). Example: Projectile base with BulletProjectile and RocketProjectile that share velocity/lifetime logic.

SCAFFOLD (Interface + Composition):

// Capability interface
public interface IDamageable
{
    void TakeDamage(float amount, Vector3 hitPoint, Vector3 hitNormal);
    bool IsAlive { get; }
}

// Focused component implementing the interface
[RequireComponent(typeof(Collider))]
public class DamageReceiver : MonoBehaviour, IDamageable
{
    [SerializeField] private float maxHealth = 100f;
    private float _currentHealth;

    public bool IsAlive => _currentHealth > 0;
    public event Action<float> OnDamaged;

    void Awake() => _currentHealth = maxHealth;

    public void TakeDamage(float amount, Vector3 hitPoint, Vector3 hitNormal)
    {
        if (!IsAlive) return;
        _currentHealth -= amount;
        OnDamaged?.Invoke(amount);
    }
}

// Consumer queries the interface, not the concrete type
void OnTriggerEnter(Collider other)
{
    if (other.TryGetComponent(out IDamageable target) && target.IsAlive)
    {
        target.TakeDamage(damage, transform.position, transform.forward);
    }
}

GOTCHA: GetComponent<IInterface>() works in Unity -- interfaces are queryable. RequireComponent enforces component dependencies at add-time. Prefer TryGetComponent over null-checking GetComponent (avoids allocation of a null wrapper in older Unity versions). Deep MonoBehaviour inheritance hierarchies (3+ levels) are the #1 Unity architecture anti-pattern.


PATTERN: Event Architecture Selection

WHEN: Two or more systems need to communicate without direct references

DECISION:

  • C# events/Actions -- Communication within a single class or tightly-coupled components on the same GameObject. Simplest, strongly typed, zero overhead.
  • ScriptableObject Event Channels -- Cross-scene communication, designer-configurable, drag-and-drop wiring in Inspector. Best for gameplay events (player died, level complete). See unity-scripting/references/scriptableobjects.md for full implementation.
  • Static Event Bus -- Project-wide typed events, code-only (no assets to manage). Fast to prototype, good for system-level events (scene loaded, settings changed). Harder to debug than SO channels.

SCAFFOLD (Static Typed Event Bus):

// Event definitions
public struct PlayerDiedEvent { public Vector3 Position; public string CauseOfDeath; }
public struct ScoreChangedEvent { public int NewScore; public int Delta; }

// Bus implementation
public static class EventBus
{
    private static readonly Dictionary<Type, Delegate> _handlers = new();

    public static void Subscribe<T>(Action<T> handler) where T : struct
    {
        var type = typeof(T);
        if (_handlers.TryGetValue(type, out var existing))
            _handlers[type] = Delegate.Combine(existing, handler);
        else
            _handlers[type] = handler;
    }

    public static void Unsubscribe<T>(Action<T> handler) where T : struct
    {
        var type = typeof(T);
        if (_handlers.TryGetValue(type, out var existing))
        {
            var result = Delegate.Remove(existing, handler);
            if (result == null) _handlers.Remove(type);
            else _handlers[type] = result;
        }
    }

    public static void Publish<T>(T evt) where T : struct
    {
        if (_handlers.TryGetValue(typeof(T), out var handler))
            ((Action<T>)handler)?.Invoke(evt);
    }

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    static void Reset() => _handlers.Clear();
}

// Usage
EventBus.Subscribe<PlayerDiedEvent>(OnPlayerDied);
EventBus.Publish(new PlayerDiedEvent { Position = pos, CauseOfDeath = "lava" });

GOTCHA: Always unsubscribe in OnDisable (not OnDestroy) to match the OnEnable subscription. Static Event Bus survives scene loads -- if a handler's object is destroyed without unsubscribing, you get MissingReferenceException. Use struct events to avoid allocation. SO Event Channels are preferred when designers need to wire events visually.


PATTERN: Manager Bootstrap Sequence

WHEN: Multiple manager systems depend on each other at startup

DECISION:

  • Boot Scene -- A dedicated "Boot" scene loads first, creates all managers, then loads the gameplay scene additively. Visual, async-friendly, supports loading screens. Best for production projects.
  • RuntimeInitializeOnLoadMethod -- Code-only bootstrap, no scene dependency. Creates managers via BeforeSceneLoad. Best for plugins, packages, or projects where any scene can be the entry point.

SCAFFOLD (Boot Scene):

public class Bootstrapper : MonoBehaviour
{
    [SerializeField] private string firstGameplayScene = "MainMenu";

    async Awaitable Start()
    {
        // Create persistent managers
        DontDestroyOnLoad(gameObject);

        // Initialize services in dependency order
        var audio = gameObject.AddComponent<AudioService>();
        var save = gameObject.AddComponent<SaveService>();

        Services.Register<IAudioService>(audio);
        Services.Register<ISaveService>(save);

        // Load saved settings before gameplay
        await save.LoadSettingsAsync(destroyCancellationToken);

        // Load the first gameplay scene (replaces Boot scene)
        await SceneManager.LoadSceneAsync(firstGameplayScene);
    }
}

SCAFFOLD (RuntimeInitializeOnLoadMethod):

public static class GameBootstrap
{
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void Bootstrap()
    {
        // Create a persistent GameObject for managers
        var go = new GameObject("[Services]");
        Object.DontDestroyOnLoad(go);

        var audio = go.AddComponent<AudioService>();
        Services.Register<IAudioService>(audio);

        // NOTE: Cannot use async/await here -- this is a static method
        // For async init, have a manager use async Awaitable Start()
    }
}

GOTCHA: RuntimeInitializeOnLoadMethod cannot use await (it's a static synchronous method). If your bootstrap needs async operations (loading configs, authenticating), use BeforeSceneLoad to create a bootstrap MonoBehaviour, then do async work in its Start(). Boot Scene approach requires adding the Boot scene to Build Settings as scene 0. Cross-ref: unity-lifecycle covers RuntimeInitializeOnLoadMethod timing in detail.


Architecture Anti-Patterns

Anti-PatternProblemAlternative
God MonoBehaviour (1000+ lines)Untestable, hard to modify, merge conflictsSplit into focused components + plain C# logic classes
Singleton for everythingTight coupling, untestable, hidden dependenciesService Locator for infra, DI for gameplay
FindObjectOfType in UpdateO(n) search every frame, slowCache reference in Start/Awake, or use events/SO
Manager-of-ManagersCentralized bottleneck, circular dependenciesEach system registers with Service Locator independently
Direct cross-references between systemsBreaks if either system is removed/replacedEvent channels or Service Locator with interfaces
Static state without domain-reload resetStale data between play sessions in Editor[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]

Related Skills

  • unity-scripting/references/scriptableobjects.md -- SO Event Channels, Runtime Sets, Variable References (full implementations)
  • unity-lifecycle -- RuntimeInitializeOnLoadMethod timing, SubsystemRegistration, DontDestroyOnLoad
  • unity-state-machines -- FSM/BT as plain C# classes following the MonoBehaviour wrapper pattern
  • unity-testing -- How Service Locator enables mocking in Edit Mode tests

Additional Resources

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.