geometry-tower-defense/design/gdd/tag-system.md

18 KiB
Raw Blame History

Tag System

Status: Approved — defined to unblock Tower Assembly (TotalStack semantics specified) Author: SepComet + agents Last Updated: 2026-04-30 Implements Pillar: Tactical preparation and adaptation — tag variety and stack depth create assembly decision space

Overview

The Tag System defines the passive modifier layer that sits on top of tower base stats. Tags are randomly attached to components at generation time and flow through to assembled towers via TowerTagAggregationService.AggregateTowerTags(). The core design question this GDD answers: what does TotalStack (13) actually mean mechanically?

The answer is tag-specific:

  • Fire: TotalStack directly scales DOT damage — more stacks means more burn damage per tick
  • Ice / Crit / Execution / Shatter: TotalStack is an on/off signal — the tag activates or it doesn't; stack count above 1 does not increase effect magnitude
  • Inferno / AbsoluteZero: Amplifier tags — they enhance the corresponding base tag but carry no independent stack value

This asymmetry is intentional. Fire is the only tag where pursuing higher stacks is mechanically rewarding. Other tags reward having the tag, not doubling up on it. This keeps assembly interesting: stacking Fire is a valid strategy, but single copies of Ice + Crit + Execution on the same tower is also powerful.

Player Fantasy

"Every component carries a hidden gift — when the right gifts align, the tower becomes more than the sum of its parts."

The Tag System delivers the fantasy of emergent tower identity. A Muzzle+Base+Bearing combination is not just a stat bundle — it carries tags that make the tower feel different. A Fire-Fire-Fire tower is a burn machine. An Ice-Crit-Execution tower is a control-and-burst threat. The tags are transparent to read, but their alignment is the strategic puzzle.

The player should feel:

  • Curious about drops — a new component might carry a tag that completes a combination
  • Deliberate in assembly — stacking the same tag twice is a meaningful choice, not a default
  • Surprised by combinations — a tower with Fire + Inferno feels different from one with just Fire, even though Inferno is invisible without Fire
  • Proud of unique builds — a 3×Fire tower with matching stats should feel like a prize drop

Detailed Design

Core Rules

TR1. Tag Attachment: Tags are attached to component instances at generation time via ComponentTagGenerationService. The generation pipeline is shared across shop, enemy drops, and event rewards (per InventoryGenerationComponent). A component may carry 0, 1, or multiple tags depending on its rarity and the RarityTagBudget rules.

TR2. Tag Flow to Tower: At assembly time, TowerTagAggregationService.AggregateTowerTags() collects tags from all three constituent components and merges them by TagType. TotalStack for each TagRuntimeData equals the number of components (13) that carry that tag.

TR3. No Tag Re-Randomization on Assembly: Tags are fixed at component-instance creation time. Assembly does not re-randomize tags. A component with TagType.Fire keeps it through disassemble and re-assembly.

TR4. Multiple Tags Per Tower: A tower can carry multiple different tags simultaneously. Tags are independent modifiers. Having Fire×2 and Ice×1 on the same tower applies both effects.

TR5. TotalStack Semantics (Per-Tag):

Tag TotalStack > 1 Effect Formula
Fire Scales DOT damage FinalBurnDamagePerTick = BaseBurnDamage × Clamp(TotalStack, 1, MaxEffectiveStack)
Ice None — binary on/off Presence of Ice tag activates slow; stack count is metadata only
Crit None — binary on/off Presence of Crit tag activates bonus crit chance; stack count is metadata only
Execution None — binary on/off Presence of Execution tag activates low-HP bonus; stack count is metadata only
Shatter None — binary on/off Presence of Shatter tag activates bonus vs. slowed targets; stack count is metadata only
Inferno Amplifies Fire Inferno has no independent effect; it only enhances existing Fire. TotalStack on Inferno is metadata.
AbsoluteZero Amplifies Ice AbsoluteZero has no independent effect; it only enhances existing Ice. TotalStack on AbsoluteZero is metadata.

Interpretation of "metadata only": For Ice / Crit / Execution / Shatter, having TotalStack = 2 or TotalStack = 3 provides no mechanical advantage over TotalStack = 1. The tag either fires (if present) or it doesn't. The stack count reflects how the tag was distributed across components, not a power multiplier.

This is intentional: it makes stacking those tags a trap choice. A player who stacks Ice on all three components gains no advantage over a player who has one Ice and two other useful tags. This prevents any single tag from becoming a dominant strategy.

TR6. Fire MaxEffectiveStack: Fire DOT damage is capped at MaxEffectiveStack stacks regardless of actual TotalStack. If a tower has TotalStack = 5 (theoretically, via future expansion), the burn damage caps at MaxEffectiveStack. Current implementation uses MaxEffectiveStack = 3. This prevents runaway DOT values if tag budgets are expanded.

TR7. Inferno / AbsoluteZero Require Base Tag: Inferno amplifies Fire damage. If a tower has Inferno but no Fire tag, Inferno does nothing. Same for AbsoluteZero and Ice. These are pure amplifiers — they have no independent effect.

TotalStack Decision Table

For assembly optimization:

Tag Is stacking valuable? Why
Fire Yes More stacks = more DOT damage, capped at MaxEffectiveStack
Ice No Binary on/off; slow strength is fixed, not stack-scaled
Crit No Binary on/off; crit chance is fixed per tag, not stack-scaled
Execution No Binary on/off; bonus damage threshold is fixed
Shatter No Binary on/off; bonus vs. slowed is fixed
Inferno No Metadata only; effect requires Fire to exist
AbsoluteZero No Metadata only; effect requires Ice to exist

Assembly guidance: The optimal tower does NOT always have 3× the same tag. The optimal tower balances a high-value tag stack (Fire) with single copies of complementary tags (Ice, Crit, Execution, Shatter). A 3×Fire tower has maximum burn DPS but no control or burst utility. A 2×Fire + 1×Ice tower sacrifices some burn for crowd control. Both are viable.

States and Transitions

The Tag System has no standalone state machine. Tags are passive modifiers that are evaluated by the Combat System at runtime.

Entity State Description
Component instance Has tags (0N) Tags attached at generation; immutable per instance
Tower instance Has TagRuntimeData[] Aggregated tags from 3 components; computed at assembly
Combat runtime Tag resolution TagEffectResolver evaluates tags on each tower attack

Interactions with Other Systems

System Direction Interface
InventoryGenerationComponent Reads Generates tags on component instances at creation time
Tower Assembly Reads AggregateTowerTags() reads component tags; TagRuntimeData[] is the output
Combat System Reads AttackPayload.TagRuntimes carries tower tags into combat; TagEffectResolver applies effects
Shop / Event / Combat drops Sources All three sources funnel through ComponentTagGenerationService — tag rarity budgets are shared

Formulas

1. Fire DOT Damage (Per-Tick)

FinalBurnDamagePerTick = BaseBurnDamage × Clamp(TotalStack, 1, MaxEffectiveStack) × (1 + SeverityBonus)

Variable Symbol Type Description
BaseBurnDamage B float From TagConfig.txt ParamJson per Fire tag row
TotalStack S int 13; clamped to MaxEffectiveStack
MaxEffectiveStack M int Cap on effective stack count; currently 3
SeverityBonus sev float From enemy debuff state; 0 if no other modifiers
FinalBurnDamagePerTick D float Final burn damage per tick

Note: Clamp(S, 1, M) means TotalStack = 1 produces the minimum burn effect; TotalStack = 2 doubles it; TotalStack = 3 triples it (capped at M=3 in current launch). Values above 3 are clamped to M.

2. Ice Slow Strength

Slow strength is binary — presence of the Ice tag activates slow. The slow strength (how much speed is reduced) is NOT scaled by TotalStack. The formula is:

slowedSpeedMultiplier = IceSlowStrength × (1 - SlowResistancePenalty)

Where IceSlowStrength is a fixed value from TagConfig.txt (e.g., 0.3 = 30% speed reduction). TotalStack does not appear in this formula.

3. Crit Bonus Damage

Crit activates on a hit if the Ice tag is present. The crit bonus multiplier is fixed from TagConfig.txt. TotalStack does not scale it.

4. Execution Bonus Damage

Execution activates when the target's current HP is below the execution threshold. The bonus damage multiplier is fixed. TotalStack does not scale it.

5. Shatter Bonus Damage

Shatter activates when the target is under a slow effect (Ice or other slow). The bonus damage is fixed. TotalStack does not scale it.

6. Inferno Amplification

InfernoAmplification = 1 + (InfernoStack × InfernoFireMultiplierBonus)

Inferno without Fire: no effect. Inferno with Fire: enhances the Fire DOT (duration and/or damage per tick, per TagConfig.txt ParamJson). Inferno's TotalStack is metadata only — it does not increase the amplification factor.

7. AbsoluteZero Amplification

AbsoluteZeroAmplification = 1 + (AbsoluteZeroStack × AbsoluteZeroIceMultiplierBonus)

Same pattern as Inferno. AZ without Ice: no effect. AZ with Ice: enhances the Ice slow (duration and/or strength). AZ's TotalStack is metadata only.

Edge Cases

EC1 — Component with No Tags If a component's Tags array is empty or null, AggregateTowerTags skips it. The tower receives tags from only the components that have them.

EC2 — All Three Components Have the Same Tag (TotalStack = 3) For Fire: TotalStack = 3 → burn damage is BaseBurn × 3 (capped at MaxEffectiveStack). For Ice/Crit/Execution/Shatter: identical to TotalStack = 1 — no difference.

EC3 — Inferno Without Fire If a tower has Inferno but no Fire tag, Inferno has zero mechanical effect. The TagRuntimeData entry for Inferno is present but TagEffectResolver skips it.

EC4 — AbsoluteZero Without Ice Same as EC3 — AZ amplifies Ice; without Ice present, AZ does nothing.

EC5 — Multiple Different Tags Tags are independent. A tower with Fire×1, Ice×1, Crit×1 applies all three effects simultaneously. There is no conflict or mutual exclusion between tags.

EC6 — Fire MaxEffectiveStack Overflow If a future expansion allows more than 3 components per tower or changes tag budgets so TotalStack > 3, Fire DOT is clamped to MaxEffectiveStack. Excess stacks are ignored.

EC7 — Same Tag on Same Component (Not Possible) Component instance tag generation does not produce duplicate tags on a single component. The generation rule is: "单组件内不重复抽取同一 Tag." So TotalStack = 3 always means 3 different components each have the tag.

EC8 — Tags on Degraded Towers A tower with 0-endurance components can still apply tags in combat. Endurance only blocks roster participation, not combat effectiveness. Tags from a degraded tower still fire.

Dependencies

Upstream Dependencies

System Type Interface Status
InventoryGenerationComponent Hard ComponentTagGenerationService generates tags on components; shared pipeline for shop, drops, event rewards Implemented
Tower Assembly Hard TowerTagAggregationService.AggregateTowerTags() produces TagRuntimeData[] Approved (design/gdd/tower-assembly.md)

Downstream Dependents

System Type Interface Status
Combat System Hard TagEffectResolver reads TagRuntimes from AttackPayload and applies effects Approved (docs/CombatNodeArchitecture.md)

Bidirectional Consistency Check

  • Tower Assembly → depends on Tag System for TotalStack semantics
  • Combat System → reads tags from AttackPayload.TagRuntimes
  • InventoryGenerationComponent → generates tags via shared pipeline

Tuning Knobs

Knob Default Safe Range Effect
MaxEffectiveStack 3 25 Caps Fire DOT stack multiplier. Lower = less burn dominance from 3×Fire stacks.
FireBaseBurnDamage varies per row Base burn damage per tick from TagConfig.txt. Scales with rarity and component type.
IceSlowStrength 0.3 0.10.8 Fraction of speed removed when Ice applies. Does not scale with TotalStack.
CritBonusMultiplier varies per row Fixed bonus damage multiplier on crit hit. Binary trigger, not stack-scaled.
ExecutionThreshold varies per row Target HP% below which Execution activates. Binary trigger.
ShatterBonusMultiplier varies per row Bonus damage vs. slowed targets. Binary trigger.
InfernoAmplification varies per row Multiplier applied to Fire DOT when Inferno is present. Does not scale with Inferno TotalStack.
AbsoluteZeroAmplification varies per row Multiplier applied to Ice slow when AbsoluteZero is present. Does not scale with AZ TotalStack.
RarityTagBudget[rarity].MinCount / MaxCount varies 03 How many tags a component of a given rarity can carry. Controls tag density per component tier.

Data-table-driven knobs (no code changes):

  • TagConfig.txt.ParamJson — per-tag tuning (damage, duration, thresholds, multipliers)
  • RarityTagBudget.txt — tag count budget per rarity tier

Visual/Audio Requirements

VFX Tag Indicators

Tag Visual Effect Notes
Fire Orange-red flame particle trail on projectile Rarity of component with Fire determines intensity
Ice Blue crystalline frost expanding from impact point Single intensity regardless of TotalStack
Crit Bright yellow-white flash on hit Single intensity
Execution Red skull flash when threshold crossed on kill Single intensity
Shatter Blue-white shatter burst on slowed target Single intensity
Inferno Purple-red underglow on Fire effect Only visible when Fire is also present
AbsoluteZero Cyan-white frost overlay on Ice effect Only visible when Ice is also present

Stack count visualization: For Fire, the burn particle count scales with TotalStack (more fire streams at higher stacks). For all other tags, no stack-scaling VFX — binary presence only.

Tag display in tower info: Tower tooltip shows each TagRuntimeData with its TotalStack count as a numeric badge (e.g., "Fire ×2"). For binary tags, the badge shows ×1 even though the count is metadata — this communicates which tags are present without misleading the player about stack value.

Acceptance Criteria

Tag Generation

  • GIVEN a component is generated via InventoryGenerationComponent, WHEN tags are assigned, THEN no tag appears twice on the same component instance.
  • GIVEN a component's rarity is White, WHEN its tags are generated, THEN the count is within RarityTagBudget[White].MinCount..MaxCount.
  • GIVEN two components of the same config have the same runSeed and ItemInstanceId, WHEN tags are generated for both, THEN both receive identical tags (deterministic).

Tag Aggregation

  • GIVEN a tower has Muzzle=[Fire], Bearing=[Ice], Base=[Fire], WHEN AggregateTowerTags() is called, THEN the result contains Fire with TotalStack=2 and Ice with TotalStack=1.
  • GIVEN a tower has Muzzle=[Fire], Bearing=[Fire], Base=[Fire], WHEN aggregation runs, THEN Fire has TotalStack=3.
  • GIVEN a tower has Muzzle=[], Bearing=[], Base=[], WHEN aggregation runs, THEN the result is Empty TagRuntimeData[].
  • GIVEN a tower has Fire×1 and Inferno×1, WHEN combat runs, THEN Inferno's amplification applies to the Fire DOT.

Fire DOT Scaling

  • GIVEN a tower with TotalStack=1 Fire deals burn damage D per tick, WHEN a tower with TotalStack=2 Fire attacks, THEN burn damage is 2×D (capped at MaxEffectiveStack).
  • GIVEN TotalStack=5 (theoretical), WHEN FinalBurnDamagePerTick is computed, THEN the effective stack is clamped to MaxEffectiveStack.

Binary Tags (No Stack Scaling)

  • GIVEN Ice tag is present (any TotalStack), WHEN the tower attacks, THEN the target receives the Ice slow effect at the configured strength.
  • GIVEN TotalStack=1 Ice vs. TotalStack=3 Ice, WHEN slow strength is compared, THEN both apply identical slow strength.
  • GIVEN Crit tag is present, WHEN a hit lands, THEN crit bonus applies at the fixed multiplier (same as any other TotalStack value).
  • GIVEN Shatter tag is present on a tower attacking a slowed target, WHEN damage is calculated, THEN shatter bonus applies regardless of TotalStack.

Amplifier Tags

  • GIVEN a tower has Inferno but no Fire, WHEN combat runs, THEN Inferno has zero mechanical effect.
  • GIVEN a tower has AbsoluteZero but no Ice, WHEN combat runs, THEN AbsoluteZero has zero mechanical effect.
  • GIVEN a tower has Fire×1 and Inferno×1, WHEN burn damage is computed, THEN Inferno's amplification multiplier is applied to the Fire DOT.

UI Display

  • GIVEN a tower has Fire with TotalStack=2, WHEN the tower tooltip is shown, THEN it displays "Fire ×2".
  • GIVEN a tower has Ice with TotalStack=1, WHEN the tower tooltip is shown, THEN it displays "Ice ×1" (not ×0 — stack count reflects presence, not power).