- Overview
- Core Concepts
- StateService
- StateAccessor
- Event System
- Status System
- StateSubscription
- Performance Considerations
- Common Issues and Solutions
- Best Practices
- Examples
- Summary
The CherryFramework StateService provides a powerful event and state management system for Unity applications. It enables decoupled communication between components through a centralized event bus and status tracking system, with support for conditional subscriptions and payload data.
| Problem | Solution with StateService |
|---|---|
| Tight coupling between components | Decoupled event-based communication |
| Complex conditional logic scattered throughout code | Centralized condition-based subscriptions |
| Manual event handler cleanup | Auto-unsubscribe via IUnsubscriber |
| Tracking temporary vs persistent states | Separate event (one-frame) and status (persistent) systems |
| Passing data with events | Type-safe payload events |
- Event System: Emit and receive events with optional payload data
- Status Tracking: Track boolean states with lifetime information
- Conditional Subscriptions: Subscribe to events with custom conditions
- One-time Subscriptions: Auto-unsubscribe after first invocation
- Frame-Aware: Tracks when events were emitted/statuses changed
- Type-Safe Payloads: Generic payload events for data passing
- Automatic Cleanup: Subscriptions tied to object lifetimes
- Debug Mode: Optional logging for debugging
┌─────────────────────────────────────────────────────────────┐
│ StateService │
├─────────────────────────────────────────────────────────────┤
│ - Dictionary<string, EventBase> _currentEvents │
│ - Dictionary<string, EventBase> _pastEvents │
│ - Dictionary<string, StateStatus> _activeStatuses │
│ - Dictionary<string, StateStatus> _inactiveStatuses │
│ - Dictionary<object, List<StateSubscription>> _subscriptions│
│ │
│ + EmitEvent(string key) │
│ + EmitEvent<T>(string key, T payload) │
│ + SetStatus(string key) / UnsetStatus(string key) │
│ + AddStateSubscription(condition, callback, subscriber) │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Events │ │ Statuses │ │ Subscriptions │
│ (1-frame) │ │ (Persistent) │ │ (Callbacks) │
└───────────────┘ └───────────────┘ └───────────────┘
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ BasicEvent │ │ StateStatus │ │ Condition │
│PayloadEvent<T>│ │ - EmitTime │ │ Callback │
│ - EmitTime │ │ │ │ DestroyAfter │
└───────────────┘ └───────────────┘ └───────────────┘
| Feature | Event | Status |
|---|---|---|
| Duration | One frame (volatile) | Persistent until changed |
| Usage | One-time notifications | Long-lived states |
| Examples | "PlayerDied", "LevelComplete" | "IsGamePaused", "IsInventoryOpen" |
| Persistence | Cleared after one update cycle | Remains until explicitly unset |
| History | Available via GetEvent() | Available via GetStatus() |
Subscriptions are tied to subscriber objects for automatic cleanup:
// Subscription tied to this object
_stateService.AddStateSubscription(
accessor => accessor.IsEventActive("GameStarted"),
() => Debug.Log("Game started!"),
this // Will auto-unsubscribe when this object is destroyed. If the object inherits from BehaviourBase or GeneralClassBase, this is handled automatically
);The StateAccessor provides a query interface for subscription conditions, exposing only relevant state-checking methods:
public class StateAccessor
{
public bool IsEventActive(string key); // Event emitted this frame?
public bool IsStatusJustBecameActive(string key); // Status activated this frame?
public bool IsStatusJustBecameInactive(string key); // Status deactivated this frame?
}Namespace: CherryFramework.StateService
Purpose: Central service that manages all events, statuses, and subscriptions.
public class StateService : GeneralClassBase, ILateTickable
{
// Constructor
public StateService(bool debugMessages);
// Event Methods
public void EmitEvent<T>(string key, T payload);
public void EmitEvent(string key);
public bool IsEventActive(string key);
public bool EventPassed(string key);
public EventBase GetEvent(string key);
public T GetPayload<T>(string key);
public bool TryGetEvent<T>(string key, out PayloadEvent<T> result);
public bool TryGetPayload<T>(string key, out T result);
// Status Methods
public void SetStatus(string key);
public void UnsetStatus(string key);
public bool IsStatusActive(string key);
public bool IsStatusJustBecameActive(string key);
public bool IsStatusInactive(string key);
public bool IsStatusJustBecameInactive(string key);
public StateStatus GetStatus(string key);
// Subscription Methods
public StateSubscription AddStateSubscription(
Predicate<StateAccessor> condition,
Action callback,
object obj = null,
bool destroyAfterInvoke = false
);
public void RemoveSubscription(StateSubscription subscription, object subscriber = null);
public void RemoveAllSubscriptions(object subscriber);
}public StateService(bool debugMessages)Example:
// In installer
var stateService = new StateService(debugMessages: true);
DependencyContainer.Instance.BindAsSingleton(stateService);public void EmitEvent<T>(string key, T payload)Example:
stateService.EmitEvent("PlayerDied", new DeathData {
position = transform.position,
killer = "Boss"
});public void EmitEvent(string key)Example:
stateService.EmitEvent("GameStarted");public bool IsEventActive(string key)Example:
if (stateService.IsEventActive("LevelComplete"))
{
ShowVictoryScreen();
}public bool EventPassed(string key)Example:
if (stateService.EventPassed("TutorialShown"))
{
// Don't show tutorial again this session
}public EventBase GetEvent(string key)Example:
var evt = stateService.GetEvent("PlayerDied");
if (evt != null)
{
Debug.Log($"Player died at time {evt.EmitTime}");
}public T GetPayload<T>(string key)Example:
int score = stateService.GetPayload<int>("ScoreUpdated");
// Returns 0 if event not found or payload wrong typepublic bool TryGetEvent<T>(string key, out PayloadEvent<T> result)
public bool TryGetPayload<T>(string key, out T result)Example:
if (stateService.TryGetPayload<Vector3>("ExplosionPosition", out var position))
{
SpawnExplosionEffect(position);
}public void SetStatus(string key)Example:
stateService.SetStatus("IsGamePaused");public void UnsetStatus(string key)Example:
stateService.UnsetStatus("IsGamePaused");public bool IsStatusActive(string key)Example:
if (stateService.IsStatusActive("IsInventoryOpen"))
{
// Don't allow movement while inventory is open
}public bool IsStatusJustBecameActive(string key)Example:
if (stateService.IsStatusJustBecameActive("IsGamePaused"))
{
ShowPauseMenu();
}public bool IsStatusInactive(string key)Example:
if (stateService.IsStatusInactive("TutorialActive"))
{
// Tutorial is not running
}public bool IsStatusJustBecameInactive(string key)Example:
if (stateService.IsStatusJustBecameInactive("IsGamePaused"))
{
HidePauseMenu();
}public StateStatus GetStatus(string key)Example:
var status = stateService.GetStatus("BossFightActive");
if (status != null)
{
Debug.Log($"Boss fight active for {Time.time - status.EmitTime } seconds");
}public StateSubscription AddStateSubscription(
Predicate<StateAccessor> condition,
Action callback,
object obj = null,
bool destroyAfterInvoke = false
)Parameters:
condition: Function that checks StateAccessor and returns true to triggercallback: Action to invoke when condition is metobj: Subscriber object (for auto-cleanup, uses callback target if null)destroyAfterInvoke: If true, subscription is removed after first invocation
Returns: The created subscription
Examples:
// Basic subscription
_stateService.AddStateSubscription(
accessor => accessor.IsEventActive("GameStarted"),
() => Debug.Log("Game started!"),
this
);
// One-time subscription
_stateService.AddStateSubscription(
accessor => accessor.IsEventActive("PlayerDied"),
() => ShowGameOverScreen(),
this,
destroyAfterInvoke: true
);
// Complex condition
_stateService.AddStateSubscription(
accessor => accessor.IsStatusActive("BossFight") &&
accessor.IsEventActive("BossDefeated"),
() => SpawnRewards(),
this
);
// Status transition
_stateService.AddStateSubscription(
accessor => accessor.IsStatusJustBecameActive("IsNightTime"),
() => EnableNightMode(),
this
);public void RemoveSubscription(StateSubscription subscription, object subscriber = null)Example:
var sub = _stateService.AddStateSubscription(condition, callback, this);
// Later...
_stateService.RemoveSubscription(sub);public void RemoveAllSubscriptions(object subscriber)Example:
// Automatically called when subscriber implements IUnsubscriber
// Can also be called manually
_stateService.RemoveAllSubscriptions(this);Namespace: CherryFramework.StateService
Purpose: Query interface passed to subscription conditions for checking event and status state.
public class StateAccessor
{
private StateService _stateService;
public StateAccessor(StateService stateService);
public bool IsEventActive(string key);
public bool IsStatusJustBecameActive(string key);
public bool IsStatusJustBecameInactive(string key);
}| Method | Description |
|---|---|
IsEventActive(string key) |
Checks if event was emitted this frame |
IsStatusJustBecameActive(string key) |
Checks if status was activated this frame |
IsStatusJustBecameInactive(string key) |
Checks if status was deactivated this frame |
_stateService.AddStateSubscription(
accessor => accessor.IsEventActive("PlayerHit") &&
accessor.IsStatusJustBecameActive("Invulnerable"),
() => PlayShieldSparkEffect(),
this
);
_stateService.AddStateSubscription(
accessor => accessor.IsStatusJustBecameInactive("Invulnerable"),
() => DisableShield(),
this
);Namespace: CherryFramework.StateService
Purpose: Base class for all events.
public abstract class EventBase
{
public int EmitTime; // Time in seconds when event was emitted
protected EventBase(int emitTime)
{
EmitTime = emitTime;
}
}Namespace: CherryFramework.StateService
Purpose: Event with typed payload data.
public class PayloadEvent<T> : EventBase
{
public T Payload;
public PayloadEvent(T payload, int emitTime) : base(emitTime)
{
Payload = payload;
}
}Frame 1: EmitEvent("PlayerDied")
→ Event added to _currentEvents
Frame 1 (LateTick):
→ Event moved to _pastEvents
→ Subscriptions processed
Frame 2: Event available via GetEvent() but IsEventActive() returns false
→ Event remains in _pastEvents for history
Namespace: CherryFramework.StateService
Purpose: Tracks status activation time.
public class StateStatus : EventBase
{
public StateStatus(int emitTime) : base(emitTime)
{
}
}Frame 1: SetStatus("BossFight")
→ Status added to _becameActiveStatuses
Frame 1 (LateTick):
→ Status moved to _activeStatuses
→ "JustBecameActive" true for this frame only
Frame 2-10: Status remains in _activeStatuses
→ IsStatusActive() returns true
→ IsStatusJustBecameActive() returns false
Frame 11: UnsetStatus("BossFight")
→ Status added to _becameInactiveStatuses
Frame 11 (LateTick):
→ Status moved to _inactiveStatuses
→ "JustBecameInactive" true for this frame only
Subscriptions are evaluated every frame during LateTick. Keep the number reasonable:
// GOOD - Reasonable number of subscriptions (dozens)
for (int i = 0; i < 50; i++)
{
_stateService.AddStateSubscription(condition, callback, this);
}
// BAD - Thousands of subscriptions will impact performance
for (int i = 0; i < 5000; i++) // Too many!
{
_stateService.AddStateSubscription(condition, callback, this);
}Keep conditions simple and efficient:
// GOOD - Simple conditions
accessor => accessor.IsEventActive("GameStart")
// GOOD - Multiple checks but still efficient
accessor => accessor.IsStatusActive("BossFight") &&
accessor.IsEventActive("BossDefeated")
// BAD - Expensive operations in conditions
accessor => {
var result = ExpensiveCalculation(); // DON'T do heavy work here
return accessor.IsEventActive("Something") && result > 100;
}Keep payload data small and relevant:
// GOOD - Small, relevant data
stateService.EmitEvent("PlayerDied", new DeathData {
position = transform.position,
killerName = "Boss"
});
// BAD - Huge data structures
stateService.EmitEvent("LevelData", entireLevelData); // Better to reference by IDUse auto-cleanup to prevent memory leaks:
// GOOD - Auto-cleanup with destroyAfterInvoke for one-time events
_stateService.AddStateSubscription(
accessor => accessor.IsEventActive("TutorialComplete"),
() => UnlockFeature(),
this,
destroyAfterInvoke: true
);
// GOOD - Subscriber implements IUnsubscriber for auto-cleanup
public class MyClass : GeneralClassBase // Auto-cleans on Dispose
{
private void Setup()
{
_stateService.AddStateSubscription(condition, callback, this);
}
}Past events are kept indefinitely. For long-running applications (hours or so) with multiple (hundreds or more) unique events, consider cleanup:
// If needed, you could extend StateService with cleanup methods
public void ClearEventsOlderThan(int seconds)
{
// Custom cleanup logic
}Symptoms: Callbacks never called even when condition should be true
Solutions:
// SOLUTION 1: Check correct state method
// For events in current frame
accessor => accessor.IsEventActive("EventName")
// For status changes
accessor => accessor.IsStatusJustBecameActive("StatusName")
// SOLUTION 2: Ensure subscription exists before event
public class SafeSubscriber : InjectClass
{
[Inject] private StateService _stateService;
public SafeSubscriber() : base()
{
// Subscribe in constructor - runs before any events
_stateService.AddStateSubscription(
accessor => accessor.IsEventActive("GameStarted"),
OnGameStarted,
this
);
}
}
// SOLUTION 3: Debug condition
var sub = _stateService.AddStateSubscription(
accessor => {
bool result = accessor.IsEventActive("Test");
Debug.Log($"Condition evaluated: {result}");
return result;
},
() => Debug.Log("Callback triggered!"),
this
);Symptoms: Subscriptions accumulate, never removed
Solutions:
// SOLUTION 1: Use IUnsubscriber
public class CleanClass : GeneralClassBase // Auto-cleans on Dispose
{
private void Setup()
{
_stateService.AddStateSubscription(
accessor => accessor.IsEventActive("SomeEvent"),
OnSomeEvent,
this // Will auto-unsubscribe when this is disposed
);
}
}
// SOLUTION 2: Manual cleanup
public class ManualCleanup : MonoBehaviour, IInjectTarget
{
[Inject] private StateService _stateService;
private StateSubscription _subscription;
private void Start()
{
_subscription = _stateService.AddStateSubscription(
accessor => accessor.IsEventActive("SomeEvent"),
OnSomeEvent,
this
);
}
private void OnDestroy()
{
_stateService.RemoveSubscription(_subscription);
}
}
// SOLUTION 3: One-time subscriptions
_stateService.AddStateSubscription(
accessor => accessor.IsEventActive("OneTimeEvent"),
() => Debug.Log("This will only run once"),
this,
destroyAfterInvoke: true // Auto-removed after first trigger
);Symptoms: Using events for persistent states or statuses for one-time triggers
Solutions:
// WRONG - Using event for persistent state
stateService.EmitEvent("IsGamePaused"); // Only lasts one frame!
// RIGHT - Use status for persistent state
stateService.SetStatus("IsGamePaused"); // Stays until UnsetStatus
// WRONG - Using status for one-time trigger
stateService.SetStatus("PlayerDied"); // Stays forever!
// RIGHT - Use event for one-time trigger
stateService.EmitEvent("PlayerDied"); // Lasts one frameSymptoms: GetPayload<T> returns default values or throws
Solutions:
// SOLUTION 1: Use TryGetPayload for safe access
if (_stateService.TryGetPayload<int>("Score", out int score))
{
UpdateScoreUI(score);
}
else
{
// Handle missing or wrong type
}
// SOLUTION 2: Define event key constants with type info
public static class GameEvents
{
public const string ScoreUpdated = "ScoreUpdated<int>";
public const string PlayerDied = "PlayerDied<DeathData>";
}
// Usage
_stateService.EmitEvent(GameEvents.ScoreUpdated, 100);
// Later
if (_stateService.TryGetPayload<int>(GameEvents.ScoreUpdated, out int score))
{
// Safe access
}Symptoms: Callback called multiple times for same event
Solutions:
// PROBLEM: This will trigger every frame while condition is true
_stateService.AddStateSubscription(
accessor => accessor.IsStatusActive("BossAlive"),
() => Debug.Log("Boss is alive!"), // Called every frame!
this
);
// SOLUTION 1: Use JustBecameActive for one-time triggers
_stateService.AddStateSubscription(
accessor => accessor.IsStatusJustBecameActive("BossAlive"),
() => Debug.Log("Boss spawned!"), // Called once
this
);
// SOLUTION 2: Use destroyAfterInvoke for one-time events
_stateService.AddStateSubscription(
accessor => accessor.IsEventActive("BossDefeated"),
() => Debug.Log("Boss defeated!"), // Called once then removed
this,
destroyAfterInvoke: true
);Symptoms: Console flooded with state service messages
Solution: Control debug mode via installer
[DefaultExecutionOrder(-10000)]
public class StateInstaller : InstallerBehaviourBase
{
[SerializeField] private bool _debugMode;
protected override void Install()
{
#if DEVELOPMENT_BUILD || UNITY_EDITOR
// Enable debug in development builds
var stateService = new StateService(debugMessages: _debugMode);
#else
// Never debug in release builds
var stateService = new StateService(debugMessages: false);
#endif
BindAsSingleton(stateService);
}
}public static class GameEvents
{
// Events (one-time notifications)
public static class Events
{
public const string GameStarted = "game_started";
public const string GameOver = "game_over";
public const string LevelComplete = "level_complete";
public const string PlayerDied = "player_died";
}
// Statuses (persistent states)
public static class Statuses
{
public const string IsPaused = "is_paused";
public const string IsInventoryOpen = "is_inventory_open";
public const string IsBossFight = "is_boss_fight";
public const string IsTutorialActive = "is_tutorial_active";
}
// Payload events with expected types
public static class PayloadEvents
{
public const string ScoreChanged = "score_changed<int>";
public const string HealthChanged = "health_changed<float>";
public const string PlayerPosition = "player_position<Vector3>";
}
}
// Usage
_stateService.EmitEvent(GameEvents.Events.GameStarted);
_stateService.SetStatus(GameEvents.Statuses.IsPaused);// GOOD - Status for long-lasting state
_stateService.SetStatus("IsNightTime");
// ... much later ...
if (_stateService.IsStatusActive("IsNightTime"))
{
// Still night
}
// GOOD - Event for one-time trigger
_stateService.EmitEvent("DayStarted");
// BAD - Using event for persistent state
_stateService.EmitEvent("IsNightTime"); // Only lasts one frame!
// BAD - Using status for one-time trigger
_stateService.SetStatus("PlayerDied"); // Stays forever!public class MyBehaviour : BehaviourBase // Auto-cleans on destroy
{
[Inject] private StateService _stateService;
private void Start()
{
// Subscriptions automatically cleaned up when this object destroys
_stateService.AddStateSubscription(
accessor => accessor.IsEventActive(GameEvents.Events.GameStarted),
OnGameStarted,
this
);
// One-time subscription auto-removed after trigger
_stateService.AddStateSubscription(
accessor => accessor.IsEventActive(GameEvents.Events.PlayerDied),
OnPlayerDied,
this,
destroyAfterInvoke: true
);
}
private void OnGameStarted() { }
private void OnPlayerDied() { }
}// Define data structure
[Serializable]
public class DamageEventData
{
public GameObject source;
public GameObject target;
public int damage;
public bool isCritical;
}
// Emit with payload
public void DealDamage(GameObject target, int damage, bool critical)
{
var damageData = new DamageEventData
{
source = gameObject,
target = target,
damage = damage,
isCritical = critical
};
_stateService.EmitEvent("damage_dealt", damageData);
}
// Subscribe with payload
_stateService.AddStateSubscription(
accessor => accessor.IsEventActive("damage_dealt"),
() => {
if (_stateService.TryGetPayload<DamageEventData>("damage_dealt", out var data))
{
Debug.Log($"{data.source} dealt {data.damage} damage to {data.target}");
if (data.isCritical)
{
ShowCriticalHitEffect(data.target.transform.position);
}
}
},
this
);// Complex game logic with multiple conditions
_stateService.AddStateSubscription(
accessor =>
accessor.IsStatusActive(GameEvents.Statuses.IsBossFight) &&
accessor.IsEventActive("BossPhase2") &&
!accessor.IsStatusActive("IsPlayerDead"),
() => {
// Boss entered phase 2 while player is alive
StartPhase2Sequence();
},
this
);
// Tutorial flow
_stateService.AddStateSubscription(
accessor =>
accessor.IsStatusActive("TutorialActive") &&
accessor.IsEventActive("PlayerMoved") &&
accessor.IsEventActive("PlayerJumped"),
() => {
// Player performed both required actions
CompleteTutorialStep();
},
this,
destroyAfterInvoke: true
);public class UIManager : BehaviourBase
{
[Inject] private StateService _stateService;
private void Start()
{
// Show/hide UI based on game state
_stateService.AddStateSubscription(
accessor => accessor.IsStatusJustBecameActive("IsInventoryOpen"),
() => ShowInventoryPanel(),
this
);
_stateService.AddStateSubscription(
accessor => accessor.IsStatusJustBecameInactive("IsInventoryOpen"),
() => HideInventoryPanel(),
this
);
// Show pause menu
_stateService.AddStateSubscription(
accessor => accessor.IsStatusJustBecameActive("IsGamePaused"),
() => ShowPauseMenu(),
this
);
}
private void ShowInventoryPanel() { }
private void HideInventoryPanel() { }
private void ShowPauseMenu() { }
}public class GameFlowController : BehaviourBase
{
[Inject] private StateService _stateService;
private void Start()
{
// Start game
_stateService.AddStateSubscription(
accessor => accessor.IsEventActive("GameInitialized"),
() => {
_stateService.SetStatus("Phase_Intro");
StartCoroutine(IntroSequence());
},
this,
destroyAfterInvoke: true
);
// Phase transitions
_stateService.AddStateSubscription(
accessor => accessor.IsEventActive("IntroComplete"),
() => {
_stateService.UnsetStatus("Phase_Intro");
_stateService.SetStatus("Phase_Gameplay");
},
this,
destroyAfterInvoke: true
);
// Boss trigger
_stateService.AddStateSubscription(
accessor => accessor.IsEventActive("ReachedBossRoom"),
() => {
_stateService.UnsetStatus("Phase_Gameplay");
_stateService.SetStatus("Phase_Boss");
StartBossFight();
},
this,
destroyAfterInvoke: true
);
}
private IEnumerator IntroSequence() { yield return null; }
private void StartBossFight() { }
}public class StateDebugger : BehaviourBase
{
[Inject] private StateService _stateService;
[SerializeField] private bool _showDebugOverlay;
private Dictionary<string, bool> _lastFrameStates = new();
private void OnGUI()
{
if (!_showDebugOverlay) return;
int y = 10;
GUI.Label(new Rect(10, y, 200, 20), "--- Active Statuses ---");
y += 25;
// Note: Would need to extend StateService to expose status list
// This is pseudo-code for illustration
foreach (var status in GetActiveStatuses())
{
GUI.Label(new Rect(10, y, 300, 20), status);
y += 20;
}
}
}// 1. Define game events and statuses
public static class GameState
{
public static class Events
{
public const string GameStarted = "game_started";
public const string GameOver = "game_over";
public const string LevelComplete = "level_complete";
public const string PlayerDied = "player_died";
public const string CheckpointReached = "checkpoint_reached";
}
public static class Statuses
{
public const string IsPlaying = "is_playing";
public const string IsPaused = "is_paused";
public const string IsGameOver = "is_game_over";
public const string IsLevelComplete = "is_level_complete";
public const string IsBossFight = "is_boss_fight";
}
public static class Payloads
{
public const string ScoreChanged = "score_changed<int>";
public const string HealthChanged = "health_changed<float>";
public const string ItemCollected = "item_collected<string>";
}
}
// 2. Installer
[DefaultExecutionOrder(-10000)]
public class GameStateInstaller : InstallerBehaviourBase
{
[SerializeField] private bool _debugState = true;
protected override void Install()
{
var stateService = new StateService(_debugState);
BindAsSingleton(stateService);
}
}
// 3. Game manager controlling flow
public class GameManager : BehaviourBase
{
[Inject] private StateService _state;
private int _score;
private float _playerHealth;
protected override void OnEnable()
{
base.OnEnable();
// Subscribe to game events
_state.AddStateSubscription(
accessor => accessor.IsEventActive(GameState.Events.GameStarted),
OnGameStarted,
this
);
_state.AddStateSubscription(
accessor => accessor.IsEventActive(GameState.Events.PlayerDied),
OnPlayerDied,
this
);
_state.AddStateSubscription(
accessor => accessor.IsEventActive(GameState.Events.LevelComplete),
OnLevelComplete,
this,
destroyAfterInvoke: true
);
// Subscribe to state changes
_state.AddStateSubscription(
accessor => accessor.IsStatusJustBecameActive(GameState.Statuses.IsPaused),
OnGamePaused,
this
);
_state.AddStateSubscription(
accessor => accessor.IsStatusJustBecameInactive(GameState.Statuses.IsPaused),
OnGameResumed,
this
);
// Subscribe to score changes with payload
_state.AddStateSubscription(
accessor => accessor.IsEventActive(GameState.Payloads.ScoreChanged),
() => {
if (_state.TryGetPayload<int>(GameState.Payloads.ScoreChanged, out int newScore))
{
UpdateScoreUI(newScore);
}
},
this
);
}
private void Start()
{
StartCoroutine(StartGameSequence());
}
private IEnumerator StartGameSequence()
{
yield return new WaitForSeconds(1f);
_state.SetStatus(GameState.Statuses.IsPlaying);
_state.EmitEvent(GameState.Events.GameStarted);
}
public void AddScore(int amount)
{
_score += amount;
_state.EmitEvent(GameState.Payloads.ScoreChanged, _score);
}
public void PlayerHit(float damage)
{
_playerHealth -= damage;
_state.EmitEvent(GameState.Payloads.HealthChanged, _playerHealth);
if (_playerHealth <= 0)
{
_state.EmitEvent(GameState.Events.PlayerDied);
}
}
public void TogglePause()
{
if (_state.IsStatusActive(GameState.Statuses.IsPaused))
{
_state.UnsetStatus(GameState.Statuses.IsPaused);
}
else
{
_state.SetStatus(GameState.Statuses.IsPaused);
}
}
private void OnGameStarted() => Debug.Log("Game started!");
private void OnGamePaused() => Time.timeScale = 0f;
private void OnGameResumed() => Time.timeScale = 1f;
private void OnPlayerDied() => _state.EmitEvent(GameState.Events.GameOver);
private void OnLevelComplete() => Debug.Log("Level complete!");
private void UpdateScoreUI(int score) => Debug.Log($"Score: {score}");
}
// 4. Player controller responding to state
public class PlayerController : BehaviourBase
{
[Inject] private StateService _state;
private void Start()
{
_state.AddStateSubscription(
accessor => accessor.IsStatusJustBecameActive(GameState.Statuses.IsGameOver),
() => DisablePlayer(),
this
);
_state.AddStateSubscription(
accessor => accessor.IsStatusActive(GameState.Statuses.IsPaused),
() => { /* Disable input */ },
this
);
_state.AddStateSubscription(
accessor => !accessor.IsStatusActive(GameState.Statuses.IsPaused),
() => { /* Enable input */ },
this
);
}
private void DisablePlayer()
{
gameObject.SetActive(false);
}
}
// 5. UI responding to state
public class GameUI : BehaviourBase
{
[Inject] private StateService _state;
[SerializeField] private GameObject _pauseMenu;
[SerializeField] private GameObject _gameOverScreen;
[SerializeField] private TMP_Text _scoreText;
private void Start()
{
_state.AddStateSubscription(
accessor => accessor.IsStatusJustBecameActive(GameState.Statuses.IsPaused),
() => _pauseMenu.SetActive(true),
this
);
_state.AddStateSubscription(
accessor => accessor.IsStatusJustBecameInactive(GameState.Statuses.IsPaused),
() => _pauseMenu.SetActive(false),
this
);
_state.AddStateSubscription(
accessor => accessor.IsStatusJustBecameActive(GameState.Statuses.IsGameOver),
() => _gameOverScreen.SetActive(true),
this
);
_state.AddStateSubscription(
accessor => accessor.IsEventActive(GameState.Payloads.ScoreChanged),
() => {
if (_state.TryGetPayload<int>(GameState.Payloads.ScoreChanged, out int score))
{
_scoreText.text = $"Score: {score}";
}
},
this
);
}
}public class AchievementSystem : BehaviourBase
{
[Inject] private StateService _state;
private Dictionary<string, bool> _unlockedAchievements = new();
private void Start()
{
// First blood achievement
_state.AddStateSubscription(
accessor => accessor.IsEventActive("EnemyKilled"),
() => UnlockAchievement("FIRST_BLOOD"),
this,
destroyAfterInvoke: true
);
// Survivalist achievement (reach level 5 without dying)
_state.AddStateSubscription(
accessor => accessor.IsEventActive("LevelUp") &&
_state.TryGetPayload<int>("LevelUp", out int level) &&
level >= 5,
() => UnlockAchievement("SURVIVALIST"),
this
);
// No damage boss kill
_state.AddStateSubscription(
accessor => accessor.IsStatusActive("BossFight") &&
accessor.IsEventActive("BossDefeated") &&
!accessor.IsEventActive("PlayerDamaged"),
() => UnlockAchievement("FLAWLESS_VICTORY"),
this
);
// Combo master (10 kills in 10 seconds)
int killCount = 0;
float comboTime = 0;
_state.AddStateSubscription(
accessor => accessor.IsEventActive("EnemyKilled"),
() => {
killCount++;
comboTime = 10f;
if (killCount >= 10)
{
UnlockAchievement("COMBO_MASTER");
}
},
this
);
}
private void Update()
{
// Decay combo timer
if (comboTime > 0)
{
comboTime -= Time.deltaTime;
if (comboTime <= 0)
{
killCount = 0;
}
}
}
private void UnlockAchievement(string achievementId)
{
if (_unlockedAchievements.ContainsKey(achievementId)) return;
_unlockedAchievements[achievementId] = true;
_state.EmitEvent("AchievementUnlocked", achievementId);
Debug.Log($"Achievement unlocked: {achievementId}");
}
}public class TutorialSystem : BehaviourBase
{
[Inject] private StateService _state;
[SerializeField] private GameObject _movementPrompt;
[SerializeField] private GameObject _jumpPrompt;
[SerializeField] private GameObject _shootPrompt;
private bool _movementDone;
private bool _jumpDone;
private bool _shootDone;
private void Start()
{
// Start tutorial when game starts
_state.AddStateSubscription(
accessor => accessor.IsEventActive("GameStarted"),
() => {
_state.SetStatus("TutorialActive");
ShowMovementPrompt();
},
this,
destroyAfterInvoke: true
);
// Movement tutorial
_state.AddStateSubscription(
accessor => accessor.IsStatusActive("TutorialActive") &&
!_movementDone &&
accessor.IsEventActive("PlayerMoved"),
() => {
_movementDone = true;
HideMovementPrompt();
ShowJumpPrompt();
},
this
);
// Jump tutorial
_state.AddStateSubscription(
accessor => accessor.IsStatusActive("TutorialActive") &&
_movementDone &&
!_jumpDone &&
accessor.IsEventActive("PlayerJumped"),
() => {
_jumpDone = true;
HideJumpPrompt();
ShowShootPrompt();
},
this
);
// Shoot tutorial
_state.AddStateSubscription(
accessor => accessor.IsStatusActive("TutorialActive") &&
_movementDone &&
_jumpDone &&
!_shootDone &&
accessor.IsEventActive("PlayerShot"),
() => {
_shootDone = true;
HideShootPrompt();
_state.UnsetStatus("TutorialActive");
_state.EmitEvent("TutorialComplete");
},
this
);
// Timeout: if player doesn't move for 10 seconds, show hint
float timeSinceLastMove = 0;
_state.AddStateSubscription(
accessor => accessor.IsStatusActive("TutorialActive") && !_movementDone,
() => {
timeSinceLastMove += Time.deltaTime;
if (timeSinceLastMove > 10f)
{
ShowMovementHint();
timeSinceLastMove = 0;
}
},
this
);
}
private void ShowMovementPrompt() => _movementPrompt.SetActive(true);
private void HideMovementPrompt() => _movementPrompt.SetActive(false);
private void ShowMovementHint() => Debug.Log("Hint: Use WASD to move");
private void ShowJumpPrompt() => _jumpPrompt.SetActive(true);
private void HideJumpPrompt() => _jumpPrompt.SetActive(false);
private void ShowShootPrompt() => _shootPrompt.SetActive(true);
private void HideShootPrompt() => _shootPrompt.SetActive(false);
}| Category | Method | Description |
|---|---|---|
| Events | EmitEvent(key) |
Emit simple event |
EmitEvent<T>(key, payload) |
Emit event with payload | |
IsEventActive(key) |
Check if event active this frame | |
EventPassed(key) |
Check if event occurred previously | |
TryGetPayload<T>(key, out T) |
Safely get payload | |
| Statuses | SetStatus(key) |
Activate persistent status |
UnsetStatus(key) |
Deactivate status | |
IsStatusActive(key) |
Check if status active | |
IsStatusJustBecameActive(key) |
Check if status activated this frame | |
IsStatusJustBecameInactive(key) |
Check if status deactivated this frame | |
| Subscriptions | AddStateSubscription(condition, callback, subscriber) |
Add conditional subscription |
RemoveSubscription(subscription) |
Remove specific subscription | |
RemoveAllSubscriptions(subscriber) |
Remove all for subscriber |
| # | Key Point | Why It Matters |
|---|---|---|
| 1 | Events are one-frame, Statuses are persistent | Choose the right tool for the job |
| 2 | Subscriptions are tied to subscriber objects | Automatic cleanup prevents memory leaks |
| 3 | Conditions use StateAccessor, not direct StateService | Encapsulation and controlled API |
| 4 | JustBecameActive/Inactive detect transitions | Perfect for one-time reactions to state changes |
| 5 | Payload events are type-safe | Pass complex data without casting |
| 6 | Emit times are tracked** | Useful for debugging and time-based logic |
| 7 | Debug mode logs all events and status changes | Invaluable for development |
| 8 | Subscriptions can be one-time | Auto-remove after first trigger |
| Use Events For | Use Statuses For |
|---|---|
| One-time notifications | Persistent game states |
| Triggers that should only happen once | Modes (paused, inventory open) |
| Passing data with payload | Long-lasting conditions |
| Frame-specific reactions | States that can be queried anytime |
| Player actions (died, jumped) | Game phases (boss fight, tutorial) |
The StateService provides a robust, decoupled communication system that makes complex game logic easier to manage and debug, with automatic cleanup to prevent memory leaks.