Reference  ›  Data structures

Every structure a game is made of

One page, all shapes. Each example is verbatim from a live game (Starforge Rivals / the Dominion probe), not idealized. Structures marked partial exist in the schema but are not fully consumed yet; see the findings doc for what's missing.

updated 2026-08-05 · value|expression union (point 1)

The storage map — where everything lives

StructureStored inAuthored byRead by
Game identitygame_info row (unique_id is the handle everywhere)Briefing (Step 1)everything
Briefinggame_meta['game_briefing']Step 1 formAI prompts, player count
Trackersgame_meta['trackers']Step 2 · Trackersengine, AI, win conditions
Zonesgame_meta['game_zones']Step 2 · Zonesengine (setup, refill, markets)
Effects vocabularygame_meta['enabled_mechanics'] + catalog assets/data/effects.jsonStep 2 · Effectscard validation, AI, effect executor
Win conditionsgame_meta['ending']Step 2 · Win conditionsengine after every action
Card typesgame_meta['card_types'] + catalogs assets/data/types/, subtypes/, card-behaviours.jsonAnatomy · 1validation, AI, renderer, zones' accepts
Actionsgame_meta['actions'] (ids) + catalog actions-default.json; game_meta['action_bindings'] reservedAnatomy · 2legal_actions via bindings
Phasesgame_meta['phases']Anatomy · 3engine flow
Setup specgame_meta['setup_spec']Anatomy · 4setup compiler (G5)
Decksgame_decks rows (+ deck_meta JSON)Step 3setup fills, distribution
Cardsgame_cards rows: card_json + columns card_type/card_subtypeStep 4 (AI / skeleton / import)everything
Familiesgame_meta['card_families'] empty scaffoldnothing yet (S1)
Capabilitiesassets/data/capabilities.json (+ per-server overrides)us / admin pageauthoring UI honesty

The value | expression union fully consumed

Cross-cutting: any numeric field marked ƒx below accepts either a plain number or an expression object. This is how Gardens scores per ten cards and how Dominion's Province pile is 8 cards at two players and 12 at three.

"starts_at": 50
"starts_at": {
  "expr": { "by_player_count": { "map": { "2": 8, "3": 12 }, "default": 12 } },
  "authored": {
    "level": "formula",
    "preset": "by_player_count",
    "params": { "2": 8, "3": 12, "default": 12 }
  }
}
KeyWho reads itWhat it is
exprengine / evaluator — only thiscompiled JSON Logic; evaluated against the live game state
authoredthe ƒx form widgetprovenance: preset + params (or level: "free" + raw json). Recompiled to expr on every save so they can never drift

Fields that accept the union today:

FieldResolvedForm
Tracker starts_at / min / maxplayer+tracker init at setup; per_turn_reset_to_X regenerationStep 2 · Trackers
Card attributes.*validated + stored; consumed when scoring lands (G12)no card editor exists yet — via generation / import
Card cost.*legal-action generation (buy affordability)no card editor exists yet
Card / deck copiesdeck instantiation at setupno card editor exists yet
setup_spec.starting_hand_sizeopening dealAnatomy · Setup
Zone auto_refill.to / constraints.max_cardsevery refill passStep 2 · Zones
ending.when and setup step draw_n_cards.nalready full JSON Logic / resolved at setupWin conditions / Anatomy

Presets, evaluator operators, the preview endpoint and how to extend all of it: How-to: expressions. Implementation: php-includes/inc-expr-value.php (union + compiler), js/expr-widget.js (the shared ƒx widget), ajax-expr-preview.php (preview), compile-on-save in ajax-step2-save.php.

Tracker fully consumed

Any number a player (or the table) owns: life, currencies, score, budgets. One concept, distinguished by role.

{
  "id": "authority",
  "label": "Authority",
  "icon": "fas fa-heart",
  "type": "counter",
  "role": "life",
  "scope": "per_player",
  "visibility": "public",
  "starts_at": 50,
  "min": 0,
  "max": null,
  "units": null,
  "regenerates": null,
  "description": "Your command standing. Starts at 50; reach zero and your fleet is finished."
}
FieldValuesWhat it does
idslug, immutablereferenced by effects, costs, endings
typecounter · boolean · enum · setvalue shape (only counter is engine-exercised)
roleresource · life · score · status · counter · commitmentdrives smart-gating, currency fallback, turn-limit fallback
scopeper_player · per_team · sharedwhere the value lives at runtime (shared → shared_trackers)
starts_at / min / maxnumbers / null / ƒxinitial value; engine clamps every adjustment
regeneratesnull · per_turn_reset_to_zero · per_turn_reset_to_X · per_round_reset_to_Xapplied at turn rollover (X = starts_at)
visibilitypublic · owner · owner_count_to_others · top_only · hiddenauthored; enforced when viewFor() lands

Zone fully consumed

Anywhere cards live during play. Per-player definitions expand to one instance per player at runtime.

{
  "id": "trade_row",
  "label": "Trade Row",
  "icon": "fas fa-store",
  "scope": "shared",
  "visibility": "public",
  "ordering": "unordered",
  "constraints": {
    "accepts_types": ["*"],
    "max_cards": 5,
    "exclusive": false
  },
  "auto_refill": {
    "from": "market_deck",
    "to": 5,
    "trigger": "after_action",
    "when_empty": "stop_refill"
  },
  "reset": null,
  "setup": {
    "start_filled_from": "market_deck",
    "shuffle_at_start": false
  }
}
FieldValuesWhat it does
scopeper_player (also accepts owner: "player") · sharedinstance-per-player vs one instance
visibilitypublic · owner · hidden/nonehidden shared zones are excluded from markets
orderingstack · unorderedstack = draw-from-top matters
constraints.accepts_typesroot type ids or "*"matched against a card's ROOT type (free text in the UI — known gap)
auto_refill{from, to, trigger, when_empty}row restocks to to from feeder after each action. Also marks the zone as a market (G6)
setup.start_filled_fromdeck NAME (a zone id here is ignored — refill owns that)compiled into a fill_zone step (G5)

Runtime key convention: hand:p0 for owned instances, bare trade_row for shared — the flat zone map (C1).

Phase fully consumed

One segment of a turn. player_driven phases offer the allowed actions until the player ends; automatic phases run their mandatory actions.

[
  {
    "id": "main",
    "label": "Main Phase",
    "kind": "player_driven",
    "scope": "per_player",
    "repeat": "until_player_ends",
    "allowed_actions": [
      "play_card",
      "buy_from_market",
      "attack_opponent",
      "end_phase"
    ],
    "mandatory_actions": []
  },
  {
    "id": "cleanup",
    "label": "Cleanup",
    "kind": "automatic",
    "scope": "per_player",
    "repeat": "once",
    "allowed_actions": [],
    "mandatory_actions": [
      {
        "action": "discard_zone",
        "params": {
          "from_zone": "hand",
          "to_zone": "discard"
        }
      },
      {
        "action": "discard_zone",
        "params": {
          "from_zone": "in_play",
          "to_zone": "discard"
        }
      },
      {
        "action": "draw_n_cards",
        "params": {
          "n": 5,
          "from_zone": "draw_pile"
        }
      }
    ]
  }
]
FieldNotes
allowed_actionsthe bot's whole menu during this phase (∩ enabled actions)
mandatory_actionsverbs: discard_zone (spares Permanents when sweeping a play area), draw_n_cards (recycles the discard). Setup (G5) uses the same vocabulary plus fill_zone
kind / repeat / scopeonly player_driven + automatic are engine-exercised

Actions & bindings enabled ids only; bindings derived

Per game, actions are today a bare id list; the catalog carries defaults; the engine resolves bindings at setup. action_bindings is the reserved home for the future Rules page.

game_meta['actions'] = [
  "play_card",
  "buy_from_market",
  "attack_opponent",
  "draw_n_cards",
  "discard_zone",
  "end_phase"
]

A catalog entry (assets/data/actions-default.json):

{
  "id": "buy_from_market",
  "label": "Buy from market",
  "category": "buy",
  "default_cost_tracker": "trade",
  "target_spec": {
    "kind": "card_in_zone",
    "zone": "market"
  },
  "requires": {
    "trackers_with_role": ["resource"],
    "shared_zone_present": true
  }
}

Resolved at setup (library->bindings) — the engine reads ONLY these:

{
  "hand": "hand",
  "draw": "draw_pile",
  "discard": "discard",
  "in_play": "in_play",
  "market": "trade_row",
  "market_zones": ["trade_row"],
  "market_feeder": "market_deck",
  "life_tracker": "authority",
  "attack_cost_tracker": "combat",
  "currencies": ["trade"],
  "currency_source": "catalog_default:buy_from_market"
}

Missing per D4/G9–G11: per-action bound parameters (play costs, phase type filters, use limits) — the Rules page's job.

Action definitions — THE CONTRACT user-definable actions · phase 1

An action is DATA: a named composition of steps in game_meta['action_definitions']. The four built-in verbs (play / buy / attack / end) remain presets; anything else — Jaipur's take, trade and sell — is authored. Bindings (costs, use limits, phase filters) apply to custom ids exactly as to built-ins; phases list custom ids in allowed_actions; every choice inside a step is answered by the bot policy and logged as a decision.

"action_definitions": {
  "sell_set": {
    "label": "Sell a set of goods",
    "steps": [
      { "do": "choose_cards", "from": "hand", "count": { "min": 1 },
        "same": "type", "as": "sold" },
      { "do": "move", "cards": "$sold", "to": "goods_discard" },
      { "do": "take_top",
        "from": { "key": "$sold.type",
                  "map": { "leather": "leather_tokens", "diamond": "diamond_tokens" } },
        "count": "$sold.count", "to": "my_tokens" },
      { "do": "take_top", "from": "bonus_3", "count": 1, "to": "my_tokens",
        "when": { ">=": [ { "var": "$sold.count" }, 3 ] } }
    ]
  }
}
TO REVIEW (Ivan): should catalog entries be able to point at a NAMED ENGINE FUNCTION ("handler": "engineApplyPlayCard") as an alternative to steps_template? Proposed answer: yes, but only as the escape hatch — steps stay the default (data composes, functions multiply); the handler field would mainly FORMALIZE the four built-in verbs as visible catalog entries instead of hidden switch branches, and license future irreducible actions. Decide before phase 2 freezes the interpreter's dispatch.

The preset layer — where designers live

Most games never open the composer: assets/data/action-presets.json ships reusable actions as step templates with SLOTS — enabling one is filling a few dropdowns. Shipped set: take_cards · take_all_matching · sell_set (with optional bonus pile) · exchange_cards (N-for-N, sizes linked) · pay_tracker_for_cards · pay_cards_for_tracker. A preset instance stores the ƒx dual-key pattern — {authored: {preset, params}, steps: […compiled…]} — the form reads authored, the interpreter reads only steps, and the server recompiles on every save so they can never drift. The raw composer below is the ~1% escape hatch.

The step vocabulary (closed — extending it is a code change)

StepFieldsMeaning
choose_cardsfrom · count (int | ƒx | {min[,max]} | "$sel.count") · filter · same (field all chosen cards must share) · chooser (actor | opponent) · as (required name)a player picks cards; binds the selection for later steps; logged as a decision
movecards: "$sel" · to · face?move a bound selection
move_allfrom · to · filter?sweep matching cards (Jaipur's "take all camels")
take_topfrom (zone or map-by-selection-field) · count · toordered piles: token stacks, bonus stacks
adjust_trackertracker · amount · of (self | opponent)numeric side effects
run_effecteffect_key + the usual effect fieldsgrafts the ENTIRE effect machinery (subjects, patterns, selection specs) into any action

Rules: when: on any step is JSON Logic over the step context ($name.count, $name.<field> — the shared field requires same). Zone references resolve relative to the ACTOR. $refs must point at earlier as names; ids are lowercase slugs; every unknown (step type, zone, tracker, effect) is a loud validation problem — validated on save and in the import gap report (dcValidateActionDefinition, php-includes/inc-action-defs.php). Legality (LIVE): an action is offered when its mandatory selections have candidates (linked counts respected), sweeps have matches, and its uses_per_turn binding has uses left; apply spends the binding and runs the interpreter. Selections group by same (greedy sells the largest uniform set, takes the max; random rolls); every choice is a logged decision; replays stay bit-identical. A definition may instead carry "handler": "engineFn" — the reviewed escape hatch, dispatched first. Companion evaluator op shipped with this phase: count_zones_where (G13) — "any 3 token piles empty" is now a writable ending.

Card sets & named conditions — THE CONTRACT shipped · engine + forms + import/export

Two small tools, one purpose: author a rule once, reference it everywhere by name. A set is a named card query (“3 treasures of the same type in my hand”); a condition is named boolean logic (“I have that set”, “my energy ≥ 5”). Both live in game_meta, both are referenced by id, and editing the definition propagates to every use because references are resolved at runtime — single source. Validated against the TcgEngine study (U1): their reusable condition assets are this exact pattern.

Set — game_meta['card_sets']

Deliberately the same shape as a choose_cards step minus the choice — it compiles onto the existing grouping/feasibility machinery (dcActionChooseGroups / dcActionFeasible).

"card_sets": {
  "treasure_triple": {
    "label": "Three treasures of a kind",
    "from": ["hand"],                  // zone id or list; per-player zones bind to the subject player
    "filter": { "type": "treasure" },  // uniform criteria: type / subtype / behaviour / tag / has_tag / <field>
    "count": { "min": 3, "max": 3 },   // ints or ƒx; max optional
    "same": "type"                     // optional same-field constraint, as in choose_cards
  }
}
Used asSpellingMeaning
Testcondition preset has_set → expr op {"has_set": {"set": "treasure_triple"}}“does the bound player currently hold this set?” — evaluator op, player-bound, same grouping logic as feasibility
Selector{"do": "choose_cards", "set": "treasure_triple", "as": "sold"}step inherits from/filter/count/same from the set; explicit keys on the step override
Auto-grab{"do": "choose_cards", "set": "treasure_triple", "auto": true, "as": "sold"}no choice: the engine takes the first maximal matching group in stable order (deterministic — replay-safe); infeasible → action illegal

Condition — game_meta['conditions']

The ƒx dual-key house pattern: forms read/write only authored, the engine reads only expr, the server recompiles on save/import so they never drift.

"conditions": {
  "can_combo": {
    "label": "Holds a treasure triple",
    "authored": { "preset": "has_set", "params": { "set": "treasure_triple" } },
    "expr": { "has_set": { "set": "treasure_triple" } }   // compiled, engine-only
  }
}

Reference spelling, valid anywhere JSON Logic is accepted: {"named": "can_combo"} — resolved by a new evaluator op via runtime lookup (depth-capped at 8 for named-in-named; unknown id = validation warning at save, warn + false at runtime, never silent).

Preset (v1 catalog)ParamsCompiles to / TcgEngine kin
has_setset idhas_set op · their set-like ConditionCount+same
tracker_comparetracker · op (== != ≥ ≤ > <) · value (ƒx)tracker_value vs value · ConditionPlayerStat
count_cards_comparezones · filter · op · value (ƒx)count_cards vs value · ConditionCount
zone_emptyzone · empty|not_emptycount_cards == 0 sugar · ConditionCardPile
card_has_tag (card-context)criterion (type/subtype/behaviour/tag)all_tags check on the bound card · ConditionCardType
card_attribute_compare (card-context)attribute · op · value (ƒx)effective attribute vs value · ConditionStat/StatCustom
freeraw JSON Logicescape hatch, stored as-is

Card-context presets are only valid where a card is bound (effect condition:); the validator enforces context. Catalog grows with the model: once-per-turn arrives with nothing new needed (uses_per_turn bindings already cover it), has-status arrives with U2.

Wiring points

HostNew keySemantics
Action bindings (built-in AND custom verbs)action_bindings[aid].requires = {"named": id} or inline exprNEW legality gate — evaluated for the acting player alongside cost/phase/uses gates; makes combo actions (“if I hold 3 of a kind I may…”) authorable
Card effectseffects[].condition = ref or exprchecked with subject bound at execution; false → effect silently skipped (logged) — closes finding SI1 (conditional effects)
Step when: · endings · active_whileaccept {"named": id}free — they already evaluate through the evaluator; only vocabulary + validation
Import/exportcard_sets + conditions in the meta whitelistround-trip with recompile-on-import; unknown refs are warnings, never blockers

Forms (build phase)

  • Step 2 § Card sets: rows of zone select + filter + count + same — all existing widgets.
  • Step 2 § Conditions: recipe picker (preset select + params), win-conditions style.
  • Anatomy actions form: Requires select per binding.
  • Card editor: condition select per effect row.

Agreed proof: a real combo action in the verification kingdom — “if you hold 3 treasures of the same type, sacrifice them and gain 6 coins” — authored end-to-end with both tools; makes the interaction-frequency metric (“does my combo ever fire?”) measurable.

Verb Forge — THE CONTRACT approved · checkpoint before build

Every verb a player can take — including today's built-ins — becomes DATA: an instance of a verb preset compiled onto a closed set of engine primitives. Full plan: plan-verb-forge.html. This section is the normative shape.

V.1 · Step primitives (closed — extending is a code change)

The existing step vocabulary grows to cover VALUES, TARGETS and CARD STATE. Existing spellings stay valid; choose_cards / move / move_all / take_top / adjust_tracker / run_effect keep working unchanged.

{ "do": "choose",        "kind": "cards|player|zone|option", "from": <zone|zones>, "of": "actor|opponent|any",
                           "filter": {…}, "count": <count>, "select": "first|random|highest:x|lowest:x",
                           "chooser": "actor|opponent", "auto": bool, "as": "name" }
{ "do": "move",          "cards": "$ref" | "$source", "from": <zone(s)>, "to": <zone>,
                           "which": "top|bottom|random", "count": <count> }      // $source = the acting card (trash!)
{ "do": "adjust_value",  "target": { "tracker": id, "of": "actor|opponent|$ref" }
                                 | { "attribute": field, "of": "$ref" },
                           "amount": <value source>, "op": "add|subtract|set" }
{ "do": "set_flag",      "cards": "$ref|$source", "flag": "exhausted|face|status",
                           "value": …, "duration": n }
{ "do": "shuffle_zone",  "zone": id, "seat": n }                                  // promoted from setup
{ "do": "fire_triggers", "cards": "$ref|$source", "trigger": "on_play|on_destroy|…" }
{ "do": "flow",          "op": "end_phase|push_steps", … }

Value sources (the amount argument, anywhere): literal · ƒx union · {"tracker": id, "of": …, "take": "all|n"} ("take": "all" = drain the pool, how attack spends combat) · {"attribute": field, "of": "$ref"} · "$sel.count". All but "take" exist today.

V.2 · THE OFFER — no second language: it derives from the first choose

The plan flagged the offer spec as the risky novel piece. Resolution: a verb's menu IS its first choose step. engineLegalActions() looks at an enabled verb, applies the verb-level gates (phase · uses · cost · requires: · forbid), and then:

First stepMenu behaviour
no choose (or auto/select)ONE menu entry; feasibility-checked (today's custom actions)
choose kind:cardsone entry per candidate card (per: "card") or per distinct design (per: "design", markets); picking the entry binds $<as> and skips the step
choose kind:playerone entry per legal target player

Menu-only keys on a first choose: "per": "card|design", and "price": {"source": "card.cost", "modify": "buy_cost"} — prices each candidate through the modifier pipeline and drops unaffordable ones (buy). Later choose steps resolve during execution (bot policy inline / human prompt later), exactly as now. The six proof cases (play · buy · attack player · attack card · activate · Jaipur's no-target sell) all reduce to this rule — worked through in the plan's phase-A checklist.

V.3 · Verb preset (assets/data/verb-presets.json) and per-game instance

/* preset */   { "id": "trash", "label": "Trash a card", "category": "remove",
                 "slots": { "from": {"type": "zone", "required": true},
                            "destination": {"type": "zone", "required": true,
                                            "hint": "usually a shared hidden out-of-game pile"} },
                 "steps_template": [
                   { "do": "choose", "kind": "cards", "from": "@{from}", "per": "card", "as": "chosen" },
                   { "do": "move", "cards": "$chosen", "to": "@{destination}" } ] }

/* instance */ "scrap": { "label": "Scrap", "enabled": true,
                 "authored": { "preset": "trash",
                               "params": { "from": "hand", "destination": "scrap_heap" } },
                 "steps": [ …recompiled on save — the engine reads ONLY this… ] }

Same storage (game_meta['action_definitions']), same dual-key discipline, same bindings (play_costs / uses_per_turn / phase_types / requires) as today's custom actions. category (attack·acquire·gain·remove·flow) is the bots' semantic hint. Built-ins keep their hardcoded path per game until that game's instances cover them (feature flag game_meta['verbs_v2']); equivalence is proven by the version diff on the reference games, one verb per commit.

Effect (vocabulary) consumed by validation, AI, engine

The verbs cards may use. A game enables MECHANICS (enabled_mechanics); the usable vocabulary = catalog effects whose mechanic is enabled.

{
  "effect_key": "draw_n_cards_to_self",
  "mechanic": "draw_cards",
  "description": "Draw a chosen number of cards from your deck into your hand.",
  "render": "Draw {amount} card(s).",
  "default_trigger": "on_play",
  "params": {
    "amount": {
      "type": "integer",
      "source": "design"
    }
  }
}
FieldNotes
mechaniclinks to mechanics-*.json; engine executor switches on this
params[].sourcedesign = the card supplies the value · player = chosen at play time (target enumeration)
renderrules-text template; {icon:x} tokens resolve in the card renderer
default_triggerused when a card omits its own trigger

Current catalog: draw_n_cards_to_self · deal_1_damage_to_opponent · deal_n_damage_to_target · gain_n_resource_to_self · discard_1_card_from_self. Known gaps: opponent-targeted scopes (G2), shared-pool scope (A3).

The subject block (point 2)

The "who" is no longer part of the effect key. Any effect entry on a card may carry a destination block; zones resolve relative to each subject:

{
  "effect_key": "draw_n_cards_to_self",
  "trigger": "on_play",
  "parameters": { "amount": 1 },
  "subject": { "who": "each_opponent" },
  "to": "discard"
}
FieldValuesNotes
subject.whoself · opponent · each_opponent · all_playersclosed vocabulary; unknown = effect targets NOBODY + warning, never coerced to self. "chosen" arrives with point 3
tozone def idoverrides the discard destination (e.g. straight to trash)
fromzone def idstored + validated; draw still uses role-based zones until a game forces it

Precedence: card-level subject > catalog default (effects.json — the old _to_self keys are now presets carrying their historical subject) > mechanic default (damage → opponent, everything else → self). Amounts compose with ƒx expressions, resolved per subject. New presets: each_opponent_draws (Council Room), opponents_discard_down_to (Militia — the victims choose what to give up via their own bot policy since point 3; the choice lands in history).

Selection specs (point 3)

A player-chosen parameter is a spec, not an empty hole. In bot-only simulation the subject's controller answers inline — greedy keeps its most useful cards, random draws from the game RNG (replays stay bit-identical) — and every answer is recorded in history as a decision entry:

"parameters": {
  "cards": {
    "source": "player",
    "chooser": "subject",
    "from": "hand",
    "count": 2,
    "filter": { "type": "treasure" }
  }
}
{ "type": "decision", "player": "p1", "kind": "choose_cards", "why": "opponents_discard_down_to", "from": "hand", "count": 2, "chosen": ["c12", "c31"] }

chooser: "subject" means the affected player picks (Militia), not the one who played the card. The runtime carries pending_decisions[] and flow.awaiting_decision for a future play-against-bots mode — both stay empty in simulation by design.

The step queue (point 4)

The turn is a mutable queue of heterogeneous steps, not a fixed phase list. flow.step_queue holds objects; plain strings normalise to phase steps (old saves with phase_queue load fine, and toArray() still emits a derived phase_queue view):

"step_queue": [
  { "kind": "phase",  "id": "cleanup" },
  { "kind": "effect", "actor": "p0", "effect": { "effect_key": "…" }, "source_card": "c12" },
  { "kind": "decision", "ref": { "…": "…" } }
]

enginePushSteps($g, $steps, $front) inserts mid-resolution — "resolve this now, then continue the turn" is a front-push. Unknown kinds are skipped with a warning. Phase defs gain scope: active_player (default) | all_players_sequential | all_players_simultaneous — an all-players automatic phase (a sweep, a reveal) runs once per seat; with bots, simultaneous ≡ sequential because bots don't peek. Zone defs carry a reserved parent: null (point 8's only structural promise — nesting itself is declared out of scope).

Modifiers (point 5)

A permanent is not an effect: it never mutates state, it changes how listed properties are read while the card is in a zone. Separate array, never inside effects:

"modifiers": [ {
  "active_while": { "in_zone": "in_play", "owner": "self" },
  "property": "buy_cost",
  "target": { "who": "controller", "filter": { "type": "resource_card" } },
  "operation": "add",
  "value": -1
} ]
FieldValues
propertyclosed catalog (assets/data/modifiable-properties.json): buy_cost · play_cost · draw_amount · damage_dealt · action_legality
operationadd · multiply · replace · forbid (action_legality only, + an action key)
target.whocontroller · opponents · all_players
valuenumber or ƒx expression

Layer order is FIXED — adds (summed) → multiplies (product) → replace, and forbid beats everything — so two cards together resolve identically regardless of play order. Results clamp at 0. end_phase can never be forbidden. The engine reads listed properties only through engineModifiedValue(); games with no modifiers skip the pipeline entirely.

Trigger patterns (point 6)

trigger accepts a preset string (on_play, on_destroy, at_start_of_turn, at_end_of_turn) or an event pattern — a subscription matched against everything that happens while the card is in play:

"trigger": { "event": "action_performed", "action": "buy_from_market", "actor": "self" }
"trigger": { "event": "tracker_adjusted", "tracker": "life", "direction": "down", "owner": "self" }
"trigger": { "event": "card_moved", "to_zone": "discard", "owner": "opponent" }
EventFiltersEmitted when
card_movedto_zone · from_zone · ownera single card is played, bought or discarded (bulk sweeps don't emit)
tracker_adjustedtracker · direction (up/down/any) · ownerany tracker changes by a non-zero delta
action_performedaction · actora player action resolves (play, buy, attack)
turn_started / turn_endedplayerturn boundaries
phase_startedphasethe flow enters a phase

Relative filters (owner/actor/player) resolve from the subscribing card's seat: self · opponent · any. Rules: subscribers are cards in in_play; chains cap at depth 8; an unknown event or filter is a loud validation problem; an unknown trigger is preserved and flagged, never rewritten (A2, fixed structurally); games with no pattern triggers skip the bus entirely. Presets still fire by their original direct paths — bit-identical replays.

Ending (win condition) evaluated after every action

{
  "ends_when": [
    {
      "label": "Reduce Authority to 0",
      "_recipe": "combat",
      "when": {
        "<=": [
          { "var": "trigger.target.trackers.authority" },
          0
        ]
      },
      "scope": "any_player",
      "terminates_at": "immediately",
      "resolution": {
        "kind": "instant",
        "winner_rule": "trigger_target_loses"
      },
      "_recipe_values": {
        "tracker": "authority",
        "threshold": 0,
        "comparator": "<="
      }
    }
  ]
}
FieldValues / notes
whenJSON Logic over the live game state; custom operators: count_cards, count_players_where, for_each_player, lookup_player, card_in, objective_count
resolution.winner_ruletrigger_target_loses · trigger_target_wins · highest_tracker · lowest_tracker · highest_metric · lowest_metric (unknown = draw). resolution.tracker names the compared tracker; resolution.metric is any evaluator expression (bound to player)
resolution.tiebreakersordered chain applied among the tied players: [{"tracker": id | "metric": expr, "objective": "maximize"|"minimize"}, …]. Each entry keeps the players best at it; exhausted chain with 2+ survivors = draw. Malformed entries decide nothing
terminates_atimmediately (default) · end_of_turn (armed: closes at the current turn's boundary) · end_of_round (armed: waits for the LAST active seat, every player gets equal turns) — all three implemented; scores evaluated fresh at close
G8 guardan ending already true at setup is suppressed for the run + warned, never a turn-1 result

Setup spec compiled into a step script (G5)

{
  "turn_order": "clockwise",
  "first_player_rule": "random",
  "starting_hand_size": 5,
  "initial_tracker_overrides": {},
  "deck_to_zone_map": {
    "Pile: Copper": "copper_pile"
  }
}

Both mapping orientations are accepted: {deck: zone} (definition files) and {zone: deck} (the Anatomy form). initial_tracker_overrides replaces a tracker's starts_at for this game: {"money": 8} = every seat; {"money": {"2": 8}} = only seat 2 (1-based) — the seat-compensation dial. Shared trackers accept the flat form. Caveat: per_turn_reset_to_X regeneration still resets to starts_at, not the override. Unknown tracker ids warn and do nothing; the Anatomy form edits flat values and shows per-seat ones read-only. Optional steps — canonical when present, otherwise compiled:

"steps": [
  {
    "action": "fill_zone",
    "params": {
      "zone": "draw_pile",
      "from_deck": "Starter Deck",
      "shuffle": true
    }
  },
  {
    "action": "draw_n_cards",
    "params": {
      "n": 5,
      "from_zone": "draw_pile"
    }
  }
]

Compilation order when steps is absent: starter deck → zones' start_filled_fromdeck_to_zone_map → leftover decks to the market feeder → opening draw. The executed script is stored on the run (library->setupSteps). No form edits the steps yet (P7).

Card type · subtype · behaviour catalog + per-game enable

game_meta['card_types'] = {
  "unit":      { "enabled": true },
  "structure": { "enabled": true }
}

A root type (assets/data/types/deckbuild.json) — the STARTING shape + the behaviour menu:

{
  "id": "unit",
  "fields": {
    "cost": {},
    "attributes.attack": {},
    "attributes.health": {},
    "effects": {}
  },
  "allowed_behaviours": [
    "Permanent",
    "Strike",
    "Targetable",
    "Hidden",
    "Outpost",
    "Lingering"
  ],
  "default_behaviours": ["Permanent"]
}

A subtype = a PRESET, not an axis (D6) — it materialises into type + behaviours at authoring:

{
  "id": "unit__striker",
  "parent": "unit",
  "label": "Striker",
  "forces_behaviours": ["Strike"],
  "ai_hints": ["glass cannon"]
}

A behaviour (card-behaviours.json) — engine keyword; may reshape the card:

{
  "behaviour": "Outpost",
  "flags": {
    "isOutpost": {
      "value": true,
      "locked": true
    }
  },
  "sim_tags": ["guard"],
  "description": "Must be destroyed before other non-Outpost targets can be attacked."
}

A card's real shape = type's base fields ± its behaviours' requires_field/removes_field. The engine currently exercises Permanent (spared by play-area sweeps); Outpost/Targetable wait on card-targeted combat (P1).

Card — THE CONTRACT normative · every field a card may or must carry

This table is the authority the card editor (editCard.php) is built against — every "editor widget" cell is live. Must: only name and type. Everything else is optional. Any numeric leaf marked ƒx accepts the value | expression union. Fields the editor doesn't own are preserved verbatim on save (merge, never rebuild).

FieldMust?ShapeValidated againstStatusEditor widget
namemuststringnon-emptylive → card_name columntext input
descriptionmaystringlive → card_desctextarea
typemustroot type idenabled types (roots resolved via dcEnabledRootTypes)live → card_type; zones' accepts, phase filters, expressions read itselect: schema_map rows with parent === null
subtypemay"root__short"exists in schema_map (built-in + game custom subtypes), parent must equal typelive → card_subtype; targetable via subtype: entry in card.all_tags ("destroy all tank units")select: rows with parent === type
behaviours[]maystring[]schema_map[type].allowed_behaviourslive — engine reads Permanent; behaviours prune/require attributescheckboxes from allowed set
tags[] renamed from familiesmaystring[]free-form (families accepted forever as a legacy alias)live — targetable via filters and card.all_tags; the FAMILY is a UI concept: the entry matching a game family entity (or the first entry) drives art styleFamily single-select + Tags multi-input, both writing this array
cost.{tracker}maynumber | ƒxkeys ⊆ currencies (dcCardCostCurrencies)live — buy affordability, legal actionsƒx widget per currency
attributes.{key}maynumber | ƒxvalues numeric/ƒx only — keys are free-form; the type's fields and attribute-types.json are advisory suggestion vocabularieslive — sum_cards scoring (vp), rendererkey/value rows, key datalisted, value ƒx widget
effects[]maysee sub-tableeffect_key ∈ vocabulary; trigger/subject/spec vocabularies belowlive — the executorrepeatable row builder
modifiers[]maysee sub-tableproperty/operation/target vocabularies belowlive — engineModifiedValue()repeatable row builder
copiesmayint | ƒx (default 1)≥ 1 after resolutionlive — deck instantiation (meta_tags.copies is the legacy fallback)ƒx widget
meta_tags.{group}mayobjectfree-formlegacy-live — the distribution dashboard counts cards by these; keep intactkey/value rows (keys from the deck's distribution groups)
img_promptmaystringlive — copied into card_ia_json.img_prompt for image generationtextarea
gen{skeleton, strength}skeleton marker = "art not yet dressed". A manual save does NOT clear itread-only
classification{}DEAD — duplicates the top-level axes; no PHP reads it; the editor never writes it (stored copies pass through)
flags{}DEAD — legacy; the normaliser deletes it when empty

The runtime tag view — card.all_tags

Storage stays structured (each axis keeps its own cardinality and validation), but at runtime every card exposes ONE prefixed membership array that filters and expressions compare against — so any axis is targetable through one mechanism:

"all_tags": ["type:unit", "subtype:unit__tank", "behaviour:Permanent", "tag:pirates"]

Built from type + subtype + behaviours[] + tags[]. Prefixes prevent collisions (a tag named "unit" ≠ the type "unit"). Effect/modifier card filters accept type · subtype · behaviour · tag criteria uniformly; expressions get a has_tag membership check. "Destroy all tanks", "buff every Permanent", "count pirate cards" are all the same comparison.

Effect entry (sub-contract)

FieldMust?Vocabulary
effect_keymusteffects.json ∩ the game's enabled_mechanics
triggermay (catalog default)preset string (cardEffectTriggers(): on_play · at_start_of_turn · at_end_of_turn · on_destroy) or pattern object (dcEventNames() + per-event filters). Unknown = preserved + flagged, never fires
subjectmay (catalog/mechanic default)cardEffectSubjects(): self · opponent · each_opponent · all_players
from / tomayzone def ids (to consumed for discards; from stored)
parameters.{p}per catalogdesign values (numbers ƒx-capable, strings) or a selection spec {source: "player", chooser: actor|subject, from: zone, count: n|ƒx, filter, optional}

Modifier entry (sub-contract)

FieldMust?Vocabulary
propertymustassets/data/modifiable-properties.json: buy_cost · play_cost · draw_amount · damage_dealt · action_legality
operationmay (default add)add · multiply · replace · forbid (forbid ⇒ property must be action_legality and an action key is required)
valuemust unless forbidnumber | ƒx
target.whomay (default controller)controller · opponents · all_players; optional target.filter (card type)
active_whilemay (default in_play/self){in_zone: zone def, owner: self}
{
  "name": "Salvage Vanguard",
  "type": "unit",
  "subtype": "unit__stealth",
  "behaviours": ["Permanent"],
  "families": [],
  "cost": { "trade": 3 },
  "attributes": { "attack": 2, "health": 3 },
  "effects": [
    {
      "effect_key": "deal_n_damage_to_target",
      "trigger": "on_play",
      "parameters": { "amount": 2 }
    }
  ],
  "description": "A nimble scout ship pieced together from star debris…",
  "copies": 1,
  "img_prompt": "…",
  "gen": { "skeleton": true },
  "meta_tags": { "type": "unit", "strength": "medium" }
}

DB columns card_name/card_desc/card_type/card_subtype are denormalised projections of card_json — every save path rewrites them together (cardRootType() / cardSubtype()). Every save path (AI, skeleton, import, editor) runs normaliseCardEffects()normaliseCardClassification()validateCardAgainstGame(); validation problems are warnings, never blockers (a designer may always save; the gap report stays honest). ƒx leaves compile server-side (dcNormaliseExprValue) so stored expr never drifts from the form.

Deck authoring container

// game_decks row; deck_meta JSON:
{
  "soft_card_cap": 60,
  "hard_card_cap": 60,
  "is_starter": 0,
  "frequency_distribution": {
    "type_distribution": {}
  }
}

Decks are design-time containers — never played from directly. Setup instantiates their contents into zones (per-player zone = each player gets their own copy). is_starter marks the opening pile; the frequency distribution feeds generation weights.

Family schema only — the unfinished axis

// game_meta['card_families'] today —
// still the empty scaffold:
{
  "enabled": false,
  "families": {
    "family_1": {
      "name": "",
      "enabled": false,
      "description": ""
    }
  }
}

Intended shape (D6) — per-game authored groups rules can target:

{
  "id": "scrapper_guild",
  "label": "Scrapper Guild",
  "color": "#c2410c",
  "description": "…"
}

Cards already carry families: []; no UI authors them, no AI uses them, no effect targets them. Finding S1.

Runtime: the Game object state only, never definitions

{
  "id": "run_a1b2c3",
  "game_unique_id": "eb36…",
  "seed": 12345,
  "status": "in_progress",

  "config": {
    "player_count": 2,
    "seat_order": ["p0", "p1"],
    "starting_hand_size": 5,
    "turn_limit": 100
  },

  "players": [
    {
      "id": "p0",
      "seat": 0,
      "status": "active",
      "controller": "bot:greedy",
      "trackers": {
        "authority": 50,
        "trade": 0,
        "combat": 0
      },
      "modifiers": []
    }
  ],

  "zones": {
    "hand:p0": {
      "def": "hand",
      "owner": "p0",
      "cards": ["c3", "c7"]
    },
    "trade_row": {
      "def": "trade_row",
      "owner": null,
      "cards": ["c21", "c22"]
    }
  },

  "shared_trackers": {},

  "card_instances": {
    "c3": {
      "definition_id": "<unique_card_id>",
      "owner": "p0",
      "zone": "hand:p0",
      "face": "up",
      "counters": {},
      "modifiers": []
    }
  },

  "flow": {
    "turn_number": 4,
    "active_player": "p1",
    "phase": "main",
    "phase_queue": ["cleanup"],
    "trigger_queue": []
  },

  "history": [
    {
      "seq": 0,
      "type": "setup",
      "seed": 12345,
      "rng_draws": 0
    }
  ],

  "rng": {
    "seed": 12345,
    "state": 87231,
    "draws": 41
  }
}

Card text and rules never enter this object — instances point at definitions by id. Same seed replays a run bit-identically. The library alongside carries the read-only catalogs + bindings + setupSteps + suppressedEndings.

The definition file — a whole game as one JSON import/export proven

{
  "format": "deckcraft-game/1",
  "name": "Starforge Rivals",

  "meta": {
    "game_briefing": {},
    "trackers": [],
    "game_zones": [],
    "phases": [],
    "actions": [],
    "enabled_mechanics": [],
    "card_types": {},
    "ending": {},
    "setup_spec": {}
  },

  "decks": [
    {
      "name": "Shared Starter Deck",
      "is_starter": 1,
      "soft": 10,
      "hard": 10,
      "cards": [
        { "…": "card_json shape, incl. copies" }
      ]
    }
  ]
}

game-tool.php export|import|delete|list over SSH. Import replaces same-name games, validates every card, runs setup, and prints the gap report. Live examples: starforge-rivals.json · dominion.json · ascension.json