SoapKit Best Practices
This guide presents battle-tested patterns and practices for building professional games with SoapKit. These recommendations come from real-world projects and help you avoid common pitfalls while maximizing the benefits of the SOAP architecture.
Architecture Principles
1. Single Responsibility Principle
✅ Good: Focused Systems
// Each system has one clear responsibility
public class HealthSystem : MonoBehaviour
{
// Only handles health logic - damage, healing, death
}
public class HealthUI : MonoBehaviour
{
// Only handles health-related UI updates
}
public class HealthAudio : MonoBehaviour
{
// Only handles health-related audio
}
❌ Bad: God Objects
// Violates single responsibility - too many concerns
public class PlayerSystem : MonoBehaviour
{
void Update()
{
HandleMovement(); // Movement concern
HandleHealth(); // Health concern
HandleInventory(); // Inventory concern
HandleAudio(); // Audio concern
HandleUI(); // UI concern
// ... this system knows too much!
}
}
2. Dependency Inversion
✅ Good: Depend on Abstractions
public class WeaponSystem : MonoBehaviour
{
// Depends on SoapKit abstractions, not concrete types
[SerializeField] private IntVariable playerHealth; // Abstract variable
[SerializeField] private IntGameEvent onDamageDealt; // Abstract event
public void DealDamage(int damage)
{
playerHealth.Subtract(damage); // Uses variable interface
onDamageDealt.Raise(damage); // Uses event interface
}
}
❌ Bad: Depend on Concrete Types
public class WeaponSystem : MonoBehaviour
{
// Depends on concrete implementations - tight coupling
public HealthSystem healthSystem; // Concrete dependency
public UIManager uiManager; // Concrete dependency
public AudioManager audioManager; // Concrete dependency
public void DealDamage(int damage)
{
healthSystem.TakeDamage(damage); // Direct method call
uiManager.UpdateHealthDisplay(); // Direct method call
audioManager.PlayDamageSound(); // Direct method call
}
}
Event Design Patterns
3. Event Naming Conventions
Events should be named as past-tense actions or state changes:
✅ Good Event Names:
OnPlayerHealthChanged // Clear what happened
OnItemEquipped // Past tense, specific action
OnLevelCompleted // Clear completion event
OnEnemySpawned // Specific spawn event
OnQuestStarted // Clear beginning event
OnInventoryFull // Clear state change
❌ Bad Event Names:
PlayerEvent // Too generic
ItemStuff // Unclear purpose
LevelThing // Vague naming
EnemyUpdate // Present tense, unclear
QuestEvent // Not specific enough
InventoryChange // What kind of change?
4. Event Data Design
Use immutable structs for event data:
✅ Good Event Data:
[System.Serializable]
public struct DamageEventData
{
public readonly int amount;
public readonly DamageType type;
public readonly GameObject source;
public readonly Vector3 hitPoint;
public DamageEventData(int amount, DamageType type, GameObject source, Vector3 hitPoint)
{
this.amount = amount;
this.type = type;
this.source = source;
this.hitPoint = hitPoint;
}
}
❌ Bad Event Data:
[System.Serializable]
public class BadDamageData
{
public int amount; // Mutable, can be changed by listeners
public string type; // String instead of enum - error prone
public Transform source; // Can become null unexpectedly
public float[] hitData; // Array - unnecessary complexity
}
5. Event Frequency Management
Handle high-frequency events efficiently:
✅ Good: Throttled High-Frequency Events
public class OptimizedMovementEvents : MonoBehaviour
{
[SerializeField] private Vector3GameEvent onPlayerMoved;
[SerializeField] private float eventThrottleRate = 0.1f; // 10fps max
private float lastEventTime;
private Vector3 lastPosition;
void Update()
{
Vector3 currentPosition = transform.position;
// Only raise event if enough time has passed and position changed significantly
if (Time.time - lastEventTime >= eventThrottleRate &&
Vector3.Distance(currentPosition, lastPosition) > 0.1f)
{
onPlayerMoved.Raise(currentPosition);
lastEventTime = Time.time;
lastPosition = currentPosition;
}
}
}
❌ Bad: Unthrottled High-Frequency Events
public class BadMovementEvents : MonoBehaviour
{
[SerializeField] private Vector3GameEvent onPlayerMoved;
void Update()
{
// Raises event every frame (60+ times per second!)
// Can cause performance issues and spam listeners
onPlayerMoved.Raise(transform.position);
}
}
Variable Management
6. Variable Scope and Lifetime
Choose appropriate variable types for different scopes:
✅ Good: Appropriate Variable Scopes
// Global/Persistent Variables - use ScriptableObject variables
[SerializeField] private IntVariable playerLevel; // Persists across scenes
[SerializeField] private StringVariable playerName; // Global player data
[SerializeField] private IntVariable totalScore; // Game-wide score
// Local/Temporary Variables - use regular fields
private float currentAnimationTime; // Temporary animation state
private bool isCurrentlyJumping; // Temporary movement state
private Vector3 localTargetPosition; // Local calculation result
❌ Bad: Wrong Variable Scopes
// Don't use ScriptableObject variables for temporary/local data
[SerializeField] private FloatVariable animationTime; // Should be local field
[SerializeField] private BoolVariable isJumping; // Should be local field
[SerializeField] private Vector3Variable tempPosition; // Should be local field
7. Variable Initialization and Reset
Handle variable initialization properly:
✅ Good: Proper Variable Initialization
public class GameManager : MonoBehaviour
{
[SerializeField] private IntVariable playerScore;
[SerializeField] private BoolVariable isGameActive;
[SerializeField] private FloatVariable gameTime;
void Start()
{
InitializeGameVariables();
}
private void InitializeGameVariables()
{
// Set initial values explicitly
playerScore.SetValue(0);
isGameActive.SetValue(false);
gameTime.SetValue(0f);
// Subscribe to changes after initialization
isGameActive.OnValueChanged += OnGameActiveChanged;
}
public void StartNewGame()
{
// Reset to initial state
playerScore.SetValue(0);
gameTime.SetValue(0f);
isGameActive.SetValue(true);
}
}
❌ Bad: Undefined Initialization
public class BadGameManager : MonoBehaviour
{
[SerializeField] private IntVariable playerScore;
[SerializeField] private BoolVariable isGameActive;
void Start()
{
// Assumes variables have correct initial values
// No explicit initialization - can lead to bugs
isGameActive.OnValueChanged += OnGameActiveChanged;
}
}
Memory Management
8. Event Subscription Lifecycle
Always unsubscribe from events to prevent memory leaks:
✅ Good: Proper Event Lifecycle Management
public class HealthUI : MonoBehaviour
{
[SerializeField] private IntGameEvent onHealthChanged;
[SerializeField] private UnitGameEvent onPlayerDied;
void OnEnable()
{
// Subscribe when enabled
if (onHealthChanged != null)
onHealthChanged.AddListener(UpdateHealthDisplay);
if (onPlayerDied != null)
onPlayerDied.AddListener(ShowDeathScreen);
}
void OnDisable()
{
// Always unsubscribe when disabled
if (onHealthChanged != null)
onHealthChanged.RemoveListener(UpdateHealthDisplay);
if (onPlayerDied != null)
onPlayerDied.RemoveListener(ShowDeathScreen);
}
private void UpdateHealthDisplay(int health) { /* Update UI */ }
private void ShowDeathScreen() { /* Show death UI */ }
}
❌ Bad: Missing Unsubscribe
public class BadHealthUI : MonoBehaviour
{
[SerializeField] private IntGameEvent onHealthChanged;
void Start()
{
// Subscribe but never unsubscribe
onHealthChanged.AddListener(UpdateHealthDisplay);
// Memory leak! Event holds reference to this object even after destruction
}
private void UpdateHealthDisplay(int health) { /* Update UI */ }
}
9. Null Safety
Always check for null references:
✅ Good: Null-Safe Event Handling
public class SafeEventHandler : MonoBehaviour
{
[SerializeField] private IntGameEvent onScoreChanged;
void OnEnable()
{
if (onScoreChanged != null)
onScoreChanged.AddListener(OnScoreChanged);
}
void OnDisable()
{
// Null check prevents errors during shutdown
if (onScoreChanged != null)
onScoreChanged.RemoveListener(OnScoreChanged);
}
private void OnScoreChanged(int score)
{
// Additional null checks for referenced objects
if (scoreText != null)
scoreText.text = score.ToString();
}
public void RaiseScoreEvent(int score)
{
// Null check before raising
if (onScoreChanged != null)
onScoreChanged.Raise(score);
}
}
❌ Bad: No Null Checking
public class UnsafeEventHandler : MonoBehaviour
{
[SerializeField] private IntGameEvent onScoreChanged;
void OnEnable()
{
// Will throw NullReferenceException if onScoreChanged is null
onScoreChanged.AddListener(OnScoreChanged);
}
void OnDisable()
{
// Will throw NullReferenceException during shutdown
onScoreChanged.RemoveListener(OnScoreChanged);
}
}
Performance Optimization
10. Efficient Event Listening
Optimize event listeners for performance:
✅ Good: Efficient Event Listeners
public class OptimizedListener : MonoBehaviour
{
[SerializeField] private IntGameEvent onHealthChanged;
// Cache expensive lookups
private Text healthText;
private Image healthBar;
private int cachedHealth = -1;
void Start()
{
// Cache UI references once
healthText = GetComponent<Text>();
healthBar = GetComponent<Image>();
}
void OnEnable()
{
onHealthChanged.AddListener(OnHealthChanged);
}
void OnDisable()
{
if (onHealthChanged != null)
onHealthChanged.RemoveListener(OnHealthChanged);
}
private void OnHealthChanged(int newHealth)
{
// Only update if value actually changed
if (cachedHealth == newHealth) return;
cachedHealth = newHealth;
// Use cached references
if (healthText != null)
healthText.text = newHealth.ToString();
}
}
❌ Bad: Inefficient Event Listeners
public class SlowListener : MonoBehaviour
{
[SerializeField] private IntGameEvent onHealthChanged;
void OnEnable()
{
onHealthChanged.AddListener(OnHealthChanged);
}
private void OnHealthChanged(int newHealth)
{
// Expensive operations on every event
var healthText = FindObjectOfType<Text>(); // Slow search
var healthBar = GameObject.Find("HealthBar").GetComponent<Image>(); // Slow search
// Update even if value hasn't changed
healthText.text = newHealth.ToString();
healthBar.fillAmount = newHealth / 100f;
// Unnecessary expensive operations
Resources.UnloadUnusedAssets(); // Very expensive!
}
}
11. Variable Access Patterns
Optimize variable access for performance:
✅ Good: Cached Variable Access
public class EfficientVariableUser : MonoBehaviour
{
[SerializeField] private IntVariable playerHealth;
[SerializeField] private FloatVariable moveSpeed;
// Cache values when they change
private int cachedHealth;
private float cachedSpeed;
void Start()
{
// Cache initial values
cachedHealth = playerHealth.Value;
cachedSpeed = moveSpeed.Value;
// Subscribe to changes to update cache
playerHealth.OnValueChanged += (h) => cachedHealth = h;
moveSpeed.OnValueChanged += (s) => cachedSpeed = s;
}
void Update()
{
// Use cached values in performance-critical code
if (cachedHealth <= 0)
HandleDeath();
transform.Translate(Vector3.forward * cachedSpeed * Time.deltaTime);
}
}
❌ Bad: Repeated Variable Access
public class SlowVariableUser : MonoBehaviour
{
[SerializeField] private IntVariable playerHealth;
[SerializeField] private FloatVariable moveSpeed;
void Update()
{
// Accesses .Value multiple times per frame - inefficient
if (playerHealth.Value <= 0)
HandleDeath();
if (playerHealth.Value < 25)
ShowLowHealthWarning();
transform.Translate(Vector3.forward * moveSpeed.Value * Time.deltaTime);
// Even worse - accessing in nested loops
for (int i = 0; i < enemies.Length; i++)
{
if (Vector3.Distance(enemies[i].position, transform.position) < moveSpeed.Value)
{
// Accessing moveSpeed.Value inside loop!
}
}
}
}
Debugging and Testing
12. Debug-Friendly Design
Design systems to be easily debuggable:
✅ Good: Debug-Friendly Implementation
public class DebuggableHealthSystem : MonoBehaviour
{
[Header("Debug Info")]
[SerializeField] private bool enableDebugLogging = true;
[SerializeField] private bool showDebugGUI = false;
[Header("Health Data")]
[SerializeField] private IntVariable currentHealth;
[SerializeField] private IntGameEvent onHealthChanged;
public void TakeDamage(int damage, string source = "unknown")
{
if (enableDebugLogging)
Debug.Log($"[HealthSystem] Taking {damage} damage from {source}. Health: {currentHealth.Value} -> {currentHealth.Value - damage}");
int newHealth = Mathf.Max(0, currentHealth.Value - damage);
currentHealth.SetValue(newHealth);
onHealthChanged.Raise(newHealth);
if (enableDebugLogging && newHealth <= 0)
Debug.Log($"[HealthSystem] Player died from {source}");
}
// Debug methods accessible from inspector
[ContextMenu("Debug: Take 25 Damage")]
private void DebugTakeDamage() => TakeDamage(25, "debug");
[ContextMenu("Debug: Heal to Full")]
private void DebugHealFull() => currentHealth.SetValue(100);
[ContextMenu("Debug: Log Health Status")]
private void DebugLogStatus() => Debug.Log($"Health: {currentHealth.Value}/100");
#if UNITY_EDITOR
void OnGUI()
{
if (!showDebugGUI || !Application.isPlaying) return;
GUILayout.BeginArea(new Rect(10, 10, 200, 100));
GUILayout.Label($"Health: {currentHealth.Value}");
if (GUILayout.Button("Damage 25")) TakeDamage(25, "debug");
if (GUILayout.Button("Heal Full")) currentHealth.SetValue(100);
GUILayout.EndArea();
}
#endif
}
❌ Bad: Hard to Debug
public class HardToDebugSystem : MonoBehaviour
{
[SerializeField] private IntVariable h; // Unclear naming
[SerializeField] private IntGameEvent e; // Unclear naming
public void D(int d) // Unclear method name
{
// No logging, no feedback
h.SetValue(h.Value - d);
e.Raise(h.Value);
if (h.Value <= 0)
{
// Magic happens with no explanation
GameObject.Find("Player").SetActive(false);
}
}
}
13. Testing Strategies
Design for testability:
✅ Good: Testable Design
public class TestableInventorySystem : MonoBehaviour
{
[SerializeField] private IntVariable itemCount;
[SerializeField] private IntVariable capacity;
[SerializeField] private StringGameEvent onItemAdded;
// Exposed properties for testing
public int ItemCount => itemCount.Value;
public int Capacity => capacity.Value;
public bool IsFull => itemCount.Value >= capacity.Value;
// Clear interface methods
public bool TryAddItem(string itemName)
{
if (IsFull) return false;
itemCount.Increment();
onItemAdded.Raise(itemName);
return true;
}
public void RemoveItem()
{
if (itemCount.Value > 0)
itemCount.Decrement();
}
// Test helper methods
public void ClearInventory() => itemCount.SetValue(0);
public void SetCapacity(int newCapacity) => capacity.SetValue(newCapacity);
}
// Corresponding test
[Test]
public void TestInventorySystem()
{
var system = CreateTestInventorySystem();
Assert.IsTrue(system.TryAddItem("Sword"));
Assert.AreEqual(1, system.ItemCount);
Assert.IsFalse(system.IsFull);
}
Team Collaboration
14. Asset Organization
Organize SoapKit assets for team collaboration:
✅ Good: Organized Asset Structure
Assets/
├── Data/
│ ├── Variables/
│ │ ├── Player/
│ │ │ ├── Combat/
│ │ │ │ ├── PlayerHealth.asset
│ │ │ │ ├── PlayerMana.asset
│ │ │ │ └── PlayerStamina.asset
│ │ │ ├── Progression/
│ │ │ │ ├── PlayerLevel.asset
│ │ │ │ ├── PlayerExperience.asset
│ │ │ │ └── PlayerSkillPoints.asset
│ │ │ └── Social/
│ │ │ ├── PlayerName.asset
│ │ │ ├── GuildName.asset
│ │ │ └── FriendsList.asset
│ │ ├── Game/
│ │ │ ├── GameScore.asset
│ │ │ ├── GameTime.asset
│ │ │ └── CurrentLevel.asset
│ │ └── UI/
│ │ ├── MenuIndex.asset
│ │ ├── VolumeLevel.asset
│ │ └── ScreenBrightness.asset
│ └── Events/
│ ├── Player/
│ │ ├── OnPlayerDied.asset
│ │ ├── OnPlayerLevelUp.asset
│ │ └── OnPlayerJoined.asset
│ ├── Game/
│ │ ├── OnGameStart.asset
│ │ ├── OnGamePause.asset
│ │ └── OnLevelComplete.asset
│ └── UI/
│ ├── OnMenuChanged.asset
│ └── OnButtonClicked.asset
15. Documentation Standards
Document your SOAP architecture:
✅ Good: Well-Documented System
/// <summary>
/// Manages player health including damage, healing, regeneration, and death.
///
/// Dependencies:
/// - PlayerHealth (IntVariable): Current health value
/// - MaxHealth (IntVariable): Maximum possible health
/// - OnHealthChanged (IntGameEvent): Raised when health changes
/// - OnPlayerDied (UnitGameEvent): Raised when player dies
///
/// Events Raised:
/// - OnHealthChanged: When health value changes
/// - OnPlayerDied: When health reaches 0
/// - OnRegenStarted: When health regeneration begins
///
/// Events Listened To:
/// - None (this system is autonomous)
///
/// Usage:
/// - TakeDamage(amount): Applies damage to player
/// - Heal(amount): Heals player by specified amount
/// - SetInvulnerable(bool): Toggles damage immunity
/// </summary>
public class HealthSystem : MonoBehaviour
{
// Implementation...
}
❌ Bad: Undocumented System
// No documentation about what this does or how it works
public class HealthSystem : MonoBehaviour
{
// Mystery variables with no explanation
[SerializeField] private IntVariable h;
[SerializeField] private IntGameEvent e1;
[SerializeField] private UnitGameEvent e2;
// Implementation with no comments...
}
Common Anti-Patterns to Avoid
16. Over-Engineering
❌ Bad: Over-Complicated for Simple Needs
// Don't create SOAP variables for every single value
public class OverEngineeredButton : MonoBehaviour
{
[SerializeField] private BoolVariable isButtonHovered; // Overkill
[SerializeField] private BoolVariable isButtonPressed; // Overkill
[SerializeField] private FloatVariable buttonAlpha; // Overkill
[SerializeField] private Vector2Variable buttonSize; // Overkill
[SerializeField] private ColorVariable buttonColor; // Overkill
// This should just be a regular Unity Button with Animator
}
✅ Good: Appropriate Scope
// Only use SOAP for data that needs to be shared across systems
public class AppropriateButton : MonoBehaviour
{
[SerializeField] private StringGameEvent onButtonClicked; // Good - other systems need this
// Local button state doesn't need SOAP variables
private bool isHovered;
private bool isPressed;
private float currentAlpha;
}
17. Event Spam
❌ Bad: Too Many Granular Events
// Don't create separate events for every tiny thing
public class EventSpammer : MonoBehaviour
{
[SerializeField] private IntGameEvent onHealthChanged1;
[SerializeField] private IntGameEvent onHealthChanged2;
[SerializeField] private IntGameEvent onHealthChanged3;
[SerializeField] private IntGameEvent onHealthIncreasedBy1;
[SerializeField] private IntGameEvent onHealthIncreasedBy5;
[SerializeField] private IntGameEvent onHealthIncreasedBy10;
// ... way too many events!
}
✅ Good: Appropriate Event Granularity
// Use fewer, more meaningful events
public class AppropriateEvents : MonoBehaviour
{
[SerializeField] private IntGameEvent onHealthChanged; // General health change
[SerializeField] private IntGameEvent onDamageTaken; // Specific damage event
[SerializeField] private IntGameEvent onHealthHealed; // Specific heal event
[SerializeField] private UnitGameEvent onPlayerDied; // Critical state change
}
Following these best practices will help you build robust, maintainable, and professional games with SoapKit. Remember: start simple and evolve complexity as needed. The SOAP architecture's strength lies in its ability to scale gracefully as your project grows. 🏗️✨
Key Principles Summary:
- Single Responsibility - One concern per system
- Null Safety - Always check for null references
- Memory Management - Subscribe and unsubscribe properly
- Performance - Cache values, throttle high-frequency events
- Debuggability - Make systems easy to inspect and test
- Documentation - Document dependencies and interactions
- Team Collaboration - Organize assets logically
Next Steps:
- Review your existing code against these practices
- Set up coding standards for your team
- Create templates that follow these patterns
- Regular code reviews focusing on SOAP architecture
By following these practices, you'll create Unity games that are not just functional, but professional, maintainable, and scalable! 🎮🚀