agentsclimarketplace

Unity game architecture

Skill IdoCohen560/claude-unity-game-studio/examples/my-first-game/.claude/skills/unity-game-architecture

AI-powered Unity game development studio for Claude Code - 49 agents, 120+ skills, and a full studio hierarchy that turns a single AI session into a coordinated game dev team. View more about me in my portfolio.

Install
npx -y skills add IdoCohen560/claude-unity-game-studio --skill unity-game-architecture

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 8 stars8 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

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.

SKILL.md

13.7 KB, as published. Nobody here has run it

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

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.