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.
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.
D1 · Three layers, and only one of them is code
| Layer | What it is | Who 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.
| Primitive | Parameters | Notes |
|---|---|---|
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.
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.
| Action | Decomposes to |
|---|---|
play_card | adjust_tracker (pay) → move_cards (hand → in_play, chosen 1) → run card effects |
buy_from_market | adjust_tracker (pay) → move_cards (market → discard, chosen 1). Refill is the zone's own rule, itself a move_cards. |
draw_n_cards | move_cards (deck → hand, top N) |
discard_n_cards | move_cards (hand → discard, chosen N) |
discard_zone | move_cards (zone → zone, all) |
attack_opponent | adjust_tracker (spend combat) → adjust_tracker (target's life −X) |
pass / end_phase | advance_flow |
activate_ability | blocked 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
| Axis | Cardinality | The question it answers | Who reads it | Contains |
|---|---|---|---|---|
| Type | exactly 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. |
| Behaviours | 0..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. |
| Families | 0..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. |
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?
- Does it change what fields the card has, or where it can be? → Type
- Is it a yes/no keyword that changes engine handling? → Behaviour
- Do rules need to reference the group during play? → Family
- 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_damageandattack_opponentjust 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
| Kind | Example | What 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.
| Status | Meaning |
|---|---|
| implemented | Built, tested, usable. |
| partial | Some of it works; the gap is stated. |
| planned | Wired in the schema, not yet executable. Shows greyed in the authoring UI with the reason. |
| ceiling | Deliberately 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
| Was | Now |
|---|---|
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.
| Finding | Status |
|---|---|
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 preview | works |
| Dead end after saving decks; starter deck never created; setup spec never persisted | fixed 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 consumers | D5 |
Generated card typed unit__stealth; card_type/card_subtype columns unused | D6 |
buy_from_market hardcodes zone "market"; game's zone is trade_row | D4 |
| Setup asks for deck ids before decks exist | D7 |
Zone accepts is free text; refill dropdown is an unimplemented stub | open |
The four questions — answered 2026-08-03
| # | Question | Decision |
|---|---|---|
| Q1 | Cards 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. |
| Q2 | Choice 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. |
| Q3 | Rules that aren't settings (“follow suit”) | Pending until needed. The evaluator can already express it; the UI stays unbuilt. |
| Q4 | Ready-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.
| Option | What it means |
|---|---|
| Build it | Card 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 now | Cards 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.
| Option | What it means |
|---|---|
| Cap the list | Offer at most 50 versions, picked reproducibly, so a re-run behaves identically. Fast, and the bot occasionally won't consider every possibility. |
| No cap | Always 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.
| Option | What it means |
|---|---|
| Add a condition builder | The 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 out | Deck-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.
| Option | What it means |
|---|---|
| Ship them | A newcomer sees a working game immediately and edits rather than specifies. They also serve as the engine's regression tests. |
| Don't | Every 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.
| # | Observed | Missing feature | Lands in |
|---|---|---|---|
| G1 | Every 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 |
| G2 | Council Room / Militia flagged: effects that target opponents (“each other player draws/discards”) | Effect target scope: each_opponent / all_players | new |
| G3 | Moat flagged: Reaction not in the behaviour catalog | Behaviour entry is trivial; actual interrupt semantics are ceiling (resolution stack) | ceiling |
| G4 | Gardens flagged: VP computed from state (“1 VP per 10 cards”) | Derived trackers / computed metrics (model doc B.7) | new |
| G5 | 240 of 260 cards never instantiated; supply piles empty | Fixed 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 |
| G6 | Even with piles filled, buying is impossible: 16 supply piles, one bindable market zone | Buy 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 zone | shipped 2026-08-03 |
| G7 | Winner rule highest_tracker resolved by fallthrough (p0 “won”) | P2 exactly — the other five ending resolutions, now reproduced | shipped 2026-08-03 |
| G8 | An ending true at setup ends the game on turn 1 with 0 actions | Guard: endings evaluated only after setup completes; a condition already true at setup is an authoring warning, not a result | shipped 2026-08-03 |
| G9 | Playing an Action card should spend 1 from the actions tracker; play is free | Per-play tracker costs on the play binding (D4 binding parameter) | P7 |
| G10 | Any 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 cards | P7 |
| G11 | Buys per turn unlimited; the buys tracker is decorative | Per-turn action-use limits fed by a tracker (binding parameter) | P7 |
| G12 | Real Dominion scores by summing a field over cards you own; inexpressible | Evaluator 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 |
| G14 | Union-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:
| # | Observed | Missing feature | Lands in |
|---|---|---|---|
| A1 | Defeated monsters went to the buyer's discard and got drawn later; the Void ended the game empty | Acquisition destination per action/card class: defeat = pay power, card to the void, reward fires — a different transaction from buying | new |
| A2 | on_defeat triggers were silently rewritten to on_play by normalisation; validation runs after it and saw nothing | The trigger vocabulary needs on_defeat/on_acquire; and unknown triggers must WARN, not coerce silently | new |
| A3 | Players gained honor all game; the shared pool never moved; the authored ending (“pool exhausted”) was unreachable | Effects that touch shared-scope trackers (drain/transfer from a pool) | new |
| A4 | terminates_at: end_of_round authored, ignored | Ending timing: immediate vs end-of-turn vs end-of-round | P2 tail |
| A5 | 88 vs 53 honor resolved as a draw at the turn limit | Turn-limit fallback must use a score-role tracker when no life-role exists | quick fix |
| A6 | “Always available” Mystic/Heavy piles sold out at exactly 20 copies | Unlimited-supply zones (or a refill-from-nothing policy) | minor |
| A7 | Honor printed on owned cards uncounted (same family as Dominion's G12) | Score-from-cards: sum_cards / resolution.metric | G12 |
| A8 | The 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
| Item | Why 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
| Item | Why 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
| Item | Note |
|---|---|
| Replay viewer depth | Automatic 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 / teams | Opponent resolution is two-player; per-team trackers unconsumed. Untested beyond 2p. |
| Config surfacing | turn_limit and step_cap are engine defaults; expose them in the setup UI. |
| Authoring polish from the walkthrough | Zone 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 breadth | Four effects ship. Reports get interesting at 12–15 verbs; shield waits on the modifier primitive. |
| meta_tags dissolution | Distribution queries still read it (D6 leftover); migrate readers, then drop. |