Use when adding any effect or ability that requires player input mid-resolution — choices, selections, modal decisions, or any WaitingFor/GameAction round-trip. Covers the continuation pattern, engine handler wiring, AI legal actions, multiplayer routing, and frontend UI.
Install
npx skillscat add phase-rs/phase/add-interactive-effect Install via the SkillsCat registry.
Adding an Interactive Effect
Interactive effects pause game resolution to wait for player input, then resume. Examples: Scry (choose top/bottom), Dig (choose cards to keep), Surveil (choose graveyard/library), Search (choose from library), Reveal+Choose (pick opponent's card).
The core mechanism is the continuation pattern: resolve_ability_chain() detects a waiting state, stashes remaining sub-abilities in pending_continuation, and returns. When the player responds, engine.rs resumes the chain.
Before you start: Trace Scry end-to-end as the simplest example:
- Resolver:
effects/scry.rs— setsWaitingFor::ScryChoice - Engine handler:
engine.rs—(WaitingFor::ScryChoice, GameAction::SelectCards)arm - Continuation:
pending_continuationresumed after player selects - AI:
legal_actions.rs— generates legal card selections - Frontend:
CardChoiceModal→ScryModal
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 "^701.22" docs/MagicCompRules.txt(etc.) for every number. If you cannot find it, do not write the annotation.
The Continuation Pattern
This is the most important architectural concept for interactive effects.
Problem
A card says "Scry 2, then draw a card." This is parsed as Scry { count: 2 } with sub_ability: Draw { count: 1 }. But Scry requires player input — the engine can't just barrel through to Draw.
Solution: pending_continuation
resolve_ability_chain(ability) called:
↓
resolve_effect(Scry) → sets state.waiting_for = ScryChoice
↓
resolve_ability_chain detects waiting state:
Has sub_ability (Draw)?
YES → store as state.pending_continuation
Return Ok(()) — chain paused
↓
[Player makes scry selection via GameAction::SelectCards]
↓
engine.rs handler processes the selection
↓
Check state.pending_continuation.take():
Some(continuation) → resolve_ability_chain(continuation)
None → return to PriorityKey code: resolve_ability_chain() — crates/engine/src/game/effects/mod.rs
// After resolve_effect() returns, check if we entered a waiting state
if matches!(state.waiting_for,
WaitingFor::ScryChoice { .. }
| WaitingFor::DigChoice { .. }
| WaitingFor::SurveilChoice { .. }
| WaitingFor::RevealChoice { .. }
| WaitingFor::SearchChoice { .. }
| WaitingFor::DiscoverChoice { .. }
| WaitingFor::TriggerTargetSelection { .. }
| WaitingFor::NamedChoice { .. }
// ← ADD YOUR NEW VARIANT HERE
) {
// Stash remaining chain as continuation
let mut sub_clone = sub.as_ref().clone();
if sub_clone.targets.is_empty() && !ability.targets.is_empty() {
sub_clone.targets = ability.targets.clone(); // propagate parent targets
}
state.pending_continuation = Some(Box::new(sub_clone));
return Ok(());
}If you skip adding your variant to this match, sub-abilities after your interactive effect will execute immediately, bypassing the player choice entirely. This is the #1 source of bugs for interactive effects.
pending_continuation storage — crates/engine/src/types/game_state.rs
pub pending_continuation: Option<PendingContinuation>,
pub struct PendingContinuation {
pub chain: Box<ResolvedAbility>,
pub parent_kind: Option<EffectKind>, // used to re-emit parent EffectResolved on drain
}The parent_kind field is set when the continuation is stashed so that draining the continuation re-emits the parent EffectResolved event (see commit e69173e2f — required for fight / DamageAll / DamageEachPlayer paths that pause mid-delivery).
Target propagation
When the continuation is created, parent targets propagate down if the sub-ability has no targets of its own. This allows chains like "Exile target creature. Its controller gains life equal to its power" to work — the sub-ability receives the creature target from the parent.
Checklist — Adding a New Interactive Effect
Phase 1 — WaitingFor + GameAction
crates/engine/src/types/game_state.rs—WaitingForenum
Add a variant carrying enough data for the frontend to render the choice UI:YourChoice { player: PlayerId, // Data the frontend needs to display options: cards: Vec<ObjectId>, // if choosing cards options: Vec<String>, // if choosing from named options // etc. },The
playerfield is required — it determines who must act.crates/engine/src/types/actions.rs—GameActionenum
Add a variant for the player's response. ReuseSelectCardsorSelectTargetsif they fit. Only create a new variant if the response shape is genuinely different:YourResponse { selection: YourSelectionType, },
Phase 2 — Effect Resolver
crates/engine/src/game/effects/<name>.rs— resolver
The resolver does three things:- Compute the choices available to the player
- Set
state.waiting_for = WaitingFor::YourChoice { ... } - Emit
GameEvent::EffectResolved
Pattern (from
effects/scry.rs):pub fn resolve(state: &mut GameState, ability: &ResolvedAbility, events: &mut Vec<GameEvent>) -> Result<(), EffectError> { let player = ability.controller; // 1. Compute choices let cards = /* ... */; // 2. Set waiting state state.waiting_for = WaitingFor::YourChoice { player, cards }; // 3. Emit event events.push(GameEvent::EffectResolved { kind: EffectKind::from(&*ability.effect), source_id: ability.source_id }); Ok(()) }crates/engine/src/game/effects/mod.rs— wire resolver + continuation match
Two changes:- Add
Effect::YourEffect { .. } => your_module::resolve(state, ability, events)toresolve_effect() - Add
WaitingFor::YourChoice { .. }to the continuation match inresolve_ability_chain()— this is critical
- Add
Phase 3 — Engine Handler
crates/engine/src/game/engine.rs—apply()match arm
Add a(WaitingFor::YourChoice { .. }, GameAction::YourResponse { .. })arm:(WaitingFor::YourChoice { player, cards, .. }, GameAction::YourResponse { selection }) => { // 1. Validate the response // 2. Apply the choice to game state // 3. Resume continuation if present: if let Some(continuation) = state.pending_continuation.take() { // Optionally inject the chosen card/target into continuation's targets: // continuation.targets = vec![TargetRef::Object(chosen_id)]; effects::resolve_ability_chain(state, &continuation, &mut events, 0) .map_err(|e| EngineError::InvalidAction(format!("{:?}", e)))?; } // 4. Return next waiting state (usually Priority, unless continuation set a new one) if !matches!(state.waiting_for, WaitingFor::Priority { .. }) { state.waiting_for.clone() } else { WaitingFor::Priority { player: state.active_player } } }Key detail: After resuming the continuation, check
state.waiting_for— the continuation might have entered another interactive state (chained choices).
Phase 4 — AI Legal Actions
crates/engine/src/ai_support/candidates.rs— candidate action generation
Legal action generation now lives in the engine crate (engine::ai_support), not inphase-ai. The entry point isengine::ai_support::legal_actions(state)which callscandidate_actions(). Add a match arm generating all legal responses for yourWaitingForvariant:WaitingFor::YourChoice { cards, .. } => { // Generate all valid selections the AI could make cards.iter().map(|&id| GameAction::YourResponse { selection: id }).collect() }Consider:
- Are all combinations valid, or only specific ones?
- Is there a "decline" / "choose nothing" option?
- For large choice sets, does the AI need a subset? (e.g., SearchChoice limits to first N)
Phase 5 — Multiplayer Routing
crates/server-core/src/session.rs—acting_player()
Add a match arm extracting theplayerfield from yourWaitingForvariant:WaitingFor::YourChoice { player, .. } => Some(*player),This ensures the server only accepts actions from the correct player.
Phase 6 — Frontend
client/src/adapter/types.ts—WaitingFor+GameActiontypes
Add TypeScript discriminated union variants.tsifymay auto-generate these from Rust — checkclient/src/wasm/for generated types and whether manual overrides exist intypes.ts.client/src/pages/GamePage.tsxorclient/src/components/— UI component
Render the choice whenwaitingFor.type === "YourChoice". Follow existing patterns:CardChoiceModal→ScryModal— card reordering (top/bottom)CardChoiceModal→DigModal— card selection (keep N)CardChoiceModal→SurveilModal— binary per-card choice (library/graveyard)ChoiceModal— simple option selection (buttons)CardChoiceModal→SearchModal— filtered card selection from listNamedChoiceModal— named choices includingCardName,NumberRange,Labeled, andLandType
Internationalize all frontend-authored text (titles, prompts, buttons, labels) via
t()—const { t } = useTranslation("game"), with keys added toclient/src/i18n/locales/en/game.jsonfirst. Card names and Oracle text stay raw (content pipeline). Boundary rule and conventions:client/src/i18n/README.md. See$add-frontend-componentPhase 2.5 for the full checklist.
Phase 7 — Multiplayer State Filtering (if hidden info)
-
crates/server-core/src/filter.rs—filter_state_for_player()
If your interactive effect reveals hidden information (opponent's hand, library cards, face-down cards):- Track revealed cards via
state.revealed_cardsduring the choice - Clear revealed status after the choice is made
- Ensure
filter_state_for_player()respects the revealed set
- Track revealed cards via
Phase 8 — Tests
- Resolver test: effect sets correct
WaitingForwith expected choices - Engine round-trip test: set up waiting state → submit action → verify state change
- Production setup test: when correctness depends on how the waiting state is produced, enter
WaitingForthrough the real effect / cast / ability / replacement path before submitting the action. A manually constructed waiting state is not enough for that claim. - Continuation test: effect with sub_ability → interactive pause → resume → sub_ability executes
- Empty/decline test: for optional choices or "up to" selections, submit the empty choice through the real
GameActionpath and verify tracked sets, continuations, and finalwaiting_forstate are correct. - Tracked-set test: if the choice feeds "those", "them", "this way", or a downstream tracked-set consumer, publish or clear a fresh tracked set on first choice resolution, including all-decline/empty paths, or prove no tracked-set consumer exists.
- Resume-state test: verify downstream continuations execute and priority / next waiting state is restored; do not only assert that the pending queue was cleared.
- AI test:
get_legal_actions()returns valid options for the waiting state - 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.
Extending ChoiceType (Named Choice System)
The NamedChoice system is a well-contained interactive pattern with low blast radius for adding new choice types. Current ChoiceType variants: CreatureType, Color, OddOrEven, BasicLandType, CardType, CardName, NumberRange { min, max }, Labeled { options }, LandType, Opponent, Player, TwoColors.
Architecture (all touchpoints)
| # | File | What to change |
|---|---|---|
| 1 | crates/engine/src/types/ability.rs — ChoiceType enum |
Add variant with typed fields |
| 2 | crates/engine/src/game/effects/choose.rs — compute_options() |
Add match arm to generate options for the new variant |
| 3 | crates/engine/src/game/engine.rs — ChooseOption handler |
May need custom validation (e.g., NumberRange validates parsed u8 in range) |
| 4 | client/src/adapter/types.ts |
Already generic (choice_type: string, options: string[]) — no change needed |
| 5 | client/src/components/modal/NamedChoiceModal.tsx |
Add rendering branch only if the existing button grid / card-name search is insufficient |
| 6 | client/src/components/modal/NamedChoiceModal.tsx — CHOICE_TYPE_TITLE_KEYS |
Map the new ChoiceType key → an i18n leaf (e.g. Keyword: "keyword"). The title renders via t(\namedChoice.title.${leaf}`), so also add namedChoice.title.toclient/src/i18n/locales/en/game.json`. Never hardcode the label. |
| 7 | crates/engine/src/ai_support/candidates.rs — NamedChoice arm |
Already generates one action per option — works for any choice type with populated options |
| 8 | crates/engine/src/parser/oracle_effect/ |
Add parser patterns for the new choice text |
| 9 | crates/engine/src/game/effects/choose.rs — tests |
Add test for compute_options() with new variant |
Key design decisions
last_named_choice: Option<String>stores the result for continuations. ForNumberRange, the stored string is the number as text (e.g.,"3"). Continuations parse it as needed. This avoids changing the continuation protocol.- Frontend
options: Vec<String>— forNumberRange,compute_options()generates["0", "1", "2", ..., "7"]. The currentButtonGridrenderer already works; a dedicated number input is optional UX polish, not required. Labeled { options }carries its options in the enum variant — unlikeColororCreatureTypewhere options are hardcoded incompute_options(),Labeledoptions come from the parser (card-specific text like "fame" / "fortune").- Multiplayer filtering: No changes needed —
NamedChoiceis public information, not filtered byfilter_state_for_player(). source_id/persist: Existing mechanism for storing choice on the source object viaChosenAttribute. New choice types may not need persistence — usepersist: falseunless the choice must be remembered across turns.
Interactive Replacement Effects
When a replacement effect (not a regular effect) needs player input, the pattern is different because the choice must happen before the zone change (see add-replacement-effect skill, Interactive Replacements section).
The key difference:
- Regular interactive effect: resolver sets WaitingFor, engine handler resumes via continuation
- Interactive replacement: the replacement pipeline pauses, stores the pending
ProposedEvent, waits for choice, then executes the zone change with the choice applied
For interactive replacements, you need to:
- Add WaitingFor + GameAction as above
- Instead of an effect resolver, modify the replacement pipeline in
replacement.rsto detect and pause - In
engine.rs, handle the response by applying the choice, then resuming the stored zone change - This is more complex — see
add-replacement-effectskill for the full pattern
Reference: Existing Interactive Effects
Card Selection Effects (reuse GameAction::SelectCards)
| Effect | WaitingFor | Complexity |
|---|---|---|
| Scry | ScryChoice { player, cards } |
Simple — binary per-card (top/bottom) |
| Dig | DigChoice { player, cards, keep_count } |
Medium — select exactly N cards |
| Surveil | SurveilChoice { player, cards } |
Simple — binary per-card (library/graveyard) |
| RevealHand | RevealChoice { player, cards, filter } |
Medium — select from filtered set, clears revealed state after |
| SearchLibrary | SearchChoice { player, cards, count } |
Complex — filter library, "fail to find" rule, multi-card select |
| Discover | DiscoverChoice { player, cards, discover_value } |
Medium — exile from top until CMC ≤ discover value, cast or put in hand |
| Connive | ConniveDiscard { player, cards, count } |
Medium — discard after draw |
| ChooseFromZone | ChooseFromZoneChoice { player, cards, count, zone } |
Medium — generic zone card selection |
| ManifestDread | ManifestDreadChoice { player, cards } |
Simple — select card from top to manifest |
| DiscardToHandSize | DiscardChoice { player, cards, count } |
Simple — select cards to discard down |
| Proliferate | ProliferateChoice { player, permanents } |
Medium — select permanents to add counters |
Targeting & Combat
| Effect | WaitingFor | Complexity |
|---|---|---|
| TargetSelection | TargetSelection { player, pending_cast, legal_targets } |
Complex — spell/ability targeting |
| MultiTargetSelection | MultiTargetSelection { player, slots, ... } |
Complex — multi-slot targeting |
| TriggerTargetSelection | TriggerTargetSelection { player, trigger_index, ... } |
Complex — trigger target selection |
| DeclareAttackers | DeclareAttackers { player, legal_attackers } |
Complex — combat phase |
| DeclareBlockers | DeclareBlockers { player, legal_blockers } |
Complex — combat phase |
| AssignCombatDamage | AssignCombatDamage { player, assignments } |
Complex — damage assignment order |
| EquipTarget | EquipTarget { player, equipment_id, legal_targets } |
Simple — target for equip |
| CopyRetarget | CopyRetarget { player, copy_id, ... } |
Medium — retarget copied spell |
Named & Modal Choices
| Effect | WaitingFor | GameAction | Complexity |
|---|---|---|---|
| NamedChoice | NamedChoice { player, choice_type, options } |
ChooseOption |
Medium — creature type, color, etc. |
| ModeChoice | ModeChoice { player, modal, pending_cast } |
SelectModes |
Medium — spell modal selection |
| AbilityModeChoice | AbilityModeChoice { player, modal, source_id, ... } |
SelectModes |
Medium — activated/triggered modal |
| Replacement | ReplacementChoice { player, count, descriptions } |
ChooseReplacement |
Simple — index-based selection |
| TopOrBottom | TopOrBottomChoice { player, card } |
ChooseTopOrBottom |
Simple — binary choice |
| ChooseRingBearer | ChooseRingBearer { player, legal_targets } |
SelectTargets |
Simple — ring bearer selection |
| ChooseLegend | ChooseLegend { player, legends } |
SelectCards |
Simple — legend rule |
Cost & Payment
| Effect | WaitingFor | GameAction | Complexity |
|---|---|---|---|
| ManaPayment | ManaPayment { player } |
Mana declaration | Medium — X costs |
| OptionalCost | OptionalCostChoice { player, costs, ... } |
ChooseOptionalCosts |
Medium — kicker, additional costs |
| DefilerPayment | DefilerPayment { player, life_per_pip, ... } |
DefilerDecision |
Medium — pay life instead of mana |
| WarpCost | WarpCostChoice { player, options, ... } |
SelectWarpCost |
Medium — alternative cost choice |
| UnlessPayment | UnlessPayment { player, cost, ... } |
PayUnlessCost |
Medium — "unless you pay" |
| DiscardForCost | DiscardForCost { player, cards, ... } |
SelectCards |
Medium — discard as cost |
| SacrificeForCost | SacrificeForCost { player, candidates, ... } |
SelectCards |
Medium — sacrifice as cost |
| ExileFromGraveyardForCost | ExileFromGraveyardForCost { player, cards, ... } |
SelectCards |
Medium — exile from yard as cost |
| HarmonizeTapChoice | HarmonizeTapChoice { player, candidates, ... } |
SelectCards |
Medium — tap creature for cost |
| WardDiscard | WardDiscardChoice { player, ... } |
SelectCards |
Simple — ward discard cost |
| WardSacrifice | WardSacrificeChoice { player, ... } |
SelectCards |
Simple — ward sacrifice cost |
Optional & Opponent Choices
| Effect | WaitingFor | GameAction | Complexity |
|---|---|---|---|
| OptionalEffect | OptionalEffectChoice { player, description } |
ChooseOptionalEffect |
Simple — "you may" effects |
| OpponentMay | OpponentMayChoice { player, description, ... } |
ChooseOptionalEffect |
Simple — opponent "may" |
| AdventureCast | AdventureCastChoice { player, card_id } |
ChooseAdventureCast |
Simple — cast adventure side |
| CopyTarget | CopyTargetChoice { player, copy_id, legal_targets } |
SelectTargets |
Medium — what to copy |
| CompanionReveal | CompanionReveal { player, companion_id } |
RevealCompanion |
Simple — companion revelation |
Game Structure
| Effect | WaitingFor | Complexity |
|---|---|---|
| Mulligan | MulliganDecision { player } |
Simple — keep/mulligan (bottoming is a MulliganDecisionPhase::BottomCards sub-phase of the same variant) |
| Sideboard | BetweenGamesSideboard { player, ... } |
Medium — sideboard swaps |
| PlayDraw | BetweenGamesChoosePlayDraw { player } |
Simple — play/draw choice |
| GameOver | GameOver { winners } |
Terminal state |
Note how many effects reuse GameAction::SelectCards — only create a new GameAction variant if the response shape is genuinely different (e.g., ChooseOption for string responses, SelectModes for index-based selection).
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Missing continuation match in resolve_ability_chain() |
Sub-abilities after your effect execute immediately, bypassing player choice | Add your WaitingFor variant to the match block in effects/mod.rs |
Missing acting_player() arm in session.rs |
Server rejects all actions for this state in multiplayer | Add the match arm |
| Missing AI legal actions | AI hangs forever waiting for a response it can't generate | Add match arm in engine/src/ai_support/candidates.rs |
| Not clearing revealed state after choice | Opponent's hidden cards remain visible permanently | Clear state.revealed_cards in the engine handler |
Testing only a manually constructed WaitingFor |
Bypasses bugs in the real effect/cast/ability path that creates the choice | Add at least one production setup test that reaches the waiting state naturally |
Resuming continuation without checking state.waiting_for |
Continuation might set another interactive state, but you overwrite it with Priority | Check waiting_for after resolve_ability_chain returns |
| Clearing pending state without testing downstream continuation | The prompt disappears but the rest of the ability never resolves | Assert the continuation's effect and final waiting state |
| Empty choice leaves an old tracked set bound | "Those/them/this way" can refer to a prior producer instead of the empty choice | Publish/clear a fresh tracked set even for all-decline paths when a consumer exists |
| Not propagating targets to continuation | Sub-ability can't reference the chosen card | Copy parent targets when sub_clone.targets.is_empty() |
Creating new GameAction when SelectCards works |
Unnecessary type proliferation | Reuse SelectCards unless response shape is genuinely different |
Self-Maintenance
After completing work using this skill:
- Verify references with the check below
- Update if stale: function names, file paths, or enum variants that moved
- Add new patterns: if you added a new interactive effect, add it to the reference table
Verification
rg -q "fn resolve_ability_chain" crates/engine/src/game/effects/mod.rs && \
rg -q "pending_continuation" crates/engine/src/types/game_state.rs && \
rg -q "fn resolve_effect" crates/engine/src/game/effects/mod.rs && \
rg -q "fn legal_actions" crates/engine/src/ai_support/mod.rs && \
rg -q "fn acting_player" crates/server-core/src/session.rs && \
rg -q "enum WaitingFor" crates/engine/src/types/game_state.rs && \
rg -q "enum GameAction" crates/engine/src/types/actions.rs && \
rg -q "ScryChoice" crates/engine/src/game/effects/mod.rs && \
echo "✓ add-interactive-effect skill references valid" || \
echo "✗ STALE — update skill references"