How to articulate a game — everything built so far
The working manual for points 1, 2, 3, 6, 7 and G12. For each capability: what it expresses, where the form is, and the JSON to write in a game-def when there is no form (card-level blocks, until the card editor exists). Structures alone: see the data structures reference.
Where everything is authored — the two paths
Every mechanic can be authored two ways, and both land in the same storage (and the card editor is the third, for cards):
| Path | When to use | How |
|---|---|---|
| Forms | designing interactively | Step 2 (trackers, zones, effects vocabulary, win conditions) and Anatomy (card types, actions + bindings, phases, setup) |
| Game-def JSON | modelling a whole game fast (probes), or card-level blocks that have no form yet | write game-defs/<name>.json, then php8.2 game-tool.php import game-defs/<name>.json over SSH |
Form coverage today, honestly:
| Capability | Form | JSON-only parts |
|---|---|---|
| ƒx values | Trackers (starts/min/max) · Setup (hand size) · Zones (refill target) | card attributes / cost / copies — no card editor exists |
| Effect subjects, triggers, decisions | catalog presets appear automatically in the Effects vocabulary | the per-card blocks (subject, pattern trigger, selection specs) |
| Action bindings | Anatomy · Actions → Bindings button (Rules page v1) | — |
| Scoring | Win conditions → recipe sources incl. Points on cards | — |
ƒx values — numbers that compute themselves (point 1)
Expresses: "8 Provinces at 2 players, 12 at 3+" · "Gardens: 1 VP per 10 cards" · "cost = 3 + your Mastery".
Form: any field with the ƒx mode toggle. Pick Formula, choose a preset, fill the slots, hit Preview to see the value at 2/3/4 players.
JSON: replace the number with the union (authored-only is enough — the server compiles expr):
"copies": {
"authored": { "level": "formula", "preset": "by_player_count",
"params": { "2": 8, "3": 12, "default": 12 } }
}
Presets: by_player_count · count_cards · count_per_n · sum_cards · tracker_value · arithmetic (nest formulas). Full guide: How-to: expressions.
Who an effect hits — the subject block (point 2)
Expresses: "each opponent draws 1" (Council Room) · "everyone gains 2 gold" · "discard straight into the trash".
Form: use the catalog presets (each_opponent_draws, opponents_discard_down_to) — they carry the subject already and appear in the vocabulary when their mechanic (draw/discard) is enabled.
JSON (card effect entry):
{ "effect_key": "draw_n_cards_to_self",
"trigger": "on_play",
"parameters": { "amount": 1 },
"subject": { "who": "each_opponent" },
"to": "trash" }
| Field | Values |
|---|---|
subject.who | self · opponent · each_opponent · all_players — unknown = hits NOBODY + warning, never self |
to | zone def id override for discard destination |
Zones resolve relative to each subject: hand means their hand. Amounts accept ƒx, resolved per subject.
Player choices (point 3, bots-only)
Expresses: "each opponent discards down to 3 — choosing which cards" (Militia) · "discard 2 treasures of your choice".
Who chooses in simulation: the affected player's own bot policy — greedy keeps its most useful cards, random draws from the game RNG. Every choice lands in history as a decision entry, visible in the replay.
JSON (selection spec inside parameters):
"parameters": {
"hand_size": 3,
"cards": { "source": "player", "chooser": "subject",
"from": "hand", "filter": { "type": "treasure" } }
}
chooser: "subject" = the affected player picks (Militia); "actor" = whoever played the card. The catalog's discard presets carry the spec already — you rarely write it by hand.
Reacting to events — trigger patterns (point 6)
Expresses: "when you buy a card, gain 1 Mastery" · "when you lose life, draw 1" · "when any card reaches the trash, …".
Form: preset triggers (on_play etc.) come from the catalog as always. Patterns are JSON-only for now.
JSON (in place of the trigger string):
"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": "trash" }
Six events: card_moved, tracker_adjusted,
turn_started/turn_ended, phase_started,
action_performed. The card must be in play to listen.
Relative filters (owner/actor/player): self · opponent · any.
A typo'd trigger is preserved and flagged — it never silently becomes
on_play.
Action rules — costs, limits, phase filters (point 7)
Expresses: "playing an Action costs 1 Action" · "1 Buy per turn" · "Kingdom cards only in the action phase, Treasures only in the buy phase".
Form: Anatomy · Actions — every enabled action shows a Bindings button: cost tracker + amount (+ which card types it applies to), a per-turn use limit tracker, and (for play_card) the per-phase card-type filter. Save with the button in the panel.
JSON (meta.action_bindings):
"action_bindings": {
"play_card": {
"phase_types": { "action": ["event"], "buy": ["resource_card"] },
"play_costs": [ { "tracker": "actions", "amount": 1, "card_types": ["event"] } ]
},
"buy_from_market": {
"uses_per_turn": { "tracker": "buys" }
}
}
An action with no binding stays free and unlimited — nothing changes until you bind.
A phase missing from phase_types = nothing playable there (Estates are
playable nowhere). Amounts accept ƒx. The trackers regenerate per turn via their own
regenerates — bindings spend, trackers refill.
Rule-changing cards — modifiers (point 5)
Expresses: "everything you buy costs 1 less this turn" (Bridge) · "+2 damage while this base is out" (Star Realms) · "your opponents cannot buy" · "draw 1 extra card".
Form: none yet — card-level, same asterisk as effects (JSON or generation until the card editor exists).
JSON (a separate modifiers array on the card — never inside effects):
"modifiers": [ {
"active_while": { "in_zone": "in_play", "owner": "self" },
"property": "buy_cost",
"target": { "who": "controller" },
"operation": "add",
"value": -1
} ]
| Property | Reads as |
|---|---|
buy_cost | market prices (filter by card type: "Silvers cost 1 less") |
play_cost | the action-binding cost of playing |
draw_amount | draw effects draw more/fewer |
damage_dealt | the OWNER's damage effects hit harder |
action_legality | operation: "forbid" + "action": "buy_from_market" — the action vanishes for the target |
Rules: fixed layer order (add → multiply → replace, forbid wins) — play order never
matters; values ƒx-capable; clamp at 0; end_phase unforbiddable. A
kingdom card is "in play" from playing it until cleanup — so an aura on an event
card naturally means "this turn" (Bridge), and on a Permanent-behaviour card it
persists. Live example: Merchant Guildhall in
dominion-verification.json.
Scoring & winners — points printed on cards (G12)
Expresses: Dominion/Ascension endgame — count the VP on everyone's cards and crown a winner; Gardens' formula executes per owner. Also: turn-limit games borrow the same scoring instead of drawing (A5).
Form: Win conditions → recipes Most at end / Multi-source score; add a source of kind Points on cards (zones + attribute, default vp). Sources mix freely: tracker + card points + counts, each with a weight.
JSON (the ending's resolution):
"resolution": {
"kind": "scoring",
"objective": "maximize",
"winner_rule": "highest_metric",
"metric": {
"authored": { "level": "formula", "preset": "sum_cards",
"params": { "zones": "draw_pile, hand, in_play, discard", "field": "vp" } }
}
}
The metric runs once per player at game end with that player bound — so
per-card ƒx attributes (Gardens) count the right person's deck. Per-player scores
land in the result ("scores": {"p0": 23, "p1": 31}) and the sim report.
Ties stay draws — no winner is ever invented. Raw JSON Logic metrics (what the form
recipes generate) work too; winner_rule: "lowest_metric" or
objective: "minimize" for point-avoidance games.
Custom actions — define your game's verbs (user-definable actions)
Expresses: any turn-verb the built-ins don't cover — Jaipur's take/trade/sell, "discard 2 to gain 1 gold", drafting, set cash-ins.
Form: Anatomy · Actions → Custom actions → pick a preset, fill its slots (zones, filters, counts, token maps), save. Enable it, allow it in a phase, bind costs/limits — exactly like a built-in. The raw step composer hides behind Advanced for the ~1% the six presets don't cover.
JSON (game-def): preset instance or raw steps —
"action_definitions": {
"take_good": { "label": "Take one good",
"authored": { "preset": "take_cards",
"params": { "from": "market", "to": "hand", "count": 1, "filter": { "tag": "good" } } } },
"sell_goods": { "label": "Sell a set", "steps": [ …composer steps… ] }
}
Presets ship in assets/data/action-presets.json (adding one there = a
new reusable action for every game, no PHP). Step vocabulary, selection linking
($sold.count), when-conditions and the handler escape
hatch: see the action contract.
Live proof: jaipur.json — the first
non-deck-builder, its entire turn structure user-defined.
Card sets & named conditions — combos, requires, conditional effects
Expresses: "if I hold 3 treasures of the same kind I may melt them for 6 coins" (combo legality) · "this effect only fires while my energy ≥ 5" (conditional effects, SI1) · "this modifier is active while I hold the set" · any reusable boolean authored once, referenced by name.
Forms: Step 2 § Card sets (zones + filter + count + same) and Step 2 § Conditions (recipe picker: holds-a-set, tracker-vs-value, card-count, zone-empty, card-context recipes, free JSON). Then wire them:
| Where | Form | What it does |
|---|---|---|
| Action legality | Anatomy · Actions → Bindings → Requires select | the action is only offered/legal while the condition holds — for built-ins AND custom actions |
| Per-effect | Card editor → effect row → Condition select | effect silently skipped (and logged) when false; the SUBJECT is bound as player |
| Custom action steps | composer: {"do": "choose_cards", "set": "id", ...} | the set fills from/filter/count/same; add "auto": true to grab the set with no decision |
| Anywhere JSON Logic goes | win conditions · step when: · modifier active_while.when | reference as {"named": "id"} |
JSON (game-def):
"card_sets": {
"treasure_triple": { "label": "Three treasures of a kind",
"from": ["hand"], "filter": { "type": "resource_card" },
"count": { "min": 3, "max": 3 }, "same": "name" } },
"conditions": {
"can_treasure_combo": { "label": "Holds a treasure triple",
"authored": { "preset": "has_set", "params": { "set": "treasure_triple" } } } },
"action_definitions": {
"treasure_combo": { "label": "Melt down a treasure triple", "steps": [
{ "do": "choose_cards", "set": "treasure_triple", "auto": true, "as": "trio" },
{ "do": "move", "cards": "$trio", "to": "trash" },
{ "do": "adjust_tracker", "tracker": "coins", "amount": 6 } ] } },
"action_bindings": {
"treasure_combo": { "requires": { "named": "can_treasure_combo" } } }
Editing a condition propagates everywhere: references resolve by runtime lookup,
never inlining. Unknown ids warn and evaluate false — a typo disables the
gate visibly instead of opening it. Live proof: the block above, in
dominion-verification.json —
fired in 20/20 greedy games (174 activations), every grab logged as an
auto_set decision, so "does my combo ever happen?" is now measurable.
Contract: sets & conditions.
Statuses — temporary behaviours with expiry (U2)
Expresses: "stun that unit for 2 turns" · "your Permanent becomes an Outpost until your next turn" · "mark a card, then destroy all marked cards" · cleanses.
How it works: a status GRANTS a behaviour at runtime:
CardInstance.statuses[] = [{behaviour, value, duration}]. Every behaviour gate and
filter reads the effective set (printed + statuses) — a status-granted Outpost blocks face
attacks exactly like a printed one, and {"behaviour": "Stunned"} filters match it.
Durations count TURN ENDS (any player's); null/omitted = permanent until removed.
Restacking the same behaviour adds values and keeps the longer duration. Statuses tick down
AFTER turn_ended triggers run, so "until end of turn" effects still see them.
Form: enable the Apply Status / Remove Status mechanics on
Step 2 § Effects vocabulary, then use the apply_status_to_cards /
remove_status_from_cards effects in the card editor — behaviour (text), duration
(turns), and the usual chooser/count/filter card selection. Remove only offers cards that
actually carry the status.
{ "effect_key": "apply_status_to_cards", "trigger": "on_play",
"subject": { "who": "opponent" },
"parameters": { "behaviour": "Stunned", "duration": 2,
"cards": { "source": "player", "chooser": "actor", "count": 1 } } }
Applications land in history as {type: "status"} entries; expiry is
silent (the application was recorded). Statuses have no semantics of their own — they make
behaviours true, and behaviours carry the semantics (Outpost, Permanent, or your own gates).
Activated abilities — tap a card for an effect (U3 / S2)
Expresses: Star Realms bases ("tap for 2 Trade"), mana rocks, repeatable engine pieces. The TcgEngine study supplied the reference shape (cost + exhaust).
How it works: a card effect with trigger: "activated" never fires
from a trigger path — while the card is in play it is OFFERED as an
activate_ability action (one per card+effect) whenever it is untapped, its
activation cost is payable, and its condition: (Sets+Conditions) holds. Applying it
pays the cost, sets exhausted when activation.exhaust is true, and runs
the effect. Exhausted cards untap at their OWNER's turn start; without exhaust the ability
repeats while affordable. Enable the activate_ability verb like any action
(Anatomy · Actions; allow it in phases; bindings — costs, uses, Requires — apply).
{ "effect_key": "gain_n_resource_to_self", "trigger": "activated",
"activation": { "cost": { "energy": 1 }, "exhaust": true },
"parameters": { "resource_type": "trade", "amount": 2 } }
Form: card editor → effect trigger activated shows the activation fields (cost tracker + amount, exhaust). Rules text renders as “Activate: …”.
Live proof: Starforge Rivals' Salvage Outpost is now “Activate (tap): gain 2 Trade” — activations fire in 17/20 greedy games, zero setup warnings, game profile unchanged vs baseline. This closes finding S2.
Combat events, choose-one, selectors, event-card targets, tokens, dice (U4–U9)
| Tool | Spelling | Notes |
|---|---|---|
| Combat events (U4) | pattern triggers on attack_declared / attack_resolved (filters: actor, target_kind card|player) and card_killed (filter: owner) | emitted around attack_card / attack_opponent and on attribute depletion |
| Choose one (U5) | effect choose_one_of with options: [{label, effects: [entries]}] | bots pick from the game RNG; the pick is a logged decision |
| Auto-selectors (U6) | selection spec gains "select": "first" | "random" | "highest:<attr>" | "lowest:<attr>" | no player decision — deterministic (random uses the seeded RNG); "buff my strongest unit" |
| Event-card target (U7) | "cards": {"source": "event_card"} on a pattern-triggered effect | the effect hits THE card from the event — “when an enemy unit dies, sap its neighbour” style; only meaningful on pattern triggers (validator warns otherwise) |
| Tokens (U8) | effect create_card_in_zone with design (card name) + amount; effect to zone or in_play | runtime card creation (tokens, spawns) — nothing to do with NFTs. Emits card_moved so “enters play” subscriptions fire |
| Dice (U9) | effect roll_dice {sides}; read back anywhere with the {"rolled_value": []} op | seeded — replays reproduce the roll; combine with conditions (“on 4+…”) |
Runtime card attributes — morale, health, anything (card-state tier)
Expresses: "reduce a chosen unit's morale by 2" · "when morale hits 0 the unit is discarded" · "+2 morale to all your units while this banner is out" · Star-Realms-style destructible bases.
| Piece | Where | JSON |
|---|---|---|
| Damage/buff a card | card editor → effect adjust_target_card_attribute | {"effect_key": "adjust_target_card_attribute", "parameters": {"field": "morale", "amount": -2, "cards": {"source": "player", "chooser": "actor", "filter": {"type": "unit"}}}} |
| Depletion rule | Anatomy · 5 "Attribute rules" | "attribute_rules": {"morale": {"min": 0, "on_deplete": {"move_to": "discard"}}} |
| Aura | card editor → modifier | {"property": "attribute:morale", "operation": "add", "value": 2, "target": {"who": "controller"}} |
| React to it | trigger pattern | {"event": "card_attribute_adjusted", "field": "morale", "direction": "down", "owner": "self"} |
The printed attribute is never mutated: adjustments land on the instance's
counters, and every read asks for the EFFECTIVE value (printed ƒx base
+ counters + auras, clamped by the rule). Depletion fires on_destroy
and moves the card — the defeat transaction, per attribute. Any depletable attribute
also makes the card a COMBAT target: the attack pool can destroy it for exactly its
remaining points (attack_card), and a card with the Outpost
behaviour blocks attacks on the player until it falls. Scoring
(sum_cards) sees base + counters. Only cards that PRINT the attribute
can deplete or be targeted by it.
The card editor — authoring cards by hand
editCard.php?card_id=… (every card row's Edit button) or
editCard.php?deck_id=… ("Create card manually" on the cards page).
Built against the card contract: every
structure the engine understands, in one form.
| Section | Authors | Fed by |
|---|---|---|
| Identity | name · description · Family + Tags (both write tags[]) | — |
| Classification | type · subtype (incl. your custom subtypes) · behaviours | schema_map, allowed_behaviours |
| Cost / Attributes / Copies | every value ƒx-capable | currencies · attribute suggestions (advisory) |
| Effects | effect + trigger (preset or pattern builder) + subject + from/to + parameters incl. player-choice specs | enabled vocabulary, event/filter map, zones, trackers |
| Modifiers | property · operation · value ƒx · target (+type/tag filter) · active zone | modifiable-properties.json |
| Meta | distribution meta_tags · image prompt; gen shown read-only | deck distribution groups |
Validate (dry run) returns warnings + the composed rules text
without writing; Save stores with warnings shown (never blocked);
Archive soft-hides the card. Fields the editor doesn't own —
skeleton markers, unknown keys — survive every save verbatim (merge, never
rebuild). ƒx formulas compile server-side; a broken formula is the one thing that
refuses to save. Endpoint: ajax-card-save.php.
The import workflow (games as code)
php8.2 game-tool.php list php8.2 game-tool.php export <gameUniqueId> > game-defs/my-game.json php8.2 game-tool.php import game-defs/my-game.json # replaces same-name game php8.2 run-sims.php <gameUniqueId> 100 greedy # then simulate
- Import never fails on card problems — it prints the gap report: validation problems (unknown effects, bad subjects/triggers/formulas) and setup warnings. Read it; it is the honest list of what the model didn't understand.
- The import re-runs every normaliser and compiles every
authoredformula — a game-def with authored-only ƒx values is fine. - New
game_metakeys must be inDC_IMPORT_META_KEYS(inc-game-import.php) or they are silently dropped — the known trap, see the tracker. - Import creates a NEW gameUniqueId (same-name replace) — re-grab it with
listbefore simulating.
Checklist — modelling a new game end to end
| # | Step | Where |
|---|---|---|
| 1 | Trackers (budgets ƒx if player-count-dependent; regenerates for per-turn budgets) | Step 2 or meta.trackers |
| 2 | Zones (per-player + shared piles; auto_refill for market rows; setup.start_filled_from) | Step 2 or meta.game_zones |
| 3 | Card types + effects vocabulary (enable the mechanics the cards need) | Anatomy · 1 / Step 2 · Effects |
| 4 | Phases + enabled actions | Anatomy · 2/3 or meta.phases/actions |
| 5 | Action bindings — costs, use limits, phase filters | Anatomy · Actions → Bindings or meta.action_bindings |
| 6 | Setup spec (starter deck, hand size ƒx, deck→zone map) | Anatomy · 4 or meta.setup_spec |
| 7 | Cards: effects (+ subject/pattern triggers where needed), attributes.vp (ƒx for Gardens-likes), copies ƒx for scaled piles | JSON (decks[].cards) or generation |
| 8 | Win conditions with a scoring resolution if points live on cards | Step 2 · Win conditions or meta.ending |
| 9 | Import, read the gap report, fix, re-import | game-tool.php |
| 10 | Simulate both bots, several player counts; check winners, turn lengths, dead cards, first-player advantage | run-sims.php / the playtest report |
Live reference implementations: dominion.json (uses every capability on this page) · starforge-rivals.json · ascension.json.