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.
Installation
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
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.
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.txt before writing. Do NOT rely on memory — 701.x and 702.x numbers are arbitrary sequential assignments that LLMs consistently hallucinate. Run grep -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
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, and
sever_battlefield_attachment_graph_on_exit empties the list before cleanup runs. Only
LKISnapshot::attachments still holds it — an "if it was equipped" predicate that reads
the live object reads an empty set, and always answers false.
Attack/block history DOES survive. It lives in durable, ObjectId-keyed ledgers on
GameState (creatures_attacked_this_turn,
creature_attacked_defenders_this_turn, creatures_blocked_this_turn), not on the
object, so a purged source's AttackedThisTurn (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 — TriggerMode enum
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 — TriggerCondition enum (if new intervening-if)
Add a variant if the trigger has a condition not covered by existing conditions.
crates/engine/src/types/ability.rs — TriggerConstraint enum (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 — GameEvent enum
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 BEFORE process_triggers() is called for them to be detected.
Phase 3 — Matcher
crates/engine/src/game/triggers.rs — matcher function
Write a matcher with signature:
crates/engine/src/game/triggers.rs — extract_target_filter_from_effect()
If the trigger's execute effect has a target that requires player selection (not SelfRef/Controller/None), add a match arm returning Some(&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"
crates/engine/src/game/triggers.rs — check_trigger_condition()
If you added a new TriggerCondition, 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)
OncePerTurn uses triggers_fired_this_turn: HashSet. OncePerGame uses triggers_fired_this_game: HashSet. If you need new tracking, add it to GameState.
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 through subject_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 standard resolve_ability_chain() path. Only modify if:
Your trigger has a new TriggerCondition that needs re-checking
Your trigger has special resolution behavior
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 if tilt 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 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 subject
Used 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