Use when adding or modifying keyword abilities — evergreen keywords (flying, trample), parameterized keywords (ward {2}, kicker {R}), protection variants, or any keyword that needs runtime behavior wired into combat, targeting, damage, or triggers.
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 keyword abilities — evergreen keywords (flying, trample), parameterized keywords (ward {2}, kicker {R}), protection variants, or any keyword that needs runtime behavior wired into combat, targeting, damage, or triggers.
Adding a Keyword Ability
Keywords are strongly-typed abilities stored on GameObjects and evaluated across combat, targeting, damage, state-based actions, and triggers. They flow through the layer system (granted/removed via ContinuousModification) and are parsed from both MTGJSON keyword arrays and Oracle text.
Before you start: Trace how Ward works end-to-end — it's a parameterized keyword with targeting interaction: Keyword::Ward(ManaCost) in keywords.rs → FromStr parsing → targeting.rs check → layer grant via AddKeyword.
CR Verification Rule: Every CR number in annotations MUST be verified by grepping docs/MagicCompRules.txt before writing. Do NOT rely on memory — 702.x keyword ability numbers are arbitrary sequential assignments that LLMs consistently hallucinate (e.g., Crew is 702.122, not 702.148). Run grep -n "^702.122" docs/MagicCompRules.txt for every number. If you cannot find it, do not write the annotation.
MTG Rules Reference
Rule
What it governs
Engine implication
702.x
Individual keyword definitions
Each keyword has specific rules for where it matters
Uses discriminant matching — Ward({W}) matches Ward({2}{U}). This is intentional: "does this creature have ward?" doesn't care about the cost. To check the specific parameter, pattern match directly.
crates/engine/src/types/keywords.rs — Keyword enum
Add the variant. Choose the right category:
Unit: YourKeyword, (no data)
Parameterized (numeric): YourKeyword(u32),
Parameterized (cost): YourKeyword(ManaCost),
Special: struct-like variant with named fields
crates/engine/src/types/keywords.rs — FromStr impl
Add parsing in the from_str() match. Three locations depending on type:
Unit keywords: Add to the lowercase match block (~line 310-380). Pattern: "yourkeyword" => Ok(Keyword::YourKeyword),
Parameterized (u32): Add to the split-on-colon block. Pattern: "yourkeyword" => Ok(Keyword::YourKeyword(value.parse()?))
Parameterized (ManaCost): Add to the parse_keyword_mana_cost() delegation block. Pattern: "yourkeyword" => parse_keyword_mana_cost(value).map(Keyword::YourKeyword)
crates/engine/src/types/keywords.rs — keyword_from_tagged()
Add a match arm for JSON deserialization (externally-tagged format). This handles the {"YourKeyword": data} format from card-data.json.
crates/engine/src/game/casting.rs — If affects casting/costs
Keywords checked here: Convoke, Delve, Improvise, Flash, Flashback, alternative costs.
Cost modification: Add to cost calculation
Timing: Add to can_cast_at_instant_speed() or similar
crates/engine/src/game/triggers.rs — If keyword generates triggers
Keywords that synthesize triggers: Prowess, Undying, Persist, Exalted, Extort.
Pattern: In process_triggers(), check obj.has_keyword(&Keyword::YourKeyword) on the relevant GameEvent, then build a synthetic ResolvedAbility.
crates/engine/src/game/keywords.rs — Optional convenience function
If the keyword is checked in multiple files, add pub fn has_your_keyword(obj: &GameObject) -> bool.
Phase 3 — Layer Integration
Keywords are granted/removed via ContinuousModification::AddKeyword / RemoveKeyword in Layer 6. This is already handled generically — no changes needed unless your keyword has special layer interactions.
Special cases:
If the keyword is a CDA (like Changeling → all creature types), see the post-fixup block in layers.rs (~line 69-88) for the pattern.
If the keyword modifies other layers (e.g., Devoid → colorless in Layer 5), you need additional ContinuousModification variants.
Phase 4 — Parser Integration
Read this first if your keyword takes a cost or a parameter ("Kicker {2}", "Ward {1}",
"Cycling {2}"). The keyword router is strict, and getting the surface wrong is the
silent-swallow bug class. Full surface table: /oracle-parser SKILL.md §3a.
The two surfaces (parser/oracle_keyword.rs). They are not interchangeable:
PERMISSIVE. Take the leading keyword and discard the remainder — by design.
Embedded grant contexts only (static / token / vote / class-level payloads), where the trailing clause belongs to the enclosing sentence: "… gains vanishing 3 if that creature doesn't have vanishing".
The axis is typed: KeywordRemainderPolicy::{DiscardRemainder, RequireAllConsuming}, one
list walk parameterized by the per-part remainder contract.
Consume-on-success. A router may advance past a line only after a strict surface returns
a keyword all-consumingly, or after it emits Effect::unimplemented. A line with an
unconsumed semantic tail must DECLINE, not commit: Cycling {2} if you control an artifact falls through to an honest, exact-unit Effect::Unimplemented rather than
vanishing with no keyword and no diagnostic. Consuming it on a permissive parse drops the
tail's semantics and the card then renders as fully supported while its text was never
modelled.
The gate.scripts/check-parser-combinators.shGate G is a whole-file invariant: a
permissive keyword-parser symbol appearing anywhere inside parse_oracle_ir,
is_semicolon_keyword_line, or is_spell_resolution_instruction_line fails the build, at
any count. The migration is complete (task #123) — do not reintroduce it.
Oracle text parsing — Keywords on their own line (e.g., "Flying\nTrample") are handled by the line classifier in oracle.rs, which routes through parse_router_keyword_list() and ultimately Keyword::from_str(). No parser changes needed if FromStr is correct.
crates/engine/src/parser/oracle_keyword.rs — the router registry (parameterized/cost-bearing keywords only)
Add the keyword's prefix to the candidate recognizer is_keyword_cost_line()and a matching case to ROUTER_KEYWORD_CASES (the #[cfg(test)] typed family registry) in the same change. A set-equality test pins the two against each other: a prefix added to the recognizer without a strict parser, a valid fixture, a semantic-suffix rejection, and a declared production reach fails the build. That test is deliberate friction — it is the exact hole silent swallowing comes back through.
crates/engine/src/parser/oracle_static/grammar.rs — map_keyword()
This delegates to Keyword::from_str(). No changes needed unless the keyword has non-standard Oracle text.
crates/engine/src/parser/oracle_effect/token.rs — parse_token_keyword_clause() (if applicable)
If the keyword appears in token descriptions ("create a 1/1 white Spirit creature token with flying"), ensure it's recognized here.
Some keywords require synthesis in synthesis.rs — converting the keyword into actual game mechanics that aren't parsed from Oracle text:
crates/engine/src/database/synthesis.rs — synthesis function
If your keyword implies game actions that aren't explicit in Oracle text (e.g., Equip → activated Attach ability, Changeling → CDA static), add a synthesize_your_keyword() function and register it in synthesize_all(). See existing functions in that file for the pattern — each takes &mut CardFace and adds the implied abilities/triggers/statics.
Phase 6 — Coverage
crates/engine/src/game/coverage.rs — check_keywords()Keyword::Unknown(s) variants are automatically flagged as unsupported. If your new keyword variant exists but isn't fully implemented, don't leave it as Unknown — add the variant to the enum and wire the behavior. Unknown should only be for keywords the engine doesn't recognize at all.
Phase 7 — Tests
crates/engine/src/types/keywords.rs — FromStr tests
Test parsing: assert_eq!("yourkeyword".parse::<Keyword>().unwrap(), Keyword::YourKeyword);
For parameterized: assert_eq!("yourkeyword:3".parse().unwrap(), Keyword::YourKeyword(3));
crates/engine/src/game/keywords.rs — has_keyword tests
Test discriminant matching works for your variant.
Runtime behavior tests in the relevant game module (combat, targeting, etc.) — at least one must drive the real pipeline (apply() / scenario runner / the /card-test cast recipe) with a revert-failing assertion. Keyword PRs shipping only FromStr/AST-shape tests are the single most common review rejection. Negative assertions need a positive reach-guard (see /card-test foot-gun 6); build keyworded test cards via from_oracle_text_with_keywords, never inline reminder text.
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.
First Strike, Double Strike, Deathtouch, Trample, Lifelink, Wither, Infect
Pattern match in damage assignment
targeting.rs — target legality
Hexproof, Shroud, Ward
has_keyword() in is_legal_target()
sba.rs — state-based actions
Indestructible
has_keyword() in check_sba()
triggers.rs — synthetic triggers
Prowess, Undying, Persist, Exalted, Extort
has_keyword() + build ResolvedAbility
casting.rs — cast timing/cost
Flash, Convoke, Delve, Improvise
has_keyword() in casting validation
Common Mistakes
Mistake
Consequence
Fix
Adding variant but missing FromStr arm
Keyword never parsed from MTGJSON data, silently becomes Unknown
Add the string match
Missing keyword_from_tagged() arm
Existing card-data.json can't deserialize the keyword
Add the JSON match arm
Using equality instead of discriminant matching
Ward({W}) != Ward({2}) — "has ward" check fails for different costs
Use has_keyword() which uses std::mem::discriminant
Adding runtime behavior but no synthesis
Keyword parsed but its implied abilities (like Equip → Attach) never created
Add synthesis function in synthesis.rs
Leaving keyword as Unknown(String) with partial support
Coverage report flags it as missing, but it partially works
Add proper enum variant
Not testing parameterized parsing
"ward:{W}" might fail due to mana cost format
Test both "Ward:{W}" and "Ward:W" formats
Consuming a keyword line via a permissive helper in a router
Silent swallow: the trailing clause's semantics are dropped, no keyword recorded, no Unimplemented raised — the card renders as fully supported
Use parse_router_keyword_line / _list / _fragment. scripts/check-parser-combinators.sh Gate G fails the build on any permissive symbol in a router context
Adding a prefix to is_keyword_cost_line() without a ROUTER_KEYWORD_CASES entry
The recognizer nominates a line no strict parser can fully parse
The set-equality test reds. Add the case, a valid fixture, and a semantic-suffix rejection in the same change
Self-Maintenance
After completing work using this skill:
Verify references with the check below
Update keyword category table if you added runtime behavior in a new location
Update the variant categories if you added a new parameterization type