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.
Instal·la
npx skillscat add phase-rs/phase/add-keyword Instal·la des del registre de SkillsCat.
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.txtbefore 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). Rungrep -n "^702.122" docs/MagicCompRules.txtfor 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 |
| 613.1f | Keywords granted/removed in Layer 6 | ContinuousModification::AddKeyword / RemoveKeyword |
| 702.2 | Evergreen keywords (flying, haste, etc.) | Unit variants on Keyword enum |
| 702.16 | Protection | Keyword::Protection(ProtectionTarget) with multi-check in protection_prevents_from() |
| 702.21 | Ward | Keyword::Ward(ManaCost) checked in targeting.rs |
Key Types
Keyword Enum — crates/engine/src/types/keywords.rs
Three categories of variants:
| Category | Examples | Data |
|---|---|---|
| Unit (simple) | Flying, Haste, Trample, Deathtouch, Lifelink, Vigilance, Reach, Defender, Menace, Indestructible, Hexproof, Shroud, Flash |
None |
| Parameterized (u32) | Dredge(u32), Modular(u32), Renown(u32), Fabricate(u32), Annihilator(u32), Crew(u32) |
Numeric value |
| Parameterized (ManaCost) | Ward(ManaCost), Kicker(ManaCost), Cycling(ManaCost), Flashback(ManaCost), Equip(ManaCost), Morph(ManaCost) |
Mana cost |
| Special | Protection(ProtectionTarget), Enchant(TargetFilter), Landwalk(String), EtbCounter { counter_type, count } |
Typed data |
| Fallback | Unknown(String) |
Unparsed name — never matches at runtime, tracked in coverage |
Storage on GameObject — crates/engine/src/game/game_object.rs
pub keywords: Vec<Keyword>, // Current (modified by layers)
pub base_keywords: Vec<Keyword>, // Original (reset each layer evaluation)Runtime Checking — crates/engine/src/game/keywords.rs
pub fn has_keyword(obj: &GameObject, keyword: &Keyword) -> bool {
obj.keywords.iter()
.any(|k| std::mem::discriminant(k) == std::mem::discriminant(keyword))
}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.
Convenience functions: has_flying(), has_haste(), has_flash(), has_hexproof(), has_shroud(), has_indestructible().
Checklist — Adding a New Keyword
Phase 1 — Type Definition
crates/engine/src/types/keywords.rs—Keywordenum
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
- Unit:
crates/engine/src/types/keywords.rs—FromStrimpl
Add parsing in thefrom_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)
- Unit keywords: Add to the lowercase match block (~line 310-380). Pattern:
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/types/keywords.rs—Displayimpl (if exists)
Add formatting for debug/log output.
Phase 2 — Runtime Behavior
Where you add logic depends on what the keyword does. Most keywords only need one or two of these.
crates/engine/src/game/combat.rs— If affects attack/block legality
Keywords checked here: Flying, Reach, Defender, Menace, Fear, Intimidate, Skulk, Shadow, Horsemanship, Haste, Vigilance.- Evasion (can't be blocked by X): Add to blocker legality checks
- Attack restriction (can't attack / must attack): Add to attacker validation
- Vigilance-like (doesn't tap): Add to attack resolution
crates/engine/src/game/combat_damage.rs— If affects damage calculation
Keywords checked here: Deathtouch, Trample, Lifelink, Wither, Infect, First Strike, Double Strike.- Damage modification: Add to
assign_combat_damage()orapply_combat_damage() - Damage timing: First Strike / Double Strike use two damage steps
- Damage modification: Add to
crates/engine/src/game/targeting.rs— If affects targeting legality
Keywords checked here: Hexproof, Shroud, Ward, Protection.- Untargetable: Add to
is_legal_target()checks - Conditional protection: Add to
protection_prevents_from()inkeywords.rs
- Untargetable: Add to
crates/engine/src/game/sba.rs— If affects state-based actions
Keywords checked here: Indestructible (prevents destruction).- Add check in
check_state_based_actions()
- Add check in
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: Inprocess_triggers(), checkobj.has_keyword(&Keyword::YourKeyword)on the relevantGameEvent, then build a syntheticResolvedAbility.crates/engine/src/game/keywords.rs— Optional convenience function
If the keyword is checked in multiple files, addpub 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
ContinuousModificationvariants.
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-parserSKILL.md §3a.
The two surfaces (parser/oracle_keyword.rs). They are not interchangeable:
| Surface | Contract | Where it may be used |
|---|---|---|
parse_router_keyword_line() / parse_router_keyword_fragment() / parse_router_keyword_list() |
STRICT / all-consuming. Return a typed keyword only when the text parses completely (keyword + permitted P/R/M tail). | The only surfaces that may license a router — or a routing classifier — to CONSUME a line. |
parse_granted_keyword_fragment() / extract_granted_keyword_list() |
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.sh Gate 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 throughparse_router_keyword_list()and ultimatelyKeyword::from_str(). No parser changes needed ifFromStris correct.crates/engine/src/parser/oracle_keyword.rs— the router registry (parameterized/cost-bearing keywords only)
Add the keyword's prefix to the candidate recognizeris_keyword_cost_line()and a matching case toROUTER_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 toKeyword::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.crates/engine/src/parser/oracle_effect/token.rs—map_token_keyword()(if applicable)
Maps token-description keywords toKeywordvariants.
Phase 5 — Synthesis (if applicable)
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 asynthesize_your_keyword()function and register it insynthesize_all(). See existing functions in that file for the pattern — each takes&mut CardFaceand 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 asUnknown— add the variant to the enum and wire the behavior.Unknownshould only be for keywords the engine doesn't recognize at all.
Phase 7 — Tests
crates/engine/src/types/keywords.rs—FromStrtests
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_keywordtests
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-testcast recipe) with a revert-failing assertion. Keyword PRs shipping onlyFromStr/AST-shape tests are the single most common review rejection. Negative assertions need a positive reach-guard (see/card-testfoot-gun 6); build keyworded test cards viafrom_oracle_text_with_keywords, never inline reminder text.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: Keyword Categories by Runtime Location
| Runtime Location | Keywords | Check Pattern |
|---|---|---|
combat.rs — blocker legality |
Flying, Reach, Fear, Intimidate, Skulk, Shadow, Horsemanship, Menace | has_keyword() in can_block() |
combat.rs — attacker validation |
Defender, Haste (summoning sickness) | has_keyword() in can_attack() |
combat.rs — attack resolution |
Vigilance (skip tap) | has_keyword() in declare_attackers() |
combat_damage.rs — damage calc |
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
Verification
rg -q "enum Keyword" crates/engine/src/types/keywords.rs && \
rg -q "fn from_str" crates/engine/src/types/keywords.rs && \
rg -q "fn keyword_from_tagged" crates/engine/src/types/keywords.rs && \
rg -q "fn has_keyword" crates/engine/src/game/keywords.rs && \
rg -q "fn protection_prevents_from" crates/engine/src/game/keywords.rs && \
rg -q "fn parse_keywords" crates/engine/src/game/keywords.rs && \
rg -q "fn synthesize_equip" crates/engine/src/database/synthesis.rs && \
rg -q "fn synthesize_changeling_cda" crates/engine/src/database/synthesis.rs && \
rg -q "fn check_keywords" crates/engine/src/game/coverage.rs && \
rg -q "fn map_keyword" crates/engine/src/parser/oracle_static/grammar.rs && \
rg -q "fn parse_router_keyword_list" crates/engine/src/parser/oracle_keyword.rs && \
rg -q "fn parse_router_keyword_fragment" crates/engine/src/parser/oracle_keyword.rs && \
rg -q "enum KeywordRemainderPolicy" crates/engine/src/parser/oracle_keyword.rs && \
rg -q "fn is_keyword_cost_line" crates/engine/src/parser/oracle_keyword.rs && \
rg -q "ROUTER_KEYWORD_CASES" crates/engine/src/parser/oracle_keyword.rs && \
rg -q "AddKeyword" crates/engine/src/types/ability.rs && \
echo "✓ add-keyword skill references valid" || \
echo "✗ STALE — update skill references"