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.
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.
| Primitive | Shape | Status |
|---|---|---|
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} | partial — adjust_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:
| Preset | Composes into | Replaces / unlocks |
|---|---|---|
play_card | adjust_value(cost) → move(hand→in_play) → fire_triggers(on_play) | the hardcoded verb |
acquire_card | adjust_value(price) → move(market→discard|hand|deck-top) | buy_from_market and "on top of your deck" acquisitions |
spend_pool_at_player | choose(player) → adjust_value(target tracker, −pool) | attack_opponent |
spend_pool_at_card | choose(card_in_play) → adjust_value(attribute, −N) | attack_card |
attack_with_card | choose(own untapped card) → choose(target) → adjust_value(target.health, −source.attack) → adjust_value(source.health, −target.attack) → set_flag(exhausted) | per-unit combat (U14) |
trash | move(→ out-of-game zone) | Star Realms scrap · Dominion trash · S3 |
destroy_target_card | choose(card_in_play, filter) → move(→ discard) + fire_triggers(on_destroy) | "destroy target base" |
draw · discard · mill · tutor | move variants | makes the zone-to-zone family uniform |
activate | adjust_value(cost) → set_flag(exhausted) → run_effect | activate_ability |
| Jaipur's six existing presets | unchanged | they 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.
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 type | Widget |
|---|---|
| zone | select of the game's zones (+ "create a zone…" inline, since trash usually needs a new one) |
| tracker | select of the game's trackers, filtered by role where it helps |
| value / count | the ƒx widget (literal or formula, with the 2/3/4-player preview) |
| card filter | the criteria builder (type · subtype · behaviour · tag · has_tag) |
| attribute | select of attributes actually used by this game's cards |
| target kind | player · own card in play · enemy card in play · any card in play |
| condition | select 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:
| Switch | Meaning | Storage |
|---|---|---|
| Enabled | the verb exists in this game at all | action_definitions[id].enabled + membership of game_meta['actions'] (kept in sync, one checkbox) |
| Available in phase | chips per player-driven phase | phases[].allowed_actions — written from this screen instead of the Phases screen |
| Legal right now | runtime gates: cost payable, uses left, requires: condition, modifier prohibitions, feasibility | bindings + 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)
- Ship the primitives + preset catalog; the old hardcoded verbs keep running.
- Re-express ONE verb (
attack_opponent→spend_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. - Repeat per verb, one commit each, each with its diff.
- When a game's
action_definitionscovers every verb it uses, the engine stops consulting the hardcoded switch for that game (feature-flagged per game, so the cutover is reversible). - 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
| # | Phase | Deliverable | Size |
|---|---|---|---|
| A | Contract | this page's schemas as the reference-structures section; checkpoint for review | 0.5d |
| B | Primitives | $source moves, adjust_value on card attributes, choose target, step spellings for flags/shuffle/fire_triggers + tests | 1.5d |
| C | Preset catalog | verb-presets.json with the day-one list, compiler extended to offer specs | 1d |
| D | The Verbs screen | rebuilt Anatomy · Actions: list, enable, rename, phase chips, slot filler, bindings, compiled preview | 2d |
| E | Migration + proof | built-ins re-expressed one at a time, each with a version diff on the reference games | 1d |
| F | AI autofill | per-verb and whole-game proposals through the existing worker | 0.5d |
| G | Docs | how-to section, tracker rows, structure map row | 0.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
| Risk | Mitigation |
|---|---|
| The offer spec is the novel part — get it wrong and the play table and bots go blind | design 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 anonymous | category on every preset; the greedy/lookahead heuristics read the category, not the id |
| Authoring gets harder than today's checkbox | presets ship with working defaults, so enabling a verb is still one click; slots only appear when the game is ambiguous |
| Silent behaviour drift during migration | version diff per verb on four reference games; a verb is done only when the profile is unchanged |
| Scope creep into a general scripting language | the primitive list is closed and this page is its contract; anything beyond it goes through the catalog handler escape hatch, never eval |