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.
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
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.
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.
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.
| Win | Opponent's Authority ≤ 0. |
|---|---|
| Trackers | Authority (life, 50→0), Trade (resource, regen), Combat (resource, regen). |
| Zones | per-player draw_pile / hand / in_play / discard; shared trade_row (5, auto-refill); shared scrap_pile. |
| Types | Ship (one-shot), Base (Permanent), Outpost (Permanent + must-attack-first). |
| Behaviours | Permanent, Outpost, Ally (extra effect when 2+ same faction in play). |
| Effects | deal_damage, gain_trade, gain_combat, gain_authority, draw, scrap, force_discard. |
| Phases | Main (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.
| Win | Most VP when game ends. Game ends when 3 supply piles empty or the Province pile empties. |
|---|---|
| Trackers | Per-turn: actions, buys, coins (all reset). Per-game: VP (hidden during play, totalled at end). |
| Zones | per-player draw_pile / hand / in_play / discard; shared supply (multiple fixed-size piles, one per buyable card); shared trash. |
| Types | Action, Treasure, Victory, Curse. |
| Behaviours | Reaction (interrupts opponent attacks), Duration (stays in play across turns). |
| Effects | gain_action, gain_buy, gain_coin, draw, discard_hand, trash_card, gain_card_to_X, attack_each_other. |
| Phases | Action (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.
| Win | First player to 100 points loses (point avoidance); player with the lowest score wins. |
|---|---|
| Trackers | points (per-player, accumulates across hands). |
| Zones | shared deck (52 ranked cards), per-player hand, shared current_trick, per-player won_tricks. |
| Types | Just ranked_card with rank + suit fields. No Units, no Events. |
| Behaviours | None. |
| Effects | None on cards. The card's value is its rank + suit; effects are entirely in the rules. |
| Phases | Deal (auto), Pass (3 cards to neighbor, simultaneous), Play tricks (lead, follow suit if possible, winner leads next), Score (count hearts + Queen of Spades). |
| Actions | play_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.
| Win | First to 12 victory points (varies by expansion). |
|---|---|
| Trackers | 5 resources per player (lumber, brick, ore, grain, wool, with per-region cap of 3); VP (per-player, public). |
| Zones | per-player hand, per-player tableau (a structured grid of settlements+roads+regions), shared event_deck, shared draw_stacks (multiple, one per card category). |
| Types | Settlement, City, Region (resource producer), Building, Unit (Hero), Action. |
| Behaviours | Permanent (most cards stay), Activated (use once per turn), Defensive. |
| Effects | produce_resource, gain_VP, build_X, exchange_resources, discard_event. |
| Phases | Production (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.
| Win | Highest total VP after Age III. VP is a sum across multiple categories: military, civilian, science (with non-linear tiers), commercial, guild, wonder, treasury. |
|---|---|
| Trackers | coins (per-player), military victories & defeats (per-player, per-age), VP (multi-axis, per-player, totalled at game-end). |
| Zones | per-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). |
| Types | Resource (raw or manufactured), Military, Science, Civilian, Commercial, Guild, Wonder. |
| Effects | produce_resource, gain_coins, gain_VP, gain_science_symbol, military_strength, chain_to_card. |
| Phases | Per 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.
| Win | Opponent at 0 life, OR opponent decked (can't draw a required card), OR poison counters ≥ 10 (some formats), OR specific card effects. |
|---|---|
| Trackers | life (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. |
| Zones | per-player library, hand, battlefield, graveyard, exile; shared command_zone (Commander); shared stack (the resolution stack). |
| Types | Land, Creature, Artifact, Enchantment, Sorcery, Instant, Planeswalker, Tribal, Battle. |
| Behaviours | Vast: Trample, Lifelink, Flying, Reach, Hexproof, Indestructible, Haste, Vigilance, Deathtouch, First Strike, Double Strike, Menace, Defender, Flash, … (~80 keyword abilities, growing). |
| Effects | Bigger than the model. Effects can themselves create triggered abilities, modify card text, copy cards, exile permanently, transform cards, and so on. |
| Phases | Untap, 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_stackfor 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.
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.
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
| Concept | State | Where |
|---|---|---|
| Card types | shipped | assets/data/types/<mechanic>.json |
| Subtypes | shipped | assets/data/subtypes/<mechanic>.json |
| Cards (instances) | shipped | game_cards.card_json |
| Behaviours catalog | shipped | assets/data/card-behaviours.json |
| Effects catalog | shipped | assets/data/effects.json |
| Effects schema (params, source, conditions) | shipped | assets/data/effects.schema.json |
| Decks | shipped | game_decks |
| Resources (per-game) | partial | game_parts — lives separately from "life", needs unification under trackers |
| Life / health trackers | partial | game_meta['life_points'] — should be a tracker like the others |
| Zones (definitions) | partial | game_meta['game_zones'] — descriptive, not engine-readable |
| Tokens / counters | missing | — |
| Setup (initial state) | missing | — |
| Turn order | missing | — |
| Phases | missing | — |
| Action vocabulary | missing | — |
| Triggers | missing | Cards have effects, but no trigger field is structured today |
| Win conditions | free-text | game_briefing.game_goal as a string — not inspectable |
| Loss conditions | missing | — |
| Visibility / hidden info | missing | Zones don't carry a visibility flag yet |
| Resolution stack / interrupts | missing | — |
| History / action log | missing | — |
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.
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
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
| Phase | Decisions | DeckCraft today |
|---|---|---|
| A — Concept | 1, 2, 3 (concept, mechanic, player count, length) | Step 1 (briefing) |
| B — Skeleton | 4. Win & loss | Free-text on Step 1 |
| 5. Trackers | Step 2, partial (resources + life live separately) | |
| 6. Zones | Step 2, descriptive only | |
| 7. Effects vocabulary | Catalog ships; per-game enabling exists; UI for it doesn't | |
| C — Anatomy | 8. Card types & behaviours | Step 2 |
| 9. Phases | Missing | |
| 10. Action vocabulary | Missing | |
| 11. Setup | Missing | |
| D — Content | 12. Library, decks, layout | Steps 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.
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 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.
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.
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_trackerskeyed 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.
The main loop wraps that:
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.
-
Immutable or mutable?
The clean answer is immutable: every
applyreturns 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. -
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 fromhand[], push todiscard[]. The card instance object itself never moves; only itslocationfield updates. - 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.
-
Visibility = a function, not stored data.
Don't try to maintain "what each player has seen". Instead,
write
viewFor(game, playerId) → redactedGamethat 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. -
Effects mutate the Game directly.
Each effect (
deal_damage,gain_resource,draw_card) is a small functioneffect(game, params, source) → voidthat mutates state. The catalog is keyed byeffect_key; resolving a card'son_playmeans 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.
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:
- A state object covering the dynamic side (per-player zones + trackers, active player, phase, log).
-
A
legal_actions(state, player)function that returns the list of action records the player could take right now. -
A
apply(state, action) → statereducer. 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. -
A random bot that picks uniformly from
legal_actions. Unintelligent but legal. - A runner that loops ask bot → apply → check win condition until the game ends, then writes a row to a results file.
- 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.
-
Unify trackers. Merge resources + life into a
single
trackersconcept with arolefield. Schema-level cleanup; no engine work, just data normalisation. -
Promote zones to engine-readable. Add
visibility,ordering,accepts_types,auto_refill_*. Today they're descriptive; the simulator needs them executable. -
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. -
Structure win/loss conditions. Replace the
free-text
game_goalwith a list of inspectable predicates:{metric, comparator, threshold}. Keep the free text as a label; derive the structure beside it. -
Define triggers on effects. Add a
triggerfield to card effects (on_play,at_start_of_turn, etc.). Most cards areon_playby default; a few will need others. -
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. - Write the state shape. One TypeScript-or-PHP type covering everything in the dynamic side section above.
-
Implement
apply()for each action + effect. Effect handlers exist already as catalogue entries; they need real implementations. Action handlers are new but small. -
Implement
legal_actions(). For the current phase, return the action types it allows, filtered by preconditions (legal targets, sufficient resources). - Random bot + runner. First playable simulator.
- Harness + reports. Automate "design feedback loop": tweak a card, run 1,000 games, see the impact.
- 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.
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.
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.
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:
- 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).
- 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:
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":
Turn / round limit — "turn 20 reached":
Zone exhausted — "market deck empty":
Multi-zone exhaustion — "3 supply piles empty" (Dominion):
Player status — "last player not eliminated":
Objective tally — "4 diseases cured" (Pandemic):
Specific card state — "boss in discard" (Slay the Spire):
Compound — "life ≤ 5 AND empty hand":
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:
Scoring resolution — rank players by a metric expression:
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?
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:
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?
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:
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)
Custom operators we register (game-specific)
| Operator | What 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:
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.
| Recipe | Representative games | Form 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.
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'].
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:
Splendor — race with end-of-round close:
Catan — multi-source race to 10 VP:
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:
Pandemic — cooperative, three end paths:
Coup — last player standing:
Splendor's tiebreaker — showing how the same JSON Logic syntax expresses "fewest cards bought":
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_atsupport) - 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
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?
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
| Type | Value shape | Examples |
|---|---|---|
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
| Role | Meaning | Examples |
|---|---|---|
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 |
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.
| Field | Purpose |
|---|---|
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.
A boolean status:
An enum status:
A set tracker (cooperative-game objectives done):
A derived counter (Catan VP):
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_points— derived counter / score / per-player, computed from cards in deck.
Hearts (4-player trick-taking)
points— counter / score / per-player, accumulates across rounds, "lowest wins" viascoring(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_statusper 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 frominfluencebeing 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
- Pick a role first (life / resource / score / status / counter / commitment). The role determines sensible defaults.
- Pick a type (counter / boolean / enum / set). Default depends on role: life / resource / score / counter / commitment → counter; status → boolean.
- 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)
- 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 concept | Lives where | Becomes |
|---|---|---|
| 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_pointsshape. - Expression access via
{"var": "player.trackers.<id>"}already works in the evaluator from Appendix A.
v1.5:
- Derived trackers. Add the
derived+metricfields 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.
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
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?
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?
| Scope | Meaning | Examples |
|---|---|---|
per_player | One instance per player | hand, draw_pile, discard, in_play |
per_team | One instance per team | won_tricks (Hearts partnerships), team_objectives |
shared | Single instance, no owner | market deck, trade_row, current_trick, shared supply |
Visibility — who can see what?
| Visibility | Meaning | Examples |
|---|---|---|
public | Everyone sees full contents (and order, if ordered) | discard pile, in_play, trade_row, current_trick |
owner | Owner sees full contents; opponents see nothing | face-down personal stash |
owner_count_to_others | Owner sees contents; opponents see only the COUNT | hand (in most games) |
top_only | Top of stack visible to all; rest hidden | some draw piles in some games |
hidden | Not even count is public; opponents have no info | face-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?
| Ordering | Meaning | Examples |
|---|---|---|
stack | LIFO. You draw from top; you push to top | draw_pile, discard, MTG stack |
queue | FIFO. Rare in card games | some real-time / round-robin zones |
unordered | Set; the order doesn't matter | hand, in_play, market |
ranked | Sorted by some property at all times | Splendor's prestige cards by tier |
positional | Each card occupies a specific slot / position | tableau, current_trick (one slot per player), formation |
Constraints
accepts_types— list of card type ids this zone allows.["*"]for any.max_cards— capacity.nullfor unbounded.exclusive—truemeans 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.trigger—after_action|start_of_turn|end_of_turn|continuous.reset.trigger—per_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_start—trueif the zone should be shuffled after initial fill.
C.3 — The schema shape
A full zone record:
A per-player hand:
A current trick zone (Hearts):
C.4 — Worked examples across real games
Star Realms
draw_pile— per-player, hidden, stack, refilled fromdiscardwhen 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
| Recipe | Generated shape |
|---|---|
personal_deck | per_player / hidden / stack / setup-from-deck-and-shuffle |
hand | per_player / owner_count_to_others / unordered / starts with N draw |
in_play | per_player / public / unordered / reset per_turn (configurable) |
discard | per_player / public / stack with browse |
shared_market | shared / public / unordered / max=N / auto_refill_from=feeder |
shared_deck | shared / hidden / stack / setup-from-deck-and-shuffle |
shared_pool | shared / public / unordered (a banked supply / token pool) |
current_trick | shared / public / positional / reset per_trick |
won_tricks | per_player or per_team / owner / stack |
tableau | per_player / public / unordered (use positional only when cards have spatial relationships) |
The add-zone form
- Pick a recipe (one of the 10 above), or "Custom" for the full form.
- Fill required fields: id, label.
- Tweak recipe defaults as needed: scope (in some recipes), capacity, auto-refill source / target.
- 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
| Today | State | Becomes |
|---|---|---|
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_emptywith the three policies (defaultrecycle_destinationfor personal decks,stop_refillfor 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.
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
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 |
Four fields above point at concepts authored in later phases:
constraints.accepts_types→ Card Types (Anatomy phase, Step 2.5+).auto_refill.trigger&reset.trigger→ Phases (Anatomy).setup.start_filled_from→ Decks (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?
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:
- Pick the next phase in the active player's turn.
- Run its
mandatory_actions(automatic, no input). - If
kind: player_driven: ask the player which ofallowed_actionsthey want, apply it, repeat perrepeat. - When the phase ends (player passes / mandatory_actions complete / end_condition fires), advance.
- 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
| Kind | Meaning | Examples |
|---|---|---|
automatic | Engine runs it without asking. Just executes mandatory_actions. | Cleanup phase, dealing cards, refilling market. |
player_driven | Engine 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. |
simultaneous | All 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
| Repeat | Meaning |
|---|---|
once | Default. Phase runs once per turn, then advances. |
until_player_ends | For player_driven phases: keep asking the active player until they pass / end the phase. |
n_times | Run exactly N times. Use with repeat_n. |
per_player | Run once for each player in seat order (good for "each player draws 2"-style automatic phases or for simultaneous_per_player). |
until_condition | Repeat 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 forplayer_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:
An automatic cleanup phase:
The top-level shape is just the ordered list:
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)
main— player_driven, repeat=until_player_ends. Allowed:play_card,buy_from_market,attack_opponent,end_phase.cleanup— automatic, repeat=once. Mandatory: discard hand, discard in_play (except permanents), draw 5.
Hearts (trick-taking)
deal— automatic, scope=shared, runs once per round. Mandatory: shuffle deck, deal 13 to each player.play_trick— player_driven, repeat=per_player (4 plays). Allowed:play_card(with follow-suit constraint).resolve_trick— automatic, repeat=once. Mandatory: determine winner, move trick towon_tricks_of_winner.(loop play_trick + resolve_trick × 13 until hands empty)score— automatic, repeat=once. Mandatory: count points, accumulate topoints.
7 Wonders (drafting)
draft_pick— simultaneous, repeat=6_times. All players commit one card from their hand, then reveal+resolve.pass_hand— automatic, repeat=once. Mandatory: each player passes hand to neighbour.(loop draft_pick + pass_hand × 6 until hands empty)military_resolution— automatic, scope=shared. Compare neighbours' military strength.
Magic: The Gathering (TCG — the stress test)
- Untap, Upkeep, Draw — all automatic.
- Main 1, Main 2 — player_driven, allowed actions vary.
- Combat: 5 sub-phases (Beginning, Declare attackers, Declare blockers, Damage, End). Some player_driven, some automatic, with priority rotation between players.
- 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.
| Recipe | Generates |
|---|---|
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_groupthat contains child phases and has its ownrepeat: until_condition. More structure, more schema. - Flat with end_condition: the LAST phase of the group has
end_conditionset; 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
sharedphase 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
| Today | State | Becomes |
|---|---|---|
| Phases | not modelled | New game_meta['phases'] with the schema above. |
| Game Rules section | free-text | The free-text rules can supplement; phases are the structured representation. |
| Turn structure in card-gen prompt | implicit / hardcoded | Card-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).
simultaneouskind support in the simulator runtime.
v2 (deferred):
- MTG-style priority / interrupts (the stack).
- Variable phase order.
D.9 — Field reference
| Field | Why it's needed | Depends on |
|---|---|---|
id | Stable identifier. History log references the phase id. | Nothing |
label | Display name in the UI. | Nothing |
description | Used in AI prompts and tooltips. | Nothing |
kind | Determines whether engine asks player or runs automatically. | Nothing |
scope | Per-player phases run for each player; shared phases run once. | Nothing |
allowed_actions | List of action ids legal during this phase. | Actions (Appendix E) |
mandatory_actions | List of action invocations the engine fires automatically. | Actions (Appendix E) |
repeat | How many times the phase iterates before advancing. | Nothing |
repeat_n | Number of iterations (when repeat=n_times). | Nothing |
end_condition | JSON 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?
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.
| Action | What it does | Common cost |
|---|---|---|
play_card | Move a card from hand → in_play; pay its cost; trigger its on_play effects. | The card's cost field |
buy_from_market | Move a card from a shared market zone → owner's discard; pay its cost. | A resource tracker (e.g. trade) |
attack_opponent | Spend a tracker to reduce a target's life tracker. | A combat tracker |
activate_ability | Use an ability on a card already in play (tap-style). | Card-defined |
draw_n_cards | Move N cards from a deck zone → hand. Manual draw (engine handles automatic draws via mandatory_actions). | Sometimes a tracker |
discard_n_cards | Move N cards from hand → discard. Manual. | Usually free |
discard_zone | Move all cards from one zone → another (cleanup helper). | None (engine action) |
pass | Skip the current decision without doing anything. | None |
end_phase | Voluntarily 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):
A game's enabled actions (in game_meta['actions']) is just a list of catalog ids:
For per-game customisation (e.g. attack uses "combat" not the default "actions" tracker), an entry can be an object overriding the catalog defaults:
E.4 — Worked examples across real games
Star Realms
play_cardbuy_from_market— cost fromtradeattack_opponent— cost fromcombat, reducesauthorityend_phase
Dominion
play_card— uses anactionstracker (1 per turn by default)buy_from_market— uses abuystracker; pays fromcoinsend_phase(between Action and Buy phases)
Hearts
play_card— with follow-suit constraint encoded inpreconditionpass_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_opponentrequires a tracker with role=life and a tracker with role=resource (the cost tracker).buy_from_marketrequires a shared zone (the market) + a tracker with role=resource.draw_n_cardsrequires 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
| Today | State | Becomes |
|---|---|---|
| Player actions | implicit | Explicit catalog + per-game enabled list (game_meta['actions']). |
| Card cost / target inference | ad-hoc in card_json | Action cost and target_spec become first-class. |
E.8 — Ship plan
v1 (Anatomy section C.3):
- Catalog at
assets/data/actions-default.jsonwith 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?
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_fromand the setup_spec'sdeck_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
| Field | Purpose |
|---|---|
turn_order | How the active player rotates: clockwise | counter_clockwise | dealer_clockwise | winner_of_last_round | random_each_turn |
first_player_rule | Who goes first: random | highest_x (with highest_x_field naming a tracker) | dealer | seat_0 |
starting_hand_size | Number of cards each player draws at setup. Common: 5 (deck-builders), 7 (MTG), 13 (trick-taking). |
initial_tracker_overrides | Per-tracker overrides of starts_at. { "authority": 50, "trade": 0 }. |
deck_to_zone_map | Which deck (from Step 3) initially populates which zone (from Appendix C). { "draw_pile": "starter_deck", "market_deck": "main_market" }. |
shuffle_seed_strategy | random (default — uses provided seed) | fixed (always same shuffle for reproducibility tests). |
F.3 — The schema shape
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_xwithhighest_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:
- Player order: turn_order dropdown, first_player_rule dropdown.
- Initial deal: starting_hand_size number input. (Per-seat overrides via "Advanced" toggle.)
- Tracker overrides: a table of (tracker, default starts_at, override). Reads live from Step 2 trackers.
- 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
| Today | State | Becomes |
|---|---|---|
| Setup | not modelled | New game_meta['setup_spec']. |
| Starting hand size | implicit (deck-builder convention: 5) | Explicit field. |
| Deck → zone wiring | implicit | Explicit 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).