UniState State Machine for Unity

repository·main·Indexed 42 days ago

https://github.com/bazyleu/unistate

A high-performance, scalable, and asynchronous state machine package for Unity (version 1.12.0). Designed to be modular and DI-friendly, it minimizes memory allocations and integrates with frameworks like VContainer, Zenject, and Reflex. It requires Unity 2022.3+ and the UniTask package.

Tokens
8.7K
Snippets
20
Records
25
Agent score
4%

What's inside UniState

  1. Performance characteristics of UniState

    main

    UniState is optimized for minimal runtime allocations and high execution speed compared to MonoBehaviour-based state machines.

    Key performance benefits:

    • Execution Speed: Up to 5000x faster than MonoBehaviour implementations.
    • Memory Allocations: 2x to 10x reduction in allocations for typical state chains.
    • Scalability: Performance does not degrade significantly as the number of states increases; execution speed remains consistent even with 200+ states.
  2. What is a State in UniState?

    main

    A State is an abstraction representing a specific condition or phase of a game (e.g., MainMenuView, GameplayState, or GameLoadingState).

    Key characteristics:

    • Logic Ownership: A state class contains all logic related to that phase, including loading and unloading resources.
    • Lifecycle: States are responsible for their own resource allocation. Once a state is exited, all allocated resources should be released using the Disposables system.
    • Decoupling: It is recommended to avoid using Unity GameObjects directly inside states. Instead, load them through an abstraction and use them as an interface (a 'View').
    • Flexibility: UniState does not restrict your architectural patterns; you can use MVC, MVVM, or ECS within a state.
    // Simple popup state example
    public class SimplePopupState : StateBase
    {
        private ISimplePopupView _view;
    
        public override async UniTask Initialize(CancellationToken token) 
        {
            _view = LoadPopupView(token);
            Disposables.Add(UnloadPopupView);
        }
    
        public override async UniTask<StateTransitionInfo> Execute(CancellationToken token)
        {
            await _view.Show(token);
            await _view.WaitForClick(token);
    
            return Transition.GoBack();
        }
        
        public override async UniTask Exit(CancellationToken token)
        {
            await _view.Hide(token);
        }
    
        private ISimplePopupView LoadPopupView(CancellationToken token) { /* Loading logic */ }
        private void UnloadPopupView() { /* Unloading logic */ }
    }
  3. Using Nested State Machines for complex flows

    main

    When a state represents a complex feature (like a Shop) that has its own internal logic and sub-states, use a nested StateMachine.

    Pattern:

    1. The Root State loads the shared resources (e.g., the Shop View) and adds them to its Disposables.
    2. The Root State then creates and executes a child StateMachine.
    3. The Child States within that machine can access the resources loaded by the Root State (e.g., via Dependency Injection).
    4. When the child machine finishes, the Root State completes and releases the shared resources.
    public class RootShopPopupState : StateBase
    {
        public override async UniTask Initialize(CancellationToken token) 
        {
            var disposable = LoadShopView();
            Disposables.Add(disposable);
        }
    
        public override async UniTask<StateTransitionInfo> Execute(CancellationToken token)
        {
            var stateMachine = StateMachineFactory.Create<StateMachine>();
            // Run the internal state machine for ShopPopup.
            await stateMachine.Execute<ShopPopupIdleState>(token);
    
            return Transition.GoBack();
        }
        
        private IDisposable LoadShopView() { /* Loading logic */ }
    }
    
    public class ShopPopupIdleState : StateBase
    {
        private IShopView _view;
        
        // IShopView is injected by the DI framework, loaded by the parent RootShopPopupState
        public ShopPopupIdleState(IShopView view)
        {
             _view = view;
        }
    
        public override async UniTask<StateTransitionInfo> Execute(CancellationToken token)
        {
            await _view.Show(token);
            return Transition.GoBack();
        }
    }
  4. Use Composite States and SubStates

    main

    Composite States allow multiple independent sub-states to run simultaneously within a single parent state. This is useful for complex UI or parallel logic.

    • Composite State: Inherit from CompositeStateBase or DefaultCompositeState.
    • SubState: Inherit from SubStateBase<TParentCompositeState>. Sub-states are linked to the parent via the generic parameter.
    • DefaultCompositeState: A ready-to-use implementation that propagates lifecycle methods to all sub-states. The composite state completes when the first sub-state finishes, then cancels the remaining sub-states.

    Note: If using DefaultCompositeState without any sub-states, it will throw NoSubStatesException.

    // Composite State implementation
    internal class FooCompositeState : DefaultCompositeState { }
    
    // SubState implementation
    internal class BazSubState : SubStateBase<DefaultCompositeState> { }
  5. How to manage resources with Disposables

    main
    To prevent memory leaks and ensure proper cleanup, use the Disposables collection within your state. When a state is exited, all items added to Disposables are released. This is the primary mechanism for unloading views, Addressables handles, or event subscriptions.
  6. Understand the State lifecycle

    main

    Every state follows a four-stage lifecycle:

    1. Initialize: Load resources (prefabs, events, etc.).
    2. Execute: The main logic loop. This method must be overridden in StateBase. It remains active until it returns a StateTransitionInfo.
    3. Exit: Cleanup logic (unsubscribing, animations).
    4. Dispose: Final resource cleanup.

    Important: If you inherit from StateBase, do not override Dispose. Instead, use the Disposables API to manage resource lifetimes safely.

  7. Quickstart: Create and run a state machine

    main

    To get started with UniState, follow these four steps:

    1. Install the package via UPM or manifest.json.
    2. Create States: Define classes inheriting from StateBase or StateBase<T>. Use the Execute method to implement logic and return a StateTransitionInfo to handle transitions.
    3. Configure DI: Register the state machine and your states in your Dependency Injection container (e.g., VContainer, Zenject, or Reflex).
    4. Run: Resolve the IStateMachine interface and call .Execute<TState>(CancellationToken.None) to start the machine.
    // 1. Define States
    public class MainMenuState : StateBase
    {
        public override async UniTask<StateTransitionInfo> Execute(CancellationToken token)
        {
            return Transition.GoTo<GameplayState>();
        }
    }
    
    public class GameplayState : StateBase
    {
        public override async UniTask<StateTransitionInfo> Execute(CancellationToken token)
        {
            return Transition.GoBack();
        }
    }
    
    // 2. Register in DI (Example using VContainer-style syntax)
    builder.RegisterStateMachine<IStateMachine, StateMachine>();
    builder.RegisterState<MainMenuState>();
    builder.RegisterState<GameplayState>();
    
    // 3. Run the State Machine
    public class Game
    {
        private readonly IStateMachine _stateMachine;
    
        public Game(IStateMachine stateMachine)
        {
            _stateMachine = stateMachine;
        }
    
        public void Start()
        {
            _stateMachine.Execute<MainMenuState>(CancellationToken.None).Forget();
        }
    }
  8. Run and manage a State Machine

    main

    The StateMachine is the entry point. You can use the built-in StateMachine class or implement IStateMachine for custom logic.

    Running: Resolve the machine and call Execute<TInitialState>(cancellationToken).

    Concurrency: A state machine only supports one active execution flow. Calling Execute() while already running throws AlreadyExecutingException. Check the IsExecuting property to prevent this.

    History: The machine tracks history for GoBack() calls. The default MaxHistorySize is 15. Set it to 0 to disable history (making GoBack() exit the machine).

    // Running a machine
    await stateMachine.Execute<FooState>(cts.Token);
    
    // Running with payload
    var payload = new BarPayload();
    await stateMachine.Execute<BarState>(payload, cts.Token);
  9. Integrate UniState with Zenject (Extenject)

    main

    UniState supports Zenject/Extenject via extension methods on the DiContainer.

    Preparation

    If Zenject/Extenject is installed via UPM, no preparation is needed. If installed manually, add the UNISTATE_ZENJECT_SUPPORT define symbol in Player Settings -> Player -> Scripting Define Symbols.

    Registration

    Use the extension methods to bind state machines and states. Use the AsSingle variants for singleton lifetimes, though standard bindings are preferred for general use.

    Usage

    Resolve the IStateMachine from the container and call Execute.

    // Registration
    private void BindStates(DiContainer container)
    {
        // General use
        container.BindStateMachine<IStateMachine, BarStateMachine>();
        container.BindState<BarState>();
        container.BindState<IBarState, BarState>();
        
        // Singleton version
        container.BindStateMachineAsSingle<IStateMachine, BarStateMachine>();
        container.BindStateAsSingle<BarState>();
        container.BindStateAsSingle<IBarState, BarState>();
    }
    
    // Usage
    public class GameEntryPoint : IStartable
    {
        private readonly IStateMachine _stateMachine;
    
        public GameEntryPoint(IStateMachine stateMachine)
        {
            _stateMachine = stateMachine;
        }
    
        public void Start()
        {
            _stateMachine.Execute<StartGameState>(CancellationToken.None).Forget();
        }
    }
  10. Upgrade from versions < 1.5.0: Breaking changes

    main

    The 1.5.0 release unified the API and removed several helper classes. Follow these mapping rules to upgrade:

    Removed APIUse Instead
    StateMachineHelperInject IStateMachine directly into states
    StateMachineFactoryInject IStateMachine directly into states
    IExecutableStateMachineIStateMachine
    RegisterAbstractState / BindAbstractStateRegisterState<TBase, TImpl> / BindState<TBase, TImpl>

    Key Migration Steps:

    1. Use IStateMachine everywhere instead of IExecutableStateMachine.
    2. Inject the state machine interface directly into your states instead of using factories or helpers.
    3. Update VContainer bindings to use the two-parameter RegisterState<TBase, TImpl> overloads.
  11. Integrate UniState with Reflex

    main

    UniState provides extension methods for Reflex's ContainerBuilder.

    Preparation

    If Reflex is installed via UPM, no preparation is needed. If installed manually, add the UNISTATE_REFLEX_SUPPORT define symbol in Player Settings -> Player -> Scripting Define Symbols.

    Registration

    Use RegisterStateMachine and RegisterState on the ContainerBuilder.

    • RegisterState(typeof(MyState)) registers the state as itself and all its implemented interfaces.
    • Use the two-parameter overload to explicitly expose an additional abstract/base contract.
    • Use Lifetime.Singleton when a singleton lifetime is required.

    Usage

    Resolve the IStateMachine from the Reflex container and call Execute.

    // Registration
    private void RegisterStates(ContainerBuilder builder)
    {
        // Recommended usage
        builder.RegisterStateMachine(typeof(StateMachine), typeof(IStateMachine));
        builder.RegisterState(typeof(BarState));
        builder.RegisterState(typeof(BarState), typeof(BarBaseState));
        
        // Singleton version (use cautiously)
        builder.RegisterStateMachine(typeof(StateMachine), typeof(IStateMachine), Lifetime.Singleton);
        builder.RegisterState(typeof(BarState), Lifetime.Singleton);
        builder.RegisterState(typeof(BarState), typeof(BarBaseState), Lifetime.Singleton);
    }
    
    // Usage
    public class DiceEntryPoint
    {
        private readonly IStateMachine _stateMachine;
        public DiceEntryPoint(IStateMachine stateMachine) => _stateMachine = stateMachine;
    
        public void Start()
        {
            _stateMachine.Execute<StartGameState>(CancellationToken.None).Forget();
        }
    }
  12. Create a state machine workflow with StateBase

    main

    To define logic in UniState, inherit from StateBase and override the Execute method. The Execute method must return a UniTask<StateTransitionInfo>, which determines the next step in the workflow using the Transition helper.

    Common transition types include:

    • Transition.GoTo<TState>(): Moves to a specific state class.
    • Transition.GoBack(): Returns to the previous state in the stack.
    • Transition.GoToExit(): Terminates the state machine.

    Example workflow:

    1. Define states inheriting from StateBase.
    2. Use Transition to drive flow (e.g., GoTo<NextState>()).
    3. Handle asynchronous operations using the provided CancellationToken.
    internal class StartGameState : StateBase
    {
        public override async UniTask<StateTransitionInfo> Execute(CancellationToken token)
        {
            // Perform logic
            await UniTask.Delay(TimeSpan.FromSeconds(2), cancellationToken: token);
            
            // Transition to the next state
            return Transition.GoTo<RollDiceState>();
        }
    }