Design  ›  Game model

Modelling a card-based board game

A conceptual reference for the parts a card-based board game has, regardless of mechanic. The goal is to know what needs to be modelled before deciding how to model it — and eventually to drive a simulator that can play out a designed game before any cards get printed.

Conceptual, not technical Mechanic-agnostic Prerequisite for the simulator

Why this matters

DeckCraft can today take a creative brief, propose pieces, build decks, generate cards, and now render those cards as printable PNGs. Notice what's missing: no one has played the game yet. A user finishes the wizard and ships their cards to a printer with no evidence the game even functions, let alone whether it's balanced or fun.

A simulator closes that loop. Run 1,000 games, see who wins and how often, find the dominant strategy, spot the dead cards, watch which turn the game usually ends on. Tune the design and re-run. The difference between "I designed a game" and "I designed a game I'm confident is playable" is exactly this loop.

A simulator needs the game described completely. Today's system describes the components well (cards, types, decks, resources) but says almost nothing about how the game is played — turn order, what counts as a legal action, when the game ends. This document maps the full territory and shows where the holes are.

A card game as a state machine

The single mental model

A card game is a function. Given the current state and a player's action, it returns a new state. The game engine asks the active player "what do you want to do?", accepts a legal answer, applies it, then asks the next player. It keeps doing this until a win or loss condition fires. Everything else — phases, triggers, effects, targeting — is machinery that lives inside that loop.

+------------+ legal_actions(state) | STATE | -----------------------+ +------------+ | ^ v | +---------------+ | apply(state, action) | active player | | +---------------+ | | +-------------------------------+ | +-------------------+ | win/loss check? | +-------------------+ | end | continue

The two interesting questions for any moment in the game:

  • What can the active player do right now? (the legal actions function)
  • If they pick action X, what does the new state look like? (the apply function)

Everything in this document maps onto answering one of those two questions for any card game.

The static side: definition

Things that don't change while a game is being played. These are facts about the design itself: how many resources exist, what the cards do, when the game ends. The user authors these once in the wizard.

Components

1. Card shapes (the types)

A card type is the data shape of a class of cards — what fields a card of that type has, what behaviours it can carry. Unit has attack and health; Event has an effect list and a single-use behaviour; Resource Card has a numeric value tied to a tracker. Types are templates, not instances.

Three different types in the same game implies three different gameplay roles. A trick-taking game might have a single type (ranked_card with rank + suit) and that's enough.

In DeckCraft today: shipped, in assets/data/types/<mechanic>.json. Six root types for deck-builders. Engine-aware via fields and allowed_behaviours.

2. Cards (the instances)

A card is one specific entity of a type. "Scrap Blitz, a Unit with attack 3, health 1, costs 2 trade, on play deal 1 damage." Cards are filled in upstream (Step 4) and the simulator treats them as immutable design data, not state.

In DeckCraft today: shipped, in game_cards. card_json holds the authored data.

3. Subtypes (soft labels)

A subtype is a flavour label on a type (Striker, Guardian, Trap). The engine is indifferent to subtypes — two cards with the same root type and the same behaviours are mechanically identical. Subtypes exist to (a) help the AI generate thematically consistent cards and (b) give designers preset shortcuts.

In DeckCraft today: shipped, in assets/data/subtypes/<mechanic>.json. 18 subtypes for deck-builders. Force-on a small set of behaviours.

4. Behaviours (engine semantics)

A behaviour is a flag that changes how the engine treats a card. Permanent means "stays on the board after play". Hidden means "face-down, owner-only visibility". Strike means "deals damage immediately". Behaviours are declarative — they have no parameters, no math; they just tag the card with a property the engine recognises.

Behaviours are how the engine distinguishes a Unit (Permanent) from an Event (Instant) without those concepts being hard-coded in the rules. Add a new behaviour, every card that carries it gains the new semantics.

In DeckCraft today: shipped, in assets/data/card-behaviours.json. Used by both Step 2 (which behaviours a game enables) and Step 4 (which the AI forces on / off when generating cards).

5. Effects (card vocabulary)

An effect is a verb the card performs — "deal damage", "draw a card", "gain 2 energy", "discard from opponent". Each effect references a mechanic (the abstract operation) and supplies parameters (amount, target, conditions).

Effects vs behaviours, the cleanest distinction:

  • Behaviour = "this card is X" (declarative, no parameters)
  • Effect = "this card does Y when Z" (verb, parameters, trigger)

A card can carry behaviours and effects independently. Strike (behaviour) tells the engine when this card resolves; deal 1 damage to opponent (effect) is what it does at that moment.

In DeckCraft today: shipped catalogue in assets/data/effects.json. Cards reference effects by effect_key + parameters. The renderer's rules-text translator already speaks this format.

6. Tokens / counters

Non-card objects that move between cards or players: damage counters on creatures, charge counters on artifacts, a "first player" marker, a tax token in some Eurogames. Tokens are identifiable but typically don't have rich attributes — a damage counter is just a number on a card.

In DeckCraft today: not modelled. Whether this matters depends on the game; a Star-Realms-like deck-builder rarely needs them, a Magic-the-Gathering-style game cannot exist without them.

7. Resources / trackers

Numeric state that persists per-player during a game: Mana, Gold, Trade, Authority, Score, Health. The proposed schema unifies "resources" and "life-like trackers" under one concept — both are numeric per-player state that effects mutate and win conditions inspect.

Each tracker has a role: resource (consumable, often regenerates), life (tracks survival, win condition), score (accumulates, win condition). A game can have any combination.

In DeckCraft today: partial. Resources live in game_parts (with part_type='resource'); life lives separately in game_meta. Unification under a single trackers concept is in the proposed schema, not yet built.

8. Decks (authored containers)

A deck is a group of cards the designer assembles for a specific role: player starter, shared market, scenario events. Decks live at the design layer — they're the answer to "how do I want to author my cards?", not "where do cards live during play?" (that's zones).

Each deck has a frequency distribution (how many of each type land in it) and a role that connects it to the game's starting setup (this deck feeds zone X at game start).

In DeckCraft today: shipped, in game_decks.

9. Zones (gameplay locations)

A zone is where cards live during play: draw pile, hand, in play, discard, trade row, banished. Each zone has rules: who owns it, who can see it, what types it accepts, max size, ordering (stack vs unordered), auto-refill.

Zones are the scaffolding the simulator moves cards across. Every card action is fundamentally a zone transition: play = hand → in_play; discard = anywhere → discard; buy = trade_row → discard.

In DeckCraft today: partial. Zones exist in game_meta['game_zones'] but are descriptive, not executable. The simulator will need them upgraded to engine-readable.

Rules

10. Setup (how the game starts)

The initial state. How many cards each player draws. What zones start filled with which decks. What the starting resource values are. Whether the deck is shuffled. Who goes first.

Setup is deterministic given a random seed (for the shuffle). A simulator runs setup once per game, then enters the play loop.

In DeckCraft today: not modelled.

11. Turn structure (the flow)

A turn is one player's window to act. A turn decomposes into phases: usually a start-of-turn phase (draw, refresh resources), a main phase (play cards, attack, buy), and an end-of-turn phase (cleanup, discard excess). Some games have many phases (Magic), some have one (most deck-builders).

Each phase is either automatic (the engine runs it without asking) or player-driven (the engine asks the player what they want to do, repeatedly, until they end the phase).

Turn order says how the active player rotates: clockwise, counterclockwise, "winner of last trick goes next", "all players act simultaneously".

In DeckCraft today: not modelled. Free-text rules on the briefing only.

12. Action vocabulary (what a player can do)

The verbs available during a player-driven phase: play_card, buy_from_market, attack_opponent, activate_ability, pass, end_phase. Each action has a cost (resources spent), a precondition (legality predicate), and an effect (state change).

The action vocabulary IS the game from the player's perspective. "What can I do?" at any moment is exactly the answer to "which actions are legal in the current state?".

In DeckCraft today: not modelled as a first-class concept. Implicitly carried by individual cards and their effects.

13. Win and loss conditions

Predicates over the game state that, when true, end the game and declare a winner. "Reduce opponent's authority to zero", "Score 100 victory points", "Empty your hand", "Cannot draw a required card".

Win conditions are typically positive ("achieve X"); loss conditions are typically negative ("X happens to you"). Some games have only one player-attributable end ("first to 30 wins"); others have drain-out conditions ("game ends when the deck runs out, highest score wins").

In DeckCraft today: a free-text game_goal field in the briefing. Not structured, not inspectable, not simulator-readable.

14. Game-level config

Player count (min/max), team structure, optional rules toggles, turn limit cap. These shape the game frame around the rules.

In DeckCraft today: partial. Player count and team flag in the briefing; nothing else.

The dynamic side: state

Things that change as the game is played. The simulator maintains exactly this state object and mutates it in response to each action.

15. Per-player state

  • Owned zones. The current contents of each zone owned by this player: their hand, their draw pile, their discard, their in play. Zone contents are ordered lists of card IDs.
  • Resources / trackers. Current values of every tracker for this player. Health, gold, mana, victory points.
  • Active modifiers. Status effects that persist across turns: "this player has +1 attack until end of turn", "this player skips their next turn". Stored as a small stack on the player.

16. Shared state

  • Shared zones. The market, the trick pile, the scoring track. Owned by no one player.
  • Shared trackers. Game-level counters: round number, dealer position.
  • Tokens in play. Damage counters on cards, charge counters, etc. Attached to a card or to a player.

17. Game-level state

  • Active player. Whose turn it is.
  • Current phase. Where in the turn structure we are.
  • Turn / round number. For game-length cap and tempo analysis.
  • The resolution stack. If the game allows interrupts (counter spells, reactions), pending effects waiting to resolve in LIFO order.

18. History (the action log)

Every action that has been taken so far, in order, with the player who took it. Triggers like "when you play your second Unit this turn..." or "if you've gained 5 damage this round..." inspect the log.

The log is also what makes a simulator debuggable. Replaying a game from the log + initial seed reproduces the exact same final state — useful when investigating why a particular run had a weird outcome.

The verbs: effects, triggers, targeting

19. An effect is a state mutator

Every effect is a function: effect(state, params, source) → state'. It reads the current state, applies its mutation, returns the new state.

  • deal_damage: subtracts from a tracker on a target.
  • draw_card: moves N cards from draw pile to hand.
  • gain_resource: adds to a tracker.
  • destroy: moves a card from in_play to discard.
  • summon: instantiates a token / card and places it in a zone.

The catalog of effects (the mechanics dictionary) is finite and shared across games. A new game adds new card content but rarely new mechanics.

20. Triggers are when an effect fires

A card lists not just what it does but when: on_play, on_attack, on_destroy, at_start_of_turn, when_opponent_plays_a_unit. The engine listens for these triggers and fires the matching effects.

Most card effects are on_play — they fire once when the card enters play, then are forgotten. Permanent cards (a behaviour) often have triggers that listen across turns. Lingering effects (another behaviour) fire after the source has left play.

21. Targeting

Effects often need a target: which opponent, which card, which resource. The simulator distinguishes:

  • Designer-supplied: hardcoded in the card ("deal 1 damage to opponent"). The simulator just resolves it.
  • Player-supplied: chosen at play time ("deal N damage to a target you choose"). The simulator must ask the active player to pick from a list of legal targets.

The current effects schema in effects.schema.json already encodes this with the source: "design" | "player" field on each parameter — one of the few places the engine model is well-specified today.

22. Resolution timing

When two effects fire simultaneously, what wins? Most games dodge this by being strictly turn-based with no interrupts. Games with instants or reactions need a resolution stack: last-in-first-out, with priority rotating between players until everyone passes.

For deck-builders, set-collection, and trick-taking games this almost never matters. For TCG-style games it's the dominant design problem. The proposed schema's flow.interrupt_rules is where this lives.

Information & visibility

A card game is partly about hidden information: what's in your opponent's hand, what's on top of the deck, what trap card you set face-down. The state object has to encode this so each player only "sees" what they should.

Every zone has a visibility:

  • public: everyone sees the full contents in order. Discard piles, the in-play board.
  • owner: only the owning player sees contents. Hand, face-down deck.
  • none: not even the owner (top-of-deck before they draw, or random face-down area).

For simulation, this matters because a bot's decision function only gets to see what its player would see. A bot reading the opponent's hand to make decisions isn't a bot, it's a cheater. Information hiding is what makes the simulator honest.

The same model, three games

Star-Realms-like deck-builder

  • Types: Unit, Structure, Event.
  • Behaviours: Permanent, Strike, Outpost.
  • Trackers: Authority (life, win condition), Trade (resource, regenerates), Combat (resource, regenerates).
  • Zones: draw_pile (per-player), hand (per-player), in_play (per-player), discard (per-player), trade_row (shared, auto-refill).
  • Phases: main (play cards / buy / attack), cleanup (in_play → discard, hand → discard, draw 5).
  • Win: opponent's authority ≤ 0.

Trick-taking partnership game (SkyBid)

  • Types: just ranked_card (rank + suit + point_value). No Units, no Events.
  • Behaviours: none (the engine doesn't distinguish cards beyond rank+suit).
  • Trackers: team_score (per-team), bid (per-team).
  • Zones: deck (shared), hand (per-player), current_trick (shared), won_tricks (per-team).
  • Phases: deal → bid → play → resolve_trick → (loop play+resolve until hands empty).
  • Win: team_score ≥ 500.

Set-collection / engine builder

  • Types: resource_card, objective.
  • Behaviours: Reusable, Consumable.
  • Trackers: victory_points (per-player, win condition), per-resource trackers.
  • Zones: market (shared), inventory (per-player), completed_objectives (per-player).
  • Phases: draw, action (multiple), end-of-round scoring.
  • Win: first to N points, or game ends after fixed rounds and highest wins.
Same shape, different content

Three radically different games, identical schema. The engine code to simulate them is the same; only the game definition (the static side) changes. That's the test of a good model: it bends, doesn't break, when faced with a new mechanic.

Six known games through this lens

The previous section gave a 30-second sketch of three game types in the schema. Here we go deeper: six concrete published games, each broken down into the model's vocabulary. The point is partly to validate — does this model actually hold up? — and partly to surface what each game would require us to add if DeckCraft wanted to support it.

Ordered from cleanest fit to hardest. By the end you'll see exactly which features the current model handles, which need small additions, and which would mean a real engineering chapter.

1. Star Realms 2-player attack/defense deck-builder

The canonical case — the schema was effectively designed around games like this. Two players race to reduce each other's Authority to zero by buying ships from a shared trade row and launching them as combat.

WinOpponent's Authority ≤ 0.
TrackersAuthority (life, 50→0), Trade (resource, regen), Combat (resource, regen).
Zonesper-player draw_pile / hand / in_play / discard; shared trade_row (5, auto-refill); shared scrap_pile.
TypesShip (one-shot), Base (Permanent), Outpost (Permanent + must-attack-first).
BehavioursPermanent, Outpost, Ally (extra effect when 2+ same faction in play).
Effectsdeal_damage, gain_trade, gain_combat, gain_authority, draw, scrap, force_discard.
PhasesMain (play / buy / attack), Cleanup (move all to discard, draw 5).

Verdict: fits cleanly Everything maps directly. The Ally bonus is the only mildly interesting piece — it's a trigger that inspects "what else is in play this turn", which the model already supports via the action log. Nothing missing.

2. Dominion 2–4 player set-collection deck-builder

The genre's foundational game. Players build their decks from a shared supply and race to score the most VPs. No direct attacks (mostly) — the competition is for cards in the supply and for VP cards in particular.

WinMost VP when game ends. Game ends when 3 supply piles empty or the Province pile empties.
TrackersPer-turn: actions, buys, coins (all reset). Per-game: VP (hidden during play, totalled at end).
Zonesper-player draw_pile / hand / in_play / discard; shared supply (multiple fixed-size piles, one per buyable card); shared trash.
TypesAction, Treasure, Victory, Curse.
BehavioursReaction (interrupts opponent attacks), Duration (stays in play across turns).
Effectsgain_action, gain_buy, gain_coin, draw, discard_hand, trash_card, gain_card_to_X, attack_each_other.
PhasesAction (play actions while you have actions), Buy (play treasures, buy cards), Cleanup (move all to discard, draw 5).

Verdict: fits cleanly with one addition The supply is a set of zones, one per buyable card, each with a fixed starting count and no refill. Today's zone model handles it via N parallel "supply_pile_provinces" / "supply_pile_market" zones. A future cleanup: a multi_zone shape that bundles related piles. The composite end-condition (3 empty piles OR Province pile empty) is two independent win conditions that the schema already accepts as a list.

Reactions are an interrupt: when an attack would resolve, the target player gets a window to play a Reaction. That's a mini-stack — not the full TCG resolution stack, but definitely beyond strictly turn-based. The schema's flow.interrupt_rules covers this.

3. Hearts 4-player trick-taking, no design surface

A classic 52-card trick-taking game. Included to show the model's floor: how much of the schema is actually needed when the game is this reduced.

WinFirst player to 100 points loses (point avoidance); player with the lowest score wins.
Trackerspoints (per-player, accumulates across hands).
Zonesshared deck (52 ranked cards), per-player hand, shared current_trick, per-player won_tricks.
TypesJust ranked_card with rank + suit fields. No Units, no Events.
BehavioursNone.
EffectsNone on cards. The card's value is its rank + suit; effects are entirely in the rules.
PhasesDeal (auto), Pass (3 cards to neighbor, simultaneous), Play tricks (lead, follow suit if possible, winner leads next), Score (count hearts + Queen of Spades).
Actionsplay_card (with follow-suit constraint), pass_card (during the pass phase).

Verdict: fits cleanly, uses a fraction of the model Hearts uses zones, trackers, types, phases, and actions, but uses zero of: effects, behaviours, subtypes, modifiers, triggers, resolution stack. That's the test of a good model — the unused features don't get in the way. A simulator could play Hearts without ever invoking the effect system.

Two interesting wrinkles: (1) the simultaneous "pass three cards to neighbour" phase isn't strictly turn-based — everyone commits at once, then reveal. That's a one-time simultaneous resolution, not full simultaneous play. (2) the "must follow suit if possible" rule is a legality predicate on play_card that's specific to trick-takers — the action vocabulary needs room for per-game preconditions.

4. Rivals for Catan 2-player tableau-builder, dice-driven

The Catan card game (originally Catan: The Card Game, rebuilt as Rivals for Catan). Each player builds a "principality" of settlements and roads in a structured tableau, producing resources via dice rolls and racing to 12 VPs.

WinFirst to 12 victory points (varies by expansion).
Trackers5 resources per player (lumber, brick, ore, grain, wool, with per-region cap of 3); VP (per-player, public).
Zonesper-player hand, per-player tableau (a structured grid of settlements+roads+regions), shared event_deck, shared draw_stacks (multiple, one per card category).
TypesSettlement, City, Region (resource producer), Building, Unit (Hero), Action.
BehavioursPermanent (most cards stay), Activated (use once per turn), Defensive.
Effectsproduce_resource, gain_VP, build_X, exchange_resources, discard_event.
PhasesProduction (roll dice, regions matching the roll produce 1 resource), Action (play / build / trade), Refill (draw cards).

Verdict: fits, with two real additions The model needs: (1) Dice as a game-level component. A roll is a random event that triggers production effects across all matching cards. Today's effect system is card-triggered; this is game-triggered. Solvable as a setup-time "Production Phase" with built-in roll_dice and fire_matching_triggers mandatory actions. (2) Structured tableaus. The principality is more than "in_play" — cards have spatial relationships (a building must attach to a settlement of yours, regions sit adjacent to specific settlements). That needs zones with slots and adjacency, not just an unordered list.

The event_deck firing into the production phase is fine in today's schema (it's a feeder zone). The resource cap of 3 per region is a per-zone tracker max, also already supported.

5. 7 Wonders 3–7 player drafting + civ scoring

A drafting game across three "ages". Every player simultaneously picks one card from a hand of seven, plays it (build, buy, or discard), then passes the rest to the next player. Three ages of increasingly powerful cards; multiple scoring categories sum at the end.

WinHighest total VP after Age III. VP is a sum across multiple categories: military, civilian, science (with non-linear tiers), commercial, guild, wonder, treasury.
Trackerscoins (per-player), military victories & defeats (per-player, per-age), VP (multi-axis, per-player, totalled at game-end).
Zonesper-player tableau (by category: resources, military, science, civilian, commercial, guild, wonder); per-player hand (rotates between players each turn); 3 age-decks; per-player wonder card (always present).
TypesResource (raw or manufactured), Military, Science, Civilian, Commercial, Guild, Wonder.
Effectsproduce_resource, gain_coins, gain_VP, gain_science_symbol, military_strength, chain_to_card.
PhasesPer turn (within an age): Choose (simultaneous), Reveal & resolve, Pass hand. Per age (after 6 turns): Military resolution. Per game (after age 3): Final scoring.

Verdict: fits, with three real additions (1) Simultaneous play. Every player's turn happens at the same time, then resolutions are batched. The model assumes one active player at a time. A simultaneous_phase shape with a "commit then reveal" sub-protocol is the right fix. (2) Hand-passing as a mechanic. Drafting is fundamentally moving cards between player-owned zones along a deterministic adjacency (left or right). The action pass_hand needs to exist as an inter-player zone transfer. (3) Multi-axis scoring. Final scoring is a composition of per-category totals with category-specific formulas (science is exponential in same-symbol counts). This is tractable: each scoring category becomes its own tracker with its own end-of-game evaluator. The win condition is sum(all_VP_trackers), comparator max.

6. Magic: The Gathering 2-player TCG — the model's stress test

The hardest case. MTG has every concept the model touches and several it doesn't. Including this game shows where DeckCraft's schema would have to grow real teeth.

WinOpponent at 0 life, OR opponent decked (can't draw a required card), OR poison counters ≥ 10 (some formats), OR specific card effects.
Trackerslife (per-player); mana pool (per-turn, multi-coloured: W/U/B/R/G + colourless); poison counters (rare); commander damage (per-source, per-recipient); city's-blessing flag.
Zonesper-player library, hand, battlefield, graveyard, exile; shared command_zone (Commander); shared stack (the resolution stack).
TypesLand, Creature, Artifact, Enchantment, Sorcery, Instant, Planeswalker, Tribal, Battle.
BehavioursVast: Trample, Lifelink, Flying, Reach, Hexproof, Indestructible, Haste, Vigilance, Deathtouch, First Strike, Double Strike, Menace, Defender, Flash, … (~80 keyword abilities, growing).
EffectsBigger than the model. Effects can themselves create triggered abilities, modify card text, copy cards, exile permanently, transform cards, and so on.
PhasesUntap, Upkeep, Draw, Main 1, Combat (5 sub-phases: Beginning, Declare attackers, Declare blockers, Damage, End of combat), Main 2, End, Cleanup.

Verdict: stretches the model in fundamental ways MTG demands:

  • The Stack. Every spell or ability goes onto a LIFO stack. Both players get priority to respond before each stack item resolves. Resolution rotates priority until everyone passes consecutively. The schema reserves flow.resolution_stack for exactly this; that field needs a real implementation, not just storage.
  • Multi-typed mana cost. Costs can be "two white and one of any colour". That's not a scalar trade-cost; it's a constraint solver. Costs become structured objects, not numbers.
  • Self-modifying cards. Effects can permanently change a card's text, type, or stats while in play. The model today treats card definitions as immutable; MTG needs per-instance overrides on top of definitions.
  • Continuous effects. "All your creatures get +1/+1 while this is in play." That's not a triggered effect — it's a state filter that runs on every read. Today's effect model is event-driven. Continuous effects need a layer system (MTG actually documents seven layers of continuous-effect resolution).
  • Combat sub-phases. Combat is its own mini-game: declare attackers, declare blockers, assign damage, with priority windows between each. The phase model accommodates this in principle (phases can nest), but the implementation is real work.

MTG is an outlier, not the target. Deck-builders, set-collection, drafting, and trick-takers are 95% of the printable card-game space and don't need any of the above. The point of including MTG in this analysis is to name the boundary: if DeckCraft ever wants to support TCG-style design, here's the list of features that boundary requires.

What the six games tell us

Plotted on a difficulty axis, the schema covers a wide band today (Star Realms, Dominion, Hearts) and needs targeted additions for the next band (Rivals for Catan, 7 Wonders). Beyond that lies the TCG ceiling (Magic) which is its own engineering chapter.

already covered small additions boundary ───────────────────── ──────────────────── ──────── Hearts Rivals for Catan Magic: The Gathering Star Realms 7 Wonders Dominion + dice / game-level triggers + the stack with priority + structured tableaus (slots) + multi-typed mana costs + simultaneous-play phase + per-instance card overrides + inter-player zone passes + continuous effects (layers) + multi-axis scoring + nested combat sub-phases

The middle column is the realistic next chapter for the model if DeckCraft wants to broaden beyond pure deck-builders. The right column is a hard line; cross it and the simulator becomes a six-month project rather than a six-week one.

Compared to DeckCraft today

ConceptStateWhere
Card typesshippedassets/data/types/<mechanic>.json
Subtypesshippedassets/data/subtypes/<mechanic>.json
Cards (instances)shippedgame_cards.card_json
Behaviours catalogshippedassets/data/card-behaviours.json
Effects catalogshippedassets/data/effects.json
Effects schema (params, source, conditions)shippedassets/data/effects.schema.json
Decksshippedgame_decks
Resources (per-game)partialgame_parts — lives separately from "life", needs unification under trackers
Life / health trackerspartialgame_meta['life_points'] — should be a tracker like the others
Zones (definitions)partialgame_meta['game_zones'] — descriptive, not engine-readable
Tokens / countersmissing
Setup (initial state)missing
Turn ordermissing
Phasesmissing
Action vocabularymissing
TriggersmissingCards have effects, but no trigger field is structured today
Win conditionsfree-textgame_briefing.game_goal as a string — not inspectable
Loss conditionsmissing
Visibility / hidden infomissingZones don't carry a visibility flag yet
Resolution stack / interruptsmissing
History / action logmissing
The shape of the gap

DeckCraft today models the static components well (types, subtypes, cards, behaviours, effects, decks). It barely touches the rules (turn structure, phases, actions, win conditions are missing or free-text). It has zero notion of state or history because nothing plays the game yet. That's the work between here and a simulator.

For a game designer: what to decide, in what order

The previous sections describe the model from an engine's point of view. From a designer's point of view, the question is more practical: "I'm starting a new game — what do I decide first, and why?" The order matters because each decision constrains the next. Pick badly early on and you'll redo a lot of work; pick deliberately and the rest of the design narrows itself.

The win condition is the anchor

Most designers want to start with cool cards or interesting mechanics. Resist. The win condition is the first mechanical decision worth making, because everything downstream exists to make winning possible (or impossible). A "deal damage" card is meaningless without a health tracker; a health tracker is meaningless without a "reduce to zero = loss" condition. Decide the goal, and the rest of the system tells you what it needs.

The decision graph

Twelve decisions, grouped into four phases. Each phase produces a deliverable; you can stop after any phase and still have something coherent.

PHASE A — Concept (creative brief) 1. Concept & vibe 2. Mechanic / genre 3. Player count, mode, length PHASE B — Skeleton (the spine of the game) 4. Win & loss conditions ←─ THE ANCHOR 5. Trackers (resources, life, score) 6. Zones (where cards live during play) 7. Effects vocabulary (the verbs) PHASE C — Anatomy (how a turn works) 8. Card types & behaviours 9. Phases / turn structure 10. Action vocabulary 11. Setup (the initial state) PHASE D — Content (filling it in) 12. Card library + decks + layout

Phase A — Concept

1. Concept & vibe

The pitch. Who plays this, in what setting, with what feeling. "Two rival factions forge fleets from the scrap of dying stars." Free-form. Doesn't constrain mechanics yet, but everything later gets sanity-checked against it: does a Trade Row fit the fiction? Does Authority sound right for "fleets"?

Unblocks: tone for AI prompts, art direction, naming. DeckCraft today: Step 1 (briefing).

2. Mechanic / genre

Deck-builder, trick-taking, set-collection, draft, TCG. This is a single big choice that brings a default skeleton with it: a deck-builder presupposes hand+discard+market zones; a trick-taking game presupposes a current_trick zone and ranked cards. Picking the genre gives you a 70% solution and you decide what to keep.

Unblocks: default zones, default action shapes, default card type families. Common trap: picking two mechanics ("a deck-builder with trick-taking elements"). Almost always one is dominant; pick that one and treat the other as a flavour. DeckCraft today: Step 1.

3. Player count, mode, length

How many players (2? 2–4? 1–6?), competitive or cooperative or team, and the rough length (15 minutes? 60? 120?). Length shapes card volume and deck sizes. Mode shapes win conditions (cooperative games need a shared loss condition). Player count shapes setup math.

Unblocks: card volume preset, deck sizes, win-condition shape. DeckCraft today: Step 1.

Phase B — Skeleton

4. Win & loss conditions

The single most important decision. Pick a small number of inspectable predicates: "reduce opponent's authority to zero", "score 100 points", "empty the central deck". For coop games: shared loss conditions ("the boss reaches the city"). For drain-out games: when the deck runs out, count score.

Write each condition in the form { metric, comparator, threshold } — a structure a simulator can evaluate. Keep the human-readable label too, but don't only have the label.

Unblocks: trackers (you now know which numeric state must exist), end-of-game state, what cards are for. Common trap: "you win when the game feels like it should end". Vague win conditions kill simulators. Be specific. DeckCraft today: free-text game_goal. Needs structure.

5. Trackers (resources, life, score)

Numeric state per player or per team. Each tracker has a role: resource (consumable, often regenerates each turn), life (tracks survival, win condition), score (accumulates, win condition), commitment (a bid, a pledge). Three trackers is a lot; five is too many.

Constraint: every tracker should connect to some decision the player makes or some win/loss condition. A tracker nobody cares about is dead weight on the table.

Unblocks: effects vocabulary (effects manipulate trackers), zone visibility (some trackers are public, some hidden), cost expressions on cards. Common trap: Inventing too many trackers because each one feels mechanically rich. The simplest games have one or two. DeckCraft today: resources in game_parts, life in game_meta. Need unification under a single trackers concept.

6. Zones (where cards live during play)

The physical layout of cards on the table. Each zone is defined once: owner (per-player / shared / per-team), visibility (public / owner-only / hidden), ordering (stack / unordered / ranked), accepts_types (which card types can sit there), auto-refill (does this zone restock from a feeder deck?).

Most decks of cards reuse the same skeleton: draw_pile, hand, in_play, discard. The interesting zones are the game-specific ones: the trade row, the trick area, the objective track, the quest line. Those are where the game's identity lives.

Unblocks: card movement vocabulary (every action is a zone transition), setup (zones get populated from decks), visibility for hidden info. DeckCraft today: in game_meta['game_zones'] but descriptive only; not engine-readable yet.

7. Effects vocabulary (the verbs)

The set of verbs cards can use: deal_damage, gain_resource, draw_card, destroy, discard_from_opponent, summon_token, look_at_top_n. Each effect needs a name, the parameters it takes, and which trackers/zones it touches.

Most games use a small subset of a shared catalog. DeckCraft ships assets/data/effects.json as the master list; a specific game enables 5–15 of them. The choice ripples: you can't have a "draw 2 cards" card if draw_card isn't in the active vocabulary.

Unblocks: card library (cards are mostly effects with parameters), action vocabulary (some actions are "trigger an effect on this card"). Common trap: Enabling every effect "just in case". A bloated vocabulary makes the design feel formless. Pick a tight set that supports the win condition. DeckCraft today: catalog exists; per-game enabling lives in game_meta['enabled_mechanics'].

Phase C — Anatomy

8. Card types & behaviours

Card types define the data shape a card carries: a Unit has attack and health, an Event has effects and a single-use behaviour, a Resource Card has a numeric value tied to a tracker. Behaviours define the engine semantics: Permanent, Strike, Hidden, Targetable. A type lists which behaviours are allowed on cards of that type.

Most games need 3–5 root types. Trick-taking games need one. Eurogames may need many.

Unblocks: the field shape for the card library, AI prompt scaffolding, layout templates per type. Common trap: Inventing a type for every flavour distinction ("Hero", "Villain", "Boss" all as types). Use subtypes for flavour; reserve types for genuine shape differences. DeckCraft today: shipped, mechanic-scoped catalogs in assets/data/types/ and subtypes/.

9. Phases / turn structure

What a turn looks like, broken into phases. A start-of-turn phase (draw, refresh resources), a main phase (player makes choices), an end-of-turn phase (cleanup). Each phase is either automatic (the engine runs it) or player-driven (the engine asks repeatedly until the player passes / ends).

Most deck-builders are 2 phases (main + cleanup). Trick-taking games are a tight loop (deal → bid → play tricks). TCG-style games have many phases with priority rotation; this is also where the simulator gets hardest.

Unblocks: action vocabulary (each phase has its allowed actions), pacing. DeckCraft today: not modelled. Free-text rules only.

10. Action vocabulary

The verbs a player can use during their turn: play_card, buy_from_market, attack_opponent, activate_ability, pass, end_phase. Each action has: a precondition (legality predicate), a cost (resources spent), and an effect (state change — usually invokes the card's effects).

The action vocabulary is the game from the player's point of view. "What can I do right now?" is exactly "which actions are legal in the current state?". Most games are 5–7 actions max.

Unblocks: the simulator's legal_actions function; ergonomic UI later. Common trap: Letting cards smuggle in arbitrary actions ("this card lets you swap two opponent cards") instead of expressing them through the action vocabulary. Keep the vocabulary closed; everything else is an effect. DeckCraft today: not modelled.

11. Setup (the initial state)

Where every zone starts: which deck shuffles into the draw pile, how many cards are dealt to each hand, what value each tracker starts at, who goes first. Setup is deterministic given a random seed; the simulator runs setup once per game and then enters the play loop.

Unblocks: the simulator's first state, anything that says "at the start of the game...". DeckCraft today: not modelled. Implicit "deal 5, draw, go" assumed in deck-builder code paths.

Phase D — Content

12. Card library, decks, visual layout

The library is the actual cards: 80–200 of them in a typical deck-builder, fewer in a trick-taking game (a standard 52-card deck is its whole library). Each card is a concrete instance of a type with chosen effects and stats. Decks are authored containers (Step 3): player starter, shared market, scenario events. Visual layout (Step 5) is mostly orthogonal — you can author the library first and pick layout last.

By the time you reach this phase, every card is mostly determined by its type, its allowed behaviours, and the active effects vocabulary. The remaining creative space is naming, flavour text, art direction, and the specific numeric values. That's where AI generation actually helps.

Unblocks: playtest, simulation, print. DeckCraft today: Step 4 generates cards via AI; Step 5 renders them. Both shipped.

Iteration: this is a first-pass order, not a one-shot

In practice every design loop reaches a phase and finds the previous phase needs revisiting. Phase B (the skeleton) is where this happens most: you draft trackers and zones, start authoring cards, and discover the trackers don't quite work. That's normal. The order above is the first time through — expect to revisit the skeleton at least once after starting the library.

The simulator becomes invaluable here. Once you can play the game (even with random bots), changes to trackers, win conditions, or phases get tested in minutes instead of hours of physical playtesting. That's the whole point.

Mapping the wizard to the design phases

PhaseDecisionsDeckCraft today
A — Concept1, 2, 3 (concept, mechanic, player count, length)Step 1 (briefing)
B — Skeleton4. Win & lossFree-text on Step 1
5. TrackersStep 2, partial (resources + life live separately)
6. ZonesStep 2, descriptive only
7. Effects vocabularyCatalog ships; per-game enabling exists; UI for it doesn't
C — Anatomy8. Card types & behavioursStep 2
9. PhasesMissing
10. Action vocabularyMissing
11. SetupMissing
D — Content12. Library, decks, layoutSteps 3, 4, 5

The wizard's Steps 1–5 cover the concept and content phases well. The skeleton phase is partly modeled but not uniformly captured. The anatomy phase (phases / actions / setup) is the wizard's biggest gap — it's where the proposed schema's "Layer 4: Flow" lives, deferred until the simulator forces the issue.

If you only do one thing

Lock the win condition in structured form before you touch a card. Half the trouble in iterating a design is that the goalposts move while you're authoring; lock the goalposts and the rest of the design has somewhere to point.

The Game object: the runtime state

Everything in the dynamic-side section above is conceptual: what changes during play. Now: where all of it lives. The simulator runs by transforming a single object — let's call it Game — that holds the entire current state of one run of the game. Every iteration of the main loop produces a new version of this object. Everything else (catalogs, rules, card definitions) is read-only and lives outside it.

The single most important rule

The Game object holds state. It does not hold definition. Card text, type schemas, the effects catalog, the rules of the game — none of those go in here. The Game object holds references by ID. State changes, definition doesn't. Mixing them is the bug that makes simulators impossible to maintain.

static, shared, read-only dynamic, per-game-run, mutates -------------------------- ------------------------------ game definition Game object ------------------ ----------- - types catalog - players (state) - subtypes catalog - shared zones (state) - behaviours catalog - card instances (state) - effects catalog - flow (turn / phase / active) - card library (definitions) - history (log of actions) - phases / rules - RNG state - win conditions

When the Game wants to know "what does card c47 do on play?", it reads card_instances.c47.definition_id, looks up that definition in the static library, and runs its effects. The Game never copies the card text.

The full structure

Roughly ten top-level fields. Some are large (history, card_instances), most are small.

Game { // ───────── identity ───────── id: "g_2026_05_02_a1b2c3" definition_id: "starforge_v3" // points at the static design seed: 12345 status: "in_progress" | "ended_winner" | "ended_draw" started_at, last_action_at // ───────── frozen-at-start config ───────── // Resolved once at setup from the definition + chosen options. // Read-only after setup. Lives here (not in the definition) because // it can vary per run: player count, variants, seat order. config { player_count seat_order: [p0, p1] starting_hand_size length_cap: { turn_limit: 50 } | null variants_enabled: [...] } // ───────── players ───────── players: [ Player { id: "p0" seat: 0 team_id: "t0" // null for solo / ffa controller: "human" | "bot:random" | "bot:greedy" | "bot:ai_v1" trackers: { // unified resources + life + score authority: 50, trade: 0, combat: 0 } zones: { // zones owned by THIS player hand: ["c12", "c4"], draw_pile: ["c8", "c1", "c5", ...], discard: [], in_play: ["c2"] } modifiers: [ // status effects on the PLAYER { id: "skip_next_turn", source: "c19", expires: { phase: "end_turn", turn: 4 } } ] }, ... ] // ───────── shared zones ───────── // Zones not owned by any player. Trade row, current trick, // shared market deck, supply piles. shared_zones: { trade_row: ["c41", "c42", "c43", "c44", "c45"], market_deck: ["c46", "c47", "c48", ...] } // ───────── shared trackers ───────── // Round number, dealer position, anything per-team or per-game. shared_trackers: { round: 1, dealer_seat: 0 } // ───────── card instances ───────── // The instance pool. Every physical card the game knows about, by // stable instance_id. The instance moves between zones; the // definition_id never changes. card_instances: { "c1": { instance_id: "c1", definition_id: "card_starforge_blitz_001", // -> static library owner_player: "p0" | null, // null for shared cards location: { kind: "zone", zone: "draw_pile", owner: "p0", index: 12 }, face: "down" | "up", // visibility counters: { damage: 0, charge: 2 }, // tokens on the card modifiers: [] // status effects }, ... } // ───────── flow ───────── flow: { turn_number: 3, round_number: 1, active_player_id: "p0", current_phase: "main", phase_queue: ["cleanup"], // remaining phases this turn // For TCG-style games with interrupts. Empty for most. resolution_stack: [], // Triggered effects waiting to fire (e.g. "at end of turn"). trigger_queue: [] } // ───────── history ───────── // The log. Every action that has happened, in order, with enough // detail to replay. THIS is the simulator's truth — losing it // means you can't reproduce or debug a run. history: [ { seq: 0, type: "setup", seed: 12345, ... }, { seq: 1, type: "play_card", actor: "p0", card: "c4", effects_resolved: [...], rng_drawn: [0.234] }, { seq: 2, type: "buy_card", actor: "p0", card: "c41", cost_paid: { trade: 4 } }, { seq: 3, type: "end_phase", actor: "p0" }, ... ] // ───────── RNG ───────── // Reproducible PRNG. Store the seed (replayable) and the current // position (so the next call doesn't restart at zero). rng: { seed: 12345, position: 47 } }

Where each piece goes (players vs shared vs flow)

The hardest call is where each piece lives. The rule: whoever owns it, owns it.

  • A card's hand is per-player → players[].zones.hand.
  • The market is shared → shared_zones.trade_row.
  • A team score is per-team → shared_trackers keyed by team, or duplicated to each player on that team.
  • "Whose turn is it" is global → flow.
  • "This player's authority is 50" is per-player → players[].trackers.
  • "Damage on this creature" is per-card → card_instances[].counters.

The trap is putting per-player things in shared_trackers. "Trade goes back to 0 at end of turn" is per-player even though every player has one. Don't unify storage just because the field name is the same.

How iteration works

The Game object is the only thing that mutates, and there's exactly one function that mutates it: apply. Every iteration runs through the same sequence of steps.

apply(game, action) → newGame 1. validate : is this action legal in the current state? 2. record : append to history (with RNG pre-state) 3. resolve_action : mutate trackers, move cards, run effects 4. resolve_triggers : fire any "on_play" / "after X" triggers (which may push more effects onto the resolution stack, looping until empty) 5. cleanup_modifiers: expire status effects whose timer ran out 6. advance_flow : end_phase ? rotate phase. end_turn ? rotate active player. 7. check_end : did this action satisfy a win/loss condition? 8. return newGame

The main loop wraps that:

while (game.status === "in_progress") { player = game.players[active player] view = redact(game, player) // only what they see legal = legal_actions(game, player) // what they could do action = player.controller.choose(view, legal) game = apply(game, action) if (turns since start > length_cap) declare_draw_or_score() }

That's it. Two functions: legal_actions and apply. Everything else is content authored upstream by the wizard.

Five practical decisions worth making upfront

These shape the implementation more than the structure does.

  1. Immutable or mutable? The clean answer is immutable: every apply returns a new Game, the old one becomes part of history. Easy to debug, undo, fork timelines. The pragmatic answer in PHP: mutate in place and rely on the history log as your time-travel mechanism. Deep clones are expensive and PHP isn't built for functional immutability. Lean: mutate in place; history is your replay. Reach for snapshots only when you actually need them.
  2. Card instance IDs. Use stable IDs assigned at setup (c1, c2, …). Cards are referenced by ID in zones, history, modifiers — never copied. This is what makes "move card from hand to discard" a one-line operation: splice from hand[], push to discard[]. The card instance object itself never moves; only its location field updates.
  3. RNG: seed-based. Every Game has a seed at start. All randomness (shuffles, random draws, dice) goes through a single PRNG seeded from it. Store both the seed (for replay) and the current PRNG position (so the next call doesn't restart). Replay = reload seed + apply history. Determinism is non-negotiable for a simulator — without it you can't reproduce a weird game to debug.
  4. Visibility = a function, not stored data. Don't try to maintain "what each player has seen". Instead, write viewFor(game, playerId) → redactedGame that walks the state and hides: other players' hands (zone visibility = owner), face-down cards in shared zones, top-of-deck cards, opponent modifiers if they're hidden. The bot's controller only ever receives the redacted view. The full Game stays intact for the simulator. This is what stops bots from cheating accidentally.
  5. Effects mutate the Game directly. Each effect (deal_damage, gain_resource, draw_card) is a small function effect(game, params, source) → void that mutates state. The catalog is keyed by effect_key; resolving a card's on_play means looking up each effect and calling it. Triggers ("when a Unit enters play") are listeners — when an action emits an event, the engine checks every active modifier/trigger to see who fires.
Two functions, one object

The whole simulator is the Game object plus legal_actions and apply. If you can name those two functions and write down the Game shape above, everything else is just filling in handlers per action type and per effect. ~1,000–1,500 lines of PHP for a working deckbuilder simulator.

The simulator: what it takes

Minimum viable simulator

The smallest thing that's still useful:

  1. A state object covering the dynamic side (per-player zones + trackers, active player, phase, log).
  2. A legal_actions(state, player) function that returns the list of action records the player could take right now.
  3. A apply(state, action) → state reducer. Each action type (play_card, attack, draw, end_phase) has a handler that mutates state. Effects from the played card run as part of the same reducer call.
  4. A random bot that picks uniformly from legal_actions. Unintelligent but legal.
  5. A runner that loops ask bot → apply → check win condition until the game ends, then writes a row to a results file.
  6. A harness that runs N games and aggregates: average game length, win-rate by going-first, most-played cards, never-played cards.

Random vs random isn't predictive of human play, but it does reveal: dead cards (never played even by random), broken loops (random infinite games), turn-order asymmetry, and whether the win condition is actually reachable.

Beyond MVP: the next steps

  • Heuristic bots. "Buy the most expensive card I can afford each turn", "play the highest-attack unit first". Crappy but better than random; reveals different patterns.
  • Human-in-the-loop UI. Render the state, accept an action from a human, apply, re-render. The same engine, with a thin frontend on top. Now the designer can play their game before printing.
  • Replay viewer. Step through a recorded log of actions to see how a specific game played out.
  • Statistical reports. Per-card play rate, average turn-of-first-play, win contribution. Feeds back into balance decisions.
  • AI bot. A model trained on simulated games that plays better than heuristics. Useful for stress-testing balance against optimal play, but vastly more work than anything above and not necessary to validate a design.

Recommended build order

Working backwards from "we have a runnable simulator", the chain of dependencies suggests this order. Each step unlocks the next and is independently useful.

  1. Unify trackers. Merge resources + life into a single trackers concept with a role field. Schema-level cleanup; no engine work, just data normalisation.
  2. Promote zones to engine-readable. Add visibility, ordering, accepts_types, auto_refill_*. Today they're descriptive; the simulator needs them executable.
  3. Lock the action vocabulary. Decide the small set of action types every game uses (play_card, buy, attack, activate, discard, pass, end_phase). Most games are 5–7 actions max.
  4. Structure win/loss conditions. Replace the free-text game_goal with a list of inspectable predicates: {metric, comparator, threshold}. Keep the free text as a label; derive the structure beside it.
  5. Define triggers on effects. Add a trigger field to card effects (on_play, at_start_of_turn, etc.). Most cards are on_play by default; a few will need others.
  6. Define phases. A list of phases per game with {id, automatic, allowed_actions, mandatory_actions}. Almost every deck-builder is two phases (main + cleanup); trick-taking games are a small loop.
  7. Write the state shape. One TypeScript-or-PHP type covering everything in the dynamic side section above.
  8. Implement apply() for each action + effect. Effect handlers exist already as catalogue entries; they need real implementations. Action handlers are new but small.
  9. Implement legal_actions(). For the current phase, return the action types it allows, filtered by preconditions (legal targets, sufficient resources).
  10. Random bot + runner. First playable simulator.
  11. Harness + reports. Automate "design feedback loop": tweak a card, run 1,000 games, see the impact.
  12. Heuristic / human / AI bots. Optional improvements; can come after the loop is closed.

What to delay

  • Resolution stack / interrupts. Skip until you actually need them. Most mechanics DeckCraft targets don't need them, and they're the single highest-complexity piece of the engine.
  • Tokens. Defer until a designed game needs them. Most deck-builders don't.
  • Visibility-aware bots. Random bots can cheat (see hidden info). When you reach heuristic / AI bots, that's when honest visibility filtering becomes important.
  • Multi-player > 2. Get 2-player working first. Most rules generalise; turn-order math is the wrinkle.
The cheap insight

You don't need a smart simulator to get value from one. A random simulator that runs 1,000 games and reports dead cards, infinite loops, and unreachable win conditions already saves real design time. The expensive parts (good bots, human UI, AI) can wait until the cheap parts have proven the loop is worth investing in.

Appendix A — Win conditions: a structured vocabulary

Win conditions sit at the very front of the design process (see For a game designer: decision #4) but are also the engine's tightest constraint — once the evaluation substrate is chosen, every later piece (legality checks, targeting, modifiers, scoring) has to plug into it.

Architectural correction after first draft

The original draft tried to define a closed enum of trigger kinds (tracker_threshold, zone_count, card_in_state, …). That was wrong: every time a new game came up the enum had to grow. Triggers, scoring metrics, legality predicates, targeting filters, and modifier conditions all want the same machinery — a generic expression evaluator over the Game object. We use JSON Logic as the expression language and jwadhams/json-logic-php as the evaluator, registering a handful of game-specific operators. Trigger "kinds" disappear; they're now patterns recipe forms generate. New conditions are data, not engine releases.

Referenced from the Roadmap, Phase 3 step 3.1; revisit before implementing.

A.0 — The architectural principle: evaluator first

One generic evaluator powers five concerns. Building a bespoke handler for each is how engines drift apart and features pile up as code instead of data.

  • Triggers — when does the game end?
  • Scoring metrics — who wins at that moment?
  • Legality predicates — can the active player do action X right now?
  • Targeting filters — which game objects are valid targets for this effect?
  • Modifier conditions"while in play, all your Units cost 1 less"

All five take a Game object and a small context (active player, source card, trigger source) and return a value (boolean for predicates, number for metrics). The expression engine is the substrate; everything else is a use-case for it.

LAYER 1 — Evaluator: jwadhams/json-logic-php (composer dep) Standard JSON Logic operators: ==, !=, >, <, >=, <=, and, or, !, +, -, *, /, max, min, var, if, in, missing, missing_some, cat Custom operators we register on top via JsonLogic::add_operation(): count_cards(zone, where?) count_players_where(predicate) for_each_player(metric, aggregate) # sum / max / min across players lookup_player(by) # by id / by trigger source objective_count(category, comparator?, threshold?) ...add as needed (no engine release required) LAYER 2 — Schema (game_meta['ending']): Triggers and metrics are EXPRESSIONS in the above language, stored as JSON. No "kind" enum, no closed list. "ending": { "ends_when": [ { "label": "Opponent's authority reaches zero", "when": { "<=": [ { "var": "trigger.target.trackers.authority" }, 0 ] }, "scope": "any_player", "terminates_at": "immediately", "resolution": { "kind": "instant", "winner_rule": "trigger_target_loses" } } ] } LAYER 3 — Recipe forms (Win Conditions page UX): Designer picks "race to N", fills in {tracker, threshold}, the form GENERATES the JSON Logic expression. Designer never sees raw JSON unless they want to. LAYER 4 — Advanced expression editor (much later, if ever): For designers who outgrow recipes.

Why jwadhams/json-logic-php and not our own evaluator

  • Mature. Since 2015, MIT-licensed, ~600 lines, zero runtime deps. Multiple language implementations (JS, PHP, Python, Ruby). Stable surface.
  • PHP 7.2 compatible. Declares "php": ">=5.6". Composer is universally available, including on the Vesta server.
  • Pure JSON. No parser to write, no operator-precedence questions, no string parsing risks.
  • {"var": "path"} accessor maps cleanly onto our Game object's dotted structure.
  • Custom operators are first-class. JsonLogic::add_operation('count_cards', $callable). Extension is data, not a fork.
  • ~Half a day to integrate vs ~3–4 days to build our own (and ours would be worse the first time).

A.1 — The two questions: end & resolution

Every win condition answers two distinct questions, and the schema separates them cleanly:

  1. When does the game end? — one or more predicates. Any one returning true ends the game (or flags it to end at the next phase boundary).
  2. Who wins at that moment? — a resolution. Either the firing trigger names the winner (instant), or scoring runs across players to pick one.

Some games answer both with the same event ("life to 0" both ends the game and names the loser). Most games separate them ("market deck empties" ends the game; weighted scoring then picks the winner). The schema accommodates both:

"ending": { "ends_when": [ // any predicate true → ends game { "label": "...", // human-readable "when": { ... }, // JSON Logic predicate "scope": "any_player", // | "active_player" | "all_players" | "shared" "terminates_at": "immediately", // | "end_of_turn" | "end_of_round" "resolution": { ... } // who wins when this fires }, ... ] }

Pairing each trigger with its own resolution lets one game have multiple end paths with different winner rules — Twilight Struggle is the classic case (instant tracker-differential win or deferred final scoring).

A.2 — Triggers as JSON Logic predicates

A trigger is a record with a JSON Logic predicate at its heart. The simulator evaluates each when after every state change; if any returns true, the corresponding resolution fires (subject to terminates_at).

Common patterns the recipe forms generate

Tracker comparison — "authority ≤ 0":

{ "<=": [ { "var": "trigger.target.trackers.authority" }, 0 ] }

Turn / round limit — "turn 20 reached":

{ ">=": [ { "var": "game.flow.turn_number" }, 20 ] }

Zone exhausted — "market deck empty":

{ "==": [ { "count_cards": { "zone": "market_deck" } }, 0 ] }

Multi-zone exhaustion — "3 supply piles empty" (Dominion):

{ ">=": [ { "count_players_where": { "predicate": { "==": [ { "count_cards": { "zone": "supply_pile_*" } }, 0 ] } } }, 3 ] }

Player status — "last player not eliminated":

{ "==": [ { "count_players_where": { "predicate": { "!=": [ { "var": "player.status" }, "eliminated" ] } } }, 1 ] }

Objective tally — "4 diseases cured" (Pandemic):

{ ">=": [ { "objective_count": { "category": "disease_cured" } }, 4 ] }

Specific card state — "boss in discard" (Slay the Spire):

{ "==": [ { "var": "game.card_instances.boss_card_001.location.zone" }, "discard" ] }

Compound — "life ≤ 5 AND empty hand":

{ "and": [ { "<=": [ { "var": "player.trackers.life" }, 5 ] }, { "==": [ { "count_cards": { "zone": "hand" } }, 0 ] } ] }

None of these are special cases the engine knows about. They're all JSON Logic, evaluated by the same code. New patterns = new combinations of the same operators.

terminates_at on the surrounding trigger record (not inside the expression) decouples predicate fired from game actually ends: "immediately" (default), "end_of_turn", "end_of_round". Splendor's "first to 15 finishes the round before scoring" sets "end_of_round".

A.3 — Resolution primitives

Two resolution kinds — instant and scoring. The kind here is justified (unlike on triggers): they're genuinely two different mental models, not two different expressions.

Instant resolution — the firing trigger names the winner:

{ "kind": "instant", "winner_rule": "trigger_aggressor" // the player who fired it wins // alternatives: // "trigger_target_loses" // "last_player_standing" // "shared_victory" // "shared_loss" (cooperative fail) }

Scoring resolution — rank players by a metric expression:

{ "kind": "scoring", "objective": "maximize", // | "minimize" for Hearts-style "metric": <json_logic_expression>, // returns a number per player "tiebreaker": <json_logic_expression> | "shared_victory" | null, "produces": "ranking" // always — even 2-player wants 1st/2nd for stats }

The metric is evaluated per player, with the player bound into the context as player. The simulator loops over players, evaluates the metric, ranks the list, returns the ordered result.

When are predicates evaluated?

Continuous evaluation, gates inside the predicate

The simulator re-evaluates every when predicate after every state change (every effect resolution, every action). The first time a predicate returns true, the trigger fires (subject to terminates_at).

Time-based or boundary-based gates ("after turn 10", "end of round 3") are encoded into the predicate itself, not configured separately. Example:

// "Game ends after turn 10": { ">=": [ { "var": "game.flow.turn_number" }, 10 ] } // "End of round 3 OR someone reaches 100 points": { "or": [ { ">=": [ { "var": "game.flow.round_number" }, 3 ] }, { ">=": [ { "var": "player.trackers.points" }, 100 ] } ] }

The engine doesn't need a separate "evaluation cadence" field per trigger — the predicate is self-gating. A future optimisation could let designers declare evaluation_at: ["end_of_turn"] to skip re-checks until the right phase boundary, but checking 1–3 predicates per action costs microseconds; not worth it for v1.

Don't confuse terminates_at with evaluation cadence. terminates_at controls when the game actually ends after the predicate fired (immediately vs let the round / turn finish). The predicate is evaluated continuously regardless.

Multi-player elimination: which recipe?

Combat is for 1v1; use Last standing for 3+ players

The Combat recipe ends the game on the first death — correct for 1v1 (the survivor wins automatically). In a 3+ player elimination game, the game shouldn't end on the first death; it should continue until only one player remains.

For 3+ player elimination, use Last standing with the life tracker as the status indicator and 0 as the eliminated value. Generated predicate:

{ "==": [ { "count_players_where": [{ "predicate": { "!=": [ { "var": "player.trackers.life" }, 0 ] }, "include_eliminated": true }] }, 1 ] }

One concept this exposes: an automatic elimination rule — "when life hits 0, the player stops taking turns and can't be targeted." That rule lives at the game-engine layer (Phase C, Anatomy) and isn't authored today. The win predicate above is mathematically correct; the simulator will need either a built-in convention ("life→0 = eliminated") or an explicit rule entry to honour it at runtime.

A.4 — The operator vocabulary

What the evaluator can do — standard JSON Logic ops (built in) plus the custom ones we register at boot. The custom list grows over time; new operators are add_operation('name', $callable), no engine release.

Standard JSON Logic operators (built in)

Comparison: ==, !=, ===, !==, >, <, >=, <= Logical: and, or, !, !!, if Arithmetic: +, -, *, /, %, max, min Access: var, missing, missing_some Strings: cat, substr, in Arrays: map, reduce, filter, all, none, some, merge

Custom operators we register (game-specific)

OperatorWhat it does
count_cards Count cards in a zone, optionally filtered by a predicate. {"count_cards": {"zone": "tableau", "where": {"==": [{"var": "card.type"}, "city"]}}}
count_players_where Count players matching a predicate. {"count_players_where": {"predicate": {"!=": [{"var": "player.status"}, "eliminated"]}}}
for_each_player Map a metric across all players, reduce by aggregate (sum / max / min / count). {"for_each_player": {"metric": {"var": "player.trackers.points"}, "aggregate": "max"}}
lookup_player Resolve a player by reference (id, trigger source, opponent of a player). Useful for cross-player metrics.
objective_count Count completed objectives, optionally by category. {"objective_count": {"category": "disease_cured"}}
card_in Predicate: is a specific card definition currently in a specific zone? Sugar over a longer var + ==.

Add operators when a real game forces one. Don't speculate; the evaluator stays clean if you only register what you've actually proven you need.

The execution context

When the simulator calls JsonLogic::apply($expression, $context), it pre-binds:

$context = [ "player" => $game->players[$activeIdx], // for predicates evaluated per-player "opponent" => $game->players[$other], // 2-player convenience "trigger" => [ // for end-condition predicates "actor" => $game->players[$lastActor], "target" => $game->players[$lastTarget], "card" => $card, "action" => $action, ], "game" => $game, // full state for global access ];

Every {"var": "player.trackers.x"} in an expression resolves through this context. The same expression syntax works whether it's a trigger, a metric, a legality check, or a targeting filter — the context shape determines what the expression can reach.

A.5 — Recipe templates (the user-facing menu)

Designers don't author JSON Logic directly — they pick a recipe on the Win Conditions page, fill in a small form, and the recipe generates the expression. Ten recipes cover the known game-design space.

RecipeRepresentative gamesForm fields → generated expression
combat Star Realms, MTG, Hearthstone {tracker, threshold} → {"<=":[{"var":"trigger.target.trackers.<tracker>"},<th>]} + instant/trigger_target_loses
race_to_n Splendor, Catan, Munchkin, Diplomacy {tracker, threshold, terminates_at} → {">=":[{"var":"player.trackers.<tracker>"},<th>]} + instant/trigger_aggressor
most_at_end Carcassonne, Wingspan, simple Eurogames {end_trigger_recipe, score_tracker} → non-tracker predicate + scoring(maximize, {"var":"player.trackers.<tracker>"})
multi_source_score Catan, 7 Wonders, Terraforming Mars {end_trigger_recipe, sources[]} → scoring(maximize, {"+":[…]} over mixed sources)
weighted_score resource→points mappings, Bohnanza-like {end_trigger_recipe, components[{tracker, weight}]} → scoring(maximize, {"+":[{"*":[w,t]}, …]})
last_standing Coup, Battleship, Werewolf {eliminated_status} → {"==":[{"count_players_where":{"predicate":{"!=":[{"var":"player.status"},"<st>"]}}},1]} + instant/last_player_standing
mission Pandemic, Spirit Island, Hanabi, Forbidden Island {objective_category, threshold} → {">=":[{"objective_count":{"category":"<cat>"}},<th>]} + instant/shared_victory
boss_defeat Slay the Spire, Forbidden Stars {boss_card_id} → {"==":[{"var":"game.card_instances.<id>.location.zone"},"discard"]} + instant
team_victory Werewolf, Avalon, Resistance, Secret Hitler per-team endings, each with its own JSON Logic predicate (v2)
hidden_objective Dead of Winter, Spyfall, Citadels per-player secret endings, dealt at setup, each one a JSON Logic predicate (v2)

Anything that doesn't fit a recipe falls back to the advanced editor (deferred). New recipes are also data — a recipe is just a form definition + a template-string for the expression. Adding a new recipe doesn't touch the engine.

A.6 — The two-phase authoring flow

The chicken-and-egg: "how does a designer specify 'victory_points > 10' before any tracker exists?" Solution: capture the shape on Step 1, fill in the details on a later page once trackers and zones exist.

Phase A — Step 1 (briefing): a single dropdown plus the existing free-text label.

"win_shape": "combat" // one of the 10 recipe ids "game_goal": "Reduce opponent's Authority to zero." // existing free text, kept

Phase B — New "Win Conditions" page (Step 2.5): once trackers / zones / objectives exist, render a recipe-specific form. Form fields are determined by the chosen shape:

  • combat → pick the tracker, threshold (default 0).
  • race_to_n → pick the tracker + threshold + whether to terminates_at end_of_round.
  • most_at_end → pick a trigger (turn count / zone empty / round count) + which tracker to score.
  • weighted_score → rows of {tracker, weight} with a "+ add row" button.
  • multi_source_score → mixed list: rows can be tracker, card_count(zone, where), or weighted wrapper.
  • etc.

The form preview shows the natural-language version ("Game ends when the deck is empty. The player with the highest victory points wins.") so the designer can sanity-check. Saved to game_meta['ending'].

Why two phases

Designers think in shapes ("scoring game", "combat game") long before they have specific trackers. Capture intent early, lock structure once the components exist. Same principle as "don't ask for a foreign-key value before the foreign table is populated".

A.7 — Worked examples (in JSON Logic)

Each block below is the actual JSON the simulator stores and the evaluator runs. None is special-cased — same operators throughout.

Star Realms — combat:

"ending": { "ends_when": [{ "label": "Authority reduced to zero", "when": { "<=": [ { "var": "trigger.target.trackers.authority" }, 0 ] }, "scope": "any_player", "terminates_at": "immediately", "resolution": { "kind": "instant", "winner_rule": "trigger_target_loses" } }]}

Splendor — race with end-of-round close:

"ending": { "ends_when": [{ "label": "First player to 15 prestige (round finishes first)", "when": { ">=": [ { "var": "player.trackers.prestige" }, 15 ] }, "scope": "any_player", "terminates_at": "end_of_round", "resolution": { "kind": "scoring", "objective": "maximize", "metric": { "var": "player.trackers.prestige" }, "tiebreaker": { "count_cards": { "zone": "tableau" } } } }]}

Catan — multi-source race to 10 VP:

"ending": { "ends_when": [{ "label": "First player to 10 victory points", "when": { ">=": [ { "+": [ { "count_cards": { "zone": "tableau", "where": { "==": [ { "var": "card.type" }, "settlement" ] } } }, { "*": [ { "count_cards": { "zone": "tableau", "where": { "==": [ { "var": "card.type" }, "city" ] } } }, 2 ] }, { "var": "player.trackers.longest_road_bonus" }, { "var": "player.trackers.largest_army_bonus" }, { "var": "player.trackers.victory_dev_cards" } ] }, 10 ] }, "scope": "any_player", "terminates_at": "immediately", "resolution": { "kind": "instant", "winner_rule": "trigger_aggressor" } }]}

Catan's "victory points" isn't a stored tracker — it's computed from settlements + cities×2 + bonuses. The same expression that gates the trigger could be lifted to a named metric (game.named_metrics.vp) if it gets reused in many places. That's an optimisation, not a primitive.

Hearts — minimize-over-rounds:

"ending": { "ends_when": [{ "label": "First player to 100 points loses; lowest score wins", "when": { ">=": [ { "var": "player.trackers.points" }, 100 ] }, "scope": "any_player", "terminates_at": "end_of_round", "resolution": { "kind": "scoring", "objective": "minimize", "metric": { "var": "player.trackers.points" } } }]}

Pandemic — cooperative, three end paths:

"ending": { "ends_when": [ { "label": "All four diseases cured (win)", "when": { ">=": [ { "objective_count": { "category": "disease_cured" } }, 4 ] }, "scope": "shared", "terminates_at": "immediately", "resolution": { "kind": "instant", "winner_rule": "shared_victory" } }, { "label": "Outbreak counter reaches 8 (loss)", "when": { ">=": [ { "var": "game.shared_trackers.outbreaks" }, 8 ] }, "scope": "shared", "terminates_at": "immediately", "resolution": { "kind": "instant", "winner_rule": "shared_loss" } }, { "label": "Player deck exhausted (loss)", "when": { "==": [ { "count_cards": { "zone": "player_deck" } }, 0 ] }, "scope": "shared", "terminates_at": "immediately", "resolution": { "kind": "instant", "winner_rule": "shared_loss" } } ]}

Coup — last player standing:

"ending": { "ends_when": [{ "label": "Only one player retains influence", "when": { "==": [ { "count_players_where": { "predicate": { ">": [ { "var": "player.trackers.influence" }, 0 ] } } }, 1 ] }, "scope": "shared", "terminates_at": "immediately", "resolution": { "kind": "instant", "winner_rule": "last_player_standing" } }]}

Splendor's tiebreaker — showing how the same JSON Logic syntax expresses "fewest cards bought":

"tiebreaker": { "count_cards": { "zone": "tableau" } } // less = better when objective is minimize

A.8 — Deliberately not supported

  • Position-based / spatial predicates. Chess checkmate, Go territory counting, area-control with adjacency rules. Need a spatial board model that's outside card-game scope.
  • Real-time / timer-based. Space Alert, Galaxy Trucker. No turn structure to hook win conditions to.
  • Bid-and-fulfill scoring nuance. Bridge, Spades. Player commits to a target; consequence varies by margin. Doable but requires per-player commit tracking layered on the resolution; defer until a designed game forces it.
  • Stalemate detection in skill games. Same problem as chess — needs domain-specific position analysis the engine doesn't have.

A.9 — Ship plan (v1 / v1.5 / v2)

v1 — ships with the structured win conditions work in Roadmap step 3.1. Six recipes cover Star Realms, Dominion, Splendor, Catan, 7 Wonders, Wingspan, Coup, Hearts, and most non-cooperative card games.

  • combat
  • race_to_n (with terminates_at support)
  • most_at_end
  • multi_source_score
  • weighted_score
  • last_standing

v1.5 — cooperative play. Adds:

  • mission (objective_count, compound triggers)
  • boss_defeat (card_in_state)
  • shared_victory / shared_loss resolution rules
  • derived_trackers (mostly v1 if Catan-style is in scope)

v2 — social-deduction and asymmetric roles. Adds:

  • team_victory (per-team endings)
  • hidden_objective (per-player secret goals dealt at setup)
  • per-player visibility on win-condition state
Don't overbuild v1

v1 covers the realistic target population for DeckCraft's first year (competitive card games, deck-builders, Eurogames). v1.5 and v2 wait until a designed game in those genres actually exists to test against. Building faction / hidden-objective support speculatively without a real test case is a recipe for wrong abstractions.

Appendix B — Trackers: a unified state vocabulary

Trackers are the single largest source of confusion in DeckCraft today: resources live in game_parts, life lives in game_meta['life_points'], victory points don't exist as a first-class concept at all. Three concepts that are the same thing wearing different hats: per-player or shared state with a value, mutated by effects, inspected by win conditions. This annex unifies them under one model and surfaces the dimensions a designer needs to think about when authoring them. Referenced from the Roadmap, Phase 3 step 3.2.

B.1 — What is a tracker, conceptually?

One sentence

A tracker is a named, addressable piece of game state that holds a value, has a known lifespan, and can be read by win conditions, mutated by effects, and displayed to players.

That definition deliberately doesn't mention "number". The most common tracker IS a numeric counter (life, mana, gold, points), but a tracker can also be:

  • A boolean flag"has this player taken the first move yet?"
  • An enum / status"alive | exhausted | eliminated"
  • A set / list"which achievements has this player collected?"

Treating these as the same kind of thing (with a type discriminator) keeps the engine simple: one read accessor (player.trackers.X), one write accessor (effects mutate player.trackers.X), one schema to author. The alternative — separate "resources" and "flags" and "statuses" tables — is what we have today, and it's why the AI prompt has three different sections for things the engine treats identically.

B.2 — Two orthogonal dimensions: type and role

Every tracker is described by two independent axes. They don't constrain each other — any type can play any role.

Dimension 1: type — the data shape of the value

TypeValue shapeExamples
counter integer (typically), with optional min/max bounds life (50), mana (3), victory points (12), damage (0)
boolean true / false has_first_player_token, is_in_combat, used_special_ability
enum one value from a fixed list status (alive | exhausted | eliminated), faction (red | blue | green)
set collection of distinct items achievements_unlocked [], factions_befriended [], spells_known []

Dimension 2: role — what the tracker semantically represents

RoleMeaningExamples
life Survival metric; usually wins/loses the game when crossed HP, authority, hull integrity
resource Spent to do things; often regenerates mana, gold, trade, action points, energy
score Accumulates; typically the win-condition metric in scoring games victory points, prestige, glory, fame
status Boolean / enum flags affecting legality or behaviour is_eliminated, has_passed, has_taken_loan
counter Generic numeric tally without a special role fatigue, taxes_paid, tricks_won_this_round
commitment A bid, pledge, or declaration the player is held to contract_bid, gambit_called, target_score
Type and role compose

Authority is {type: counter, role: life}. Mana is {type: counter, role: resource}. Player status is {type: enum, role: status}. "Has used hero ability this game" is {type: boolean, role: status}. VP is {type: counter, role: score}. Achievements collected is {type: set, role: score}. Two dimensions, six×four combinations, almost all of them sensible.

B.3 — Other properties (the rest of a tracker definition)

Beyond type and role, every tracker carries some metadata. Most fields are optional with sensible defaults.

FieldPurpose
id Unique slug. authority, victory_points, achievements_unlocked. Referenced by effects and expressions.
label Human-readable display name. "Authority", "Victory Points".
description One-line explanation, used in AI prompts and the wizard UI.
scope per_player | per_team | shared. Determines where the tracker lives in the Game object.
visibility public (everyone sees it) | owner (only the owning player) | private (hidden until reveal). Used by the visibility-redaction layer for honest bots.
starts_at / starts_value / starts_set Initial value at setup. Field name varies by type.
min / max For counter: optional bounds. min often 0 (resources can't go negative); max often null (unbounded).
regenerates For counter: when does it auto-reset? null | per_turn_reset_to_zero | per_turn_reset_to_X | per_round_reset_to_X | never. Mana usually per_turn_reset_to_zero; life almost always null.
values For enum: the allowed values list. e.g. ["alive", "exhausted", "eliminated"].
icon / units Display hints. Icon is a Font Awesome class; units is the unit suffix ("points", "HP", "gold").
derived + metric derived: true means the value isn't stored; it's computed on read from the JSON Logic metric expression. Used for Catan VP. (See B.7.)

B.4 — The schema shape

A tracker's full record. Optional fields can be omitted; the engine uses the type-appropriate default.

{ "id": "authority", "label": "Authority", "description": "Your fleet's command authority. Drops to 0 and you've lost.", "type": "counter", "role": "life", "scope": "per_player", "visibility": "public", "starts_at": 50, "min": 0, "max": null, "regenerates": null, "icon": "fa-shield", "units": "AUTH" }

A boolean status:

{ "id": "has_passed", "label": "Passed this round", "type": "boolean", "role": "status", "scope": "per_player", "visibility": "public", "starts_value": false, "regenerates": "per_round_reset_to_X", "reset_value": false }

An enum status:

{ "id": "status", "label": "Status", "type": "enum", "role": "status", "scope": "per_player", "visibility": "public", "values": ["alive", "exhausted", "eliminated"], "starts_value": "alive" }

A set tracker (cooperative-game objectives done):

{ "id": "diseases_cured", "label": "Diseases cured", "type": "set", "role": "score", "scope": "shared", "visibility": "public", "starts_set": [] }

A derived counter (Catan VP):

{ "id": "victory_points", "label": "Victory Points", "type": "counter", "role": "score", "scope": "per_player", "visibility": "public", "derived": true, "metric": { "+": [ { "count_cards": ["tableau", "player", [{"==":[{"var":"card.type"}, "settlement"]}]] }, { "*": [ { "count_cards": ["tableau", "player", [{"==":[{"var":"card.type"}, "city"]}]] }, 2 ] }, { "var": "player.trackers.longest_road_bonus" }, { "var": "player.trackers.largest_army_bonus" } ] } }

B.5 — Worked examples across real games

How well-known games would express their tracker set in this schema.

Star Realms (2-player attack/defense deck-builder)

  • authority — counter / life / per-player, starts 50, never regenerates.
  • trade — counter / resource / per-player, starts 0, per_turn_reset_to_zero.
  • combat — counter / resource / per-player, starts 0, per_turn_reset_to_zero.

Dominion (deck-builder)

  • actions — counter / resource / per-player, per_turn_reset_to_X (reset to 1).
  • buys — counter / resource / per-player, reset to 1 each turn.
  • coins — counter / resource / per-player, reset to 0 each turn.
  • victory_pointsderived counter / score / per-player, computed from cards in deck.

Hearts (4-player trick-taking)

  • points — counter / score / per-player, accumulates across rounds, "lowest wins" via scoring(minimize).

Pandemic (cooperative)

  • outbreaks — counter / status / shared, >= 8 = loss.
  • infection_rate — counter / status / shared, increases at epidemic events.
  • diseases_cured — set / score / shared, win when size == 4.
  • player_status per player — enum / status / per-player (alive | infected | quarantined).

7 Wonders (drafting)

  • coins — counter / resource / per-player.
  • military_victories, military_defeats — counter / score / per-player, accumulates per age.
  • vp_civilian, vp_science, vp_commercial, vp_guild, vp_wonder — multiple derived counters / score / per-player. Total VP is a metric expression that sums them all.

Coup (elimination)

  • influence — counter / life / per-player (cards still face-down in front of you), 0 = eliminated.
  • coins — counter / resource / per-player, accumulates.
  • player_status — enum / status / per-player (derived from influence being 0).

Across all these, the same schema. No game required a tracker shape we don't have. That's the validation.

B.6 — UI presentation in the wizard

The Step 2 (Pieces) page gets a single Trackers section. Replace the current "Resources" and "Life points" subsections with one unified list. Each row in the list is a tracker; the form fields shown vary by chosen type.

The add-tracker form

  1. Pick a role first (life / resource / score / status / counter / commitment). The role determines sensible defaults.
  2. Pick a type (counter / boolean / enum / set). Default depends on role: life / resource / score / counter / commitment → counter; status → boolean.
  3. Type-specific fields appear:
    • counter → starts_at, min, max, regenerates
    • boolean → starts_value (true/false)
    • enum → values (list editor), starts_value (dropdown)
    • set → starts_set (initially empty almost always)
  4. Common fields always shown: id, label, description, scope, visibility, icon.

Pre-canned tracker recipes

Like with win conditions, designers should be able to skip the form by picking a recipe. Top recipes:

  • life_points — counter / life / per-player / starts 20-50 / no regen.
  • generic_resource — counter / resource / per-player / starts 0 / per-turn reset.
  • victory_points — counter / score / per-player / starts 0 / never resets.
  • action_points — counter / resource / per-player / starts N / per-turn reset to N.
  • player_status — enum / status / per-player / values [alive, eliminated].
  • achievement_set — set / score / per-player / starts empty.
  • shared_threat — counter / status / shared / starts 0 (Pandemic outbreaks-style).

Pick a recipe, fill in the (id, label, optional overrides), done. Three clicks for the common case.

List view

The trackers list shows for each row:

  • icon + label (left)
  • type / role badges
  • scope (per-player / shared / per-team)
  • starts-at value
  • edit / delete buttons

Hover-tooltip shows the full description. A "+ Add tracker" button at the bottom opens the recipe picker.

B.7 — Edge cases

Derived trackers

Some "trackers" aren't stored — they're computed from other game state. Catan VP is the classic case: settlements + cities×2 + bonuses. Storing it means keeping it in sync every time anything changes; computing it on read is simpler.

Schema: add derived: true + metric: <json_logic>. The simulator computes the value when expressions read it, never writes it. Effects can't mutate a derived tracker (the engine rejects the mutation; you'd mutate one of the underlying trackers / cards instead).

Per-card counters (NOT trackers)

Damage on a creature, charges on an artifact, age counters — these are per-card, not per-player. They live on card_instances[].counters.X. The same value-type model applies (counters can be numeric, boolean, enum, or set) but the addressing is different. Treat per-card counters as a parallel concept that reuses the type taxonomy — not as a sub-case of trackers.

System trackers (engine-managed)

Some state is intrinsic to the engine and shouldn't be editable as a user tracker: turn_number, round_number, active_player_id, current_phase. These live in game.flow (the flow object), not in the user's trackers list. Expressions can still reference them via {"var": "game.flow.turn_number"} — same access pattern, different storage location.

Per-team trackers

For team games (Hearts partnerships, Werewolf), trackers can be per-team. scope: per_team. The engine stores them in a parallel game.team_trackers map keyed by team_id. Most games don't need this; ship the shape in the schema but don't build the UI until a team game actually arrives.

Hidden trackers

Some trackers are deliberately invisible to opponents: Dominion's mid-game VP totals, secret roles, hidden hand values. visibility: owner means only the owning player sees the value; redacted from other players' views. visibility: private goes further — even the owner doesn't know (e.g. a face-down identity card).

B.8 — Compared to DeckCraft today

Today's conceptLives whereBecomes
Resources game_parts with part_type='resource' Trackers with type: counter, role: resource.
Life points game_meta['life_points'] A tracker with type: counter, role: life.
Victory points Doesn't exist as first-class concept A tracker with type: counter, role: score.
Card behaviour flags (e.g. is_eliminated) Implied by card location / not modelled Tracker with type: boolean, role: status on the player.
Counters on cards (damage, charge) Not modelled Per-card counters (parallel concept, see B.7).

The migration is straightforward: read existing game_parts(part_type='resource') rows, write them as trackers in the new shape. Read existing game_meta['life_points'], write as a tracker. Drop the part_type='resource' code path. UI on Step 2 collapses two sections into one.

B.9 — Ship plan

v1 (Roadmap step 3.2):

  • Schema for the four types (counter / boolean / enum / set) with full metadata.
  • The six recipes from B.6.
  • UI in Step 2: replace Resources + Life subsections with the unified Trackers section. Recipe-based add form.
  • Migration from the old game_parts + life_points shape.
  • Expression access via {"var": "player.trackers.<id>"} already works in the evaluator from Appendix A.

v1.5:

  • Derived trackers. Add the derived + metric fields and the engine-side compute-on-read. Needed for Catan and 7 Wonders.
  • Per-card counters. Parallel concept, reuses type taxonomy. Needed when MTG-style card-level state shows up.

v2:

  • Per-team trackers (deferred until a team game ships).
  • Hidden / private visibility tied to honest-bot view redaction.
Why this matters before the simulator

The simulator's evaluator already speaks {"var": "player.trackers.X"}. Without unified trackers, every win-condition recipe has to special-case "is this thing in game_parts or game_meta?". Unifying first means the recipes don't need to know.

B.10 — Field reference: when each field is authored, design vs runtime

All tracker fields are design-time

Trackers describe state shape and initial values. Nothing on a tracker definition changes during play. The current value at any moment of a game lives in the Game object (player.trackers.<id>), not on the schema. So when this table says "design", it means "authored once in the wizard, never edited during a game".

Field Why it's needed Authored in Depends on
id Stable identifier. Effects reference it ({"var":"player.trackers.life"}); win conditions inspect it. Immutable after first save. Step 2 (Trackers) Nothing
label Display name shown in the UI and on rendered cards. Step 2 Nothing
description One-line explanation. Read by AI prompts when generating cards; shown in tooltips. Step 2 Nothing
type Data shape: counter / boolean / enum / set. Locks which other fields apply. Step 2 Nothing
role Semantic category: life / resource / score / status / counter / commitment. Used by recipe filters and AI prompts. Step 2 Nothing
scope Where the tracker lives: per_player / per_team / shared. Determines storage location in the Game object. Step 2 Nothing
visibility Who can see the value at runtime. Used by the bot view-redaction layer (honest bots can only read what their player can see). Step 2 Nothing
starts_at / starts_value / starts_set Initial value at game setup. Field name varies by type. The runtime value can move from here, but the design lives here. Step 2 Nothing
min / max For counter: optional bounds. Engine clamps mutations to these at runtime. null = unbounded. Step 2 Nothing
regenerates For counter: when the engine resets the value automatically (per_turn_reset_to_zero, etc). Design-time policy, runtime application. Step 2 Phases (Anatomy) — the "per_turn" / "per_round" trigger requires phases to be defined for the engine to know when those moments happen. Authorable now; engine wiring lands when phases ship.
values (for enum) Allowed values for an enum tracker (e.g. ["alive", "exhausted", "eliminated"]). Step 2 Nothing
icon / units Display hints (Font Awesome class, suffix). Used by the renderer and the UI list. Step 2 Nothing
derived + metric Marks a tracker as computed (not stored). The metric is a JSON Logic expression evaluated on read. Catan VP, 7 Wonders multi-source. Step 2 Other trackers + zones + card types — the metric typically references other trackers and counts cards in zones. Author once those exist.

Bottom line: nothing on a tracker definition is "runtime" in the strict sense — it's all design data. The runtime state derived from these definitions lives in the Game object. The two fields that depend on later authoring (regenerates for phases, derived/metric for cross-references) can still be filled in now — they just become live once the things they reference exist.

Appendix C — Zones: where cards live during play

Zones are the second-most-confused concept in DeckCraft today, right after trackers. They exist in game_meta['game_zones'] as descriptive blobs — helpful for the AI prompt, useless to the simulator. Every card action is fundamentally a zone transition (play = hand → in_play, discard = anywhere → discard, buy = trade_row → discard), so the simulator needs zones promoted from prose to engine-readable structure. This annex defines that structure and the dimensions a designer authors. Referenced from the Roadmap, Phase 3 step 3.3.

C.1 — What is a zone, conceptually?

One sentence

A zone is a named container that holds cards during play, with rules about who owns it, who can see its contents, how cards inside are ordered, and how cards arrive and leave.

Zones are the scaffolding the simulator moves cards across. The Game object stores zones as ordered lists of card-instance ids; cards reference their current location via card_instances[id].location.zone. A well-defined zone tells the engine:

  • Who owns it — per-player, shared, or per-team.
  • Who can see what's in it — public, owner-only, count-only-to-others, or fully hidden.
  • How cards inside are ordered — stack, unordered, ranked, or positional.
  • What kinds of cards are allowed — "any" or a constrained type list.
  • Capacity — max cards, exclusive (one slot), unbounded.
  • Auto-behaviours — refill from a feeder zone, reset each turn, shuffle on fill.
  • Setup — what populates it at game start.

That's the whole engine view. Anything beyond it — "the discard pile sits to the right of the deck", "cards in the tableau show their backs face-up after age 2" — is UI / flavour / out of scope.

C.2 — The dimensions

Scope — who owns the zone?

ScopeMeaningExamples
per_playerOne instance per playerhand, draw_pile, discard, in_play
per_teamOne instance per teamwon_tricks (Hearts partnerships), team_objectives
sharedSingle instance, no ownermarket deck, trade_row, current_trick, shared supply

Visibility — who can see what?

VisibilityMeaningExamples
publicEveryone sees full contents (and order, if ordered)discard pile, in_play, trade_row, current_trick
ownerOwner sees full contents; opponents see nothingface-down personal stash
owner_count_to_othersOwner sees contents; opponents see only the COUNThand (in most games)
top_onlyTop of stack visible to all; rest hiddensome draw piles in some games
hiddenNot even count is public; opponents have no infoface-down deck before drawing, secret reserve

More exotic visibility (Splendor reserved cards, MTG library after scry, individually-revealed cards inside an otherwise hidden zone) is per-card visibility — lives on the card instance via a face field, not on the zone.

Ordering — how are cards arranged inside?

OrderingMeaningExamples
stackLIFO. You draw from top; you push to topdraw_pile, discard, MTG stack
queueFIFO. Rare in card gamessome real-time / round-robin zones
unorderedSet; the order doesn't matterhand, in_play, market
rankedSorted by some property at all timesSplendor's prestige cards by tier
positionalEach card occupies a specific slot / positiontableau, current_trick (one slot per player), formation

Constraints

  • accepts_types — list of card type ids this zone allows. ["*"] for any.
  • max_cards — capacity. null for unbounded.
  • exclusivetrue means one card maximum (slot-style).

Auto-behaviours

  • auto_refill.from — feeder zone id to draw from when below threshold.
  • auto_refill.to — target count to maintain (e.g., 5 for Star Realms trade row).
  • auto_refill.triggerafter_action | start_of_turn | end_of_turn | continuous.
  • reset.triggerper_turn | per_round | per_phase | null.
  • reset.destination — where do contents go on reset (often: discard).
  • setup.start_filled_from — deck id to populate from at game start.
  • setup.shuffle_at_starttrue if the zone should be shuffled after initial fill.

C.3 — The schema shape

A full zone record:

{ "id": "trade_row", "label": "Trade Row", "description": "Five cards available to buy each turn. Refills from the trade deck.", "icon": "fa-store", "scope": "shared", "visibility": "public", "ordering": "unordered", "constraints": { "accepts_types": ["*"], "max_cards": 5, "exclusive": false }, "auto_refill": { "from": "market_deck", "to": 5, "trigger": "after_action" }, "reset": null, // doesn't reset; cards stay until bought "setup": { "start_filled_from": "market_deck", "shuffle_at_start": false // already shuffled when feeder is built } }

A per-player hand:

{ "id": "hand", "label": "Hand", "scope": "per_player", "visibility": "owner_count_to_others", "ordering": "unordered", "constraints": { "accepts_types": ["*"], "max_cards": null }, "reset": null, "setup": { "start_filled_from": "draw_pile", "draw_count": 5 } }

A current trick zone (Hearts):

{ "id": "current_trick", "label": "Current Trick", "scope": "shared", "visibility": "public", "ordering": "positional", // one slot per player, in play order "constraints": { "accepts_types": ["*"], "max_cards": 4, "exclusive": false }, "reset": { "trigger": "per_trick", "destination": "won_tricks_of_winner" }, "setup": { "start_filled_from": null } }

C.4 — Worked examples across real games

Star Realms

  • draw_pile — per-player, hidden, stack, refilled from discard when empty.
  • hand — per-player, owner_count_to_others, unordered.
  • in_play — per-player, public, unordered, reset per_turn to discard (except Bases / Outposts).
  • discard — per-player, public, stack.
  • trade_row — shared, public, unordered, max=5, auto_refill_from=market_deck.
  • market_deck — shared, hidden, stack.
  • scrap_pile — shared, public, stack (cards removed from game).

Dominion

  • Per-player: draw_pile (stack, hidden), hand (owner_count_to_others), in_play (public), discard (public, stack with browse).
  • Shared: supply_pile_<card_id>one zone per buyable card (10-15 piles), public, stack, fixed starting count.
  • trash — shared, public, stack (cards removed from game).

Hearts

  • deck — shared, hidden, stack (pre-shuffle).
  • hand — per-player, owner_count_to_others, unordered.
  • current_trick — shared, public, positional, reset per_trick.
  • won_tricks — per-player, owner, stack (used for scoring).

7 Wonders

  • tableau — per-player, public, positional (categorised by colour).
  • hand — per-player, owner_count_to_others, unordered. Special: rotates between players each turn (passes to neighbour). Modelled as a regular zone whose contents are moved by the engine at end-of-turn.
  • age_deck_1, age_deck_2, age_deck_3 — shared, hidden, stack.
  • wonder_card — per-player, public, exclusive (max 1).
  • discard — shared, hidden, stack (buy-from-discard via Olympia wonder).

MTG (the stress test)

  • library — per-player, hidden, stack.
  • hand — per-player, owner_count_to_others, unordered.
  • battlefield — per-player, public, unordered (positionally rendered for tapped/untapped via card state).
  • graveyard — per-player, public, stack with browse.
  • exile — per-player, public, unordered.
  • command — per-player, public, unordered (Commander format).
  • stack — shared, public, stack (last-in-first-out resolution). Special: contents are owned by different players (each stack item references its source player). See C.6.

Same schema across all of them. Same set of dimensions. Where a game stretches the model (MTG's stack, 7 Wonders' rotating hands, Catan's spatial principality), the stretch is named and addressed in C.6.

C.5 — UI presentation in the wizard

Step 2 (Pieces) gets a dedicated Zones section. Like trackers, zones are recipe-driven — pick a recipe, fill in the (id, label, optional overrides), done. A few common recipes cover most games.

Pre-canned zone recipes

RecipeGenerated shape
personal_deckper_player / hidden / stack / setup-from-deck-and-shuffle
handper_player / owner_count_to_others / unordered / starts with N draw
in_playper_player / public / unordered / reset per_turn (configurable)
discardper_player / public / stack with browse
shared_marketshared / public / unordered / max=N / auto_refill_from=feeder
shared_deckshared / hidden / stack / setup-from-deck-and-shuffle
shared_poolshared / public / unordered (a banked supply / token pool)
current_trickshared / public / positional / reset per_trick
won_tricksper_player or per_team / owner / stack
tableauper_player / public / unordered (use positional only when cards have spatial relationships)

The add-zone form

  1. Pick a recipe (one of the 10 above), or "Custom" for the full form.
  2. Fill required fields: id, label.
  3. Tweak recipe defaults as needed: scope (in some recipes), capacity, auto-refill source / target.
  4. Optional metadata: description (shown to AI), icon, order_in_list.

List view

For each zone, show:

  • icon + label
  • scope badge (per-player / shared / per-team)
  • visibility badge (public / owner / hidden / etc.)
  • ordering badge (stack / unordered / positional)
  • capacity (if set) and auto-refill (if set)
  • edit / delete buttons

C.6 — Edge cases and stretches

Multiple parallel piles (Dominion supply)

Dominion has one supply zone per buyable card type: 10-15 piles, each holding a fixed count of one card. Each pile is its own zone with id supply_pile_<card_id>. The "all supply piles" concept is then a multi-zone selector (e.g. {"count_zones_where": {"id_pattern": "supply_pile_*", "predicate": {"==":[{"count_cards":[...]}, 0]}}}). v1.5 introduces the multi-zone selector; v1 lists each pile explicitly.

Hand-passing as a mechanism (7 Wonders drafting)

7 Wonders' hand rotates between players each turn. Modelled as a regular per-player zone whose contents are moved by the engine at the end of each turn via a pass_zone action that transfers contents along an adjacency (left / right). The zone itself doesn't change scope; its contents do. v1.5.

Cards in shared zones owned by different players (MTG stack)

The MTG stack is shared, but each item on it has a "source player". Solved at the card-instance level: each card-instance carries its controller_player_id, independent of the zone's owner. Zone scope stays "shared"; per-card ownership lives on the instance. v1.5 if MTG-style play comes onto the roadmap.

Spatial / adjacency (Catan principality, Carcassonne map)

Some tableaus have spatial structure: cards have positions AND neighbour relationships. Out of scope for v1 and v2. Card-based spatial games are a different design problem (it's a board, not a card layout). Document as a non-goal so designers don't try to model these in DeckCraft.

Per-card visibility within a zone (Splendor reserved, MTG scry)

A few cards inside an otherwise-hidden zone are visible to one or more players. Solved at the card-instance level via a revealed_to: [<player_ids>] field. Zone visibility stays "hidden"; the per-card override lifts the curtain selectively. v1.5.

Cards that "sit on" other cards (MTG attached enchantments, Catan settlements + roads)

Cards can be attached to other cards rather than sitting in a zone independently. Solved via card-instance attached_to field. The zone is still the host's zone (battlefield); the attachment is per-instance state. Same parallel-concept treatment as per-card counters in Appendix B.

Empty-feeder behaviours

When auto_refill.from is itself empty, what happens? Three policies:

  • stop_refill — zone shrinks gracefully (Star Realms when market_deck runs out).
  • refill_from_other — cascade to a secondary feeder.
  • recycle_destination — reshuffle a destination zone (e.g. discard) back into the feeder. This is the deck-builder pattern: when draw_pile is empty, shuffle discard into draw.

The auto_refill object grows a when_empty field with these three values. v1 ships with recycle_destination as the default for personal decks (matches deck-builder norms); stop_refill for shared markets.

C.7 — Compared to DeckCraft today

TodayStateBecomes
game_meta['game_zones'] descriptive blob Same key, new structured shape per C.3. Old free-text descriptions migrate into the description field.
Zone visibility not modelled First-class enum on each zone (C.2). Required for honest-bot view redaction in the simulator.
Zone ordering not modelled First-class enum (stack / unordered / positional / etc).
Auto-refill rules not modelled Structured auto_refill object on the zone (C.2).
Setup population not modelled setup.start_filled_from + setup.shuffle_at_start on the zone (C.3).
Per-card location not modelled card_instances[id].location.zone + .owner + .index (Game object, see main doc §Game object).

The migration is mostly additive: read the existing free-text zones, present a recipe-based form per zone, designer confirms / overrides defaults, save back in the new shape. UI on Step 2 keeps a "Zones" section but its contents become a richer editor. The existing AI-prompt context (which today reads the free-text descriptions) can read the new description field unchanged.

C.8 — Ship plan

v1 (Roadmap step 3.3):

  • Schema for the four scopes, five visibilities, five orderings, plus constraints + auto_refill + reset + setup.
  • The 10 recipes from C.5.
  • Zones editor on Step 2 with the recipe picker and the per-recipe form.
  • Migration from the old free-text game_zones.
  • auto_refill.when_empty with the three policies (default recycle_destination for personal decks, stop_refill for shared markets).
  • Card location updated on every action via the simulator's apply().

v1.5:

  • Multi-zone selectors (id_pattern: "supply_pile_*") for Dominion-style supply.
  • Hand-passing as an action verb (pass_zone_to_neighbour) for 7 Wonders / drafting games.
  • Per-card ownership in shared zones via card-instance controller_player_id (MTG stack).
  • Per-card visibility via card_instances[].revealed_to[] (Splendor reserved, MTG scry).
  • Card attachment (card_instances[].attached_to) for enchantments / overlays.

v2:

  • Per-team zones (rare; only when a team game ships).
  • Custom auto-refill triggers beyond the four canonical ones.

Deliberately never:

  • Spatial / adjacency zones (Carcassonne, Catan board). Different design problem; not card-game scope.
Why this matters before action vocabulary (3.5) and apply() (3.9)

Every action handler boils down to: "check legality on the source zone, remove the card from there, add it to the destination zone, fire any auto-refill." Without zones being engine-readable, every action handler has to re-derive that logic from prose. With zones first, action handlers are 5-line state mutations against a known shape.

C.9 — Field reference: when each field is authored, design vs runtime

All zone fields are design-time, but several reference concepts that don't exist yet

Like trackers, every field on a zone is authored once and doesn't change during play. The runtime contents of a zone (which cards are currently in it) live in the Game object as ordered lists of card-instance ids. The schema below describes the zone's rules, not its current contents.

Several fields are forward references — they point at concepts that get authored in later steps (Anatomy, Decks). You can fill them in now, but the references won't fully validate until those steps ship. They're left in the form deliberately so you can sketch the full picture early.

Field Why it's needed Authored in Depends on
id Stable identifier. Effects move cards between zones by id; count_cards takes a zone id. Step 2 (Zones) Nothing
label Display name in the UI and on rendered card backs / play mats. Step 2 Nothing
description One-line explanation. Read by AI prompts; shown in tooltips. Step 2 Nothing
icon Display hint (Font Awesome class) for the UI list. Step 2 Nothing
scope per_player / per_team / shared. Determines storage location and how many instances of the zone exist at runtime. Step 2 Nothing
visibility Who can see the zone's contents at runtime. Used by the bot view-redaction layer. Step 2 Nothing
ordering How cards inside are arranged: stack / queue / unordered / ranked / positional. Step 2 Nothing
constraints.max_cards / exclusive Capacity bounds. null = unbounded. Engine rejects mutations that would exceed this. Step 2 Nothing
constraints.accepts_types Which card types this zone allows. ["*"] for any. Engine validates each "move card here" action. Step 2 Card Types (Anatomy) — the meaningful ids (unit, structure, resource_card) come from the card-types catalog. Until then, leave as ["*"].
auto_refill.from Which zone feeds this one when below threshold (Star Realms market_deck → trade_row). Step 2 Other zones — reference is to a sibling zone id. Author the feeder zone first or just type the id; validation will catch misspellings.
auto_refill.to Target count to maintain (e.g. 5 for Star Realms trade row). Step 2 Nothing
auto_refill.trigger When refill fires: after_action, start_of_turn, end_of_turn, continuous. Step 2 Phases (Anatomy)start_of_turn / end_of_turn require the engine to know when those moments are. Authorable now; honoured at runtime once phases ship.
auto_refill.when_empty What happens when the feeder is empty: stop_refill / recycle_destination (deck-builder default) / refill_from_other. Step 2 Nothing
reset.trigger When the zone empties itself: per_turn, per_round, per_phase, per_trick. Step 2 Phases (Anatomy) — same dependency as auto_refill.trigger.
reset.destination Where contents go on reset (often discard). Step 2 Other zones — references a sibling zone id.
setup.start_filled_from Deck or zone id used to populate this zone at game setup. Step 2 Decks (Step 3 / Phase D) — usually a deck id from the Decks step. Authorable now (just type the id); validates once decks are authored.
setup.shuffle_at_start Whether to shuffle after the initial fill. Step 2 Nothing
Forward references: leave them, don't remove them

Four fields above point at concepts authored in later phases:

  • constraints.accepts_typesCard Types (Anatomy phase, Step 2.5+).
  • auto_refill.trigger & reset.triggerPhases (Anatomy).
  • setup.start_filled_fromDecks (Step 3, Phase D).

Keep them in the form. The user can sketch their game's full shape early; references resolve later as those phases ship. The validator will eventually flag broken refs once the referenced concepts are authored.

Appendix D — Phases: how a turn is structured

Phases describe how a turn unfolds. A turn is an ordered sequence of phases; some phases are automatic (the engine runs them without asking), others are player-driven (the engine asks the active player what they want to do, then applies the chosen action). Phases are the missing layer between "actions exist" (Appendix E) and "the simulator can actually play" — they're what tells the engine when each action is legal and when control passes to the next player.

Spec'd here as part of Phase C (Anatomy) in the designer model. Referenced from Plan: Anatomy page — section C.2.

D.1 — What is a phase, conceptually?

One sentence

A phase is a named segment of a turn with rules about which actions are legal during it, how it ends, and what runs automatically before control passes to the next phase.

The simulator's main loop walks the phase list:

  1. Pick the next phase in the active player's turn.
  2. Run its mandatory_actions (automatic, no input).
  3. If kind: player_driven: ask the player which of allowed_actions they want, apply it, repeat per repeat.
  4. When the phase ends (player passes / mandatory_actions complete / end_condition fires), advance.
  5. When all phases are done, advance to the next active player.

Per Appendix B, every state mutation also re-evaluates win conditions (Appendix A). Phases don't have to know about end-of-game checks; that's the apply() reducer's responsibility.

D.2 — The dimensions

Kind — how the engine treats the phase

KindMeaningExamples
automaticEngine runs it without asking. Just executes mandatory_actions.Cleanup phase, dealing cards, refilling market.
player_drivenEngine asks the active player which of allowed_actions to take. Player can take many actions before ending.Main phase in a deck-builder, action phase in a Eurogame.
simultaneousAll players commit one action concurrently, then resolve. Used for drafting (7 Wonders), bidding.7 Wonders draft pick, sealed bidding round.

Repeat — how many times the phase fires before advancing

RepeatMeaning
onceDefault. Phase runs once per turn, then advances.
until_player_endsFor player_driven phases: keep asking the active player until they pass / end the phase.
n_timesRun exactly N times. Use with repeat_n.
per_playerRun once for each player in seat order (good for "each player draws 2"-style automatic phases or for simultaneous_per_player).
until_conditionRepeat until a JSON Logic predicate (end_condition) returns true.

Allowed vs mandatory actions

  • allowed_actions: a list of action ids (from Appendix E catalog) the active player can choose from during this phase. Only meaningful for player_driven / simultaneous.
  • mandatory_actions: a list of action invocations the engine fires automatically when the phase begins (or each iteration). E.g. {action: "draw_n_cards", params: {n: 5}}.

D.3 — The schema shape

A phase record:

{ "id": "main", "label": "Main Phase", "description": "Player plays cards, buys from market, attacks.", "kind": "player_driven", // | "automatic" | "simultaneous" "scope": "per_player", // most phases run per player; some run shared (e.g. dealing) "allowed_actions": ["play_card", "buy_from_market", "attack_opponent", "end_phase"], "mandatory_actions": [], // none here; player drives "repeat": "until_player_ends", // | "once" | "n_times" | "per_player" | "until_condition" "repeat_n": null, // for n_times "end_condition": null // JSON Logic for until_condition }

An automatic cleanup phase:

{ "id": "cleanup", "label": "Cleanup", "kind": "automatic", "scope": "per_player", "mandatory_actions": [ { "action": "discard_zone", "params": { "zone": "hand" } }, { "action": "discard_zone", "params": { "zone": "in_play" } }, { "action": "draw_n_cards", "params": { "from": "draw_pile", "n": 5 } } ], "repeat": "once" }

The top-level shape is just the ordered list:

"phases": [ { /* main */ }, { /* cleanup */ } ]

Most games have 2–6 phases. Magic has 12+. Hearts has 3 (deal, play, score) running in a loop.

D.4 — Worked examples across real games

Star Realms (deck-builder)

  1. main — player_driven, repeat=until_player_ends. Allowed: play_card, buy_from_market, attack_opponent, end_phase.
  2. cleanup — automatic, repeat=once. Mandatory: discard hand, discard in_play (except permanents), draw 5.

Hearts (trick-taking)

  1. deal — automatic, scope=shared, runs once per round. Mandatory: shuffle deck, deal 13 to each player.
  2. play_trick — player_driven, repeat=per_player (4 plays). Allowed: play_card (with follow-suit constraint).
  3. resolve_trick — automatic, repeat=once. Mandatory: determine winner, move trick to won_tricks_of_winner.
  4. (loop play_trick + resolve_trick × 13 until hands empty)
  5. score — automatic, repeat=once. Mandatory: count points, accumulate to points.

7 Wonders (drafting)

  1. draft_picksimultaneous, repeat=6_times. All players commit one card from their hand, then reveal+resolve.
  2. pass_hand — automatic, repeat=once. Mandatory: each player passes hand to neighbour.
  3. (loop draft_pick + pass_hand × 6 until hands empty)
  4. military_resolution — automatic, scope=shared. Compare neighbours' military strength.

Magic: The Gathering (TCG — the stress test)

  1. Untap, Upkeep, Draw — all automatic.
  2. Main 1, Main 2 — player_driven, allowed actions vary.
  3. Combat: 5 sub-phases (Beginning, Declare attackers, Declare blockers, Damage, End). Some player_driven, some automatic, with priority rotation between players.
  4. End, Cleanup — automatic.

Magic stretches the model with its priority system (interrupts during opponent's turn). The schema accommodates basic phases; full priority-stack support lives in a v2 chapter.

D.5 — Recipes (the user-facing menu)

Designers don't author phase lists from scratch — they pick a recipe that generates a sensible default phase list, then tweak.

RecipeGenerates
standard_deckbuilder 2 phases: main (player_driven, until_player_ends, allows play / buy / attack / end) + cleanup (automatic, discards hand+in_play, draws 5).
trick_taking_round 3+ phases: deal (auto, shared) + play_trick (per_player) + resolve_trick (auto), looped via repeat. Optional bid phase prepended.
simple_one_action 1 phase: turn (player_driven, takes one action then ends). For lightweight games.
multi_action_eurogame 3 phases: upkeep (auto, refresh resources) + actions (player_driven, N actions) + cleanup. repeat_n on actions controls budget.
drafting_age 2 phases: draft_pick (simultaneous) + pass_hand (auto). Looped per age.
custom Empty phase list; user adds phases one at a time via a "+ Add phase" form.

UI presentation

List view (one row per phase, drag-handle for reorder, edit/delete buttons), with "+ Add phase" opening either the recipe picker (for the FIRST phase) or a smaller "blank phase" dialog (for added phases). Each phase row shows: order number, label, kind badge (auto/player/simultaneous), action count badges. Allowed-action dropdowns read live from the Actions section (Appendix E) so adding an action upstream makes it selectable here.

D.6 — Edge cases

Loops that span multiple phases (Hearts trick-loop)

Some games loop a sub-sequence (play + resolve) until a condition holds. Two approaches:

  • Group container: introduce a phase_group that contains child phases and has its own repeat: until_condition. More structure, more schema.
  • Flat with end_condition: the LAST phase of the group has end_condition set; if false, control loops back to the FIRST phase of the group. Simpler but harder to express.

v1 ships flat-with-end_condition; phase groups land if the simpler form proves insufficient.

Round structure (turns × N within a round, plus round-end phases)

Some games have a round = turn-of-each-player + round-end scoring. Two ways to model:

  • Per-player phases run for each player in seat order; round-end phases are scope: shared.
  • Round number tracker increments via a shared phase or a mandatory action.

The schema supports both via scope + repeat: per_player.

Priority rotation (MTG instants during opponent's turn)

Out of scope for v1. Requires a stack-based interrupt system. Ship phases as strictly sequential (active player only) and add priority later if a designed game forces it.

Variable phase order (player chooses next phase)

Some Eurogames let the active player pick which sub-phase to do next. Modelled as a single player_driven phase whose allowed_actions include phase-selection actions. Defer for now; not required by any v1 target game.

D.7 — Compared to DeckCraft today

TodayStateBecomes
Phasesnot modelledNew game_meta['phases'] with the schema above.
Game Rules sectionfree-textThe free-text rules can supplement; phases are the structured representation.
Turn structure in card-gen promptimplicit / hardcodedCard-gen can read the phase list to know "this is a 2-phase deck-builder" and constrain accordingly.

D.8 — Ship plan

v1 (Anatomy section C.2):

  • Schema for phase records (kind, scope, allowed_actions, mandatory_actions, repeat).
  • The 6 recipes from D.5 (standard_deckbuilder, trick_taking_round, simple_one_action, multi_action_eurogame, drafting_age, custom).
  • List + recipe picker + per-phase form, autosave to game_meta['phases'].
  • Cross-reference: allowed_actions dropdown reads live from the Actions section.

v1.5 (when needed):

  • Phase groups for explicit looping (Hearts trick-loop).
  • simultaneous kind support in the simulator runtime.

v2 (deferred):

  • MTG-style priority / interrupts (the stack).
  • Variable phase order.

D.9 — Field reference

FieldWhy it's neededDepends on
idStable identifier. History log references the phase id.Nothing
labelDisplay name in the UI.Nothing
descriptionUsed in AI prompts and tooltips.Nothing
kindDetermines whether engine asks player or runs automatically.Nothing
scopePer-player phases run for each player; shared phases run once.Nothing
allowed_actionsList of action ids legal during this phase.Actions (Appendix E)
mandatory_actionsList of action invocations the engine fires automatically.Actions (Appendix E)
repeatHow many times the phase iterates before advancing.Nothing
repeat_nNumber of iterations (when repeat=n_times).Nothing
end_conditionJSON Logic predicate (when repeat=until_condition).Trackers, zones (for the predicate to reference)

Appendix E — Action vocabulary: the verbs players use

Actions are the verbs a player (or the engine) uses to mutate game state. Each action has a precondition (what makes it legal), an optional cost (a tracker spend), and an effect (what state change it produces). The list of enabled actions for a game is what the simulator's legal_actions(state) function reads from.

Spec'd here as part of Phase C (Anatomy).

E.1 — What is an action, conceptually?

One sentence

An action is a verb a player can perform during a player-driven phase, with a precondition that determines legality, an optional cost paid from a tracker, and an effect that mutates state (zone transitions, tracker changes, triggered card effects).

Actions vs effects (Appendix B). They overlap conceptually but live at different layers:

  • An effect is a card-vocabulary verb (deal_damage, gain_resource) — what cards do.
  • An action is a player-vocabulary verb (play_card, buy_from_market, attack) — what the player does on their turn.

A play_card action causes a card to enter the board, which then triggers that card's effects. Two different layers, both needed.

E.2 — The canonical catalog

A small fixed catalog ships with DeckCraft (in assets/data/actions-default.json). Designers enable a subset for their game. The catalog is closed: new actions require a small engine update (since each action's effect is hand-written). Most games use 5–7 actions.

ActionWhat it doesCommon cost
play_cardMove a card from hand → in_play; pay its cost; trigger its on_play effects.The card's cost field
buy_from_marketMove a card from a shared market zone → owner's discard; pay its cost.A resource tracker (e.g. trade)
attack_opponentSpend a tracker to reduce a target's life tracker.A combat tracker
activate_abilityUse an ability on a card already in play (tap-style).Card-defined
draw_n_cardsMove N cards from a deck zone → hand. Manual draw (engine handles automatic draws via mandatory_actions).Sometimes a tracker
discard_n_cardsMove N cards from hand → discard. Manual.Usually free
discard_zoneMove all cards from one zone → another (cleanup helper).None (engine action)
passSkip the current decision without doing anything.None
end_phaseVoluntarily end a player_driven phase.None

Add new entries to actions-default.json as designed games require them. Each addition needs a matching handler in the simulator's apply() reducer.

E.3 — Schema shape

An action catalog entry (in actions-default.json):

{ "id": "buy_from_market", "label": "Buy from market", "description": "Buy a card from a shared market zone.", "precondition": { // JSON Logic — must be true to be legal ">=": [ { "var": "player.trackers.trade" }, 1 ] }, "cost": { "tracker": "trade", // which tracker to spend "amount": "card.cost.trade" // how much (often card-defined) }, "effect_kind": "move_card", // primitive effect template "params": { "from_zone": "trade_row", "to_zone": "discard" }, "target_spec": { // what the player picks "kind": "card_in_zone", "zone": "trade_row" }, "requires": { // smart-filter prerequisites "trackers_with_role": ["resource"], "zones_with_id": ["trade_row"] } }

A game's enabled actions (in game_meta['actions']) is just a list of catalog ids:

"actions": [ "play_card", "buy_from_market", "attack_opponent", "end_phase" ]

For per-game customisation (e.g. attack uses "combat" not the default "actions" tracker), an entry can be an object overriding the catalog defaults:

"actions": [ "play_card", { "id": "buy_from_market", "cost_tracker": "trade" }, { "id": "attack_opponent", "cost_tracker": "combat", "target_tracker": "authority" }, "end_phase" ]

E.4 — Worked examples across real games

Star Realms

  • play_card
  • buy_from_market — cost from trade
  • attack_opponent — cost from combat, reduces authority
  • end_phase

Dominion

  • play_card — uses an actions tracker (1 per turn by default)
  • buy_from_market — uses a buys tracker; pays from coins
  • end_phase (between Action and Buy phases)

Hearts

  • play_card — with follow-suit constraint encoded in precondition
  • pass_card — for the pre-trick passing phase (custom catalog entry)

7 Wonders

  • play_card — paying resource costs (often via neighbouring trade)
  • build_wonder_stage (custom)
  • discard_for_coins (custom)

Coup

  • play_card (claim a role)
  • challenge (custom — challenge a claim)
  • block (custom — block an action with a counter-claim)

E.5 — UI presentation in the wizard

A multi-select checklist similar to the Effects section: one row per action in the catalog, with a checkbox, label, description, and a smart-filter status badge.

Smart filtering rules

Each catalog entry has a requires field listing what the game's schema must contain for the action to make sense. Examples:

  • attack_opponent requires a tracker with role=life and a tracker with role=resource (the cost tracker).
  • buy_from_market requires a shared zone (the market) + a tracker with role=resource.
  • draw_n_cards requires at least one zone with ordering=stack (a deck-like zone).

Actions whose requirements aren't met render greyed out with a "needs X" message — same UX as the Effects section.

Per-action customisation

For actions with multiple plausible parameter choices (which tracker to spend, which target tracker), clicking the row opens a small inline form. E.g. attack_opponent might offer dropdowns for cost_tracker (combat / actions / etc) and target_tracker (life / authority / hp).

E.6 — Edge cases

Custom actions outside the catalog

Actions like Hearts' "pass 3 cards to neighbour" or 7 Wonders' "build a wonder stage" don't fit canonical entries. Two paths:

  • Add to the catalog with a hand-written simulator handler. Right path when the action will be reused.
  • Compose from primitives (e.g. "pass cards" = move_zone with target=neighbour). Works if the primitives exist.

v1 ships the canonical 7–9 from E.2; expand the catalog as new games demand.

Triggered abilities (cards that act as actions)

Some cards have on-play effects that themselves trigger an action ("when played, attack the opponent"). That's the card's effect, not an action — it's invoked by the engine via the effect catalog (Appendix B), not the action catalog.

Targeted vs untargeted actions

The target_spec field tells the engine what input to ask the player for: pick a card, pick a player, pick a zone. For untargeted actions (pass, end_phase) it's null.

E.7 — Compared to DeckCraft today

TodayStateBecomes
Player actionsimplicitExplicit catalog + per-game enabled list (game_meta['actions']).
Card cost / target inferencead-hoc in card_jsonAction cost and target_spec become first-class.

E.8 — Ship plan

v1 (Anatomy section C.3):

  • Catalog at assets/data/actions-default.json with the 9 canonical entries from E.2.
  • Multi-select checklist UI with smart filtering against the schema.
  • Per-action customisation form for tracker-binding choices.
  • Saves to game_meta['actions'].

v1.5:

  • Catalog grows as new games demand: pass_card, challenge, build_wonder_stage, etc.
  • Each catalog addition pairs with a simulator apply() handler.

Appendix F — Setup: how the game starts

Setup describes the initial state of a game: who goes first, how many cards each player draws, where each deck sits at the start, what value each tracker starts at if different from the tracker's default. It's the data the simulator's setup() function consumes to build the initial Game object.

Spec'd here as part of Phase C (Anatomy).

F.1 — What is setup, conceptually?

One sentence

Setup is the deterministic process that builds the initial Game state from the design + a random seed. Run once per game, before the play loop starts.

The simulator's setup(definitionId, seed) reads:

  • Trackers (Appendix B) — initialises each tracker with its starts_at / starts_value / starts_set, optionally overridden by setup_spec.
  • Zones (Appendix C) — creates per-player and shared zone instances; populates from feeder decks per zone's setup.start_filled_from and the setup_spec's deck_to_zone_map.
  • Decks (Step 3) — sources of cards used to populate zones at setup.
  • Setup spec (this appendix) — game-level config for everything else (turn order, first player, hand sizes).

F.2 — The fields

FieldPurpose
turn_orderHow the active player rotates: clockwise | counter_clockwise | dealer_clockwise | winner_of_last_round | random_each_turn
first_player_ruleWho goes first: random | highest_x (with highest_x_field naming a tracker) | dealer | seat_0
starting_hand_sizeNumber of cards each player draws at setup. Common: 5 (deck-builders), 7 (MTG), 13 (trick-taking).
initial_tracker_overridesPer-tracker overrides of starts_at. { "authority": 50, "trade": 0 }.
deck_to_zone_mapWhich deck (from Step 3) initially populates which zone (from Appendix C). { "draw_pile": "starter_deck", "market_deck": "main_market" }.
shuffle_seed_strategyrandom (default — uses provided seed) | fixed (always same shuffle for reproducibility tests).

F.3 — The schema shape

"setup_spec": { "turn_order": "clockwise", "first_player_rule": "random", "starting_hand_size": 5, "initial_tracker_overrides": { "authority": 50, "trade": 0, "combat": 0 }, "deck_to_zone_map": { "draw_pile": "starter_deck", // each player's draw_pile gets the starter deck "market_deck": "main_market" // the shared market_deck gets the main_market }, "shuffle_seed_strategy": "random" }

F.4 — Worked examples

Star Realms

  • turn_order: clockwise · first_player_rule: random
  • starting_hand_size: 3 (player 1) / 5 (player 2 — first-player advantage offset)
  • initial_tracker_overrides: { authority: 50 }
  • deck_to_zone_map: { draw_pile: "starter_deck", market_deck: "main_market" }

The split hand size is a v1 corner case — handle by allowing per-seat overrides in starting_hand_size: { "0": 3, "1": 5 }.

Hearts

  • turn_order: counter_clockwise (or follow-trick-winner)
  • first_player_rule: highest_x with highest_x_field: "card.contains_2_of_clubs" (card-derived; advanced)
  • starting_hand_size: 13
  • deck_to_zone_map: { deck: "standard_52" }

7 Wonders

  • turn_order: simultaneous (no rotation; everyone acts each "turn")
  • starting_hand_size: 7 (one age deck dealt)
  • deck_to_zone_map: { hand: "age_1_deck", "age_2_deck": null, "age_3_deck": null } (only age 1 starts dealt)

F.5 — UI presentation in the wizard

A single form (no recipes — game-level config). Sections:

  1. Player order: turn_order dropdown, first_player_rule dropdown.
  2. Initial deal: starting_hand_size number input. (Per-seat overrides via "Advanced" toggle.)
  3. Tracker overrides: a table of (tracker, default starts_at, override). Reads live from Step 2 trackers.
  4. Deck → zone mapping: a table of (zone with feed, deck to load from). Both dropdowns read live (zones from Step 2, decks from Step 3 — show "no decks yet" if Step 3 not authored).

No recipe picker — the form is concise and a single setup configuration per game makes sense.

F.6 — Edge cases

Per-seat hand size (Star Realms first-player offset)

Most games use a single number. Some give first player fewer cards to offset their advantage. Schema: starting_hand_size can be a number OR an object { "0": 3, "1": 5 }.

Card-derived first-player rule

Hearts: "player with the 2 of clubs leads". Modelled via first_player_rule: "card_holder" + card_holder_predicate: { "==": [{"var":"card.value"},"2"] }. v1.5 — for v1, ship random / dealer / seat_0 only.

Setup that involves player choice (drafting your hand)

Some games let players draft / mulligan their starting hand (MTG mulligan, some Eurogames). That's a setup PHASE (per Appendix D) that runs after the deterministic setup completes. Models naturally as a phase with scope: shared running once.

Asymmetric setup (different starting state per player role)

Vast: The Crystal Caverns has different setup per permanent role (knight has X, dragon has Y). Defer to v2 — requires per-player roles which aren't in v1.

F.7 — Compared to DeckCraft today

TodayStateBecomes
Setupnot modelledNew game_meta['setup_spec'].
Starting hand sizeimplicit (deck-builder convention: 5)Explicit field.
Deck → zone wiringimplicitExplicit map.

F.8 — Ship plan

v1 (Anatomy section C.4):

  • Single form for setup_spec with all fields from F.2.
  • Cross-reference live from trackers + zones; gracefully empty when decks (Step 3) not yet authored.
  • Saves to game_meta['setup_spec'].

v1.5:

  • Per-seat starting_hand_size override.
  • Card-derived first_player_rule (Hearts-style).

v2:

  • Asymmetric per-role setup.
  • Setup phase (player-choice mulligan).