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.
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.
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:
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 "^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 Priority
// After resolve_effect() returns, check if we entered a waiting stateif 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 continuationletmut 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));
returnOk(());
}
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.
pub pending_continuation: Option<PendingContinuation>,
pubstructPendingContinuation {
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 — WaitingFor enum
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 player field is required — it determines who must act.
crates/engine/src/types/actions.rs — GameAction enum
Add a variant for the player's response. Reuse SelectCards or SelectTargets if 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 { ... }
Add WaitingFor::YourChoice { .. } to the continuation match in resolve_ability_chain() — this is critical
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:ifletSome(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 in phase-ai. The entry point is engine::ai_support::legal_actions(state) which calls candidate_actions(). Add a match arm generating all legal responses for your WaitingFor variant:
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 the player field from your WaitingFor variant:
This ensures the server only accepts actions from the correct player.
Phase 6 — Frontend
client/src/adapter/types.ts — WaitingFor + GameAction types
Add TypeScript discriminated union variants. tsify may auto-generate these from Rust — check client/src/wasm/ for generated types and whether manual overrides exist in types.ts.
client/src/pages/GamePage.tsx or client/src/components/ — UI component
Render the choice when waitingFor.type === "YourChoice". Follow existing patterns:
CardChoiceModal → SearchModal — filtered card selection from list
NamedChoiceModal — named choices including CardName, NumberRange, Labeled, and LandType
Internationalize all frontend-authored text (titles, prompts, buttons, labels) via t() — const { t } = useTranslation("game"), with keys added to client/src/i18n/locales/en/game.json first. Card names and Oracle text stay raw (content pipeline). Boundary rule and conventions: client/src/i18n/README.md. See $add-frontend-component Phase 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_cards during the choice
Clear revealed status after the choice is made
Ensure filter_state_for_player() respects the revealed set
Phase 8 — Tests
Resolver test: effect sets correct WaitingFor with 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 WaitingFor through the real effect / cast / ability / replacement path before submitting the action. A manually constructed waiting state is not enough for that claim.
Empty/decline test: for optional choices or "up to" selections, submit the empty choice through the real GameAction path and verify tracked sets, continuations, and final waiting_for state 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 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.
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.
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. For NumberRange, 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> — for NumberRange, compute_options() generates ["0", "1", "2", ..., "7"]. The current ButtonGrid renderer already works; a dedicated number input is optional UX polish, not required.
Labeled { options } carries its options in the enum variant — unlike Color or CreatureType where options are hardcoded in compute_options(), Labeled options come from the parser (card-specific text like "fame" / "fortune").
Multiplayer filtering: No changes needed — NamedChoice is public information, not filtered by filter_state_for_player().
source_id / persist: Existing mechanism for storing choice on the source object via ChosenAttribute. New choice types may not need persistence — use persist: false unless 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).
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.rs to 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-effect skill for the full pattern
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