How-to  ›  Author mechanics

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.

written 2026-08-05 · covers everything shipped through G12

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):

PathWhen to useHow
Formsdesigning interactivelyStep 2 (trackers, zones, effects vocabulary, win conditions) and Anatomy (card types, actions + bindings, phases, setup)
Game-def JSONmodelling a whole game fast (probes), or card-level blocks that have no form yetwrite game-defs/<name>.json, then php8.2 game-tool.php import game-defs/<name>.json over SSH

Form coverage today, honestly:

CapabilityFormJSON-only parts
ƒx valuesTrackers (starts/min/max) · Setup (hand size) · Zones (refill target)card attributes / cost / copies — no card editor exists
Effect subjects, triggers, decisionscatalog presets appear automatically in the Effects vocabularythe per-card blocks (subject, pattern trigger, selection specs)
Action bindingsAnatomy · Actions → Bindings button (Rules page v1)
ScoringWin 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" }
FieldValues
subject.whoself · opponent · each_opponent · all_players — unknown = hits NOBODY + warning, never self
tozone 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
} ]
PropertyReads as
buy_costmarket prices (filter by card type: "Silvers cost 1 less")
play_costthe action-binding cost of playing
draw_amountdraw effects draw more/fewer
damage_dealtthe OWNER's damage effects hit harder
action_legalityoperation: "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:

WhereFormWhat it does
Action legalityAnatomy · Actions → Bindings → Requires selectthe action is only offered/legal while the condition holds — for built-ins AND custom actions
Per-effectCard editor → effect row → Condition selecteffect silently skipped (and logged) when false; the SUBJECT is bound as player
Custom action stepscomposer: {"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 goeswin conditions · step when: · modifier active_while.whenreference 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)

ToolSpellingNotes
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 effectthe 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_playruntime 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": []} opseeded — 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.

PieceWhereJSON
Damage/buff a cardcard 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 ruleAnatomy · 5 "Attribute rules""attribute_rules": {"morale": {"min": 0, "on_deplete": {"move_to": "discard"}}}
Auracard editor → modifier{"property": "attribute:morale", "operation": "add", "value": 2, "target": {"who": "controller"}}
React to ittrigger 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.

SectionAuthorsFed by
Identityname · description · Family + Tags (both write tags[])
Classificationtype · subtype (incl. your custom subtypes) · behavioursschema_map, allowed_behaviours
Cost / Attributes / Copiesevery value ƒx-capablecurrencies · attribute suggestions (advisory)
Effectseffect + trigger (preset or pattern builder) + subject + from/to + parameters incl. player-choice specsenabled vocabulary, event/filter map, zones, trackers
Modifiersproperty · operation · value ƒx · target (+type/tag filter) · active zonemodifiable-properties.json
Metadistribution meta_tags · image prompt; gen shown read-onlydeck 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 authored formula — a game-def with authored-only ƒx values is fine.
  • New game_meta keys must be in DC_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 list before simulating.

Checklist — modelling a new game end to end

#StepWhere
1Trackers (budgets ƒx if player-count-dependent; regenerates for per-turn budgets)Step 2 or meta.trackers
2Zones (per-player + shared piles; auto_refill for market rows; setup.start_filled_from)Step 2 or meta.game_zones
3Card types + effects vocabulary (enable the mechanics the cards need)Anatomy · 1 / Step 2 · Effects
4Phases + enabled actionsAnatomy · 2/3 or meta.phases/actions
5Action bindings — costs, use limits, phase filtersAnatomy · Actions → Bindings or meta.action_bindings
6Setup spec (starter deck, hand size ƒx, deck→zone map)Anatomy · 4 or meta.setup_spec
7Cards: effects (+ subject/pattern triggers where needed), attributes.vp (ƒx for Gardens-likes), copies ƒx for scaled pilesJSON (decks[].cards) or generation
8Win conditions with a scoring resolution if points live on cardsStep 2 · Win conditions or meta.ending
9Import, read the gap report, fix, re-importgame-tool.php
10Simulate both bots, several player counts; check winners, turn lengths, dead cards, first-player advantagerun-sims.php / the playtest report

Live reference implementations: dominion.json (uses every capability on this page) · starforge-rivals.json · ascension.json.