Use when adding or modifying triggered abilities — ETB, dies, attacks, damage dealt, spell cast, phase-based, or any "When/Whenever/At the beginning of" ability. Covers TriggerDefinition, TriggerMode, the matcher registry, APNAP ordering, targeting, intervening-if, constraints, and parser wiring.
Install
npx skillscat add phase-rs/phase/add-trigger Install via the SkillsCat registry.
Adding a Triggered Ability
Triggered abilities fire in response to game events and go on the stack (MTG Rule 603). They differ from replacement effects (which modify events) and static abilities (which exist continuously). This skill covers the full trigger pipeline: event fired → matcher detects → APNAP ordering → targeting → stack → resolution.
Before you start: Trace how ChangesZone (ETB/dies) triggers work end-to-end. They're the most complete reference: parse_trigger_line() in oracle_trigger.rs → TriggerDefinition → process_triggers() → match_changes_zone() → stack placement → resolve_top().
CR Verification Rule: Every CR number in annotations MUST be verified by grepping
docs/MagicCompRules.txtbefore writing. Do NOT rely on memory — 701.x and 702.x numbers are arbitrary sequential assignments that LLMs consistently hallucinate. Rungrep -n "^603.2" docs/MagicCompRules.txt(etc.) for every number. If you cannot find it, do not write the annotation.
MTG Rules Reference
| Rule | What it governs | Engine implication |
|---|---|---|
| 603.2 | A triggered ability triggers when its event occurs | process_triggers() scans all permanents against the event |
| 603.3 | Triggered abilities are placed on the stack (APNAP order) | Sort by (is_nap, timestamp) before stack placement |
| 603.3b | Active player's triggers go on stack first (resolve last) | NAP triggers pushed after AP triggers |
| 603.4 | Intervening-if checked at fire-time AND resolution-time | TriggerCondition checked in both process_triggers() and resolve_top() |
| 603.5 | Triggered abilities target when placed on stack | extract_target_filter_from_effect() → targeting phase before stack |
| 603.6c | "Once each turn" constraint | TriggerConstraint::OncePerTurn tracked in triggers_fired_this_turn |
Key Types
TriggerDefinition — crates/engine/src/types/ability.rs
pub struct TriggerDefinition {
pub mode: TriggerMode, // Event type: ChangesZone, DamageDone, SpellCast, etc.
pub execute: Option<Box<AbilityDefinition>>, // Effect to run when triggered
pub valid_card: Option<TargetFilter>, // Filter on the event subject ("another creature")
pub origin: Option<Zone>, // For zone changes: from where
pub destination: Option<Zone>, // For zone changes: to where
pub trigger_zones: Vec<Zone>, // Active zones (default: Battlefield)
pub phase: Option<Phase>, // For phase triggers (Upkeep, End, etc.)
pub optional: bool, // "You may" triggers
pub combat_damage: bool, // Combat damage only filter
pub secondary: bool, // Multi-target secondary indicator
pub valid_target: Option<TargetFilter>, // Controller filter for events (You, Opponent)
pub valid_source: Option<TargetFilter>, // Source filter for damage/spell triggers
pub description: Option<String>, // Human-readable text
pub constraint: Option<TriggerConstraint>, // OncePerTurn, OncePerGame, OnlyDuringYourTurn
pub condition: Option<TriggerCondition>, // Intervening-if condition
}TriggerMode — crates/engine/src/types/triggers.rs
The event discriminant. ~160 variants organized by category:
| Category | Example Modes | Typical Event |
|---|---|---|
| Zone changes | ChangesZone, ChangesZoneAll |
GameEvent::ZoneChanged |
| Damage | DamageDone, DamageDoneOnce, ExcessDamage |
GameEvent::DamageDealt |
| Spells | SpellCast, SpellCopy, Countered |
GameEvent::SpellCast |
| Combat | Attacks, Blocks, AttackerBlocked, AttackerUnblocked |
GameEvent::AttackersDeclared |
| Permanents | Taps, Untaps, Sacrificed, Destroyed |
GameEvent::Tapped |
| Cards | Drawn, Discarded, Milled, Exiled, Revealed |
GameEvent::CardDrawn |
| Life | LifeGained, LifeLost |
GameEvent::LifeChanged |
| Phases | Phase (with phase: Some(Phase::Upkeep)) |
GameEvent::PhaseStarted |
| Tokens | TokenCreated, TokenCreatedOnce |
GameEvent::TokenCreated |
| Other | Explored, Fight, Transformed, DayTimeChanges |
Various |
| Fallback | Unknown(String) |
Never matches (parsed but unimplemented) |
TriggerConstraint — crates/engine/src/types/ability.rs
Rate-limiting:
OncePerTurn— tracked instate.triggers_fired_this_turnOncePerGame— tracked instate.triggers_fired_this_gameOnlyDuringYourTurn— checked at fire-time viastate.active_player
TriggerCondition — crates/engine/src/types/ability.rs
Intervening-if (checked at fire AND resolution):
LifeGainedThisTurn { minimum: u32 }— "if you've gained N or more life this turn"
The Trigger Pipeline
GameEvent fired (zone change, damage, spell cast, etc.)
↓
process_triggers(state, events) — crates/engine/src/game/triggers.rs
↓
For each battlefield permanent (+ graveyard if trigger_zones includes Graveyard):
For each TriggerDefinition on the object:
↓
1. Get matcher from registry by TriggerMode
2. Call matcher(event, trigger_def, source_id, state)
3. If matched:
a. Check constraint (OncePerTurn, etc.)
b. Check condition (intervening-if at fire-time)
c. Build ResolvedAbility from execute field
d. Add to pending list
↓
Also check keyword-based triggers:
Prowess → synthetic trigger for noncreature spells
↓
Sort pending by APNAP order: (is_nap, timestamp)
Reverse so NAP triggers resolve first on stack
↓
For each pending trigger:
extract_target_filter_from_effect()
├─ No targets needed → push to stack directly
├─ Exactly 1 legal target → auto-target, push to stack
└─ Multiple legal targets → store in state.pending_trigger
→ engine returns WaitingFor::TriggerTargetSelection
→ player selects via GameAction::SelectTargets
→ engine pushes to stack with selected targets
↓
Stack resolves later:
TriggeredAbility with condition → re-check intervening-if (Rule 603.4)
If condition false → ability is countered (not executed)The Purged Source — Last Known Information (CR 608.2h)
A trigger routinely has to answer questions about an object that no longer exists. The
source dies and its own dies-trigger asks "if you control a Zombie"; a token sacrifices
itself and the CR 111.7 SBA purges it from state.objects before the ability resolves. A
live-only lookup answers false/None there, and the ability silently fails to fire or is
removed from the stack — CR 113.7a says the ability on the stack exists independently of its
source, so the source's death must not make its own predicates unanswerable.
CR 608.2h is the rule: if the object is no longer in the public zone the effect expected
it in, "the effect uses the object's last known information." (CR 608.2i is the
narrower look-back rule for effects that ask about previous game states — attacked this
turn, etc.)
The mechanism. apply_zone_exit_cleanup() (game/zones.rs) captures an LKISnapshot
into state.lki_cache on every battlefield/exile exit — name, P/T, base P/T, core types,
counters, controller, and the pre-sever attachment set. Live state always wins; LKI
answers only when the object is gone, so every LKI-aware helper is a strict no-op for a
source that still exists.
Use the LKI-aware helpers — never a bare live lookup:
| Question | Helper | Where |
|---|---|---|
| Does the trigger's subject match this filter? | subject_filter_matches_with_lki() |
game/trigger_matchers.rs — single authority for match_sacrificed, exploiter_matches_subject_filter, match_connives |
Who is "you" for a source-relative ControllerRef::You? |
source_controller_or_lki() |
game/filter.rs — feeds FilterContext; the CR 603.4 intervening-if re-check runs after the SBA purge |
Two traps that decide whether your test proves anything:
- Attachments do NOT survive on the live object. CR 704.5m/n unattach an Aura/Equipment
as an SBA the moment the permanent leaves, andsever_battlefield_attachment_graph_on_exitempties the list before cleanup runs. OnlyLKISnapshot::attachmentsstill holds it — an "if it was equipped" predicate that reads
the live object reads an empty set, and always answersfalse. - Attack/block history DOES survive. It lives in durable,
ObjectId-keyed ledgers onGameState(creatures_attacked_this_turn,creature_attacked_defenders_this_turn,creatures_blocked_this_turn), not on the
object, so a purged source'sAttackedThisTurn(CR 508.1a) is still answerable.
Non-vacuity: a printed card keeps its core_types and controller across a zone change
and filter_inner has no zone gate, so a regression test that merely moves a printed
creature to the graveyard passes either way and proves nothing. The vector that actually
discriminates an LKI path from a bare live match is the ceased-to-exist token (CR 111.7)
— use one.
Checklist — Adding a New Trigger
Phase 1 — Type Definition
crates/engine/src/types/triggers.rs—TriggerModeenum
Add a variant for your event type. Skip if an existing mode fits. Convention: use PascalCase verb form matching the event (SpellCast,DamageDone,Attacks).crates/engine/src/types/ability.rs—TriggerConditionenum (if new intervening-if)
Add a variant if the trigger has a condition not covered by existing conditions.crates/engine/src/types/ability.rs—TriggerConstraintenum (if new constraint)
Add a variant if the trigger has a rate-limit not covered by existing constraints.
Phase 2 — Event Emission
The trigger pipeline responds to GameEvent variants. If no existing event covers your trigger:
crates/engine/src/types/events.rs—GameEventenum
Add a variant for the event your trigger responds to. Include enough data for the matcher to validate (who, what, where).Emit the event at the appropriate point in the game logic module (e.g.,
combat.rs,zones.rs,effects/). Events must be emitted BEFOREprocess_triggers()is called for them to be detected.
Phase 3 — Matcher
crates/engine/src/game/triggers.rs— matcher function
Write a matcher with signature:fn match_your_event( event: &GameEvent, trigger: &TriggerDefinition, source_id: ObjectId, state: &GameState, ) -> boolPattern:
- Match on the
GameEventvariant - Check
valid_cardfilter viavalid_card_matches() - Check trigger-specific fields (
origin,destination,combat_damage,valid_target, etc.) - Return true if all checks pass
- Match on the
crates/engine/src/game/trigger_matchers.rs—build_trigger_registry()
Add an entry:registry.insert(TriggerMode::YourEvent, match_your_event);If you skip this, the trigger will parse correctly but never fire at runtime — a silent failure.
Phase 4 — Target Extraction (if trigger effect targets)
crates/engine/src/game/triggers.rs—extract_target_filter_from_effect()
If the trigger'sexecuteeffect has a target that requires player selection (notSelfRef/Controller/None), add a match arm returningSome(&target_filter).If you skip this, the trigger will go on the stack without requesting targets. The effect will fail at resolution with no valid targets.
Phase 5 — Parser
crates/engine/src/parser/oracle_trigger.rs—parse_trigger_line()
The trigger parser handles three main patterns:- "When/Whenever X, do Y" — One-shot or recurring event trigger
- "At the beginning of X, do Y" — Phase-based trigger
- "Whenever X deals combat damage to a player, do Y" — Damage trigger
Add detection for your trigger's Oracle text pattern. Key sub-parsers:
- Subject parsing: "~", "another creature", "a creature you control"
- Event verb parsing: "enters", "dies", "attacks", "deals damage"
- Intervening-if extraction: "if you've gained life this turn"
- Constraint parsing: "triggers only once each turn"
crates/engine/src/parser/oracle_trigger.rs— parser tests
Every new pattern needs a test:#[test] fn trigger_your_new_pattern() { let t = parse_trigger_line("When ~ enters, draw a card."); assert_eq!(t.mode, TriggerMode::ChangesZone); assert_eq!(t.destination, Some(Zone::Battlefield)); assert!(matches!(*t.execute.unwrap().effect, Effect::Draw { count: 1 })); }
Phase 6 — Condition/Constraint Tracking (if new)
crates/engine/src/game/triggers.rs—check_trigger_condition()
If you added a newTriggerCondition, add evaluation logic here. This is called both at fire-time and resolution-time.crates/engine/src/types/game_state.rs— tracking state (if new constraint)OncePerTurnusestriggers_fired_this_turn: HashSet.OncePerGameusestriggers_fired_this_game: HashSet. If you need new tracking, add it toGameState.LKI: can the source or subject be GONE when this condition is checked?
If yes (dies triggers, sacrifice triggers, self-exploiting tokens, any intervening-if re-checked at resolution per CR 603.4), route the lookup throughsubject_filter_matches_with_lki()/source_controller_or_lki()— see The Purged Source. A bare live lookup fails closed and the ability silently never fires.
Phase 7 — Stack Resolution
-
crates/engine/src/game/stack.rs—resolve_top()
Usually no changes needed — triggered abilities resolve through the standardresolve_ability_chain()path. Only modify if:- Your trigger has a new
TriggerConditionthat needs re-checking - Your trigger has special resolution behavior
- Your trigger has a new
Phase 8 — Tests
- Parser test: Oracle text → correct
TriggerDefinition(verbatim Oracle text, never a paraphrase) - Matcher test: event + trigger def → matches/doesn't match — the doesn't-match case needs a positive reach-guard proving the card parsed (zero
Effect::Unimplemented), or it passes vacuously - Integration test: full game flow → event fires → trigger on stack → resolves, with an assertion that FAILS if the change is reverted (see
/card-test); parser + matcher tests alone are shape tests and do not prove runtime behavior - APNAP test: multiple player triggers → correct stack order
- Constraint test: "once per turn" fires once, not twice
- Verify per CLAUDE.md § "Canonical verification pattern" —
cargo fmt --all, then iftilt get uiresource clippy >/dev/null 2>&1:./scripts/tilt-wait.sh --timeout 240 clippy test-engine card-data; else:cargo clippy --all-targets -- -D warnings+cargo test -p phase-engine+./scripts/gen-card-data.sh.
Reference: Existing Matchers Worth Studying
| Matcher | TriggerMode | What it checks | Complexity |
|---|---|---|---|
match_changes_zone |
ChangesZone |
origin, destination, valid_card | Simple — canonical reference |
match_spell_cast |
SpellCast |
caster (valid_target), spell type (valid_card) | Medium — two filters |
match_damage_done |
DamageDone |
source, target, combat_damage flag | Medium — three filters |
match_attacks |
Attacks |
attacker (valid_card or SelfRef) | Simple |
match_phase |
Phase |
phase match + controller (valid_target) | Simple |
match_life_gained |
LifeGained |
who gained (valid_target) | Simple |
The valid_card_matches() Helper
Shared function that validates the valid_card filter against the event's subject:
SelfRef → subject must be the trigger source
Another → subject must NOT be the trigger source
Typed { type_filters, controller, props } → full filter evaluation against the subjectUsed by all matchers — always call this instead of reimplementing filter logic.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Missing registry entry in build_trigger_registry() |
Trigger parses but never fires — completely silent failure | Always add the registry entry |
Missing extract_target_filter_from_effect() arm |
Triggered ability goes on stack without targets, fails at resolution | Add the match arm for targeting effects |
Emitting event AFTER process_triggers() call |
Trigger misses the event entirely | Emit events before the trigger scan |
| Not checking intervening-if at both fire and resolve time | Trigger fires when it shouldn't, or resolves when condition is false | Use TriggerCondition — checked automatically at both points |
| APNAP ordering wrong | Triggers resolve in wrong player order | process_triggers() handles this — don't manually reorder |
trigger_zones empty (default) |
Trigger only fires from battlefield, not graveyard | Set trigger_zones: vec![Zone::Graveyard] for dies triggers that work from graveyard |
Forgetting valid_card: Some(SelfRef) on "When ~" triggers |
Trigger fires for any permanent, not just the source | Set valid_card for self-triggers |
| Live-only lookup for a source/subject that may be purged | Fails closed (CR 608.2h): a dies/sacrifice trigger's own intervening-if reads false and the ability is removed from the stack |
Use subject_filter_matches_with_lki() / source_controller_or_lki() |
| Reading attachments off a purged object | CR 704.5m/n already unattached them; the live list is empty and "if it was equipped" always answers false |
Read LKISnapshot::attachments (the pre-sever set) |
| LKI regression test that moves a printed creature to the graveyard | Vacuous — the card keeps core_types/controller across zones, so the test passes without the LKI path |
Discriminate with a ceased-to-exist token (CR 111.7) |
Self-Maintenance
After completing work using this skill:
- Verify references with the check below
- Update the reference table if you added a new matcher
- Update TriggerCondition/Constraint sections if you added new variants
Verification
rg -q "fn process_triggers" crates/engine/src/game/triggers.rs && \
rg -q "fn collect_matching_triggers" crates/engine/src/game/triggers.rs && \
rg -q "fn extract_target_filter_from_effect" crates/engine/src/game/triggers.rs && \
rg -q "fn build_trigger_registry" crates/engine/src/game/trigger_matchers.rs && \
rg -q "fn subject_filter_matches_with_lki" crates/engine/src/game/trigger_matchers.rs && \
rg -q "fn source_controller_or_lki" crates/engine/src/game/filter.rs && \
rg -q "fn apply_zone_exit_cleanup" crates/engine/src/game/zones.rs && \
rg -q "struct LKISnapshot" crates/engine/src/types/game_state.rs && \
rg -q "struct TriggerDefinition" crates/engine/src/types/ability.rs && \
rg -q "enum TriggerMode" crates/engine/src/types/triggers.rs && \
rg -q "enum TriggerConstraint" crates/engine/src/types/ability.rs && \
rg -q "enum TriggerCondition" crates/engine/src/types/ability.rs && \
rg -q "fn parse_trigger_line" crates/engine/src/parser/oracle_trigger.rs && \
rg -q "pending_trigger" crates/engine/src/types/game_state.rs && \
rg -q "TriggerTargetSelection" crates/engine/src/types/game_state.rs && \
rg -q "fn resolve_top" crates/engine/src/game/stack.rs && \
echo "✓ add-trigger skill references valid" || \
echo "✗ STALE — update skill references"