Docs: Model reference

Model reference

Every key of a Tabletwin game model, layer by layer. A model is one YAML file. The engine plays it (in the player and in the validator's test games); the validator checks it. This page describes what both actually do.

Related pages:

  • expressions.md: the expression language used in every _x field, and which names are in scope where.
  • board-geometry.md: boards and functions in depth.
  • engine.md: how a model is run, step by step.
  • guide.md: how to turn a rulebook into a model.
  • cheatsheet.md: the essentials of this page on one page, with complete patterns.
  • tactics.md: tactic files (players as a separate layer).
  • schema.json: JSON Schema for editors; the validator also uses it to report unknown keys. Where it disagrees with this page, this page wins.

In the tables, E means the engine uses the field, V means it is for people and the viewer only. A V field never changes how the game plays.

1. Conventions

Text and expression (_x)

Most fields that carry rules come in two forms:

FormExampleMeaning
field with textpre: "has enough gold"For people and the viewer. The engine cannot run it.
field_x with an expressionpre_x: "gold[actor] >= 3"What the engine runs.
field with a number, list, bool or dictamount: -1, die: [1, 2, 3]A literal value, used as is (only for the fields listed below).

If both are present, _x wins. An _x field may also hold a YAML number or boolean (if_x: true); it is turned into text and evaluated.

Fields that accept a literal (a non-text value without _x): value, amount, what, at, n, die, options, min, max, items, until (of repeat), target, of. For these, a text value without _x is an error at run time: field 'what' is text without what_x. The exception is value in set and let: there a text is a literal string (value: open stores 'open'; value: "x + 1" stores the text 'x + 1').

So to place the string 'gold' in a zone, write what_x: "'gold'", not what: gold.

Fields that only work as _x. What the engine does when only the text form is present:

Text field without _xEngine
pre (action), if (trigger), cond (if op), when (branch case), first (priority protocol), of (exec), change (modifier, modify), p (outcome)Error NotExecutable when reached
over, until (loop)Ignored: the loop runs its children once per round, and never stops by itself
actor (step), controller (trigger)Ignored: no actor / the event's actor decides
if (end condition)The condition is skipped
if (modifier)Ignored: the modifier always applies
filter (search)Ignored: every item is a candidate
who (end condition)Ignored, so who_x is missing: error when a competitive win/loss fires, and at the first end check for eliminate

The validator counts most of these places as gaps (its list "What the engine still misses", see §20).

Paths

KindSyntaxExample
Variable path (var in set, add)name or name[expr]score[current], pieces['e4']
Zone path (from, to, zone)name or name[expr]deck, hand[actor]

The part in [ ] is an expression, evaluated with the local names of the op. A zone with owner: player must be indexed (hand[1]); without an index the engine raises zone 'hand' belongs to a player; write hand[player].

Players

Players are the integers 0 … num_players − 1. In expressions, players is that list and n_players its length.

YAML 1.1 pitfalls

Models are read as YAML 1.1:

  • yes, no, on, off (lower, title or upper case) are booleans, like true and false. A key on: becomes true and is rejected (key true is not text; put it in quotes). That is why triggers use event:, not on:, and branches use switch:. options: [yes, no] gives [True, False].
  • 010 is octal (8), 1:30 is sexagesimal (90). Quote such values.
  • Dates (2024-01-01) stay text.
  • Keys must be text; << merge keys work.

2. Top-level keys

KeyTypeMeaning
gametextVShort id (lower case, hyphens)
labeltextVDisplay name
num_playersintegerENumber of players (default 2)
modecompetitive / cooperativeE VHow end conditions pay out (default competitive; anything else is an error)
playerstextVE.g. "2–4 players"
summarytextVTwo or three sentences about the game
demonstrates, scope, descriptiontextVWhat the model shows and what it does (not) cover. This scope is unrelated to scope in end
utility{min, max}VRange of a player's return. The engine does not use it
constantsdictE§3
boardsdictE§4
functionsdictE§5
statedictE§6
flownodeEThe main flow. Required by the engine (§9)
flowsdictENamed subflows
actionsdictE§10
triggerslistE§14
modifierslistE§15
prioritydictE§13
outcomesdictE§17
agentsdictE§18
contentdictE§16
codedictE§21
open_questionslistV§22
endlistE§19

The JSON Schema also requires game, label, actions and end, and allows no other top-level keys.

3. constants

{name: value}. Fixed numbers, lists and tables. Every expression sees them under their name. Use upper case. Numbers you may want to change later (a target score, a hand size) belong here: the site always runs a model with the values written in it, so you change them in one place.

constants:
  GOAL: 20
  CARDS: [{id: ace, value: 1}, {id: king, value: 13}]

Constants are model data: they are never changed during play. A dict from constants placed in a zone with place stays read-only; use create to make an instance (§7).

4. boards

{name: board}. Fixed boards with geometry: square or hex cells, neighbours, holes, walls. Expressions call board functions with the board name as first argument (neighbours('map', cell)). Full description in board-geometry.md.

boards:
  board: {kind: square, width: 8, height: 8, names: letters, neighbours: 8}

5. functions

{name: function}. Your own pure functions in the expression language.

FieldMeaning
paramsEList of parameter names (^[A-Za-z][A-Za-z0-9_]*$). name? is optional (default None). Call by position or by name
letE{name: expression}, evaluated in order; each sees the parameters and earlier lets
xEThe result (required)
text, noteVExplanation
functions:
  other:
    params: [p]
    x: "1 - p"

A function sees its parameters, its lets and the global part of the scope (constants, state, derived values, board functions, other functions; recursion up to 100 calls deep). It does not see the caller's local names (actor, self, bound names): pass them as arguments. Its name must not clash with a constant, variable, zone, derived value, builtin, board function or reserved name (modified content players n_players eliminated results None). See board-geometry.md.

6. state

state.entity_types

{name: {attrs: {name: type}, extends?: name, note?}}. V only: describes the kinds of things, and holds of a zone may name one. Things in zones are plain values (text, number or dict).

state.zones

{name: zone}. Places where things lie. In expressions a zone is a list; a zone with owner: player is a list of lists (hand[1]). Index 0 is the top.

FieldDefaultMeaning
ownerE Vglobalglobal: one list. player: one list per player, path name[i]. team, entity: descriptive (treated as global)
visibilityE VpublicWho sees what is drawn into it: public everyone, owner only the owning player, hidden nobody
shuffledE Vfalsetrue: drawing from it is random (a chance node); order is unknown except a known top and bottom (§8)
persistE V–campaign: kept by reset. Any other value is a validator error
drawn_fromE–For a hidden zone: the shuffled zone its cards come from. A player that looks ahead redistributes its cards together with the unknown part of that zone, so it does not peek. See tactics.md
init_xEemptyInitial content (a list; for owner: player a list of lists). Dicts become instances (§7)
kindV–grid, hexgrid, graph, plane, stack, set, bag, slot, track
holds, size, label, noteV–Descriptive
state:
  zones:
    deck: {kind: stack, visibility: hidden, shuffled: true, init_x: "list(CARDS)"}
    hand: {kind: set, owner: player, visibility: owner}
    discard: {kind: stack}

init_x of a zone sees the constants, content, players, n_players, board and own functions, and all variables. It does not see other zones.

state.vars

{name: var}.

FieldDefaultMeaning
scopeE Vglobalplayer: one value per player (a list; path name[i]). team, entity: descriptive
initENoneLiteral initial value (null is None). With scope: player and a non-list value, every player gets a copy
init_xE–Initial value as an expression; sees constants, content, players, n_players, board and own functions, and the variables declared above it
persistE V–campaign: kept by reset
typeV–int, real, bool, enum, ref, set, list, dict, str (descriptive)
range, label, noteV–Descriptive
state:
  vars:
    score: {scope: player, type: int, init: 0}
    current: {type: ref, init: 0}
    pool_size: {init_x: "2 * n_players"}

state.derived

{name: "expression"} or {name: {text, x}}. Named values, computed each time an expression uses them (directly or through another derived value). A derived value may use derived values declared above it. It is computed in the scope of the expression that uses it; write it so that it needs only the global state.

state:
  derived:
    total: "sum(score)"
    best: {text: "highest score so far", x: "max(score)"}
    leaders: "[p for p in players if score[p] == best]"

7. Instances

A dict in a zone is an instance: a thing with its own state that can change during play (damage, exhausted, tokens). Instances come from create with a dict, and from a zone's init_x that yields dicts.

  • Each instance gets a unique uid (1, 2, 3, … in order of creation).
  • If the source dict is model data (from constants or content), the instance copies its simple fields (numbers, text, booleans, None) and refers to the source under the key origin (inst['origin']['effect']). Nested fields such as effect or triggers are not copied. A dict built by an expression is copied whole.
  • with_x on create adds initial state: with_x: "{'damage': 0}".
  • Read: c['damage'], c.get('exhausted'), c['origin']['effect'].
  • Write: {op: add, of_x: c, attr: damage, amount: 2}, {op: set, of_x: c, attr: exhausted, value: true}. Changing model data itself is an error: 'X' is model data; make an instance with create first.
  • In options, chance outcomes and observations an instance is named by its label, else id, else uid. Two instances with the same label are indistinguishable to a player.
  • exec needs a dict with effect; for an instance use of_x: "c['origin']".
- {op: create, what_x: "content['units']['archer']", to: "army[actor]", with_x: "{'damage': 0}", bind: u}
- {op: add, of_x: u, attr: damage, amount: 1}

8. Stack order

Index 0 of a zone is the top: draw and look take from there. Inserting without position appends at the end (the bottom).

For a zone with shuffled: true the order is unknown, except for a known top and a known bottom:

  • known on top: after look, or after place/create/move/draw with position: top;
  • known at the bottom: after place/create/move/draw with position: bottom;
  • the rest is unordered; a chance node decides which card it is when it is drawn or looked at;
  • something inserted without position (or position: random) goes among the unknown cards;
  • shuffle makes all of it unknown again.

Drawing takes the known top first, then (chance node, uniform) one of the unknown cards, and only when those are gone the known bottom. For a zone without shuffled, shuffle does nothing and draw always takes index 0.

9. flow and flows

flow is the main flow: one node, usually a sequence or loop. flows is {name: node} with named subflows (used by subflow nodes and the run op).

FieldOnMeaning
idallE VUnique in the whole model (validator error on duplicates)
kindallE Vsequence, loop, branch, step, subflow
labelallVDisplay name (else id)
childrensequence, loopE VList of nodes, run in order
over_xloopEList (or other iterable) of values to run the children for, evaluated at the start of each round. Usually players
asloopEVariable set to each value of over_x before the children run. Declare it in state.vars (a validator error otherwise): the engine creates an undeclared one only when the loop first sets it, so it does not exist before that, and reset leaves it alone
until_xloopEStop condition, tested before each round. Without it the loop repeats round after round (with over_x: all values again, from the first) and never stops by itself; the game ends through end
onceloopE Vtrue: exactly one round (each value of over_x once)
over, untilloopVText for people
orderloopVfixed, clockwise_from_first, initiative, simultaneous, deck_order, players_choose (descriptive; other values are a validator error)
casesbranchE V[{when, when_x, label, note, children}]. The first case whose when_x is true runs; none true: nothing. A case without when_x reached before a match is an error
switchbranchVWhat the branch looks at (text)
refsubflowE VName in flows
dostepE VAction name, or a list of action names among which the actor chooses
actor_xstepEWho acts (player index). Needed if more than one action is legal, and for any choose in the action without its own actor_x
actorstepVText for people
secretstepEOnly the actor sees which action was chosen
visible_to_xstepEWho sees the choice (list of player indexes)
windowallE VA priority window opened after the node: a name from priority.windows, or true (all speeds)
end_eventallE VEvent name (or list) emitted when the node is done, after its window
noteallVExplanation

What each kind does:

  • sequence: children in order.
  • loop: before each round, if until_x is true the loop ends. With over_x, the children run once per value (with as set); without it, once per round. More than 10,000 rounds is an error (loop does not end). Eliminated players are not skipped: filter them yourself (over_x: "[p for p in players if p not in eliminated]").
  • branch: the first case with a true when_x. Use when_x: "True" for a default case.
  • subflow: runs flows[ref]. results[ref] is cleared when it starts; end conditions with scope: ref apply while it runs (§19).
  • step: (1) evaluate actor_x (else the actor is None); (2) legal actions are those in do whose pre_x is true, with actor in scope; (3) none legal: the step is skipped; (4) one legal: it runs without a decision; (5) several: a decision for the actor (an error without actor_x); (6) the action's cost, then its effect, with the same local names; (7) end conditions are checked.

After a node's own work comes its window, then its end_events (each followed by an end check). On a loop both happen once, after the whole loop.

flow:
  id: game
  kind: loop
  over_x: players
  as: current
  children:
    - {id: turn, kind: step, actor_x: current, do: [roll, stop]}

Flow expressions (over_x, until_x, when_x, actor_x, visible_to_x) see only the global part of the scope: no actor and no local names. Use the loop variable from as.

10. actions

{name: action}.

FieldMeaning
pre_xEWhen the action is legal (default: always). Sees actor (and self for abilities; window and stack in a window)
costEOps run first when the action is played. With lifo_stack they run immediately, while effect waits on the stack. Same local names as pre_x; in a window that includes stack as it is before this action is put on it
effectEList of ops
speedE VMakes the action playable in priority windows whose allows contains this speed (§13). Actions with a speed are usually not in any step
pre, actor, label, noteVDescriptive
actions:
  stop:
    pre: the pot is not empty
    pre_x: "pot > 0"
    effect:
      - {op: add, var: "score[current]", amount_x: pot}
      - {op: set, var: pot, value: 0}

In pre_x, cost and effect, actor is the player who acts: the step's actor_x, in a window the player who has priority, and when an effect resolves from the stack the player who played it. Names bound in cost (by choose, let, …) are still there in effect, also when the effect resolves later from the stack.

11. Ops

An effect is a list of ops, run top to bottom. Each op is a dict with op and its fields. Local names: bind in choose, roll, let, look, search, create adds a local name for the rest of the effect. if and repeat share the local names with the surrounding effect; for_each, emit (impact and triggers), exec and sample work on a copy, so names bound inside do not leak out.

OpFieldsSummary
setvar or of_x+attr; value/value_xSet a variable or an instance attribute
addvar or of_x+attr; amount/amount_xAdd to a variable or attribute
letbind, value/value_xBind a local name
placewhat/what_x, to, at/at_x, positionPut a value in a zone
createwhat/what_x, to, n/n_x, with_x, position, bindAdd new instances
movewhat/what_x, from, to, positionMove items between zones
destroywhat/what_x, fromRemove an item from play
drawfrom, to, n/n_x, visible_to_x, positionTake the top card(s)
shufflezoneMake a shuffled zone's order unknown again
lookfrom, n/n_x, bind, actor_x, visible_to_xLook at the top card(s)
searchfrom, bind, filter_x, as, actor_x, min/max, visible_to_xLook through a zone and pick
revealwhat/what_x, visible_to_xShow a value
rolldie/die_x, bind, visible_to_xUniform chance node
chooseoptions/options_x, bind, actor_x, secret, visible_to_x, min/maxA decision
ifcond_x, then, elseConditional
for_eachitems/items_x, as, bodyLoop over values
repeatuntil/until_x, bodyLoop until a condition
emitevent, impactFire an event (triggers)
samplemodelDraw from an outcome model
execof_x, contentRun a content item's effect
runflowRun a named flow inside the effect
windownameOpen a priority window
canceltarget/target_xCancel a stack item
modifystat, change_x, layer, if_x, untilAdd a temporary modifier
resetscopeCampaign: start a new scenario
callcodeEscape hatch: code outside the model (not executable)
sayactor, channel, constraint, bind, agentFree speech (not executable yet)

Any other op is a validator error and NotExecutable at run time. Every op may carry a note (V).

set

FieldDefaultMeaning
var–Variable path. Required unless attr is given
of / of_x–With attr: the instance to change
attr–Attribute name (literal)
value / value_x–New value. A text value is a literal string
- {op: set, var: phase, value: combat}          # the string 'combat'
- {op: set, var: "score[actor]", value_x: "score[actor] * 2"}
- {op: set, of_x: target, attr: exhausted, value: true}

add

FieldDefaultMeaning
var–Variable path. Required unless attr is given
of / of_x, attr–Add to an instance attribute; a missing (or falsy) attribute counts as 0
amount / amount_x–Added with + (negative subtracts; on a list variable + concatenates)
- {op: add, var: "gold[actor]", amount: -3}
- {op: add, of_x: unit, attr: damage, amount_x: "hit['power']"}

let

FieldMeaning
bindLocal name (required)
value / value_xValue. A text value is a literal string
- {op: let, bind: target, value_x: "hand[actor][0]"}

place

FieldDefaultMeaning
what / what_x–The value
to–Zone path
at / at_x–Index: replaces the element at that index (negative counts from the end)
position–Without at: top, bottom, random or none (§8). Any other value is an error

place does not make an instance; use create for things with their own state.

- {op: place, what_x: "'X'", to: grid, at_x: square}

create

FieldDefaultMeaning
what / what_x–The thing; a dict becomes a new instance (§7)
to–Zone path
n / n_x1Number of copies
with_x–Dict of extra initial state, merged into each instance
position–top, bottom, random or none
bind–Local name for the (last) instance created
- {op: create, what_x: "content['tokens']['wound']", to: "wounds[actor]", n: 2}

move

FieldDefaultMeaning
what / what_x–An item or a list of items
from, to–Zone paths
position–top, bottom, random or none

For each item the first equal element (deep equality) in from is removed and inserted into to. An item that is not in from is an error: move: 'X' is not in hand[actor] (the path as written).

- {op: move, what_x: card, from: "hand[actor]", to: discard}

destroy

FieldDefaultMeaning
what / what_x–The item
from–Zone path. Without it: the first zone (in declaration order) that holds the item

Not found is an error. Adds the event destroy:<label>.

draw

FieldDefaultMeaning
from, to–Zone paths
n / n_x1Number of cards; stops early when from is empty
visible_to_xdestinationWho sees the drawn card. Default: from the destination's visibility (public everyone, owner its owner, hidden nobody)
position–Where it goes in to

From a shuffled zone without a known top and with more than one unknown card, each draw is a chance node (uniform over the unknown cards; §8). Otherwise the top card is taken.

- {op: draw, from: deck, to: "hand[actor]", n: 2}

shuffle

FieldMeaning
zoneZone path. Without it the op does nothing

Forgets the known top and bottom of a shuffled zone. On a zone without shuffled it does nothing.

look

FieldDefaultMeaning
from–Zone path
n / n_x1How many cards from the top
bind–Local name for the list of cards seen
actor_xthe local actorWho looks
visible_to_x[actor]Who sees them (everyone if there is no actor)

On a shuffled zone, chance nodes fix the unknown cards among the top n; afterwards they are the known top.

FieldDefaultMeaning
from–Zone path
bind–Local name for the chosen item (required)
filter_xallWhich items may be chosen; the item is named it (or as)
asitName of the item in filter_x
actor_xthe local actorWho searches
min, max (and _x)–Choose a subset, as in choose; bind gets a list
visible_to_x[actor]Who sees the choice

The searcher sees the whole zone (an observation for the actor only). Then a choose among the matching items follows (one match: no decision). No match: bind is None, or [] with min/max. search does not move or shuffle anything; add move and shuffle yourself.

- {op: search, from: deck, filter_x: "it['type'] == 'ally'", bind: found}
- {op: if, cond_x: "found is not None", then: [{op: move, what_x: found, from: deck, to: "hand[actor]"}]}
- {op: shuffle, zone: deck}

reveal

FieldDefaultMeaning
what / what_x–The value shown
visible_to_xeveryoneWho sees it

Adds an observation. The zone's visibility does not change.

roll

FieldDefaultMeaning
die / die_x–List of faces (numbers, text or dicts)
bind–Local name for the face rolled
visible_to_xeveryoneWho sees the result

A chance node; each face is equally likely. A random card from a hand: die_x: "hand[p]".

- {op: roll, die: [1, 2, 3, 4, 5, 6], bind: pips}

choose

FieldDefaultMeaning
options / options_x–The options (a list or other iterable)
bind–Local name for the chosen option (required)
actor_xthe local actorWho chooses
secretfalseOnly the chooser sees the choice
visible_to_xeveryoneWho sees the choice
min / min_x, max / max_x–Subset choice (see below)
agent–Name in agents (validator checks it exists; the engine ignores it)

One option: no decision (the player lists it under "automatic"). No options: an error. More than one option and no actor: NotExecutable.

Subset. With any of min, max, min_x, max_x, the chooser picks elements one at a time, in the order of options: each time from the elements after the last one picked (only those that still leave enough elements to reach min), plus the option done once at least min are chosen. Defaults: min 0, max the number of options. It stops at max, at done, or when no candidates are left. bind gets the list. No options and min 0 gives []; fewer than min available is an error. Each element is its own decision, at place <place> · 1, · 2, …

- {op: choose, options_x: "hand[actor]", bind: discards, min: 0, max: 2}

if

FieldMeaning
cond_xCondition (required; cond alone is an error)
then, elseLists of ops; else optional

for_each

FieldDefaultMeaning
items / items_x–Values to loop over, evaluated once at the start
asitLocal name of the current value
body–Ops, run once per value with a copy of the local names
- op: for_each
  items_x: "[p for p in players if p != actor]"
  as: p
  body: [{op: add, var: "gold[p]", amount: 1}]

If items_x is a zone itself (hand[actor]) and the body moves things out of that zone, the loop skips items. Loop over a copy: items_x: "list(hand[actor])".

repeat

FieldMeaning
until / until_xTested before each pass; the body runs while it is false
bodyOps (same local names as the surrounding effect)

More than 10,000 passes is an error (repeat does not end).

emit

FieldMeaning
eventEvent name (literal text; there is no event_x)
impactOptional ops: what the event itself does

Order: triggers with timing when/before; then instead triggers (the first one that fires replaces the impact); then the impact (if not replaced); then after triggers. Afterwards, temporary modifiers with until equal to this event expire. Triggers see the local names at the emit plus event. A chain of more than 50 nested emits is an error. See §14.

- op: emit
  event: attack
  impact: [{op: add, of_x: target, attr: damage, amount_x: power}]

sample

FieldMeaning
modelName in outcomes
inputsText for people (the engine passes the local names instead)

Evaluates the model's derived_x and p_x, makes a chance node over its outcomes, adds the event <model>:<outcome id> and runs that outcome's effect. See §17.

exec

FieldMeaning
of_xThe content item (a dict with effect). Required
contentCatalogue path for the validator (e.g. cards.*); the engine ignores it

Runs the item's effect with self set to the item, in place content <id>. Adds the event exec:<id>. An item with status: missing or without effect is NotExecutable.

run

FieldMeaning
flowName in flows

Runs the named flow inside this effect, with its own cursor. results[flow] is cleared first; end conditions with scope: flow apply while it runs.

window

FieldMeaning
nameName in priority.windows; without it all speeds are allowed

Opens a priority window from inside an effect. The window sees the op's local names (and first_x is evaluated with them). See §13.

cancel

FieldMeaning
target / target_xIndex in stack of the innermost open window (0 is the bottom; −1 the top). None does nothing; an index out of range is an IndexError

Sets cancelled: true on that stack item when the op runs: in a cost at once, in an effect (the usual place) only when that effect resolves. Until then the target still shows cancelled: false, so two players can both target the same item. Cancelling an item that is already cancelled is allowed and changes nothing. A cancelled item is removed without effect when it would resolve (event cancelled:<action>; its cost was already paid). Outside an open window: an error. Only useful with resolution: lifo_stack.

Fix the target when the counter is played: bind it in cost with let (cost ops see stack), and cancel it in effect. Items below the counter do not move before it resolves, so the index stays valid. Here the target is the topmost item of another player that is not yet cancelled:

counter:
  speed: fast
  pre_x: "any(s['actor'] != actor and not s['cancelled'] for s in stack)"
  cost:
    - {op: let, bind: target, value_x: "max(i for i, s in enumerate(stack) if s['actor'] != actor and not s['cancelled'])"}
  effect: [{op: cancel, target_x: target}]

A bare target: -1 in effect means "the item directly below this counter when it resolves" (the counter itself is already off the stack), which may be your own item or one that is already cancelled.

modify

FieldDefaultMeaning
stat–Which quantity (required)
change_x–New value; value is the value so far (required)
layer0Order among modifiers
if_x–Only active while true
until–Event at which the modifier expires; without it, it lasts until reset

Adds a temporary modifier. The local names at the time of modify (except stack) are kept and visible in if_x and change_x; self (if any) is kept as its content item. See §15.

- {op: modify, stat: attack, change_x: "value + 2 if who == actor else value", until: round_end}

reset

FieldDefaultMeaning
scopescenarioLabel only (event reset:<scope>)

Campaign: every variable and zone without persist: campaign gets its initial value again (init_x is recomputed); eliminated and temporary modifiers are cleared. results is kept.

call

FieldMeaning
codeName in code

Escape hatch: marks a place where the work would be done by code outside the model (code.<name>). A model cannot bring its own code, so a call always stops the engine with NotExecutable (code 'x' → m:f is not implemented) and counts as a gap. Use it only to record honestly what the ops cannot express yet; avoid it where the model can express the rule.

say

Fields actor, channel, constraint, bind, agent. Free speech for language games. Always NotExecutable for now (say needs a language agent); the validator warns without agent.

12. Places, options and events

The engine names every decision and chance node by its place (where). Tactics match on it (at:), and the player shows it for every question.

PlaceWhere it comes from
step <id>Choice between the actions of a step
action <name>A choose, roll, draw, look or search in the action's effect (also for abilities)
action <name> (cost)The same in its cost
<place> · <k>The k-th element of a subset choice (action play · 1)
window <name>A priority decision; window None when opened with window: true or a window op without a name
trigger <id>The use/skip decision of an optional trigger, and ops in its do (id of the trigger, else of its content item)
content <id>Ops in an effect run by exec
<place> · <model>The chance node of a sample
<place> · <event>Ops in the impact of an emit
end <node id>The emit of an end_event (only seen in error messages)

Option labels made by the engine: pass (priority window), done (end of a subset choice), use / skip (optional trigger). Other options are labelled by their value: text as is, an instance by label/id/uid, anything else in Python notation.

Events recorded by the engine (the player shows them after each question; the ones you do not emit yourself appear under "messages"):

EventWhen
<event>Every emit (including end_events)
instead:<trigger id>An instead trigger fired
stack:<action>, resolves:<action>, cancelled:<action>lifo_stack: put on the stack, resolved, removed as cancelled
<model>:<outcome id>A sample
exec:<id>An exec
destroy:<label>A destroy
reset:<scope>A reset
eliminated:<player>An eliminate end condition
<scope>:<result>A scoped end condition ended a subflow

13. Priority windows

priority:
  protocol:
    first: the active player            # V
    first_x: "current"                  # E: who gets priority first
    closes: all_pass_in_succession      # E: or single_pass_each
    resolution: immediate               # E: immediate, nested or lifo_stack
    after_action: next                  # E: next or same
    order: clockwise_from_first         # V
  windows:
    combat: {label: Combat, allows: [fast], note: ...}
FieldDefaultMeaning
protocol.first_xEthe local actor, else player 0Who gets priority first, and again after each stack item resolves; evaluated once when the window opens. first without first_x is an error
protocol.closesEall_pass_in_successionall_pass_in_succession: closes when all non-eliminated players pass one after the other. single_pass_each: a player who passes is out; closes when all are out
protocol.resolutionEimmediateimmediate: cost and effect at once. nested: the engine treats it like immediate (use trigger timing for reactions). lifo_stack: cost at once, effect on a stack
protocol.after_actionEnextWho has priority after a played action: the next player, or the same
protocol.order, protocol.noteVclockwise_from_first, any_order, active_then_clockwise
windows.<name>.allowsE–List of speeds playable in this window
windows.<name>.label, noteV

How a window runs:

  • Candidates are all actions (including content abilities) with a speed that the window allows (window: true or a window op without a name: any speed). No candidates: the window closes at once.
  • Priority passes from player to player (index + 1, wrapping; eliminated players are skipped). A player with legal candidates (pre_x true) chooses one of them or pass; without legal candidates the player passes automatically.
  • In scope (in pre_x, cost and effect of the candidates): actor (who has priority), window (the window name, None for true), stack (list of {action, actor, cancelled}, bottom first; empty unless lifo_stack), plus the local names of a window op. cost sees the stack before the action is put on it.
  • immediate/nested: cost, effect, then an end check.
  • lifo_stack: cost runs, the action goes on the stack (event stack:X). When all players pass with a non-empty stack, the top item resolves: if cancelled it is removed (event cancelled:X, no effect, no end check); otherwise its effect runs (event resolves:X) with actor = who played it, the names bound in its cost, and stack without the item; then an end check. Then priority returns to the player first_x gave when the window opened (first_x is not evaluated again, even if the effect changed what it reads), and passing starts over (single_pass_each: everyone is back in). That player passes automatically if they have no legal candidate.
  • All pass with an empty stack: the window closes (no end check).
  • pass is reserved: an action named pass with a speed is a validator error. speed and allows are free names; they only have to match.

14. triggers

A list of rules that react to emit events.

FieldDefaultMeaning
idE V–Unique name; used in the place trigger <id>
eventE V–Event name it reacts to (exact match)
timingE Vafterwhen or before (before the impact), instead (replaces the impact; only the first that fires), after
optionalE VfalseThe controller decides use or skip
controller_xEthe event's actorWho decides on an optional trigger. Without it and without an actor: an error
if_xE–Condition, tested right before the trigger would fire (an earlier trigger can disable a later one)
doE V–Ops
if, controller, source, label, noteVsource: where the rule comes from (card, rule, keyword)

Within one timing phase: mandatory triggers first, then optional ones; each group in model order, model triggers before content triggers. In scope: the local names at the emit, event, and self for a content trigger. A trigger on an end_event has only event.

triggers:
  - id: thorns
    event: attack
    timing: after
    if_x: "target['id'] == 'bramble'"
    do: [{op: add, of_x: attacker, attr: damage, amount: 1}]

15. Modifiers

A modifier changes a quantity (stat) wherever the rules ask for it with modified('stat', base, name=value, ...).

Static modifiers live in the top-level modifiers list and on content items. Temporary ones are added by the modify op.

FieldDefaultMeaning
statE V–Which quantity. Modifiers without stat are ignored
change_xE–New value; value is the value so far. Missing: an error when applied
layerE V0Lower layers first; within a layer the static ones (top-level list first, then content items), then temporary ones in order of creation
if_xE–Only active while true
untilE–(temporary) expires at this event
id, source, target, change, if, duration, noteVduration: permanent, round, turn, while

modified('stat', base, k=v, ...): the extra keyword arguments are local names in if_x and change_x (e.g. who attacks). A content modifier also sees self, and applies only while its item's active_x is true. Modifiers that refer to each other more than 20 deep are an error.

modifiers:
  - {id: veteran, stat: attack, layer: 1, if_x: "who == 0", change_x: "value + 1"}
actions:
  hit:
    effect:
      - {op: add, var: "hp[1]", amount_x: "-modified('attack', 2, who=actor)"}

The validator warns when a stat is never read with modified('stat', ...) (a literal string).

16. content

Content is the game's material as data: cards, dice, faces, skills. The structure is free: {catalogue: {key: item}}, nested as deep as you like, lists allowed. Expressions see it as content.

content:
  cards:
    bolt:
      id: bolt
      label: Lightning Bolt
      status: verified
      text: "Deal 3 damage."
      effect: [{op: add, var: "hp[1 - actor]", amount: -3}]

An item is a dict with an id:

FieldMeaning
id, labelE VIdentity and name
statusVverified, uncertain or missing. The validator warns for every item without one of these
textVThe card text in your own words. An item with text but no behaviour counts as a gap
effectEOps, run with exec (self is the item)
active_xEWhen the item's triggers and modifiers apply; self is the item. Without it they always apply
triggers, modifiersE VAs in §14 and §15, bound to the item
abilitiesE VActions on the card itself (id unique across all actions and abilities, speed, pre_x, cost, effect). Playable in priority windows like any action with speed; self is the item. Without speed never playable (validator warning). The validator rejects them in a step's do
keywordsE V"Sentinel, Ranged" or a list. Their behaviour lives in the rules, which test them, e.g. 'Ranged' in c.get('keywords', ''). The validator warns for a keyword that no rule expression mentions in quotes
facesVDie faces as "type number"; the validator checks that type is a key of some face_types dict in content
source, cost, requires, noteV

Triggers, modifiers and abilities belong to the item (the card kind), not to one instance. With two copies of a card in play, its trigger still fires once per event; if_x picks the right instance through the event's local names (e.g. card['origin']['id'] == self['id'], where card is a name bound before the emit), and active_x limits when the item counts at all (e.g. only while a copy is in play).

17. outcomes

{name: model}. An outcome model replaces physics or skill by a probability distribution.

FieldMeaning
outcomesE V[{id, p, p_x, effect, note}]. The p_x must be ≥ 0 and add up to 1 (± 1e-6), else an error. The validator requires p (text) on each outcome as well
derived_xE{name: expression}, evaluated in order before p_x; visible in p_x and effect
skillVDepends on skill (skill marker instead of chance)
inputs, params, derived, docVDescriptive
outcomes:
  shot:
    skill: true
    derived_x: {q: "sigmoid(skill[actor] - DIFFICULTY)"}
    outcomes:
      - {id: hit, p: q, p_x: q, effect: [{op: add, var: "score[actor]", amount: 1}]}
      - {id: miss, p: 1 - q, p_x: "1 - q"}

Used by {op: sample, model: shot}; the chance node's labels are the outcome ids.

18. agents

{name: agent}.

FieldMeaning
policy_xEReturns the label of the option to choose. In scope: options (list of labels), decider (the player), and the global state. A result that is not one of the labels: a random choice
kindVsearch, heuristic, llm, human
roles, sees, prompt, noteVDescriptive

In the player, an agent with policy_x appears under Players as "heuristic ", next to random players, "always the first option" and playing seats yourself. Tactic files (see tactics.md) are the other way to describe how someone plays.

agents:
  greedy: {kind: heuristic, policy_x: "'stop' if 'stop' in options and pot >= 10 else options[0]"}

19. end

A list of end conditions. At every check they are tested in list order, and the first one that fires ends the game (or, with a scope, the subflow); later ones are not tested. eliminate and continue never stop the scan.

FieldMeaning
idE VName; the reason reported when the game ends
if_xEWhen the condition holds. Without if_x the condition is skipped
resultE Vwin, loss, draw, score, eliminate, continue
who_xEwin/loss in a competitive game: the winner resp. loser (one index; a list, tuple or set is an ExprError when the condition fires, and a validator error when who_x is obviously one, such as a list literal or comprehension). eliminate: an index or a list
returns_xEscore: list with one number per player
scopeE VA name in flows: applies only while that subflow runs (§9)
if, who, noteVText
checkVimmediate, end_of_turn, end_of_round, end_of_scenario (descriptive)

When: after each step's action (a skipped step has no check), after each action played (or resolved) in a window, and after each end_event. Not halfway through an effect, not after a cost, not after a cancelled item, not when a window closes, not at the start or end of a loop round.

What:

  • A condition with scope is skipped unless that subflow is running. If it holds and the result is win, loss or draw, results[scope] is set to the result (event <scope>:<result>), the subflow ends and the calling flow continues. A scope equal to the main flow's id means the whole game.
  • eliminate: the players from who_x are added to eliminated (event eliminated:<p>); play goes on.
  • continue: nothing.
  • The first other condition that holds ends the game. A score (or eliminate, continue) condition with a scope is only tested while that subflow runs and then acts as without scope: a scoped score ends the whole game.

Returns per player:

ResultCompetitiveCooperative
win+1 for who_x, −1 for everyone else+1 for everyone
loss−1 for who_x, +1 for everyone else−1 for everyone
draw0 for every player0 for every player
scorereturns_xreturns_x

Several winners (or losers) in a competitive game cannot be expressed with win/loss: who_x must be one player index. Use score, e.g. returns_x: "[1 if p in winners else -1 for p in players]".

If the main flow finishes without an end condition, the game ends with 0 for everyone and the reason flow finished without an end condition.

end:
  - {id: won, if_x: "score[current] >= GOAL", result: win, who_x: current}
  - {id: out, if_x: "any(hp[p] <= 0 for p in players)", result: eliminate, who_x: "[p for p in players if hp[p] <= 0]"}
  - {id: tally, if_x: "rounds_played >= ROUNDS", result: score, returns_x: "[float(s) for s in score]"}

20. Validator

The validator page shows the errors and warnings below, the executability with a few figures, the gaps ("What the engine still misses", per layer), the flow as a text tree, and the result of 20 random test games. See engine.md for what each part means.

Errors (must fix): unknown mode; persist other than campaign; duplicate flow id; unknown kind or order; branch without cases; subflow to a missing flow; loop as variable not declared in state.vars; window not in priority.windows; a step's action missing from actions; unknown op; undeclared zone in from/to/zone; undeclared code, flow (run), outcome model (sample) or agent (choose/say); exec content path that does not exist; end_event that is not a name or list of names; expression syntax errors; invalid board; function name clash, missing x, bad params or let; unknown trigger timing; unknown resolution or closes; a speed action named pass; end scope that is not a named flow; competitive win/loss whose who_x obviously gives several players (a list, tuple or set literal, a comprehension, or list(), sorted(), set(), tuple()); outcome model without outcomes or an outcome without p; ability without id or with a name that already exists; undeclared face type; open question without question.

Warnings: drawn_from to a zone that is not shuffled, or on a public zone; content item without a valid status; ability without speed; keyword not used by any rule; board or function never used; say without agent; exec without content or of_x; optional trigger without controller_x; speed not allowed by any window; a fast action that is always playable in a window that is opened (below); modifier stat (static or via modify) never read with modified(); trigger on an event that is never emitted; action not used by any step (unless it has note or speed); flow never called; open question with a status other than open, assumed, answered; a key the JSON Schema does not allow (unknown key 'x' at actions.y: a typo, or a field the engine ignores).

Always playable (action X: always playable in window Y; random players may keep the window open forever): the action has a speed that window Y allows, Y is opened somewhere (a node's window or a window op; window: true allows every speed), and the action has no pre/pre_x, no cost, and nothing in its effect that could end the game: no set/add of a variable, and no zone op on a zone, that an end condition's if_x reads (also through derived values and functions), no emit that a trigger listens to, no attribute write, and no run, exec, sample, call, reset or say. Such an action can be played again and again: random players rarely all pass in succession, and an agent that never passes hits window does not close. Give it a pre_x (a resource, once per window) or a cost.

Unknown keys: the validator walks the model alongside the JSON schema and reports every key where the schema allows no other keys, with its path. content has a free structure and is not checked; constants and other free dicts neither.

The validator does not check that a var path names a declared variable, nor that expressions only use known names; those fail at run time, which the test games usually show.

Figures:

FigureMeaning
executableShare of engine-needed fields that have an _x or a literal (rules and content together)
rulesThe same, for flow, actions, triggers, modifiers, priority, outcomes and end
contentShare of content items with behaviour (or text, or status: missing) that are fully executable, with counts; - without content
stepsNumber of step nodes
opsNumber of ops in actions, triggers, outcomes and content
codeShare of call ops
open questionsNumber of open_questions

Markers of a step (shown in the diagram; from the ops of its actions, including flows they run):

  • choice: several actions in do, or a choose, say or search;
  • chance: shuffle, roll, draw from a shuffled zone, look at a shuffled zone, sample from a model without skill;
  • skill: sample from a model with skill: true;
  • hidden: choose with secret, move/draw to an unshuffled zone with visibility hidden or owner, search, look;
  • window: a window on the step, or a window op in its actions (a window on any other kind of node is not counted);
  • trigger: an emit of an event some trigger listens to;
  • language: say, or choose with an llm agent;
  • persistent: set/add on a persistent variable, move/draw/place/create into a persistent zone, or reset;
  • code: call.

21. code

{name: {lang, ref, doc, kind}}. Describes code the rules would need but the ops cannot express: doc says what it does, ref names it ("module:function"), lang its language. kind: content (should really be in content) or algorithm. A call to it is never executable; the share of call ops measures what the metamodel does not (yet) express.

22. open_questions

A list of gaps or ambiguities in the source: what the rulebook does not say, and what the model assumes. The engine ignores them; the diagram shows them as a gap report, and the profile counts them.

FieldMeaning
idVShort unique name (required by the JSON Schema)
questionVThe question, in one or two sentences. Required: missing is a validator error
aboutVWhat it concerns: a path in the model (actions.attack, flow.round, end.won) or a short topic (setup)
statusVopen (undecided; the default when missing), assumed (the model follows an assumption, given in answer), answered (the source or the designer settled it). Anything else is a validator warning
answerVThe assumption or the answer
sourceVWhere the question comes from (rulebook page, FAQ, forum thread)

The JSON Schema allows no other fields. Write one entry per question; every assumed entry should say in answer what the model does.

open_questions:
  - {id: q1, about: actions.attack, question: "Can a unit attack twice?", status: assumed, answer: "No", source: "rulebook p. 6"}
  - {id: q2, about: setup, question: "Who starts?", status: open}

23. Limits

LimitValueError
Rounds of one loop10,000loop does not end
Passes of repeat10,000repeat does not end
Iterations of one window10,000window does not close
Nested emits50trigger chain too deep
Own function call depth100more than 100 calls deep
Modifier nesting20modifiers for 'x' refer to each other in a cycle
range() length10,000,000range too large

24. Tools on the site

ToolUse it to
ValidatorCheck a pasted model (and optionally its view): errors, warnings, executability, gaps, the flow as a tree, 20 random test games. Then "Open in the player"
PlayerFollow one game question by question, with random players, a heuristic from agents, or seats played by hand. "Game (seed)" replays the same game
DiagramSee the rules as a text tree and a track diagram ("Draw your own model")

Everything runs in the browser; nothing is uploaded. Playing many games to measure win rates and comparing variants or tactics are not yet available on the site. See engine.md.

This page as Markdown