Use when adding or modifying frontend UI components — interactive overlays for WaitingFor states, game board elements, card choice modals, animation effects, or any React component that interfaces with the engine via GameAction dispatch.
Install
npx skillscat add phase-rs/phase/add-frontend-component Install via the SkillsCat registry.
Adding a Frontend Component
Hard rules — all frontend work must respect these (see CLAUDE.md § Design Principles):
- The frontend is a display layer, not a logic layer. It renders engine-provided state and dispatches user actions — nothing more. It must never compute, derive, filter, or re-interpret game data. If a component needs a value the engine doesn't expose, the fix is to add it to the engine's output — not to calculate it client-side. Any "smart" frontend code is a bug.
- CR-correctness is non-negotiable. The frontend must never contradict the Comprehensive Rules. If it displays information (legal targets, valid choices, game state), that information must come directly from the engine, which is the CR-validated source of truth. Never approximate engine logic in TypeScript.
- Build reusable component patterns. New overlays and modals should follow existing patterns (CardChoiceModal, ModeChoiceModal). Extract shared behavior into composable components rather than duplicating across one-off implementations.
- All frontend-authored text is internationalized. Every user-facing string the frontend authors (titles, labels, buttons, tooltips, placeholders, log templates, status messages) MUST go through
t()viareact-i18next— never a hardcoded literal in JSX. The boundary rule: a string getst()if and only if the frontend authored it. Engine/card-database pass-through (card names, Oracle text, interpolated enum strings like phase/mana/counter type) stays raw — it is localized by a separate MTGJSON content pipeline, nott(). Seeclient/src/i18n/README.md(the authority) before adding any string.
The React/TypeScript frontend communicates with the Rust engine through a transport-agnostic adapter layer. Game state flows from engine → adapter → Zustand stores → React components. Player actions flow in reverse via dispatch(). This skill covers wiring new UI components into this pipeline.
Before you start: Trace how ScryChoice works end-to-end. The current path is: WaitingFor::ScryChoice in Rust → TypeScript type in adapter/types.ts → GamePage.tsx renders CardChoiceModal → CardChoiceModal routes to ScryModal → dispatch({ type: "SelectCards", data: { cards } }).
Architecture Overview
Engine (Rust/WASM)
↓ ActionResult { events, waiting_for }
Adapter (WasmAdapter / WebSocketAdapter / TauriAdapter)
↓ GameEvent[], GameState, WaitingFor
Stores (Zustand)
├─ gameStore: gameState, waitingFor, legalActions, events, dispatch
├─ uiStore: selectedObjectId, targetingMode, combatMode
└─ animationStore: activeStep, queue, positionRegistry
↓
React Components
├─ GamePage.tsx — routes WaitingFor → overlays/modals
├─ components/modal/ — interactive overlays (CardChoiceModal, ModeChoiceModal, ReplacementModal, NamedChoiceModal)
├─ components/board/ — battlefield, permanents, player areas
├─ components/hand/ — player/opponent hand
├─ components/combat/ — attacker/blocker controls
├─ components/animation/ — VFX overlay
└─ components/log/ — game event logKey Files
Type Definitions — client/src/adapter/types.ts
Manually maintained TypeScript discriminated unions mirroring Rust serde output (tag="type", content="data"):
// WaitingFor — ~19 variants, determines which overlay to show
type WaitingFor =
| { type: "Priority"; data: { player: PlayerId } }
| { type: "ScryChoice"; data: { player: PlayerId; cards: ObjectId[] } }
| { type: "DigChoice"; data: { player: PlayerId; cards: ObjectId[]; keep_count: number } }
// ...
// GameAction — ~18 variants, player responses
type GameAction =
| { type: "SelectCards"; data: { cards: ObjectId[] } }
| { type: "ChooseReplacement"; data: { index: number } }
// ...
// GameEvent — ~33 variants, for log + animations
type GameEvent =
| { type: "DamageDealt"; data: { source_id: ObjectId; target: TargetRef; amount: number } }
// ...Game Store — client/src/stores/gameStore.ts
interface GameStoreState {
gameState: GameState | null;
waitingFor: WaitingFor | null;
legalActions: GameAction[];
events: GameEvent[]; // Latest batch
eventHistory: GameEvent[]; // Rolling window (last 1000)
adapter: EngineAdapter | null;
}Key action: dispatch(action: GameAction) → adapter.submitAction → animations → state update.
UI Store — client/src/stores/uiStore.ts
Ephemeral UI state — targeting mode, combat selections, hovered/selected objects. Combat selections stay in uiStore until the player confirms (optimistic UI pattern).
Dispatch Pipeline — client/src/game/dispatch.ts
User action
→ Capture DOM snapshot (pre-animation positions)
→ adapter.submitAction(action)
→ normalizeEvents(events) → AnimationSteps
→ enqueueSteps (animation store)
→ Wait for animation duration
→ Update gameStore (state, waitingFor, legalActions)
→ Save to localStorageWaitingFor → UI Routing — client/src/pages/GamePage.tsx
Conditional rendering based on waitingFor.type + playerId check:
{(waitingFor?.type === "TargetSelection" ||
waitingFor?.type === "TriggerTargetSelection") &&
waitingFor.data.player === playerId && <TargetingOverlay />}
<ModeChoiceModal />
<CardChoiceModal />
{waitingFor?.type === "ReplacementChoice" &&
waitingFor.data.player === playerId && <ReplacementModal />}All overlays gate on waitingFor.data.player === playerId to prevent the wrong player from seeing choices in multiplayer.
Checklist — Adding a New Frontend Component
Phase 1 — TypeScript Types
client/src/adapter/types.ts—WaitingForunion (if new interactive state)
Add a variant matching the RustWaitingForenum. Must match the serde output format exactly:| { type: "YourChoice"; data: { player: PlayerId; cards: ObjectId[]; /* ... */ } }The
playerfield is required — it gates UI visibility.client/src/adapter/types.ts—GameActionunion (if new response type)
Add the response variant. ReuseSelectCardsif the response is just card IDs.| { type: "YourResponse"; data: { selection: /* ... */ } }client/src/adapter/types.ts—GameEventunion (if new event for log/animation)| { type: "YourEvent"; data: { /* event payload */ } }client/src/adapter/types.ts—GameObjectinterface (if new fields on objects)
Add optional fields with?:to avoid breaking existing state deserialization.
Phase 2 — Component Implementation
Three common patterns for new components:
Pattern A: Card Choice Overlay (most interactive effects)
Used by: Scry, Dig, Surveil, Reveal, Search, DiscardToHandSize.
// client/src/components/modal/YourOverlay.tsx
import { useTranslation } from "react-i18next";
import { useGameStore } from "../../stores/gameStore";
import { useUiStore } from "../../stores/uiStore";
import { useGameDispatch } from "../../hooks/useGameDispatch";
export function YourOverlay({ data }: { data: YourChoiceData }) {
const { t } = useTranslation("game"); // namespace = source directory, not subject
const objects = useGameStore((s) => s.gameState?.objects ?? {});
const inspectObject = useUiStore((s) => s.inspectObject);
const dispatch = useGameDispatch();
const [selected, setSelected] = useState<Set<string>>(new Set());
const handleConfirm = () => {
dispatch({ type: "SelectCards", data: { cards: [...selected] } });
};
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
>
{/* Card display + selection UI */}
<button onClick={handleConfirm} disabled={!isValid}>
{t("yourOverlay.confirm")}
</button>
</motion.div>
</AnimatePresence>
);
}Key conventions:
useGameStorefor game state (objects, zones)useUiStore.inspectObjectfor card preview on hoveruseGameDispatch()to send actions- Framer Motion
AnimatePresencefor entry/exit animations - Dark theme:
bg-gray-900,ring-1 ring-gray-700, accent colors (cyan, amber, emerald) fixed inset-0 z-50for full-screen overlay backdrop
Pattern B: Simple Choice Modal
Used by: Mulligan, Play/Draw, modal spells.
<ChoiceModal
title="Choose an option"
options={[
{ id: "opt1", label: "Option A", description: "Does X" },
{ id: "opt2", label: "Option B", description: "Does Y" },
]}
onChoose={(id) => dispatch({ type: "YourResponse", data: { choice: id } })}
/>Pattern C: Board Element
For non-overlay components (new zone display, counter indicators, status badges):
- Place in the relevant subdirectory (
components/board/,components/zone/, etc.) - Subscribe to
useGameStorefor data - No dispatch needed if read-only
Phase 2.5 — Internationalize User-Facing Text
Every string the component authors must be a translation key, not a literal. Do this as you write the component, not as a cleanup pass.
- Pick the namespace by source directory. A component's namespace is where it lives, not what it's about:
components/draft/*→"draft",components/lobby/*→"multiplayer", in-game overlays (components/modal/,board/,combat/, etc.) →"game".commonis the implicit default ns (shared buttons like Cancel/Confirm may already live there — reuse before adding).const { t } = useTranslation("game"); // opt into a ns; common is always available - Add the key to
client/src/i18n/locales/en/<ns>.jsonfirst. English is the typing oracle — referencing a key that doesn't exist inen/fails type-check. Other locales fall back to English automatically; you do not edites/fr/de/it/pt(machine-translated separately).- Key shape: nested dot paths,
camelCaseleaves,<componentOrFeature>.<element>(e.g."yourOverlay.confirm","yourOverlay.title").
- Key shape: nested dot paths,
- Plurals via CLDR, not string math. Use
key_one/key_other+t(key, { count }). Nevercount === 1 ? "item" : "items". - Interpolate engine data raw into a translated template. The template is chrome (translate it); the values are engine data (leave raw):
t("yourOverlay.summary", { counterType, count }). - Leave engine/card pass-through untouched. Card names, Oracle/reminder text, and enum strings (phase, mana type, counter type) are not wrapped — they are localized by the content pipeline (
hooks/useEngineCardData.ts), nott(). When in doubt, apply the boundary rule: did the frontend author this sentence, or is it engine data flowing through? - Never call
i18n.changeLanguagedirectly. The preferences store owns the active language:usePreferencesStore.getState().setLanguage(lng).
See client/src/i18n/README.md for the full convention set.
Phase 3 — GamePage Routing
-
client/src/pages/GamePage.tsx— conditional render
Add your overlay to theGamePageContentcomponent alongside existing overlays:{waitingFor?.type === "YourChoice" && waitingFor.data.player === playerId && ( <YourOverlay data={waitingFor.data} /> )}
If your overlay is a card choice type, integrate into the existing CardChoiceModal switch instead of adding a new top-level conditional.
Phase 4 — Animation Integration (if applicable)
client/src/animation/eventNormalizer.ts— Event grouping
If your newGameEventshould trigger visual effects:- Add to
OWN_STEP_TYPESif it should always start a new animation step - Add to
MERGE_TYPESif it should merge into the preceding step - Add duration to
EVENT_DURATIONSinanimation/types.ts
- Add to
client/src/components/animation/AnimationOverlay.tsx— Visual effect
Add rendering for your event type if it needs VFX (particles, arcs, screen effects).
Phase 5 — Game Log (if applicable)
client/src/viewmodel/logFormatting.ts— Event formatting
Add a case for yourGameEventtype to produce a human-readable log string. Translate the template, interpolate engine data raw —t("log.yourEvent", { objectId, amount })where the sentence is chrome and the IDs/amounts are engine data. The log type label is frontend-authored (translate it); the card/object it refers to is not.client/src/components/log/LogEntry.tsx— Custom rendering (if needed)
Most events use the default text format. Only add custom rendering for events that need icons, card references, or special formatting.
Phase 6 — Multiplayer Considerations
Player gating — Every interactive overlay MUST check
waitingFor.data.player === playerId. Without this, both players see the choice UI.State filtering — If the component displays hidden information (opponent's hand, library cards), ensure the server-side filter in
crates/server-core/src/filter.rscorrectly hides/reveals cards. The frontend should display whatever the filtered state contains — don't add client-side visibility logic.
Phase 7 — Component Tests
Colocate the test:
client/src/components/modal/__tests__/YourOverlay.test.tsx— every component directory keeps its tests in a sibling__tests__/.Build state with factory convenience methods from
client/src/test/factories/. Chain methods instead of passing raw override objects — this is the required pattern:const bear = gameObjectFactory.creature(2, 2).onBattlefield().withId(3).build(); const bolt = gameObjectFactory.instant().inHand().withId(9).named("Lightning Bolt").build(); const state = gameStateFactory .withPlayers(0, 1) .withObjects(bear, bolt) // derives objects map, battlefield, next_object_id .targetSelection({ player: 0 }) // replaces waiting_for exactly .build();Available:
gameObjectFactory(card types, zones,tapped(),named(),withId(),commander()…),gameStateFactory(withPlayers,withObjects,withStack,inPhase,commander(), one method perWaitingForvariant),waitingForFactory(standaloneWaitingForbuilder),playerFactory,buildEngineAdapterMock. If a newWaitingForvariant needs a dedicated per-variant factory (for example,SearchChoiceWaitingForFactory), it must extendWaitingForFactory<T>so it inheritswithDataand the shared typed construction contract; then expose it throughwaitingForFactoryand have thegameStateFactoryconvenience method delegate to that builder. Do not create an independent Fishery factory that reconstructs the union shape. If a new variant or field has no convenience method, add one to this existing factory hierarchy — don't hand-roll object literals in tests; literals drift from the serde contract as types evolve.
Object, player, and stack-entry ids auto-increment via fisherysequence, so builds never collide — only chain.withId()when the test asserts on a specific id. Field values are otherwise deterministic (no faker): the engine contract is exact.Seed the store, don't mount providers. Components read Zustand directly, so tests arrange by setting store state. Use
setGameStoreForTestfromtest/helpers/gameStoreHelpers.ts(wrapssetStateinact()and returns a stubbeddispatch), andcleanup+ reset inafterEach.Assert the dispatch contract. The highest-value assertion for an interactive overlay is that the interaction produces the exact
GameActionthe engine expects:expect(dispatch).toHaveBeenCalledWith({ type: "SelectCards", data: { cards: [9] } });This is the serde boundary — a misspelled field deserializes to nothing in prod.
Test the player gate. Seed
waitingForwith the other player's ID (e.g.gameStateFactory.targetSelection({ player: 1 })while rendering as player 0) and assert the overlay does not render.Assert real English strings.
src/test-setup.tsinitializes i18next with the realen/*.jsonlocales, soscreen.getByText(/confirm/i)works. A string that won't resolve usually means the key is missing fromen/<ns>.json(Phase 2.5).Queries and interaction: prefer semantic queries (
getByRole,getByText);fireEventis the suite's prevailing convention. Useawait screen.findBy*for async appearance instead of single-assertionwaitFor.Run via Tilt: check the
test-frontendresource (tilt logs test-frontend/./scripts/tilt-wait.sh test-frontend check-frontend) — do not runpnpm testdirectly while Tilt is up.
Component Directory Reference
| Directory | Purpose | Examples |
|---|---|---|
components/modal/ |
Interactive overlays for WaitingFor states | CardChoiceModal, ModeChoiceModal, ReplacementModal, ChoiceModal, NamedChoiceModal, BattleProtectorModal, TributeModal, CombatTaxModal |
components/board/ |
Battlefield elements | PermanentCard, GameBoard, PlayerArea, CommandDisplay |
components/card/ |
Card rendering | CardImage, CardPreview, ArtCropCard |
components/combat/ |
Combat interaction | AttackerControls, BlockerControls, DamageAssignmentModal |
components/controls/ |
Game controls | PhaseTracker, PassButton, LifeTotal |
components/hand/ |
Hand display | PlayerHand, OpponentHand |
components/hud/ |
Player info display | PlayerHud, OpponentHud, ManaPoolSummary |
components/zone/ |
Zone displays | GraveyardPile, LibraryPile, ZoneViewer |
components/stack/ |
Stack display | StackDisplay, StackEntry |
components/targeting/ |
Target selection | TargetingOverlay, TargetArrow |
components/animation/ |
Visual effects | AnimationOverlay, DamageFloat, DeathShatter |
components/log/ |
Game event log | GameLog, LogEntry, GameLogPanel |
components/lobby/ |
Multiplayer lobby | GameList, ReadyRoom, HostSetup |
Styling Conventions
- Tailwind CSS v4 — utility classes, no CSS modules
- Dark theme:
bg-gray-900base,bg-gray-800cards,ring-1 ring-gray-700borders - Accent colors: Cyan (
text-cyan-400) for info, Amber (text-amber-400) for warnings, Emerald (text-emerald-400) for success, Red (text-red-400) for danger - Card sizing: CSS custom properties
--card-w,--card-h(set by preferences store) - Animations: Framer Motion for all transitions.
AnimatePresencefor mount/unmount. Staggered delays:delay: 0.1 + index * 0.08 - Responsive:
max-w-md/max-w-smfor modals,inset-0for full-screen backdrops
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Missing player gate (waitingFor.data.player === playerId) |
Both players see the overlay in multiplayer | Always check player ID |
| Types don't match Rust serde output | Deserialization silently fails, waitingFor is null |
Match exact tag="type", content="data" format |
| Dispatching action without waiting for animation | State updates before animation completes, visual glitch | Use useGameDispatch() which handles the pipeline |
| Adding client-side visibility logic | Diverges from server-filtered state, multiplayer security hole | Trust the filtered state from the adapter |
Modifying gameStore directly |
Bypasses animation pipeline and persistence | Always go through dispatch() |
Not using AnimatePresence |
Overlay appears/disappears instantly | Wrap in AnimatePresence with enter/exit transitions |
| Hardcoded user-facing string in JSX | Untranslatable; breaks 6-locale support | Route frontend-authored text through t() (Phase 2.5) |
Wrapping card/Oracle/enum text in t() |
Double-localizes engine data; key never resolves | Leave engine pass-through raw; only t() frontend-authored text |
Hand-rolled count === 1 ? "x" : "xs" pluralization |
Wrong for non-English CLDR rules | key_one/key_other + t(key, { count }) |
Hand-rolled GameState/GameObject literals in tests |
Silently drifts from serde contract as types evolve | Chain factory convenience methods; extend the factory class when one is missing |
Standalone per-variant WaitingFor factory |
Loses withData behavior and duplicates the discriminated-union contract |
Extend WaitingForFactory<T>, expose it through waitingForFactory, and delegate from gameStateFactory |
| No dispatch-shape assertion in overlay tests | Wrong GameAction field names pass tests, fail in prod |
Assert the exact action object on the dispatch mock |
| No player-gate test | Multiplayer choice-leak regressions ship silently | Assert overlay absent when waitingFor.data.player !== playerId |
Self-Maintenance
After completing work using this skill:
- Verify references with the check below
- Update directory reference table if new component directories were added
- Update WaitingFor routing section if new overlay patterns emerged
Verification
test -f client/src/adapter/types.ts && \
test -f client/src/stores/gameStore.ts && \
test -f client/src/stores/uiStore.ts && \
test -f client/src/stores/animationStore.ts && \
test -f client/src/pages/GamePage.tsx && \
test -f client/src/game/dispatch.ts && \
test -f client/src/animation/eventNormalizer.ts && \
test -f client/src/components/modal/CardChoiceModal.tsx && \
test -f client/src/i18n/README.md && \
test -d client/src/i18n/locales/en && \
test -f client/src/test/factories/gameStateFactory.ts && \
test -f client/src/test/factories/gameObjectFactory.ts && \
test -f client/src/test/helpers/gameStoreHelpers.ts && \
test -f client/src/test-setup.ts && \
rg -q "class GameStateFactory" client/src/test/factories/gameStateFactory.ts && \
rg -q "class GameObjectFactory" client/src/test/factories/gameObjectFactory.ts && \
rg -q "type WaitingFor" client/src/adapter/types.ts && \
rg -q "type GameAction" client/src/adapter/types.ts && \
rg -q "type GameEvent" client/src/adapter/types.ts && \
rg -q "useGameDispatch" client/src/hooks/useGameDispatch.ts && \
echo "✓ add-frontend-component skill references valid" || \
echo "✗ STALE — update skill references"