publicsealedclassPlayerStats:MonoBehaviour{publicfloatspeed=5f;}// 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);}
Only BaseValue and CurrentValue are serialized (the active modifications are not), so the two restore paths deliberately differ:
JSON (Serializer.JsonStringify / JsonDeserialize) keeps the CurrentValue that was written. An attribute saved while buffed loads with the buff still in the number, because recalculating from a base value with no modifications would silently drop it.
Unity serialization (a [SerializeField] on a MonoBehaviour or ScriptableObject) recalculates from the base value on the first read, because the modifications that produced the saved number belong to a session that has ended.
Call ClearCache() after a JSON load if you intend to rebuild the modifications yourself and want the value to follow them.
The cache also follows the base value. Writing BaseValue through the Inspector, a prefab apply or an undo assigns the field without running any code, so CurrentValue recalculates on the next read rather than reporting a number computed from the old base.
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;}}}
For health, mana, stamina, ammo, shields, and similar resources, keep one component responsible for mutable current state and use Attributes only for caps or rates. This avoids hidden restore bugs when an effect expires.
Set stackingMode on the effect asset to control reapplication:
Stack keeps separate handles (respecting maximumStacks, trimming the oldest when the cap is reached).
Refresh reuses the first handle; set resetDurationOnReapplication = true if the timer should reset on reapplication.
Replace removes existing handles in the same group before adding a new one.
Ignore rejects duplicate applications.
Use stackGroup = CustomKey with a shared stackGroupKey when different assets should share a stack identity.
Inspect active stacks with EffectHandler.GetEffectStackCount(effect) or tag counts for debugging and UI.
An OnEffectRemoved subscriber that re-applies the same effect can leave a Stack eviction no smaller than it started. ApplyEffect then returns null and logs a warning rather than registering a handle past maximumStacks, so a Stack application is one of the two cases where a non-instant effect answers null.
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.
Removing an effect happens in two steps, and callbacks only ever run in the second.
Detach. The handle leaves every index the handler keeps -- active effects, stack group, expiration, periodic schedule, behaviours and cosmetics -- before a single line of your code runs.
Notify and release. Attribute modifications are removed, then tags, then cosmetics, then OnEffectRemoved, then EffectBehavior.OnRemove on each cloned behaviour.
What that buys you:
Queries tell the truth from inside teardown.IsEffectActive, GetEffectStackCount and GetActiveEffects all report the handle as gone by the time your first callback runs.
Removing twice is a no-op, and so is removing a handle that was never applied. A callback that calls RemoveEffect on its own handle -- directly, or through RemoveAllEffects -- returns immediately instead of recursing.
Re-applying during teardown gives you a new, independent handle. The old one stays removed, and RemoveAllEffects leaves the new effect active rather than silently forgetting it.
Self-removal stops the rest of the pass. A behaviour that removes its own handle from OnTick or OnPeriodicTick gets no further tick callbacks for that handle this frame, and neither do the other behaviours cloned alongside it.
A callback that throws still leaves the handler consistent. The remaining phases run, every behaviour clone and cosmetic instance is destroyed, and the first exception is rethrown afterwards. The same holds while applying: if a callback throws during ApplyEffect, the partial application is unwound before the exception reaches you.
One throwing subscriber does not strand its siblings. Removal isolates per unit of work: per AttributesComponent, per attribute modification, per tag, per cosmetic component and per behaviour. Every modification comes off and every tag's count drops even when the first notification throws, because the handle has already left every index and nothing could remove them on a retry. Application and ticking are the other way round -- the first exception abandons the rest of that pass, because an application unwinds to nothing and a tick runs again next frame.
publicoverridevoidOnPeriodicTick(EffectBehaviorContextcontext,PeriodicEffectTickContexttick){if(3<=tick.executedTicks){// Safe from inside the callback. This behaviour and its siblings receive OnRemove before// control returns here, and no further ticks are delivered for this handle.context.handler.RemoveEffect(context.handle);}}
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
❌ 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: Can a callback remove or re-apply an effect while that effect is being removed?
Yes. See Removal Is Two-Phase above. The handle is detached before any callback runs, so removing it again is a no-op, queries report it inactive, and re-applying produces a new independent 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.
Ensure the attribute field matches a public/private Attribute field name on an AttributesComponent subclass.
Regenerate the Attribute Metadata Cache to update editor dropdowns.
Effect didn’t clean up cosmetics
Confirm RequiresInstance is set correctly and components either clean up themselves (CleansUpSelf) or are destroyed by EffectHandler.
Duration didn’t refresh on reapplication
Set resetDurationOnReapplication = true on the AttributeEffect.
"defines periodic or behaviour data but is Instant"
An Instant effect returns no handle, so its periodicEffects and behaviors can never run. Either give the effect a Duration/Infinite duration type or clear those lists. The message appears in the Inspector when the effect is edited, and once per effect on the first application.
"has an unassigned CosmeticEffectData entry"
A slot in the effect's Cosmetic Effects list is empty. Empty slots cannot be instanced and are skipped; remove the slot or assign a prefab.
A saved attribute loads without its buff
JSON keeps the written CurrentValue; Unity serialization recalculates from the base value. See Saving and Loading an Attribute.
While the Effects System handles traditional buff/debuff mechanics well, it can also be used to build 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 sound 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"));}
Attribute field discovery is cached (and can be precomputed by the Attribute Metadata Cache generator).
Tag queries provide overloads for lists to minimize allocations; prefer IReadOnlyList<string> overloads in hot paths.
Cosmetics can be a significant cost; prefer shared presenters when possible.
An AttributeEffect authoring mistake is reported once per effect, not once per application. Each report used to render the whole effect to JSON, measured at 20.5 us on 6000.4.6f1.
Related:
README section: "Effects, Attributes, and Tags"
Attribute Metadata Cache (Editor Tools) for dropdowns and performance