// Every buff needs its own custom MonoBehaviour:publicclassHasteEffect:MonoBehaviour{privatefloatduration=5f;privatefloatoriginalSpeed;privatePlayerStatsplayer;voidStart(){player=GetComponent<PlayerStats>();originalSpeed=player.speed;player.speed*=1.5f;// Apply speed boost}voidUpdate(){duration-=Time.deltaTime;if(duration<=0){player.speed=originalSpeed;// RestoreDestroy(this);}}}// 20 effects × 50 lines each = 1000 lines of repetitive code// Designers can't create effects without programmer
// ❌ BAD: CurrentHealth as an AttributepublicclassPlayerStats:AttributesComponent{publicAttributeCurrentHealth=100f;// DON'T DO THIS!publicAttributeMaxHealth=100f;// This is fine}// Multiple systems modify CurrentHealth:voidTakeDamage(floatdamage){// Direct mutation bypasses the effects systemplayerStats.CurrentHealth.BaseValue-=damage;// Problem 1: If an effect was modifying CurrentHealth,// it still applies! Now calculations are wrong.// Problem 2: If you remove an effect, it may restore// the ORIGINAL base value, undoing damage taken.// Problem 3: Save/load becomes complicated - do you save// base or current? What about active modifiers?}
// ✅ GOOD: CurrentHealth is a regular field, MaxHealth is an AttributepublicclassPlayerStats:AttributesComponent{// Regular field - modified by combat/healing systems directlyprivatefloatcurrentHealth=100f;// Attribute - modified by buffs/effectspublicAttributeMaxHealth=100f;publicfloatCurrentHealth{get=>currentHealth;set=>currentHealth=Mathf.Clamp(value,0,MaxHealth.Value);}protectedoverridevoidAwake(){base.Awake();// Initialize current health to maxcurrentHealth=MaxHealth.Value;// When max health changes, clamp current healthOnAttributeModified+=(attributeName,oldVal,newVal)=>{if(attributeName==nameof(MaxHealth)){// If max decreased, ensure current doesn't exceed new maxif(currentHealth>newVal){currentHealth=newVal;}}};}}// Combat system can now safely modify current healthvoidTakeDamage(floatdamage){playerStats.CurrentHealth-=damage;// Simple and correct}// Effects system modifies max healthvoidApplyHealthBuff(){// MaxHealth × 1.5 (buffs max, current stays same)player.ApplyEffect(healthBuffEffect);}
When you use Attributes for frequently mutated "current" values:
State conflicts - The effects system and other systems fight over the value
Save/load bugs - Unclear whether to save base value or current value with modifiers
Unexpected restorations - Removing an effect may restore old base value, losing damage/healing
Performance overhead - Recalculating modifications on every damage tick
Complexity - Need to carefully coordinate between effects and direct mutations
The Golden Rule: If a value is modified by systems outside the effects system regularly (combat, regeneration, consumption), it should NOT be an Attribute. Use a regular field instead and let Attributes handle the maximums/limits.
// PowerUpEffect.asset:// - durationType: Duration (10 seconds)// - effectTags: ["CanDash", "CanDoubleJump", "PoweredUp"]// - modifications: (EMPTY)publicvoidGrantPowerUp(){player.ApplyEffect(powerUpEffect);// Player now has special abilities for 10 seconds}
Cosmetic effects handle the visual and audio presentation of effects. They provide a clean separation between gameplay logic (tags, attributes) and presentation (particles, sounds, UI).
usingUnityEngine;usingWallstopStudios.UnityHelpers.Tags;publicclassParticleCosmeticEffect:CosmeticEffectComponent{[SerializeField]privateParticleSystemparticles;// RequiresInstance = true creates a new instance per application// RequiresInstance = false shares one instance across all applicationspublicoverrideboolRequiresInstance=>true;// CleansUpSelf = true means you handle destruction yourself// CleansUpSelf = false means EffectHandler destroys the GameObjectpublicoverrideboolCleansUpSelf=>false;publicoverridevoidOnApplyEffect(GameObjecttarget){base.OnApplyEffect(target);// Attach cosmetic to targettransform.SetParent(target.transform);transform.localPosition=Vector3.zero;// Start visual effectparticles.Play();}publicoverridevoidOnRemoveEffect(GameObjecttarget){base.OnRemoveEffect(target);// Stop particlesparticles.Stop();// If CleansUpSelf = false, GameObject is destroyed automatically// If CleansUpSelf = true, you must handle destruction}}
publicclassStatusIconCosmetic:CosmeticEffectComponent{publicoverrideboolRequiresInstance=>false;// SHARED[SerializeField]privateImageiconImage;privateintactiveCount=0;publicoverridevoidOnApplyEffect(GameObjecttarget){base.OnApplyEffect(target);activeCount++;// Show icon if this is first applicationif(activeCount==1){iconImage.enabled=true;}}publicoverridevoidOnRemoveEffect(GameObjecttarget){base.OnRemoveEffect(target);activeCount--;// Hide icon when no more applicationsif(activeCount==0){iconImage.enabled=false;}}}
RequiresInstance = true (Instanced):
New cosmetic instance created for each application
Best for: Particles, per-effect animations, independent visuals
publicclassFireParticleCosmetic:CosmeticEffectComponent{publicoverrideboolRequiresInstance=>true;// INSTANCED[SerializeField]privateParticleSystemfireParticles;publicoverridevoidOnApplyEffect(GameObjecttarget){base.OnApplyEffect(target);// Each instance is independenttransform.SetParent(target.transform);transform.localPosition=Vector3.zero;fireParticles.Play();}}
publicclassFadeOutEffect:CosmeticEffectComponent{publicoverrideboolCleansUpSelf=>true;// MANUAL[SerializeField]privatefloatfadeOutDuration=1f;privateboolisRemoving=false;publicoverridevoidOnRemoveEffect(GameObjecttarget){base.OnRemoveEffect(target);if(!isRemoving){isRemoving=true;StartCoroutine(FadeOutAndDestroy());}}privateIEnumeratorFadeOutAndDestroy(){// Fade out over timefloatelapsed=0f;SpriteRenderersprite=GetComponent<SpriteRenderer>();ColororiginalColor=sprite.color;while(elapsed<fadeOutDuration){elapsed+=Time.deltaTime;floatalpha=1f-(elapsed/fadeOutDuration);sprite.color=newColor(originalColor.r,originalColor.g,originalColor.b,alpha);yieldreturnnull;}// Now safe to destroyDestroy(gameObject);}}
publicclassBuffCosmetic:CosmeticEffectComponent{[SerializeField]privateParticleSystembuffParticles;[SerializeField]privateAudioSourceaudioSource;[SerializeField]privateAudioClipapplySound;[SerializeField]privateAudioClipremoveSound;publicoverrideboolRequiresInstance=>true;publicoverrideboolCleansUpSelf=>false;publicoverridevoidOnApplyEffect(GameObjecttarget){base.OnApplyEffect(target);// Position cosmetic on targettransform.SetParent(target.transform);transform.localPosition=Vector3.zero;// Play effectsbuffParticles.Play();audioSource.PlayOneShot(applySound);}publicoverridevoidOnRemoveEffect(GameObjecttarget){base.OnRemoveEffect(target);audioSource.PlayOneShot(removeSound);buffParticles.Stop();// Automatic cleanup after this}}
publicclassStatusOverlayCosmetic:CosmeticEffectComponent{[SerializeField]privateSpriteRendereroverlaySprite;[SerializeField]privateColoroverlayColor=Color.red;publicoverrideboolRequiresInstance=>false;// SHAREDpublicoverrideboolCleansUpSelf=>false;privateSpriteRenderertargetSprite;publicoverridevoidOnApplyEffect(GameObjecttarget){base.OnApplyEffect(target);targetSprite=target.GetComponent<SpriteRenderer>();if(targetSprite!=null){// Tint the spritetargetSprite.color=overlayColor;}}publicoverridevoidOnRemoveEffect(GameObjecttarget){base.OnRemoveEffect(target);if(targetSprite!=null){// Restore original colortargetSprite.color=Color.white;}}}
Populate the periodicEffects list on an AttributeEffect to schedule damage/heal-over-time, resource regen, or scripted pulses without external coroutines.
Each definition supports initialDelay, interval, and maxTicks (0 = infinite) plus its own AttributeModification bundle applied on every tick.
Periodic payloads run only for Duration/Infinite effects; they automatically stop after maxTicks or when the effect handle is removed.
Combine multiple definitions for mixed cadences (e.g., fast minor regen + slower burst heals).
Attach EffectBehavior ScriptableObjects to the behaviors list for per-handle runtime logic.
The system clones behaviours on application and calls OnApply, OnTick (each frame), OnPeriodicTick (after periodic payloads fire), and OnRemove.
Behaviours are ideal for integrating bespoke systems (e.g., camera shakes, AI hooks, quest tracking) while keeping designer-authored effects data-driven.
Keep behaviours stateless or store per-handle state on the cloned instance; clean up in OnRemove.
Instead of managing effects through inspector references or Resources.Load calls, consider using an enum-based registry for centralized, type-safe access to all your effects:
// 1. Define an enum for all your effectspublicenumEffectType{HastePotion,StrengthBuff,PoisonDebuff,ShieldBuff,FireDamageOverTime,}// 2. Create a centralized registrypublicclassEffectRegistry:ScriptableObject{[System.Serializable]privateclassEffectEntry{publicEffectTypetype;publicAttributeEffecteffect;}[SerializeField]privateEffectEntry[]effects;privateDictionary<EffectType,AttributeEffect>effectLookup;privatevoidOnEnable(){effectLookup=effects.ToDictionary(e=>e.type,e=>e.effect);}publicAttributeEffectGetEffect(EffectTypetype){returneffectLookup.TryGetValue(type,outAttributeEffecteffect)?effect:null;}}// 3. Usage - type-safe and refactorablepublicclassPlayerAbilities:MonoBehaviour{[SerializeField]privateEffectRegistryeffectRegistry;publicvoidDrinkHastePotion(){// Compiler ensures this effect existsAttributeEffecthaste=effectRegistry.GetEffect(EffectType.HastePotion);this.ApplyEffect(haste);// Typos are caught at compile time// effectRegistry.GetEffect(EffectType.HastPotoin); // ❌ Won't compile}}
usingSystem.ComponentModel;publicenumEffectType{[Description("Haste Potion")]HastePotion,[Description("Strength Buff (10s)")]StrengthBuff,[Description("Poison DoT")]PoisonDebuff,[Description("Shield (+50 Defense)")]ShieldBuff,}// Custom PropertyDrawer can display Description in inspector// Or use Unity's [InspectorName] attribute in Unity 2021.2+:// [InspectorName("Haste Potion")] HastePotion,
Cached Name Pattern for Performance:
If you're doing frequent lookups or displaying effect names in UI, cache the enum-to-string mappings:
publicstaticclassEffectTypeExtensions{privatestaticreadonlyDictionary<EffectType,string>DisplayNames=new(){{EffectType.HastePotion,"Haste Potion"},{EffectType.StrengthBuff,"Strength Buff"},{EffectType.PoisonDebuff,"Poison"},{EffectType.ShieldBuff,"Shield"},};publicstaticstringGetDisplayName(thisEffectTypetype){returnDisplayNames.TryGetValue(type,outstringname)?name:type.ToString();}}// Usage in UIvoidUpdateEffectTooltip(EffectTypeeffectType){tooltipText.text=effectType.GetDisplayName();// No allocations, no typos, refactor-safe}
Benefits:
✅ Type safety - Compiler catches typos and missing effects ✅ Refactoring - Rename effects across the entire codebase reliably ✅ Autocomplete - IDE suggests all available effects ✅ Performance - Dictionary lookup avoids Resources.Load overhead ✅ No magic strings - Effect references are code symbols, not brittle strings
Drawbacks:
⚠️ Centralization - All effects must be registered in the enum and registry ⚠️ Designer friction - Programmers must add enum entries for new effects ⚠️ Scalability - With 100+ effects, enum becomes unwieldy (consider categories) ⚠️ Asset decoupling - Effects are tied to code enum, harder to add via mods/DLC
❌ Modding/DLC systems (effects defined outside codebase)
❌ Very large effect catalogs (enums become bloated)
❌ Rapid prototyping (slows iteration)
Integration with Unity Helpers' Built-in Enum Utilities:
This package already includes high-performance EnumDisplayNameAttribute and ToCachedName() extensions (see EnumExtensions.cs:437-478). You can use these for better performance:
usingWallstopStudios.UnityHelpers.Core.Attributes;usingWallstopStudios.UnityHelpers.Core.Extension;publicenumEffectType{[EnumDisplayName("Haste Potion")]HastePotion,[EnumDisplayName("Strength Buff (10s)")]StrengthBuff,[EnumDisplayName("Poison DoT")]PoisonDebuff,}// High-performance cached display name (zero allocation after first call)voidUpdateEffectTooltip(EffectTypeeffectType){tooltipText.text=effectType.ToDisplayName();// Uses EnumDisplayNameCache<T>}// Or use ToCachedName() for the enum's field name without attributesvoidLogEffect(EffectTypeeffectType){Debug.Log($"Applied: {effectType.ToCachedName()}");// Uses EnumNameCache<T>}
Performance characteristics:
ToDisplayName(): O(1) lookup, zero allocations (array-based for enums ≤256 values)
ToCachedName(): O(1) lookup, zero allocations, thread-safe with concurrent dictionary
Both use aggressive inlining and avoid boxing
This eliminates the need to manually maintain a DisplayNames dictionary as shown in the earlier example—the package already provides optimized caching infrastructure.
// Check if effect has a specific tagboolhasTag=effect.HasTag("Haste");// Check if effect has any of the specified tagsboolhasAny=effect.HasAnyTag(new[]{"Haste","Speed","Boost"});boolhasAnyFromList=effect.HasAnyTag(myTagList);// IReadOnlyList<string> overload
// Check if effect modifies a specific attributeboolmodifiesSpeed=effect.ModifiesAttribute("Speed");// Get all modifications for a specific attributeusingvarlease=Buffers<AttributeModification>.List.Get(outList<AttributeModification>mods);effect.GetModifications("Speed",mods);foreach(AttributeModificationmodinmods){Debug.Log($"Action: {mod.action}, Value: {mod.value}");}
// Check if a single tag is activeif(player.HasTag("Stunned")){DisableInput();}// Check if any of the tags are activeif(player.HasAnyTag(new[]{"Stunned","Frozen","Sleeping"})){PreventMovement();}// Check if all tags are activeif(player.HasAllTags(new[]{"Wet","Grounded"})){ApplyElectricShock();}// Check if none of the tags are activeif(player.HasNoneOfTags(new[]{"Invulnerable","Untargetable"})){AllowDamage();}
// Get the active count for a tagif(player.TryGetTagCount("Poisoned",outintstacks)&&stacks>=3){TriggerCriticalPoisonWarning();}// Get all active tagsList<string>activeTags=player.GetActiveTags();foreach(stringtaginactiveTags){Debug.Log($"Active tag: {tag}");}
Collection Type Support:
All tag query methods support multiple collection types with optimized implementations:
IReadOnlyList<string> (optimized with index-based iteration)
// Example with different collection typesHashSet<string>immunityTags=new(){"Invulnerable","Immune"};if(player.HasAnyTag(immunityTags)){PreventDamage();}List<string>crowdControlTags=new(){"Stunned","Rooted","Silenced"};if(player.HasNoneOfTags(crowdControlTags)){EnableAllAbilities();}
// Check if a specific effect is currently activeif(effectHandler.IsEffectActive(hasteEffect)){ShowHasteIndicator();}// Get the stack count for an effectinthasteStacks=effectHandler.GetEffectStackCount(hasteEffect);Debug.Log($"Haste stacks: {hasteStacks}");// Get remaining duration for a specific effect instanceif(effectHandler.TryGetRemainingDuration(effectHandle,outfloatremaining)){UpdateDurationUI(remaining);}
No! Use Attributes for values primarily modified by the effects system (MaxHealth, Speed, AttackDamage). CurrentHealth is modified by multiple systems (combat, healing, regeneration) and should be a regular field. See "Understanding Attributes: What to Model and What to Avoid" section above for details. Mixing direct mutations with effect modifications causes state conflicts and save/load bugs.
Q: Why didn't I get an EffectHandle?
Instant effects modify the base value permanently and do not return a handle (null). Duration/Infinite do.
Q: Do modifications stack across multiple effects?
Yes. Each Attribute applies all active modifications ordered by action: Addition → Multiplication → Override.
Q: How do I remove just one instance of an effect?
Keep the EffectHandle returned from ApplyEffect and pass it to RemoveEffect(handle).
Q: Two systems apply the same tag. Who owns removal?
The tag is reference‑counted. Each application increments the count; removal decrements it. The tag is removed when the count reaches 0.
Q: When should I use tags vs. checking stats?
Use tags to represent categorical states (e.g., Stunned/Poisoned/Invulnerable) independent of numeric values. Check stats for numeric thresholds or calculations.
Q: How do I check if an effect modifies a specific attribute?
Use effect.ModifiesAttribute("AttributeName") to check if an effect contains modifications for a specific attribute, or effect.GetModifications("AttributeName", buffer) to retrieve all modifications for that attribute.
Q: How do I query tag counts or check multiple tags at once?
Use TryGetTagCount(tag, out int count) to get the active count for a tag, HasAllTags(tags) to check if all tags are active, or HasNoneOfTags(tags) to check if none are active.
While the Effects System handles traditional buff/debuff mechanics well, it can also be used to build robust capability systems that drive complex gameplay decisions across your entire codebase. This section explores advanced patterns that use tags extensively for architectural purposes.
// ❌ OLD WAY: Scattered boolean flagspublicclassPlayerController:MonoBehaviour{publicboolisInvulnerable;publicboolcanDash;publicboolhasDoubleJump;publicboolisInvisible;// 50+ booleans later...voidTakeDamage(floatdamage){if(isInvulnerable)return;// ...}voidUpdate(){if(Input.GetKeyDown(KeyCode.Space)&&canDash)Dash();}}// Problems:// 1. Every system needs direct references to check flags// 2. Adding temporary effects requires custom timers// 3. Multiple sources granting same capability = conflicts// 4. No centralized place to see what capabilities exist// 5. Difficult to debug "why can't I do X?"
// ✅ NEW WAY: Tag-based capability systempublicclassPlayerController:MonoBehaviour{voidTakeDamage(floatdamage){// Any system can grant "Invulnerable" tagif(this.HasTag("Invulnerable"))return;// ...}voidUpdate(){// Check capability before allowing actionif(Input.GetKeyDown(KeyCode.Space)&&this.HasTag("CanDash"))Dash();}}// Benefits:// 1. Decoupled - systems query tags, don't need direct references// 2. Multiple sources work automatically (reference-counted)// 3. Temporary effects are free - just apply/remove tag// 4. Debuggable - inspect TagHandler to see all active tags// 5. Designer-friendly - add capabilities via ScriptableObjects
The Problem: Many different sources need to grant invulnerability (power-ups, cutscenes, dash moves, debug mode). Without tags, you need complex logic to track all sources.
// ❌ DON'T: Check multiple specific tagsif(target.HasTag("DashInvulnerable")||target.HasTag("PowerStarInvulnerable")||target.HasTag("DebugInvulnerable")){// Now you need to update this everywhere you add a new invulnerability source!}// ✅ DO: Check one general capability tagif(target.HasTag("Invulnerable")){// Works with all current and future invulnerability sources}
The Problem: AI needs to make decisions based on complex state (player stealth, environmental conditions, buffs, etc.). Without a unified system, you end up with brittle if/else chains.
// === Setup effects that grant capability tags ===// Stealth.asset:// - effectTags: ["Invisible", "Stealthy"]// - modifications: (none - just tags)// InWater.asset:// - effectTags: ["Wet", "Swimming"]// - modifications: Speed × 0.5// OnFire.asset:// - effectTags: ["Burning", "OnFire"]// - modifications: Health + (-5 per second)// === AI uses tags to make robust decisions ===publicclassEnemyAI:MonoBehaviour{publicvoidUpdateAI(){GameObjectplayer=FindPlayer();// 1. Visibility checksif(player.HasTag("Invisible")){// Can't see invisible targets - use last known positionPatrolToLastKnownPosition();return;}// 2. Threat assessmentif(player.HasTag("Invulnerable")&&player.HasTag("PowerStar")){// Player is powered up - flee!Flee(player.transform.position);return;}// 3. Environmental awarenessif(this.HasTag("Burning")){// On fire - prioritize finding waterGameObjectwater=FindNearestWater();if(water!=null){MoveTowards(water.transform.position);return;}}// 4. Tactical decisionsif(player.HasTag("Stunned")||player.HasTag("Slowed")){// Player is vulnerable - aggressive pursuitAggressiveAttack(player);return;}// 5. Element interactionsif(this.HasTag("Wet")&&player.HasTag("ElectricWeapon")){// We're wet and player has electric weapon - dangerous!MaintainDistance(player,minDistance:10f);return;}// Default behaviorChaseAndAttack(player);}// Helper: Check multiple conditions easilyboolCanEngageInCombat(){// Can't fight if we're stunned, fleeing, or in a cutscenereturn!this.HasTag("Stunned")&&!this.HasTag("Fleeing")&&!this.HasTag("InCutscene");}}
Why This Works:
✅ Readable - AI logic is self-documenting ("if player is invisible")
✅ Extensible - Add new capabilities without modifying AI code
✅ Composable - Combine multiple tags for complex conditions
✅ Testable - Apply tags in tests to verify AI behavior
✅ Designer-friendly - Designers can create new effects that AI automatically responds to
// === Setup unlock effects ===// UnlockDoubleJump.asset:// - durationType: Infinite (permanent unlock)// - effectTags: ["CanDoubleJump", "HasUpgrade"]// QuestKeyItem.asset:// - durationType: Infinite// - effectTags: ["HasKeyItem", "CanEnterDungeon"]// TutorialComplete.asset:// - durationType: Infinite// - effectTags: ["TutorialComplete", "CanAccessMultiplayer"]// === Usage throughout game systems ===// Ability systempublicclassPlayerAbilities:MonoBehaviour{voidUpdate(){// Jumpif(Input.GetKeyDown(KeyCode.Space)){if(isGrounded){Jump();}// Double jump only works if unlockedelseif(this.HasTag("CanDoubleJump")&&!hasUsedDoubleJump){Jump();hasUsedDoubleJump=true;}}// Dashif(Input.GetKeyDown(KeyCode.LeftShift)){if(this.HasTag("CanDash")){Dash();}else{ShowMessage("Unlock dash ability first!");}}}}// Level gatepublicclassDungeonGate:MonoBehaviour{voidOnTriggerEnter2D(Collider2Dother){GameObjectplayer=other.gameObject;if(player.HasTag("HasKeyItem")){// Has the key - open gateOpenGate();}else{// Missing key - show hintShowMessage("You need the Ancient Key to enter.");}}}// UI systempublicclassMainMenuUI:MonoBehaviour{[SerializeField]privateButtonmultiplayerButton;voidUpdate(){// Disable multiplayer until tutorial is completemultiplayerButton.interactable=player.HasTag("TutorialComplete");}}// Save systempublicclassSaveSystem:MonoBehaviour{publicSaveDataCreateSaveData(GameObjectplayer){// Save all permanent unlocksvarsaveData=newSaveData{unlockedAbilities=newList<string>()};// Check all capability tagsif(player.HasTag("CanDoubleJump"))saveData.unlockedAbilities.Add("DoubleJump");if(player.HasTag("CanDash"))saveData.unlockedAbilities.Add("Dash");if(player.HasTag("HasKeyItem"))saveData.unlockedAbilities.Add("KeyItem");returnsaveData;}publicvoidLoadSaveData(GameObjectplayer,SaveDatasaveData){// Reapply permanent unlocksforeach(stringabilityinsaveData.unlockedAbilities){AttributeEffectunlock=LoadUnlockEffect(ability);player.ApplyEffect(unlock);}}}
Why This Works:
✅ Persistent - Infinite duration effects work like permanent flags
✅ Serializable - Easy to save/load by checking tags
✅ Discoverable - Inspect TagHandler to see all unlockables
✅ No hardcoded strings - Create unlock effects as assets
// === Setup element effects ===// Wet.asset:// - durationType: Duration (10 seconds)// - effectTags: ["Wet", "ConductsElectricity"]// - cosmeticEffects: water drips// Burning.asset:// - durationType: Duration (5 seconds)// - effectTags: ["Burning", "OnFire"]// - modifications: Health + (-5 per second)// - cosmeticEffects: fire particles// Frozen.asset:// - durationType: Duration (3 seconds)// - effectTags: ["Frozen", "Immobilized"]// - modifications: Speed × 0// Electrified.asset:// - durationType: Duration (4 seconds)// - effectTags: ["Electrified", "Stunned"]// - modifications: Speed × 0// === Interaction system ===publicclassElementalInteractions:MonoBehaviour{[SerializeField]privateAttributeEffectwetEffect;[SerializeField]privateAttributeEffectburningEffect;[SerializeField]privateAttributeEffectfrozenEffect;[SerializeField]privateAttributeEffectelectrifiedEffect;publicvoidOnEnvironmentalEffect(GameObjecttarget,stringeffectType){switch(effectType){case"Water":// Apply wettarget.ApplyEffect(wetEffect);// Water puts out fireif(target.HasTag("Burning")){target.RemoveEffects(target.GetHandlesWithTag("Burning"));CreateSteamParticles(target.transform.position);}break;case"Fire":// Fire dries wet targetsif(target.HasTag("Wet")){target.RemoveEffects(target.GetHandlesWithTag("Wet"));CreateSteamParticles(target.transform.position);}else{// Set on fire if drytarget.ApplyEffect(burningEffect);}break;case"Ice":// Ice freezes wet targets (stronger effect)if(target.HasTag("Wet")){target.ApplyEffect(frozenEffect);target.RemoveEffects(target.GetHandlesWithTag("Wet"));}break;case"Electric":// Electric shocks wet targetsif(target.HasTag("Wet")){// Extra damage and stuntarget.ApplyEffect(electrifiedEffect);target.TakeDamage(20f);// Bonus damageCreateElectricParticles(target.transform.position);}break;}}publicfloatCalculateElementalDamageMultiplier(GameObjectattacker,GameObjecttarget){floatmultiplier=1f;// Fire does extra damage to frozen targets (they thaw)if(attacker.HasTag("FireWeapon")&&target.HasTag("Frozen"))multiplier*=1.5f;// Electric does massive damage to wet targetsif(attacker.HasTag("ElectricWeapon")&&target.HasTag("Wet"))multiplier*=2.0f;// Ice does extra damage to burning targets (extinguish)if(attacker.HasTag("IceWeapon")&&target.HasTag("Burning"))multiplier*=1.5f;returnmultiplier;}}
Why This Works:
✅ Composable - Elements interact naturally through tags
✅ Discoverable - All active elements visible in TagHandler
✅ Designer-friendly - Create new elements without code changes
✅ Debuggable - See the exact element state at any moment
✅ Extensible - Add new elements and interactions easily
// ❌ OLD WAY: Rigid state machinepublicenumPlayerState{Idle,Walking,Running,Jumping,Attacking,Stunned,// What if player is jumping AND attacking?// What if player is attacking AND stunned?// Need combinatorial explosion of states!}privatePlayerStatecurrentState;voidUpdate(){switch(currentState){casePlayerState.Stunned:// Can't do anything when stunnedreturn;casePlayerState.Attacking:// Can't move while attacking// But what if we want to allow movement during some attacks?break;// 50 more cases...}}
// ✅ NEW WAY: Flexible tag-based statevoidUpdate(){// States can overlap naturallyboolisGrounded=CheckGrounded();boolisMoving=Input.GetAxis("Horizontal")!=0;// Check capabilities, not rigid statesif(this.HasTag("Stunned")||this.HasTag("Frozen")){// Can't act while crowd-controlledreturn;}// Movementif(isMoving&&!this.HasTag("Immobilized")){Move();// Can attack while moving (if not attacking already)if(Input.GetButtonDown("Fire1")&&!this.HasTag("Attacking")){Attack();}}// Jumpingif(Input.GetButtonDown("Jump")&&isGrounded){if(this.HasTag("CanJump")&&!this.HasTag("Jumping")){Jump();}}// Special abilitiesif(Input.GetButtonDown("Dash")){if(this.HasTag("CanDash")&&!this.HasTag("Dashing")){Dash();}}}// Actions apply tags to themselvesvoidAttack(){// Apply "Attacking" tag for duration of attackthis.ApplyEffect(attackingEffect);// 0.5s duration// Play animation...}voidDash(){// Apply multiple tags during dashthis.ApplyEffect(dashingEffect);// Effect grants: ["Dashing", "Invulnerable", "FastMovement"]// All removed automatically after duration}
Why This Works:
✅ Composable - Multiple states can be active simultaneously
✅ Flexible - Easy to add conditional behavior based on tags
✅ No spaghetti - Avoid complex state transition logic
✅ Self-documenting - Tag names describe what's happening
✅ Designer-friendly - Add new states via ScriptableObjects
// ✅ Good: Clear categories"Status_Stunned""Ability_CanDash""Quest_HasKeyItem""Element_Burning"// ❌ Bad: Ambiguous"Stunned"// Status or ability?"Fire"// On fire or has fire weapon?
/// Tags Registry/// ===================================/// Invulnerable - Cannot take damage from any source/// Stunned - Cannot perform any actions (move, attack, cast)/// InCombat - Currently engaged in combat (prevents resting, saving)/// Invisible - Cannot be seen by AI or targeted/// CanDash - Has unlocked dash ability/// CanDoubleJump - Has unlocked double jump ability/// Wet - Conducts electricity, prevents fire, can be frozen/// Burning - Takes fire damage over time, can ignite others
// effectTags serve multiple purposes:// - Internal organization (removable via GetHandlesWithTag + RemoveEffects)// - Gameplay queries (checked via HasTag)// - Effect identification and categorization// Example effect:// HastePotion.asset:// - effectTags: ["Haste", "Potion", "Buff", "MovementBuff"]// - Use "Haste" for gameplay queries (player.HasTag("Haste"))// - Use "Potion" for finding/removing all potions// - Use "Buff" for UI categorization
Test tag combinations - Verify interactions work correctly
[Test]publicvoidTestInvulnerability_MultipleSourcesStack(){GameObjectplayer=CreateTestPlayer();// Apply invulnerability from two sourcesEffectHandle?dash=player.ApplyEffect(dashInvulnerability);EffectHandle?powerup=player.ApplyEffect(powerupInvulnerability);Assert.IsTrue(player.HasTag("Invulnerable"));// Remove one source - should still be invulnerableplayer.RemoveEffect(dash.Value);Assert.IsTrue(player.HasTag("Invulnerable"));// Remove second source - now vulnerableplayer.RemoveEffect(powerup.Value);Assert.IsFalse(player.HasTag("Invulnerable"));}