18 KiB
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 (1–3) actually mean mechanically?
The answer is tag-specific:
- Fire:
TotalStackdirectly scales DOT damage — more stacks means more burn damage per tick - Ice / Crit / Execution / Shatter:
TotalStackis 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 (1–3) 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 (0–N) | 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 | 1–3; 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
TotalStacksemantics ✅ - Combat System → reads tags from
AttackPayload.TagRuntimes✅ - InventoryGenerationComponent → generates tags via shared pipeline ✅
Tuning Knobs
| Knob | Default | Safe Range | Effect |
|---|---|---|---|
MaxEffectiveStack |
3 | 2–5 | 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.1–0.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 | 0–3 | 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
runSeedandItemInstanceId, 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 withTotalStack=2and Ice withTotalStack=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=1Fire deals burn damage D per tick, WHEN a tower withTotalStack=2Fire attacks, THEN burn damage is2×D(capped at MaxEffectiveStack). - GIVEN
TotalStack=5(theoretical), WHENFinalBurnDamagePerTickis computed, THEN the effective stack is clamped toMaxEffectiveStack.
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=1Ice vs.TotalStack=3Ice, 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).