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.
The storage map — where everything lives
| Structure | Stored in | Authored by | Read by |
|---|---|---|---|
| Game identity | game_info row (unique_id is the handle everywhere) | Briefing (Step 1) | everything |
| Briefing | game_meta['game_briefing'] | Step 1 form | AI prompts, player count |
| Trackers | game_meta['trackers'] | Step 2 · Trackers | engine, AI, win conditions |
| Zones | game_meta['game_zones'] | Step 2 · Zones | engine (setup, refill, markets) |
| Effects vocabulary | game_meta['enabled_mechanics'] + catalog assets/data/effects.json | Step 2 · Effects | card validation, AI, effect executor |
| Win conditions | game_meta['ending'] | Step 2 · Win conditions | engine after every action |
| Card types | game_meta['card_types'] + catalogs assets/data/types/, subtypes/, card-behaviours.json | Anatomy · 1 | validation, AI, renderer, zones' accepts |
| Actions | game_meta['actions'] (ids) + catalog actions-default.json; game_meta['action_bindings'] reserved | Anatomy · 2 | legal_actions via bindings |
| Phases | game_meta['phases'] | Anatomy · 3 | engine flow |
| Setup spec | game_meta['setup_spec'] | Anatomy · 4 | setup compiler (G5) |
| Decks | game_decks rows (+ deck_meta JSON) | Step 3 | setup fills, distribution |
| Cards | game_cards rows: card_json + columns card_type/card_subtype | Step 4 (AI / skeleton / import) | everything |
| Families | game_meta['card_families'] empty scaffold | — | nothing yet (S1) |
| Capabilities | assets/data/capabilities.json (+ per-server overrides) | us / admin page | authoring 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 }
}
}
| Key | Who reads it | What it is |
|---|---|---|
expr | engine / evaluator — only this | compiled JSON Logic; evaluated against the live game state |
authored | the ƒx form widget | provenance: preset + params (or level: "free" + raw json). Recompiled to expr on every save so they can never drift |
Fields that accept the union today:
| Field | Resolved | Form |
|---|---|---|
Tracker starts_at / min / max | player+tracker init at setup; per_turn_reset_to_X regeneration | Step 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 copies | deck instantiation at setup | no card editor exists yet |
setup_spec.starting_hand_size | opening deal | Anatomy · Setup |
Zone auto_refill.to / constraints.max_cards | every refill pass | Step 2 · Zones |
ending.when and setup step draw_n_cards.n | already full JSON Logic / resolved at setup | Win 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."
}
| Field | Values | What it does |
|---|---|---|
id | slug, immutable | referenced by effects, costs, endings |
type | counter · boolean · enum · set | value shape (only counter is engine-exercised) |
role | resource · life · score · status · counter · commitment | drives smart-gating, currency fallback, turn-limit fallback |
scope | per_player · per_team · shared | where the value lives at runtime (shared → shared_trackers) |
starts_at / min / max | numbers / null / ƒx | initial value; engine clamps every adjustment |
regenerates | null · per_turn_reset_to_zero · per_turn_reset_to_X · per_round_reset_to_X | applied at turn rollover (X = starts_at) |
visibility | public · owner · owner_count_to_others · top_only · hidden | authored; 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
}
}
| Field | Values | What it does |
|---|---|---|
scope | per_player (also accepts owner: "player") · shared | instance-per-player vs one instance |
visibility | public · owner · hidden/none | hidden shared zones are excluded from markets |
ordering | stack · unordered | stack = draw-from-top matters |
constraints.accepts_types | root 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_from | deck 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"
}
}
]
}
]
| Field | Notes |
|---|---|
allowed_actions | the bot's whole menu during this phase (∩ enabled actions) |
mandatory_actions | verbs: 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 / scope | only 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 ] } }
]
}
}
"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)
| Step | Fields | Meaning |
|---|---|---|
choose_cards | from · 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 |
move | cards: "$sel" · to · face? | move a bound selection |
move_all | from · to · filter? | sweep matching cards (Jaipur's "take all camels") |
take_top | from (zone or map-by-selection-field) · count · to | ordered piles: token stacks, bonus stacks |
adjust_tracker | tracker · amount · of (self | opponent) | numeric side effects |
run_effect | effect_key + the usual effect fields | grafts 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 as | Spelling | Meaning |
|---|---|---|
| Test | condition 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) | Params | Compiles to / TcgEngine kin |
|---|---|---|
has_set | set id | has_set op · their set-like ConditionCount+same |
tracker_compare | tracker · op (== != ≥ ≤ > <) · value (ƒx) | tracker_value vs value · ConditionPlayerStat |
count_cards_compare | zones · filter · op · value (ƒx) | count_cards vs value · ConditionCount |
zone_empty | zone · empty|not_empty | count_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 |
free | raw JSON Logic | escape 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
| Host | New key | Semantics |
|---|---|---|
| Action bindings (built-in AND custom verbs) | action_bindings[aid].requires = {"named": id} or inline expr | NEW 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 effects | effects[].condition = ref or expr | checked with subject bound at execution; false → effect silently skipped (logged) — closes finding SI1 (conditional effects) |
Step when: · endings · active_while | accept {"named": id} | free — they already evaluate through the evaluator; only vocabulary + validation |
| Import/export | card_sets + conditions in the meta whitelist | round-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 step | Menu behaviour |
|---|---|
no choose (or auto/select) | ONE menu entry; feasibility-checked (today's custom actions) |
choose kind:cards | one entry per candidate card (per: "card") or per distinct design (per: "design", markets); picking the entry binds $<as> and skips the step |
choose kind:player | one 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"
}
}
}
| Field | Notes |
|---|---|
mechanic | links to mechanics-*.json; engine executor switches on this |
params[].source | design = the card supplies the value · player = chosen at play time (target enumeration) |
render | rules-text template; {icon:x} tokens resolve in the card renderer |
default_trigger | used 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"
}
| Field | Values | Notes |
|---|---|---|
subject.who | self · opponent · each_opponent · all_players | closed vocabulary; unknown = effect targets NOBODY + warning, never coerced to self. "chosen" arrives with point 3 |
to | zone def id | overrides the discard destination (e.g. straight to trash) |
from | zone def id | stored + 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
} ]
| Field | Values |
|---|---|
property | closed catalog (assets/data/modifiable-properties.json): buy_cost · play_cost · draw_amount · damage_dealt · action_legality |
operation | add · multiply · replace · forbid (action_legality only, + an action key) |
target.who | controller · opponents · all_players |
value | number 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" }
| Event | Filters | Emitted when |
|---|---|---|
card_moved | to_zone · from_zone · owner | a single card is played, bought or discarded (bulk sweeps don't emit) |
tracker_adjusted | tracker · direction (up/down/any) · owner | any tracker changes by a non-zero delta |
action_performed | action · actor | a player action resolves (play, buy, attack) |
turn_started / turn_ended | player | turn boundaries |
phase_started | phase | the 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": "<="
}
}
]
}
| Field | Values / notes |
|---|---|
when | JSON Logic over the live game state; custom operators: count_cards, count_players_where, for_each_player, lookup_player, card_in, objective_count |
resolution.winner_rule | trigger_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.tiebreakers | ordered 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_at | immediately (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 guard | an 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_from → deck_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).
| Field | Must? | Shape | Validated against | Status | Editor widget |
|---|---|---|---|---|---|
name | must | string | non-empty | live → card_name column | text input |
description | may | string | — | live → card_desc | textarea |
type | must | root type id | enabled types (roots resolved via dcEnabledRootTypes) | live → card_type; zones' accepts, phase filters, expressions read it | select: schema_map rows with parent === null |
subtype | may | "root__short" | exists in schema_map (built-in + game custom subtypes), parent must equal type | live → card_subtype; targetable via subtype: entry in card.all_tags ("destroy all tank units") | select: rows with parent === type |
behaviours[] | may | string[] | ⊆ schema_map[type].allowed_behaviours | live — engine reads Permanent; behaviours prune/require attributes | checkboxes from allowed set |
tags[] renamed from families | may | string[] | 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 style | Family single-select + Tags multi-input, both writing this array |
cost.{tracker} | may | number | ƒx | keys ⊆ currencies (dcCardCostCurrencies) | live — buy affordability, legal actions | ƒx widget per currency |
attributes.{key} | may | number | ƒx | values numeric/ƒx only — keys are free-form; the type's fields and attribute-types.json are advisory suggestion vocabularies | live — sum_cards scoring (vp), renderer | key/value rows, key datalisted, value ƒx widget |
effects[] | may | see sub-table | effect_key ∈ vocabulary; trigger/subject/spec vocabularies below | live — the executor | repeatable row builder |
modifiers[] | may | see sub-table | property/operation/target vocabularies below | live — engineModifiedValue() | repeatable row builder |
copies | may | int | ƒx (default 1) | ≥ 1 after resolution | live — deck instantiation (meta_tags.copies is the legacy fallback) | ƒx widget |
meta_tags.{group} | may | object | free-form | legacy-live — the distribution dashboard counts cards by these; keep intact | key/value rows (keys from the deck's distribution groups) |
img_prompt | may | string | — | live — copied into card_ia_json.img_prompt for image generation | textarea |
gen | — | {skeleton, strength} | — | skeleton marker = "art not yet dressed". A manual save does NOT clear it | read-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)
| Field | Must? | Vocabulary |
|---|---|---|
effect_key | must | effects.json ∩ the game's enabled_mechanics |
trigger | may (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 |
subject | may (catalog/mechanic default) | cardEffectSubjects(): self · opponent · each_opponent · all_players |
from / to | may | zone def ids (to consumed for discards; from stored) |
parameters.{p} | per catalog | design 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)
| Field | Must? | Vocabulary |
|---|---|---|
property | must | assets/data/modifiable-properties.json: buy_cost · play_cost · draw_amount · damage_dealt · action_legality |
operation | may (default add) | add · multiply · replace · forbid (forbid ⇒ property must be action_legality and an action key is required) |
value | must unless forbid | number | ƒx |
target.who | may (default controller) | controller · opponents · all_players; optional target.filter (card type) |
active_while | may (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