Decisions  ›  Rules & primitives

Rules, primitives & actions

Decisions taken on 3 August 2026, after authoring a complete game (Starforge Rivals) through the real UI. These supersede parts of the earlier docs: where this file and Modelling a card game or the Game object plan disagree, this file wins.

DECIDED — ready to build against Supersedes: action catalog as a flat list; mechanics-*.json layering

Why this file exists

The earlier documents were written before anyone had authored a real game. Doing that surfaced two things: the model is sound, but the action layer is both under-specified and over-enumerated. This file records what we settled on, so the engine gets built against decisions rather than guesses.

The governing principle: the platform never says “that mechanic is not allowed.” It ships sensible defaults, warns when something looks orphaned, and lets the designer express anything the primitives can compose — including combinations nobody has published yet.

D1 · Three layers, and only one of them is code

LayerWhat it isWho authors it
Primitives The only operations that mutate state. Implemented in PHP. Us, once. Never authorable.
Composites Named, parameterised bundles of primitives — buy_card, play_card, draw. Pure data. Shipped as presets; new ones need no engine work. Us as presets; designers as custom entries.
Bindings This game's parameters for a composite: which zones, which tracker pays, how many cards. The designer, on the Rules step.

The composite layer earns its place even though it adds nothing the primitives can't express: players and designers think in verbs (“buy”), cost and legality attach to a whole transaction rather than to each primitive, simulation reports read in the designer's vocabulary, and the AI needs a small named set to generate cards against.

This also resolves the muddle in mechanics-core.json / mechanics-effect.json / mechanics-system.json, which currently list primitives (move_card), composites (draw_cards), a legality rule (check_target_validity) and a trigger (combo_trigger) side by side as if they were peers. Those three files get re-sorted into the layers above; legality and triggers move out of “mechanics” entirely.

D2 · The seven primitives

Closed set. Everything else composes from these.

PrimitiveParametersNotes
move_cards from-zone, to-zone, selector, position selector: top N · bottom N · player-chosen N · all · matching-filter
reorder_zone zone, mode shuffle (seeded RNG) · reverse · player-ordered
adjust_tracker target, tracker, delta | set-to, clamp target may be a player, a team, or the shared scope
set_card_state card, field, value face up/down · exhausted/ready · attached-to. The concept ready_cards implies but nothing models today.
create_card / destroy_card definition, zone, owner / card tokens and summons — instances with no deck of origin
advance_flow end phase | end turn | set active player the only primitive that touches flow
add_modifier / remove_modifier target, what it changes, operation, duration, source “+2 attack until end of turn”. Added 2026-08-03 — see D9. Target may be a card or a player; duration may be end-of-turn, while-source-in-play, N turns, or permanent.

A card's effects are themselves composites of these, so “fire the card's effects” is recursion, not another primitive.

Why the seventh was needed. adjust_tracker changes a player's number and set_card_state flips a discrete flag, but neither expresses “this card's attack is 2 higher until the turn ends.” The runtime state has carried modifiers: [] on both CardInstance and Player since 3.8 — serialized, restored, and completely unwritable, because no primitive produced one.

D3 · Today's nine actions, decomposed

Five of the nine catalog entries are the same primitive with different parameters. This is what makes the composite layer data rather than code.

ActionDecomposes to
play_cardadjust_tracker (pay) → move_cards (hand → in_play, chosen 1) → run card effects
buy_from_marketadjust_tracker (pay) → move_cards (market → discard, chosen 1). Refill is the zone's own rule, itself a move_cards.
draw_n_cardsmove_cards (deck → hand, top N)
discard_n_cardsmove_cards (hand → discard, chosen N)
discard_zonemove_cards (zone → zone, all)
attack_opponentadjust_tracker (spend combat) → adjust_tracker (target's life −X)
pass / end_phaseadvance_flow
activate_abilityblocked references card abilities that do not exist in the card schema. Either add an abilities[] field to card types, or drop it from v1.

Acquisition needs no further actions: buying, drawing, tutoring, stealing and recruiting are all “move a card into a zone I control” with a different from-zone.

D4 · Actions are enabled and bound, together

Enabling an action must bind its parameters to this game's zones and trackers, chosen from dropdowns and pre-filled from the preset. Not doing this is a live bug:

buy_from_market hardcodes target_spec.zone = "market". In Starforge Rivals the market zone is called trade_row; no zone named market exists. The action shows ✓ ready and points at nothing. Meanwhile requires.shared_zone_present only checks that some shared zone exists — it never binds which.

Gating stays, but as a consequence of binding rather than a separate rule: if no zone can satisfy a shape's from-parameter, the preset can't be configured and is offered greyed, with the reason. Phases keep referencing actions by id, which already works.

D5 · Currency is a binding, never a tracker property

We will not hardcode which trackers may be spent. Paying life to buy or to act is a published mechanic (Magic's Phyrexian mana; “pay 7 life, draw 7”), and a model that forbids it is already too narrow.

Spendability is a property of the transaction, not the tracker — Combat is not un-spendable, it is spent on attacking. So the cost tracker is one of the parameters bound in D4:

buy_card:   { from: trade_row, to: discard, cost: { source: card.cost, currency: trade } }
attack:     { cost: { tracker: combat }, applies_to: { tracker: authority, of: opponent } }

A game where cards are bought with life sets currency: authority. Nothing in the engine objects. Multi-currency costs (“2 wood + 1 brick”) are expressible because a binding may name several allowed denominations.

An optional tracker hint — spendable, defaulting true for role: resource and false for role: score — may exist to pre-filter dropdowns and to warn (“nothing can ever spend this tracker”). It is advisory only and must never block a binding.

The data for this partly exists already: the catalog declares default_cost_tracker: "trade" for buying and "combat" for attacking. A grep for those keys across the whole codebase returns zero consumers — which is exactly why the generator priced a card at {trade: 3, combat: 2}.

D6 · The card model — three axes, content, metadata rewritten 2026-08-03 — to be curated

Re-evaluated from scratch, grounded in the five consumers of card data: the engine (legality + resolution), other cards' rules (group references), the AI generator (constraints + steering), the renderer (template + fields), and balancing (copies, distribution, reports). The rule: every axis must answer a question no other axis answers, and each consumer gets exactly one place to look.

The three classification axes

AxisCardinalityThe question it answersWho reads itContains
Typeexactly 1 What is this, structurally? Engine (zones' accepts, legality), renderer (template), AI (which fields to fill) unit, structure, event… Defines base fields + which behaviours are allowed.
Behaviours0..n How does the engine treat it? Engine, always Yes/no mechanical keywords: Permanent, Strike, Outpost (guard), Blitz (attack on deploy). Each may requires_field / removes_field.
Families0..n (v1 enforces max 1) What group can rules point at during play? Engine (counting/targeting: “each Blob you control”), AI (theming), distribution Per-game authored groups — the guilds/factions: {id, label, color, description}. Currently an empty scaffold in card_families; needs building.
A card's shape is not the type alone. Behaviours add and remove fields (Strike removes health, Targetable adds defence). Shape = type's base fields plus the behaviours' additions/removals. The type is the starting point and the menu; the behaviours finish the shape. The behaviours catalog already implements this.

Subtype: a shortcut, not an axis

A subtype is a pre-configured instance of a type — a shortcut that, when chosen, sets values on the real axes: its parent type, some forced behaviours (forces_behaviours), stat tendencies, and AI hints (“glass cannon”). The engine never reads it; by the time the engine looks, the subtype has already become type + behaviours + fields. It is stored on the card (subtype: "unit__stealth") as provenance and for re-generation consistency. The catalog's own _meta already says most of this; the correction is that a subtype can force behaviours, so “the engine ignores it” is true only because its effects are materialised at authoring time.

MTG's creature types (“other Elves get +1”) look like subtypes but are mechanically families — addressable groups. That is why the router below asks the family question before the subtype question.

The router — where does a new idea go?

  1. Does it change what fields the card has, or where it can be? → Type
  2. Is it a yes/no keyword that changes engine handling? → Behaviour
  3. Do rules need to reference the group during play? → Family
  4. Does it only steer generation? → Subtype (a preset)

The card definition, final shape

{
  "name":        "Salvage Vanguard",
  "type":        "unit",                        // axis 1 — exactly one, root id
  "behaviours":  ["Permanent", "Blitz"],        // axis 2
  "families":    ["scrapper_guild"],            // axis 3 — array; v1 uses max 1
  "subtype":     "unit__stealth",               // preset used; provenance only
  "cost":        { "trade": 3 },                // content — denominated per D5
  "attributes":  { "attack": 2, "health": 3 },  // content — shaped by type+behaviours
  "effects":     [ { "effect_key": "…", "trigger": "on_play", "parameters": {} } ],
  "description": "…",
  "gen":         { "img_prompt": "…", "strength": "medium" }   // engine-invisible
}

Normalisation on save (same pattern as the 3.4 trigger work): the AI keeps emitting one label (unit__stealth); the save path splits it into type root + subtype, fills the so-far-unused card_type/card_subtype columns, and coerces families to an array. meta_tags dissolves later (its type → axis 1, strength → gen, family → axis 3) — the distribution queries still read it, so that is a separate curated step, not part of this change.

Decisions taken rather than reopened: family membership is max one per card in v1 (Star Realms model) with the schema already array-shaped per D9, so multi-guild is an enable, not a migration; the schema term stays family — “Guilds” is what a given game may name its families.

D7 · A Rules step, placed after Decks

Phases, actions and setup reference zones, trackers, card types and decks. Today they live on Anatomy, which runs before Decks, so the deck→zone mapping asks for deck ids that will not exist for two more steps — the UI admits it: “Decks not authored yet show as a free-text field.”

New order, so every reference resolves at the moment it is asked for:

1. Briefing
2. Skeleton      trackers · zones · win conditions · effects vocabulary
3. Card types    the shapes cards come in
4. Decks         containers + frequency distribution (needs card types)
5. RULES         phases · actions (enable-and-bind) · setup   ← everything it references exists
6. Cards         generated knowing the rules
7. Layout

Card types move ahead of Decks because the frequency distribution already slices by type. Cards move after Rules, which also makes generation better informed.

D8 · Design is iterative; the tooling must assume it

A game is authored, simulated, found wanting, and revised — several times. Two consequences:

  • Suggestions become proactive. The machinery already exists: effects and actions grey out when prerequisites are missing and un-grey live when the piece is added. The change is direction — from “this stays grey until you add a life tracker” to “you added Authority; deal_damage and attack_opponent just became possible, enable them?”
  • Referential integrity becomes load-bearing. v2 means renaming a tracker and deleting a zone that other things point at. SchemaStore.findReferencesTo() stops being a nicety.

And the reason the simulator comes before polish: a design can't be validated statically. You run it.

D9 · Wire everything; ship a subset. What an “ability” actually is

We are not building a deck-builder tool. The primitives must be able to express the whole 95% band — deck-builders, set-collection, drafting, trick-taking — even while only a small subset is switched on for testing. Anything left out now is left out as a disabled feature, never as a missing wire.

“Ability” is three different things

KindExampleWhat it needs
Activated ability “Tap this Base for 2 Trade” No new primitive — it composes from pay + exhaust + effect. What is missing is a place on the card to write it (abilities[] on the card type) and a use-limit counter.
Temporary modifier “+2 attack until end of turn” The new primitive. Attaches to one card or one player, with a duration the engine expires.
Continuous, dynamic scope “All your units get +1 while this is in play” Same modifier, but membership must be re-evaluated whenever a unit enters or leaves play. This is the layer problem that makes TCG engines hard. deferred — the field carries a scope so it can be added later without a schema change.

The capability registry

Every primitive, composite and feature is declared in one registry with an honest status, and an admin page lists them so support is switched on step by step.

StatusMeaning
implementedBuilt, tested, usable.
partialSome of it works; the gap is stated.
plannedWired in the schema, not yet executable. Shows greyed in the authoring UI with the reason.
ceilingDeliberately out of scope for the foreseeable term.

This fixes a trap we have already hit: the action catalog contains activate_ability, described as “Card defines the ability's cost and effect” while no such field exists anywhere — and the authoring UI happily shows it ✓ ready. Declared-but-not-implemented becomes a visible state instead of a lie.

Where the ceiling honestly sits (from the six-game analysis in the model doc): the resolution stack with priority windows, dynamic-scope continuous effects, per-instance rewriting of a card's text, and simultaneous-play phases. Those separate “a card game engine” from “Magic”, and stay ceiling until something forces them.

What this changes in the engine plan

WasNow
apply() switches over an open-ended action list, one handler per action Six primitive handlers plus a composite executor that walks a bound action's primitive list
Legality read from per-action ad-hoc rules Legality derived from the binding: does the from-zone hold a card, can the cost tracker pay, does the to-zone accept the root type
AI prompted from the static catalog AI prompted from this game's bound actions — otherwise it keeps inventing costs in the wrong currency

Everything already built stays: the Game object, the flat zone map, the seeded RNG, the JSON Logic evaluator, unified trackers, effect triggers. This only reshapes apply()'s interior and the action data feeding it.

Evidence — what authoring a real game proved

Starforge Rivals (eb362133ac184632000ad893d754d6e4) was authored end to end through the UI on 2026-08-03. All earlier games in the database are testing scratchpads and prove nothing about the authoring model.

FindingStatus
Recipes produce a coherent game from a blank start; cross-recipe references agreed (zone draw_pile ↔ cleanup phase's from-zone)model holds
Win-condition builder emits exactly the JSON Logic the evaluator consumes, with a plain-English previewworks
Dead end after saving decks; starter deck never created; setup spec never persistedfixed 2026-08-03
Card generation broken on MariaDB (MySQL-only ->> operator)fixed 2026-08-03
Generated card priced in Combat; default_cost_tracker has zero consumersD5
Generated card typed unit__stealth; card_type/card_subtype columns unusedD6
buy_from_market hardcodes zone "market"; game's zone is trade_rowD4
Setup asks for deck ids before decks existD7
Zone accepts is free text; refill dropdown is an unimplemented stubopen

The four questions — answered 2026-08-03

#QuestionDecision
Q1Cards that act while in play Wire it, ship it disabled. The abilities[] field, the modifier primitive and the use-a-card-in-play composite all get built into the schema now, and sit planned in the registry until the engine can run them. See D9.
Q2Choice explosion Keep one configurable action for now (a single deal_damage with parameters rather than an enumerated variant per target); revisit when a real board gets crowded enough to hurt.
Q3Rules that aren't settings (“follow suit”) Pending until needed. The evaluator can already express it; the UI stays unbuilt.
Q4Ready-made example games Yes, after the generic system works. Not a funnel, and not before the engine runs.

The original wording of each question is kept below for context.

Q1 · Should a card be able to do something while it sits on the table?

In Star Realms a Base stays in play and you may use it on your turn — “tap this for 2 Trade.” Today a card can act when it is played, automatically at the start or end of a turn, or when it is destroyed. What it cannot do is sit there offering the player a choice. Nowhere in the card schema is there a place to write such an ability down.

OptionWhat it means
Build itCard types gain an abilities list; the Rules step gains an “use a card in play” action; the AI learns to write abilities. Bases, artifacts and tap-for-effect designs become possible.
Skip for nowCards act on play, per turn, or on destruction. A Base can still give a permanent bonus — it just can't offer an on-demand choice.

Recommendation: skip for v1. Nothing in the app can store it today, and the deck-builders we're targeting play fine without it. Add it the first time a design actually needs it.

Q2 · When an action has a huge number of possible choices, do we list them all?

“Deal 2 damage to a target” with thirty cards on the table is thirty different versions of the same action. The simulator works by laying out every complete option so a bot can pick one — and on a big board that list can get very long, which makes each simulated turn slower.

OptionWhat it means
Cap the listOffer at most 50 versions, picked reproducibly, so a re-run behaves identically. Fast, and the bot occasionally won't consider every possibility.
No capAlways exhaustive. A crowded board could make a thousand-game run slow.

Recommendation: cap at 50 and log whenever the cap is actually hit, so we find out whether it ever matters instead of guessing.

Q3 · Do we support rules that can't be expressed by filling in a form?

Most rules are settings: which zone, how many cards, what it costs. Some are not. In a trick-taking game, “you must play the suit that was led, if you have one” is a condition, and no amount of dropdowns expresses it.

OptionWhat it means
Add a condition builderThe Rules step gains a way to write “this action is only legal when…”. Cheaper than it sounds: the engine behind it is the same one already powering win conditions.
Leave it outDeck-builders, set-collection and drafting games work. Trick-taking games do not.

Recommendation: leave the UI unbuilt until you want a trick-taker. The underlying mechanism already exists, so this stays cheap to add later.

Q4 · Do we ship ready-made example games to start from?

Starting from a blank game stays fully supported either way — that is settled. The question is only whether we also ship a couple of complete, pre-filled games (a deck-builder, a trick-taker) that a designer can open and edit instead of building from nothing.

OptionWhat it means
Ship themA newcomer sees a working game immediately and edits rather than specifies. They also serve as the engine's regression tests.
Don'tEvery game starts blank. More freedom on the first screen, more decisions before anything runs.

Recommendation: yes, but later — and the first one is free, since Starforge Rivals can simply be saved as one.

(A fifth question — what happens to already-saved games when the steps are reordered — answered itself: step order is only navigation, the stored data is identical either way.)

Pending — the simulator arc written 2026-08-03, after 3.1–3.13 shipped

The full loop exists and runs: author → skeleton cards for free → simulate (random + greedy, web batches of 10 or CLI/cron) → report (win balance, end reasons, length, demand, dead cards) → replay any seed decision by decision. What follows is what is honestly not done, in priority order. This section is the working TODO for the engine; strike items as they land.

Findings — the Dominion probe 2026-08-03

Dominion was authored as a definition file (game-defs/dominion.json) and imported deliberately including everything we suspected DeckCraft cannot express. The import report and two simulated games recorded fourteen concrete gaps. Headline: the game ended on turn 1 with zero actions taken — the supply piles never filled, so “Province pile empty” was true at setup, and the unimplemented winner rule crowned p0 by fallthrough. Each row names the missing feature.

#ObservedMissing featureLands in
G1Every draw card flagged: no draw effect exists in effects.json (the mechanic exists; no catalog entry uses it)draw_n_cards_to_self added to effects.json; the engine branch already existed — the gap was pure vocabulary. Dominion seed 1 shortened from 74 to 41 turns with working draw. Broader breadth (scry, tutor, …) still open.shipped 2026-08-03
G2Council Room / Militia flagged: effects that target opponents (“each other player draws/discards”)Effect target scope: each_opponent / all_playersnew
G3Moat flagged: Reaction not in the behaviour catalogBehaviour entry is trivial; actual interrupt semantics are ceiling (resolution stack)ceiling
G4Gardens flagged: VP computed from state (“1 VP per 10 cards”)Derived trackers / computed metrics (model doc B.7)new
G5240 of 260 cards never instantiated; supply piles emptyFixed as agreed: setup is the game's first automatic phase — a step script (fill_zone / draw_n_cards) that is authored (setup_spec.steps) or COMPILED from deck_to_zone_map (by deck name), zones' start_filled_from, the starter deck and the feeder link, then stored visibly on the library.shipped 2026-08-03
G6Even with piles filled, buying is impossible: 16 supply piles, one bindable market zoneBuy composite accepts a set of market zones (any visible shared zone that refills or was deck-filled at setup); one action per distinct affordable design per zoneshipped 2026-08-03
G7Winner rule highest_tracker resolved by fallthrough (p0 “won”)P2 exactly — the other five ending resolutions, now reproducedshipped 2026-08-03
G8An ending true at setup ends the game on turn 1 with 0 actionsGuard: endings evaluated only after setup completes; a condition already true at setup is an authoring warning, not a resultshipped 2026-08-03
G9Playing an Action card should spend 1 from the actions tracker; play is freePer-play tracker costs on the play binding (D4 binding parameter)P7
G10Any card is playable in any player-driven phase (Treasures during Action phase, Estates anytime as no-ops)Per-phase card-type filters + an “unplayable from hand” property for Victory cardsP7
G11Buys per turn unlimited; the buys tracker is decorativePer-turn action-use limits fed by a tracker (binding parameter)P7
G12Real Dominion scores by summing a field over cards you own; inexpressibleEvaluator aggregate sum_cards(zone-set, field)new
G13“Any 3 supply piles empty” unwritable (documented inside the definition file itself)Evaluator count_zones_where (its own TODO #5)new
G14Union-of-actions warning fired again (phases reference cleanup helpers not in the enabled list)Already known (Q3-adjacent authoring warning); harmless

Reading of the probe: the model held — trackers, zones, phases, types and cards all expressed Dominion's structure without contortion. What failed is concentrated in three places: setup's deck→zone plumbing (G5/G6), ending resolutions (G7/G8), and the binding parameters the Rules step was always going to own (G9–G11). Nothing new was wrong with the axes.

Findings — the Ascension probe 2026-08-03

Ascension (game-defs/ascension.json): center-row deck-builder with dual currencies, defeat-to-void, and a shared honor pool. Much of it worked on arrival — three market zones bound (center row + both always-available piles), the row refilled to 6 from its feeder, Runes-and-Power dual costs priced correctly via the currency fallback, and Permanent constructs generated per-turn income. Seed 1: p1 ended with 88 honor to p0's 53 — and the game was declared a draw. The gaps:

#ObservedMissing featureLands in
A1Defeated monsters went to the buyer's discard and got drawn later; the Void ended the game emptyAcquisition destination per action/card class: defeat = pay power, card to the void, reward fires — a different transaction from buyingnew
A2on_defeat triggers were silently rewritten to on_play by normalisation; validation runs after it and saw nothingThe trigger vocabulary needs on_defeat/on_acquire; and unknown triggers must WARN, not coerce silentlynew
A3Players gained honor all game; the shared pool never moved; the authored ending (“pool exhausted”) was unreachableEffects that touch shared-scope trackers (drain/transfer from a pool)new
A4terminates_at: end_of_round authored, ignoredEnding timing: immediate vs end-of-turn vs end-of-roundP2 tail
A588 vs 53 honor resolved as a draw at the turn limitTurn-limit fallback must use a score-role tracker when no life-role existsquick fix
A6“Always available” Mystic/Heavy piles sold out at exactly 20 copiesUnlimited-supply zones (or a refill-from-nothing policy)minor
A7Honor printed on owned cards uncounted (same family as Dominion's G12)Score-from-cards: sum_cards / resolution.metricG12
A8The deliberate typo effect key was the ONLY validation flag — caught correctly— (positive control; validation works)

Reading: G5/G6/G1 already paid off — the market machinery, setup fills and draw effects carried a second genre with zero new authoring gaps. Ascension's genuinely new asks are the defeat transaction (A1/A2) and shared-pool effects (A3); everything else is polish on known items.

Next — highest value per effort

ItemWhy it matters
P1 · Card-targeted combat v1 sends ALL damage to the opponent's life tracker. Structures are authored Targetable with a defence value and can never actually be attacked; Outpost/guard semantics (must be destroyed first) are not enforced; on_destroy triggers can never fire because nothing destroys cards. This is the biggest gap between the authored vocabulary and what the engine runs.
P2 · All six ending recipes exercised winner rules + setup guard shipped 2026-08-03; per-recipe fixtures still pending Only the combat recipe (trigger_target_loses) is proven against a real game. race_to_n / most_at_end / multi_source_score / weighted_score / last_standing have different winner rules and none has been run end to end. Needs one authored fixture per recipe.
P3 · Missing trigger dispatch at_end_of_turn is authorable on effects but the engine never fires it (start-of-turn and on_play are wired; on_destroy blocked on P1).
P4 · Stats keyed by card id, not name The report keys buy/play counts by card name. Two designs named "Salvage Raider" already merge, and the dress pass will rename skeletons, fragmenting history. Key by unique_card_id, display the name.
P5 · The dress pass Skeletons carry gen.skeleton=true but the pass that AI-names/flavours/arts a proven card set does not exist yet, and the cards page has no button for bulk skeletons (endpoint only).

Soon — unblocks depth

ItemWhy it matters
P6 · Composites as data (finish D1) engineApply() is a PHP switch: what buying MEANS is code. Moving each composite's primitive list into the catalog makes custom player actions pure data — the last step of the three-layer promise. Wait until a design needs an action the six composites can't express, then do it.
P7 · Rules step UI (D4/D7) Bindings are derived by heuristics at setup (correct for Starforge, guessed in general). The enable-and-bind UI, stored action_bindings, and the step reorder (Rules after Decks) remain unbuilt; the Anatomy setup section still asks for deck ids before decks exist.
P8 · Planned primitives modifier (the 7th primitive — schema wired, engine can't run it), set_card_state (exhausted/attached fields missing), create/destroy_card (tokens). Plus abilities[] on card types and the use_ability composite (Q1: wire-but-disabled — the wiring itself is still pending).
P9 · Seat-order experiments p1 wins 57% (random) / 60% (greedy) — structural second-player advantage. The classic fix is per-seat opening hand size; setup_spec needs a per-seat field and the report a per-seat comparison so the fix can be measured.
P10 · Bot ladder Parameterised heuristic bots (aggro vs economy weights) turn the simulator into a strategy playtester; MCTS later. Requires viewFor() redaction first so smarter bots can't read hidden state (random/greedy don't read state at all).

Later / when it hurts

ItemNote
Replay viewer depthAutomatic phases (cleanup's discard/draw) show consequences but not their own rows; hand contents shown as counts only.
Sim throughput~30–40 ms/game; the ending check serializes full state every apply. Lazy re-evaluation (only when a referenced tracker changed) is a ~10× win. Irrelevant until batches feel slow.
3+ players / teamsOpponent resolution is two-player; per-team trackers unconsumed. Untested beyond 2p.
Config surfacingturn_limit and step_cap are engine defaults; expose them in the setup UI.
Authoring polish from the walkthroughZone accepts is free text; the refill dropdown is a stub; the status pill reads “0 effects” while 5 are enabled; AI duplicate card names uncontrolled.
Effects vocabulary breadthFour effects ship. Reports get interesting at 12–15 verbs; shield waits on the modifier primitive.
meta_tags dissolutionDistribution queries still read it (D6 leftover); migrate readers, then drop.