The game view
A StateUpdate carries one field, gameView - a complete
GameViewDto snapshot of the game. It is the only carrier of authoritative
game state: the client throws away its previous view and re-renders the board
from each one.
GameViewDto
Section titled “GameViewDto”The whole-game snapshot: turn structure, both players, the battlefield, the stack, and game-end state.
interface GameViewDto { gameId: string; turn: number; step: StepKind; combatAssignments: Array<CombatAssignmentDto>; activePlayerId: string; priorityPlayerId: string; players: Array<PlayerDto>; zones: Array<ZoneDto>; stack: Array<StackObjectDto>; gameOver: boolean; winnerId: string | null; monarchId: string | null; initiativeHolderId: string | null; dayTime: DayTime; activePlaneNames?: Array<string>;}#[serde(rename_all = "camelCase")]pub struct GameViewDto { pub game_id: String, pub turn: u32, pub step: StepKind, pub combat_assignments: Vec<CombatAssignmentDto>, pub active_player_id: String, pub priority_player_id: String, pub players: Vec<PlayerDto>, pub zones: Vec<ZoneDto>, pub stack: Vec<StackObjectDto>, pub game_over: bool, pub winner_id: Option<String>, pub monarch_id: Option<String>, pub initiative_holder_id: Option<String>, pub day_time: DayTime, pub active_plane_names: Option<Vec<String>>,}
References: CombatAssignmentDto , DayTime , PlayerDto , StackObjectDto , StepKind , ZoneDto
PlayerDto
Section titled “PlayerDto”One player’s scalar state - life, counters, mana pool, statuses. Cards live in
the top-level zones list, not on the player.
interface PlayerDto { id: string; name: string; status: PlayerStatus; isHuman: boolean; life: number; maxHandSize: number; unlimitedHandSize: boolean; landsPlayedThisTurn: number; maxLandPlaysPerTurn: number; unlimitedLandPlays: boolean; cardsDrawnThisTurn: number; damagePrevention: number; isExtraTurn: boolean; extraTurnCount: number; controlledBy?: string; playerKeywords: Array<string>; commanderCasts: Record<string, number>; dungeonState?: DungeonStateDto; activeSchemeNames?: Array<string>; teamNumber?: number; counters: { [key in PlayerCounterKind]?: number }; manaPool: { [key in ManaColor]?: number }; commanderDamage: Record<string, number>; hasCityBlessing: boolean; hasEnduringStory: boolean; ringLevel: number; speed: number;}#[serde(rename_all = "camelCase")]pub struct PlayerDto { pub id: String, pub name: String, pub status: PlayerStatus, pub is_human: bool, pub life: i32, #[serde(default)] pub max_hand_size: i32, #[serde(default)] pub unlimited_hand_size: bool, #[serde(default)] pub lands_played_this_turn: i32, #[serde(default)] pub max_land_plays_per_turn: i32, #[serde(default)] pub unlimited_land_plays: bool, #[serde(default)] pub cards_drawn_this_turn: i32, #[serde(default)] pub damage_prevention: i32, #[serde(default)] pub is_extra_turn: bool, #[serde(default)] pub extra_turn_count: u32, pub controlled_by: Option<String>, #[serde(default)] pub player_keywords: Vec<String>, #[serde(default)] pub commander_casts: HashMap<String, u32>, pub dungeon_state: Option<DungeonStateDto>, pub active_scheme_names: Option<Vec<String>>, pub team_number: Option<i32>, pub counters: BTreeMap<PlayerCounterKind, u32>, pub mana_pool: BTreeMap<ManaColor, u32>, pub commander_damage: HashMap<String, i32>, pub has_city_blessing: bool, pub has_enduring_story: bool, pub ring_level: i32, pub speed: i32,}
References: DungeonStateDto , ManaColor , PlayerCounterKind , PlayerStatus
ZoneDto and CardView - visibility
Section titled “ZoneDto and CardView - visibility”Cards arrive as one ZoneDto per (zone, owner) pair. Each zone’s cards are CardViews ordered top-first
where the order is public knowledge, and count is the zone’s size.
interface ZoneDto { zone: ZoneKind; ownerId: string; cards: Array<CardView>; count: number;}// One entry per (zone, owner) pair; battlefield cards are bucketed by controller.#[serde(rename_all = "camelCase")]pub struct ZoneDto { pub zone: ZoneKind, pub owner_id: String, // Ordered top-first where order is public knowledge // count can be > cards.len if hidden cards are present (library) pub cards: Vec<CardView>, pub count: usize,}
References: CardView , ZoneKind
type CardView = | ({ visibility: "visible" } & CardDto) | { visibility: "hidden"; id: string };#[allow(clippy::large_enum_variant)]#[serde( tag = "visibility", rename_all = "camelCase", rename_all_fields = "camelCase")]pub enum CardView { Visible(CardDto), Hidden { id: String },}
References: CardDto
An engine that computes per-recipient views applies visibility per card:
- Hands - visible entries only for the owner (or under reveal effects);
other seats get the
countalone. - Library -
countonly, plus the top card as a visible entry when the recipient may look at it (e.g. “play with the top card of your library revealed”). - Face-down exile (foretell and similar) - a
hiddenentry per card, so clients render an anonymous face-down card without learning its identity. - Face-down battlefield permanents (morphs) - the
permanent itself is public. Recipients who may not see the face get a
redacted
CardDto- blankidentity, no text or costs - while public state (counters, tapped, power/toughness) stays. Clients render a face-down card with an emptyidentity.nameas a card back.
StepKind
Section titled “StepKind”Where the turn currently stands - GameViewDto.step. Also referenced by
chooseAction’s pass-until response.
type StepKind = | "untap" | "upkeep" | "draw" | "main1" | "combatBegin" | "combatDeclareAttackers" | "combatDeclareBlockers" | "combatFirstStrikeDamage" | "combatDamage" | "combatEnd" | "main2" | "endOfTurn" | "cleanup";#[serde(rename_all = "camelCase")]pub enum StepKind { #[default] Untap, Upkeep, Draw, Main1, CombatBegin, CombatDeclareAttackers, CombatDeclareBlockers, CombatFirstStrikeDamage, CombatDamage, CombatEnd, Main2, EndOfTurn, Cleanup,}CardDto
Section titled “CardDto”The engine’s snapshot of a single card - every zone holds these. Only id and
the card’s identity are needed to round-trip a choice; the rest drives richer
rendering. The prompt pages that show cards reference this same type.
interface CardDto { id: string; identity: CardIdentity; color: string; manaCost: string; cmc: number; types: Array<string>; subtypes: Array<string>; supertypes: Array<string>; power: string | null; toughness: string | null; basePower?: number; baseToughness?: number; finalChapter?: number; classLevel?: number; classLevels: Array<ClassLevelDto>; sagaChapters: Array<SagaChapterDto>; text: string; choices: Array<CardChoiceDto>; controllerId: string; ownerId: string; tapped: boolean; isCrewed: boolean; isAttacking: boolean; attackingPlayerId?: string; attackTargetId?: string; keywords: Array<string>; counters: Record<string, number>; damage: number; summoningSick: boolean; isCopy: boolean; isDoubleFaced: boolean; isTransformed: boolean; isFaceDown: boolean; isBestowed: boolean; phasedOut: boolean; exerted: boolean; isRingBearer: boolean; attachedTo?: string; attachmentIds: Array<string>; mergedCardIds: Array<string>; flashbackCost?: string; kickerCost?: string; effectiveManaCost?: string; commanderTax?: number; madnessCost?: string; isMadnessExiled: boolean; isPlotted: boolean; isWarpExiled: boolean; foil: boolean; wouldDieInCombat: boolean;}#[serde(rename_all = "camelCase", default)]pub struct CardDto { pub id: String, pub identity: CardIdentity, pub color: String, pub mana_cost: String, pub cmc: i32, pub types: Vec<String>, pub subtypes: Vec<String>, pub supertypes: Vec<String>, pub power: Option<String>, pub toughness: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub base_power: Option<i32>, #[serde(skip_serializing_if = "Option::is_none")] pub base_toughness: Option<i32>, #[serde(default, skip_serializing_if = "Option::is_none")] pub final_chapter: Option<i32>, #[serde(default, skip_serializing_if = "Option::is_none")] pub class_level: Option<i32>, pub class_levels: Vec<ClassLevelDto>, pub saga_chapters: Vec<SagaChapterDto>, pub text: String, #[serde(default)] pub choices: Vec<CardChoiceDto>, pub controller_id: String, pub owner_id: String, pub tapped: bool, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub is_crewed: bool, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub is_attacking: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub attacking_player_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub attack_target_id: Option<String>, pub keywords: Vec<String>, // Keyed by the engine's canonical `CounterType` display form ("P1P1", // "Loyalty", one-off counter names uppercase); both producers must match it. pub counters: BTreeMap<String, u32>, pub damage: i32, pub summoning_sick: bool, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub is_copy: bool, pub is_double_faced: bool, pub is_transformed: bool, pub is_face_down: bool, pub is_bestowed: bool, pub phased_out: bool, pub exerted: bool, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub is_ring_bearer: bool, #[serde(skip_serializing_if = "Option::is_none")] pub attached_to: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub attachment_ids: Vec<String>, // Mutate pile: the card ids merged under this top card. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub merged_card_ids: Vec<String>, #[serde(skip_serializing_if = "Option::is_none")] pub flashback_cost: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub kicker_cost: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub effective_mana_cost: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub commander_tax: Option<i32>, #[serde(skip_serializing_if = "Option::is_none")] pub madness_cost: Option<String>, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub is_madness_exiled: bool, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub is_plotted: bool, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub is_warp_exiled: bool, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub foil: bool, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub would_die_in_combat: bool,}
References: CardChoiceDto , CardIdentity , ClassLevelDto , SagaChapterDto
CardIdentity
Section titled “CardIdentity”Which card a CardDto or StackObjectDto is - name, printing
(setCode/cardNumber), and whether it’s a token.
interface CardIdentity { name: string; setCode: string; cardNumber: string; isToken: boolean; tokenScript?: TokenScript;}#[serde(rename_all = "camelCase", default)]pub struct CardIdentity { pub name: String, pub set_code: String, pub card_number: String, pub is_token: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub token_script: Option<TokenScript>,}
References: TokenScript
CombatAssignmentDto
Section titled “CombatAssignmentDto”A declared block: which blocker is assigned to which attacker.
interface CombatAssignmentDto { blockerId: string; attackerId: string;}#[serde(rename_all = "camelCase")]pub struct CombatAssignmentDto { pub blocker_id: String, pub attacker_id: String,}StackObjectDto
Section titled “StackObjectDto”A spell or ability currently on the stack. Its targets are
TargetRefs, each tagged with the
TargetingIntent the engine inferred for it.
interface StackObjectDto { id: string; sourceId: string; controllerId: string; ownerId: string; identity: CardIdentity; text: string; sourceAbilityText?: string; isPermanentSpell: boolean; isCasting: boolean; isDoubleFaced: boolean; faceIndex: number; targets: Array<TargetRef>;}#[serde(rename_all = "camelCase", default)]pub struct StackObjectDto { pub id: String, pub source_id: String, pub controller_id: String, pub owner_id: String, pub identity: CardIdentity, pub text: String, #[serde(skip_serializing_if = "Option::is_none")] pub source_ability_text: Option<String>, pub is_permanent_spell: bool, pub is_casting: bool, pub is_double_faced: bool, pub face_index: u8, pub targets: Vec<TargetRef>,}
References: CardIdentity , TargetRef
TargetingIntent
Section titled “TargetingIntent”A semantic hint describing what an effect intends to do to its target - the
client uses it to pre-highlight sensible targets. Carried by
TargetRef and by the targeting prompts.
type TargetingIntent = | "damage" | "destroy" | "sacrifice" | "exile" | "bounce" | "mill" | "discard" | "counter" | "tap" | "untap" | "copy" | "buff" | "debuff" | "heal" | "loseLife" | "reveal" | "draw" | "fetch" | "gainControl" | "fight" | "attach" | "attack" | "block" | "hostile" | "friendly"; Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS, strum_macros::Display,)]#[serde(rename_all = "camelCase")]pub enum TargetingIntent { #[default] Damage, Destroy, Sacrifice, Exile, Bounce, Mill, Discard, Counter, Tap, Untap, Copy, Buff, Debuff, Heal, LoseLife, Reveal, Draw, Fetch, GainControl, Fight, Attach, Attack, Block, Hostile, Friendly,}State patches
Section titled “State patches”An engine may send a stateDelta instead of a state. It carries a patch
against the last state the client applied, base (the fingerprint of that
state) and fingerprint (the fingerprint of the result). An engine that sends
patches also puts fingerprint on every full state.
To ensure correctness, it is recommended that a client should apply the patch only when base equals the fingerprint of the state it holds.
Patches are completely optional. An engine may always send full states. A conforming client should accept patches.
Patch encoding
Section titled “Patch encoding”A patch has the same shape as the state it edits. Objects merge: each key in
the patch is applied to the same key in the state, keys absent from the patch
are untouched. Four reserved keys carry the rest:
| Key | Meaning |
|---|---|
$v | Literal replacement. |
$d | Removal. |
$k | Keyed array edits. An object of element key to patch. |
$o | Keyed array order. The full list of element keys, in their new order. |
Arrays whose elements all have a stable key are edited element by element
under $k. The key is the element’s id; zone entries have none, so theirs is
"<zone>/<ownerId>". $k on a key that does not exist inserts the element at
the end. $o lists every key in the new order and is omitted when the order did
not change. Any other array is replaced whole.
Scalars and arrays appear as themselves, so null is a value, not a deletion.
An object that must replace instead of merge is wrapped in $v.
Example
Section titled “Example”Player 1’s Lightning Bolt (card-7) resolves against player 0. The stack
empties, the card goes on top of player 1’s graveyard, player 0 drops to 17
and gets priority back:
{ "kind": "stateDelta", "base": "9f3c1a0e5b7d2461", "fingerprint": "4d0e8b2a7c91f356", "patch": { "gameView": { "priorityPlayerId": "player-0", "players": { "$k": { "player-0": { "life": 17 } } }, "zones": { "$k": { "graveyard/player-1": { "count": 2, "cards": { "$k": { "card-7": { "$v": { "visibility": "visible", "id": "card-7", ... } } }, "$o": ["card-7", "card-3"] } } } }, "stack": { "$d": ["card-7"], "$o": [] } } }}players keeps its order, so it has $k and no $o. The graveyard gains a
new element, so its full CardView arrives under $v and $o puts it first.
The stack loses its only element, so $o is empty.