Plan  ›  Verb Forge

Verb Forge — primitives, verb presets, and the screen that ties them together

Today a game's verbs are half data, half PHP: play_card, buy_from_market, attack_opponent are hardcoded recipes with a JSON manifest beside them, while user-defined actions are fully composed data. Verb Forge removes the asymmetry: every verb becomes a preset instance the designer can enable, configure, rename and inspect, executed by a small closed set of engine primitives. Approved 2026-08-07; contract-first, checkpoint before build.

planned · not built ~60% of the machinery already ships

Why, in one screen's worth of evidence

The Star Realms core-set probe (49 designs, imported clean, 15/15 games ending by Authority in ~21 turns) left 40 approximations. Every one traces to a verb the platform can't express: scrap this card, destroy target base, put the next ship you buy on top of your deck. Meanwhile "scrap" turned out not to need a mechanic at all — it is move of the SOURCE card into an out-of-game zone. The gap was never semantics; it was that verbs aren't composable.

Principle: the engine owns a closed set of primitives. Everything a designer sees — including attack — is data composed from them, with a per-game name. Nothing that a game means should live in a switch.

A · The contract

A.1 · Primitives (engine, closed)

Eight state mutators plus the value-source argument type (which already exists as ƒx). Adding one is a code change, on purpose.

PrimitiveShapeStatus
move{from: zone|zones|$source, to: zone, which: selection|top|bottom|random, count}partial — exists for zones; needs $source (move the acting card) and position modes
adjust_value{target: {tracker|card_attribute, of: actor|subject|$ref}, amount: <value source>, op: add|subtract|set}partialadjust_tracker exists; card attributes only via an effect
choose{kind: cards|player|zone|option, chooser, from, filter, count, select: first|random|highest:x|lowest:x, auto}partial — cards + option exist; target (card/player) missing
shuffle_order{zone, seat?, mode: shuffle|reverse}exists (setup verb; needs promoting to a step)
create_instance / remove_instance{design, zone, amount} / {cards} (remove = move to an out-of-game zone)exists as the create_card mechanic
set_flag{cards, flag: exhausted|face|status, value, duration}exists (exhaust, statuses) — needs a step spelling
fire_triggers{cards, trigger: on_play|on_destroy|…}internal — engine-only today; play_card needs it as a step
flow{op: end_phase|push_steps|extra_turn, …}exists (step queue)
value source (argument type)literal · ƒx · {tracker} · {card_attribute} · {count_cards} · {rolled_value}exists — the ƒx layer

A.2 · Verb preset (shipped catalog, assets/data/verb-presets.json)

A preset carries three things — the recipe, how the engine offers it, and a bot hint. The offer spec is the part with no precedent today and the one to design carefully: it is what the play table renders and what the bots enumerate.

{
  "id": "trash",
  "label": "Trash a card",              // default label; the GAME may rename it
  "category": "remove",                  // bot hint: attack | acquire | gain | remove | flow
  "slots": {
    "from":        { "type": "zone",  "required": true,  "label": "Trash cards from" },
    "destination": { "type": "zone",  "required": true,  "label": "Trashed cards go to",
                     "hint": "usually a shared, hidden, out-of-game pile" },
    "count":       { "type": "count", "default": 1 }
  },
  "offer": { "kind": "card_in_zone", "zone": "@{from}", "one_action_per": "card" },
  "steps_template": [
    { "do": "move", "cards": "$chosen", "to": "@{destination}" }
  ]
}

Offer kinds (closed): none (a single action, e.g. end phase) · card_in_zone · card_in_play · player · zone · affordable_card_in_zone (prices each candidate and drops what the actor cannot pay). The engine enumerates candidates, binds the choice as $chosen, and runs the steps.

A.3 · Per-game instance (game_meta['action_definitions'], unchanged shape)

"scrap": {
  "label": "Scrap",                       // the button in THIS game
  "enabled": true,
  "authored": { "preset": "trash",
                "params": { "from": "hand", "destination": "scrap_heap", "count": 1 } },
  "steps":  [ …compiled… ],               // recompiled on save; the engine reads only this
  "offer":  { …compiled… }
}

Same dual-key discipline as ƒx, conditions and today's custom actions: authored is the form's source of truth, the compiled halves are the engine's. Bindings (play_costs, uses_per_turn, phase_types, requires) keep working exactly as they do now, for built-in and custom alike.

A.4 · Presets we ship on day one

The current built-ins, re-expressed, plus the ones the probes proved missing:

PresetComposes intoReplaces / unlocks
play_cardadjust_value(cost) → move(hand→in_play) → fire_triggers(on_play)the hardcoded verb
acquire_cardadjust_value(price) → move(market→discard|hand|deck-top)buy_from_market and "on top of your deck" acquisitions
spend_pool_at_playerchoose(player) → adjust_value(target tracker, −pool)attack_opponent
spend_pool_at_cardchoose(card_in_play) → adjust_value(attribute, −N)attack_card
attack_with_cardchoose(own untapped card) → choose(target) → adjust_value(target.health, −source.attack) → adjust_value(source.health, −target.attack) → set_flag(exhausted)per-unit combat (U14)
trashmove(→ out-of-game zone)Star Realms scrap · Dominion trash · S3
destroy_target_cardchoose(card_in_play, filter) → move(→ discard) + fire_triggers(on_destroy)"destroy target base"
draw · discard · mill · tutormove variantsmakes the zone-to-zone family uniform
activateadjust_value(cost) → set_flag(exhausted) → run_effectactivate_ability
Jaipur's six existing presetsunchangedthey already live in this format

B · The Verbs screen (Anatomy · Actions, rebuilt)

One page where every verb of the game lives — built-in or invented, all in the same list. Replaces today's split between a hardcoded checklist and a separate "Custom actions" block.

┌ VERBS ─────────────────────────────────────────────── [ + Add verb ] [ ✨ AI setup ] ┐ │ │ │ ☑ Play a card preset: play_card main ⚙ Configure ⋮ │ │ hand → in play, pays the card's cost, fires on-play │ │ ⚠ 1 warning: no currency tracker bound │ │ │ │ ☑ Buy preset: acquire_card main ⚙ Configure ⋮ │ │ trade row → discard, priced in Trade, 1 use/turn │ │ │ │ ☑ Attack preset: spend_pool_at_player main ⚙ Configure ⋮ │ │ spends Combat → reduces opponent Authority │ │ │ │ ☑ Scrap preset: trash main ⚙ Configure ⋮ │ │ hand → scrap heap (out of game) renamed from "Trash" │ │ │ │ ☐ Attack with a unit preset: attack_with_card ⚙ Configure ⋮ │ │ disabled — enable to use per-unit combat │ │ │ │ ☑ Sell a set composer (raw steps) main ⚙ Edit steps ⋮ │ └──────────────────────────────────────────────────────────────────────────────────────┘

Row anatomy: enable checkbox · editable label (inline rename) · which preset it came from · phases where it is allowed (chips) · a plain-English summary compiled from the filled slots · inline validation warnings · Configure · overflow menu (duplicate, view compiled steps, delete).

Add verb opens the preset picker, grouped by category (Move & draw · Economy · Combat · Card state · Flow · Advanced/composer), each with a one-line description and an example. Choosing one opens Configure.

Configure is the slot filler — the same widget family the Jaipur actions already use, extended:

Slot typeWidget
zoneselect of the game's zones (+ "create a zone…" inline, since trash usually needs a new one)
trackerselect of the game's trackers, filtered by role where it helps
value / countthe ƒx widget (literal or formula, with the 2/3/4-player preview)
card filterthe criteria builder (type · subtype · behaviour · tag · has_tag)
attributeselect of attributes actually used by this game's cards
target kindplayer · own card in play · enemy card in play · any card in play
conditionselect of named conditions (requires:) — already built

Below the slots, the same panel shows Bindings (cost, uses per turn, allowed phases, requires) — today a separate button — and a read-only compiled preview: the steps and the offer spec, so nothing is opaque. The raw-steps composer stays as the Advanced escape hatch.

C · Enable / disable / availability

Three distinct switches that today are spread over two pages and one hardcoded list:

SwitchMeaningStorage
Enabledthe verb exists in this game at allaction_definitions[id].enabled + membership of game_meta['actions'] (kept in sync, one checkbox)
Available in phasechips per player-driven phasephases[].allowed_actions — written from this screen instead of the Phases screen
Legal right nowruntime gates: cost payable, uses left, requires: condition, modifier prohibitions, feasibilitybindings + conditions (already built)

Disabling a verb never deletes its configuration — it just stops being offered, so a designer can A/B a rule without losing the setup (and the version diff will show what the change did to the game).

D · AI autofill

Same shape as the strategy generator that already ships: one background call through the CLI worker, the game definition as context, a closed JSON answer, designer review.

  • Per verb ("✨ suggest setup"): fills this preset's slots from the game's zones and trackers — "trash: you have no out-of-game zone; create scrap_heap (shared, hidden) and send trashed cards there".
  • Whole game ("AI setup"): proposes the verb list for the game as briefed, each with slots filled, as a diff to review — nothing is written until accepted.

Rails, unchanged from the strategy work: the answer is validated against the closed preset/slot vocabulary; unknown ids are warnings, never silent; the designer sees every proposed value before it lands.

E · Migrating the built-ins (without breaking four games)

  1. Ship the primitives + preset catalog; the old hardcoded verbs keep running.
  2. Re-express ONE verb (attack_opponentspend_pool_at_player) and run Starforge under both: same seeds, same stored profile. The version diff is the equivalence proof — endings, pacing and per-card usage must not move.
  3. Repeat per verb, one commit each, each with its diff.
  4. When a game's action_definitions covers every verb it uses, the engine stops consulting the hardcoded switch for that game (feature-flagged per game, so the cutover is reversible).
  5. Delete the switch only when all four reference games are migrated and green.

Old games with no action_definitions keep working forever: an absent instance means "use the shipped preset with its defaults", which reproduces today's behaviour exactly.

Phases & sizing

#PhaseDeliverableSize
AContractthis page's schemas as the reference-structures section; checkpoint for review0.5d
BPrimitives$source moves, adjust_value on card attributes, choose target, step spellings for flags/shuffle/fire_triggers + tests1.5d
CPreset catalogverb-presets.json with the day-one list, compiler extended to offer specs1d
DThe Verbs screenrebuilt Anatomy · Actions: list, enable, rename, phase chips, slot filler, bindings, compiled preview2d
EMigration + proofbuilt-ins re-expressed one at a time, each with a version diff on the reference games1d
FAI autofillper-verb and whole-game proposals through the existing worker0.5d
GDocshow-to section, tracker rows, structure map row0.5d

~7 days. The payoff to measure at the end: re-run the Star Realms probe and watch the 40 approximations fall to the handful that are genuinely exotic (copy-a-card, per-turn play counters).

Risks & open questions

RiskMitigation
The offer spec is the novel part — get it wrong and the play table and bots go blinddesign it in phase A against six real cases (play from hand, buy affordable, attack player, attack card, activate in play, custom with no target); it is the checkpoint's main question
Bots lose semantics when verbs become anonymouscategory on every preset; the greedy/lookahead heuristics read the category, not the id
Authoring gets harder than today's checkboxpresets ship with working defaults, so enabling a verb is still one click; slots only appear when the game is ambiguous
Silent behaviour drift during migrationversion diff per verb on four reference games; a verb is done only when the profile is unchanged
Scope creep into a general scripting languagethe primitive list is closed and this page is its contract; anything beyond it goes through the catalog handler escape hatch, never eval